feat(proxy): stream structured screen updates
- commit
- c6e0a7889a8544e7a579504cea4a8384612d1f2c
- author
- brice · · 2026-08-02 10:13 UTC
- parent
- 087e78d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
58 files · +3680 -1644
M cmd/vev-perf-harness/evidence.go +13 -0
| @@ -200,7 +200,17 @@ func mergeProcessTraces(mappings []processMapping) ([]span, error) { | |||
| 200 | 200 | // and exact correlation domain may pair, and each pair's ticks must retain | |
| 201 | 201 | // its own start-before-end ordering. | |
| 202 | 202 | starts := map[string]traceRecord{} | |
| 203 | + | observerGap := false | |
| 203 | 204 | for _, r := range records { | |
| 205 | + | if r.Component == "observer" && r.Kind == "transport_diagnostic" && !r.Valid { | |
| 206 | + | // A bounded producer queue reports dropped marks explicitly. Any | |
| 207 | + | // in-flight span may have lost either endpoint, so discard those | |
| 208 | + | // partial samples; an end after the gap is not independently | |
| 209 | + | // attributable to a missing start. | |
| 210 | + | starts = map[string]traceRecord{} | |
| 211 | + | observerGap = true | |
| 212 | + | continue | |
| 213 | + | } | |
| 204 | 214 | for _, pair := range spanPairs { | |
| 205 | 215 | key := fmt.Sprintf("%s/%s/%s/%d/%d/%d/%d", r.ProcessID, r.Component, m.Scenario, m.Run, r.Sequence, r.RequestID, r.Epoch) | |
| 206 | 216 | if r.Kind == pair.start { | |
| @@ -214,6 +224,9 @@ func mergeProcessTraces(mappings []processMapping) ([]span, error) { | |||
| 214 | 224 | k := pair.name + "/" + key | |
| 215 | 225 | start, ok := starts[k] | |
| 216 | 226 | if !ok { | |
| 227 | + | if observerGap { | |
| 228 | + | continue | |
| 229 | + | } | |
| 217 | 230 | return nil, errors.New("span end without same-process start") | |
| 218 | 231 | } | |
| 219 | 232 | if r.Tick < start.Tick { | |
M cmd/vev-perf-harness/evidence_test.go +22 -0
| @@ -364,6 +364,28 @@ func TestHarnessRejectsCrossProcessAndBadTraceSpans(t *testing.T) { | |||
| 364 | 364 | } | |
| 365 | 365 | } | |
| 366 | 366 | ||
| 367 | + | func TestHarnessSkipsUnpairedEndAfterObserverGap(t *testing.T) { | |
| 368 | + | path := filepath.Join(t.TempDir(), "trace.jsonl") | |
| 369 | + | records := []traceRecord{ | |
| 370 | + | {ProcessID: "one", Component: "observer", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: "transport_diagnostic", Valid: false}, | |
| 371 | + | {ProcessID: "one", Component: "ipc", Scenario: "s", Run: 1, Sequence: 2, RequestID: 2, Epoch: 2, Kind: "adapter_receive_end", Tick: 20, Valid: true}, | |
| 372 | + | } | |
| 373 | + | var lines []string | |
| 374 | + | for _, record := range records { | |
| 375 | + | lines = append(lines, mustJSON(record)) | |
| 376 | + | } | |
| 377 | + | if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")+"\n"), 0o600); err != nil { | |
| 378 | + | t.Fatal(err) | |
| 379 | + | } | |
| 380 | + | spans, err := mergeProcessTraces([]processMapping{{ProcessID: "one", ClockDomain: "one", TracePath: path, Scenario: "s", Run: 1}}) | |
| 381 | + | if err != nil { | |
| 382 | + | t.Fatalf("observer gap did not invalidate the dropped span: %v", err) | |
| 383 | + | } | |
| 384 | + | if len(spans) != 0 { | |
| 385 | + | t.Fatalf("spans=%+v, want no sample after observer gap", spans) | |
| 386 | + | } | |
| 387 | + | } | |
| 388 | + | ||
| 367 | 389 | func TestHarnessExcludesFailedSpanDurationsWhileValidatingPairing(t *testing.T) { | |
| 368 | 390 | base := func(kind string, tick int64, valid bool) traceRecord { | |
| 369 | 391 | return traceRecord{ProcessID: "one", Component: "adapter", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: kind, Tick: tick, Valid: valid} | |
M cmd/vev-perf-harness/launcher_test.go +1 -1
| @@ -625,7 +625,7 @@ func TestHarnessUsesPublicRoleCommandsAndPTYWorkloads(t *testing.T) { | |||
| 625 | 625 | }) | |
| 626 | 626 | } | |
| 627 | 627 | input := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1")) | |
| 628 | - | 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") { | |
| 628 | + | if string(inputMarker([]byte(input))) != "__VEV_HARNESS_s_r1_measured-1__" || strings.Count(input, "printf 'vev perf output\\n'") != 128 || strings.Contains(input, "while [ $i -lt 128 ]") || !strings.HasSuffix(input, "printf '__VEV_HARNESS_s_r1_measured-1__\\n'\n") { | |
| 629 | 629 | t.Fatalf("workload is not real PTY shell input with an observable marker: %q", input) | |
| 630 | 630 | } | |
| 631 | 631 | } | |
M cmd/vev-perf-harness/runner.go +15 -24
| @@ -381,7 +381,10 @@ func roleArgs(s scenario, m processMapping) roleCommand { | |||
| 381 | 381 | ||
| 382 | 382 | func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand { | |
| 383 | 383 | session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run) | |
| 384 | - | remote := []string{"attach", "harness@127.0.0.1:" + session} | |
| 384 | + | // Remote benchmark sessions are created by the first Hello on the peer | |
| 385 | + | // daemon. Omitting the session suffix keeps that Hello ephemeral; a named | |
| 386 | + | // attach would fail because the fresh per-run daemon has no session yet. | |
| 387 | + | remote := []string{"attach", "harness@127.0.0.1"} | |
| 385 | 388 | switch m.Role { | |
| 386 | 389 | case "daemon": | |
| 387 | 390 | return roleCommand{Args: []string{"--daemon"}} | |
| @@ -469,7 +472,7 @@ func workloadResize(s scenario, sequence uint64) (uint16, uint16, bool) { | |||
| 469 | 472 | // by cliProcess; it is not a synthetic timestamp. | |
| 470 | 473 | func workloadInput(s scenario, run int, phase string) []byte { | |
| 471 | 474 | marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase)) | |
| 472 | - | body := "printf 'vev perf %s\\n'" | |
| 475 | + | body := fmt.Sprintf("printf 'vev perf %s\\n'", s.ID) | |
| 473 | 476 | markerSeparator := "; " | |
| 474 | 477 | switch s.Workload { | |
| 475 | 478 | case "active_output": | |
| @@ -477,37 +480,25 @@ func workloadInput(s scenario, run int, phase string) []byte { | |||
| 477 | 480 | if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") { | |
| 478 | 481 | cell = '0' + byte(sequence%2) | |
| 479 | 482 | } | |
| 480 | - | body = fmt.Sprintf(`printf '\033[10;20H%c'; : '%%s'`, cell) | |
| 483 | + | body = fmt.Sprintf(`printf '\033[10;20H%c'; : '%s'`, cell, s.ID) | |
| 481 | 484 | case "all_output": | |
| 482 | - | body = `printf '%%-120s' 'vev perf line %s'` | |
| 485 | + | body = fmt.Sprintf(`printf '%%-120s' 'vev perf line %s'`, s.ID) | |
| 483 | 486 | case "inactive_output": | |
| 484 | - | body = `printf '\033[38;2;20;120;220mred'; printf '\033[38;2;220;80;40mgreen'; printf '\033[38;2;80;220;120m%s'; printf '\033[0m'` | |
| 487 | + | body = fmt.Sprintf(`printf '\033[38;2;20;120;220mred'; printf '\033[38;2;220;80;40mgreen'; printf '\033[38;2;80;220;120m%s'; printf '\033[0m'`, s.ID) | |
| 485 | 488 | case "interactive_flood": | |
| 486 | - | body = "i=0; while [ $i -lt 128 ]; do printf 'vev perf output %s\\n'; i=$((i+1)); done" | |
| 489 | + | body = strings.TrimSuffix(strings.Repeat("printf 'vev perf output\\n'; ", 128), "; ") | |
| 487 | 490 | case "resize_sweep": | |
| 488 | - | cols, _ := resizeWorkloadGeometry(s, phase) | |
| 489 | - | body = fmt.Sprintf(`i=0; while [ "$i" -lt 100 ]; do set -- $(stty size); [ "$2" = "%d" ] && break; i=$((i+1)); sleep 0.01; done; set -- $(stty size); [ "$2" = "%d" ] && printf 'vev perf resize-sweep %%s\n'`, cols, cols) | |
| 490 | - | markerSeparator = " && " | |
| 491 | + | body = fmt.Sprintf("printf 'vev perf resize-sweep %s\\n'", s.ID) | |
| 491 | 492 | case "copy_search": | |
| 492 | - | body = "printf 'vev perf copy-search %s\\n'" | |
| 493 | + | body = fmt.Sprintf("printf 'vev perf copy-search %s\\n'", s.ID) | |
| 493 | 494 | case "snapshot_output_resize": | |
| 494 | - | cols, _ := resizeWorkloadGeometry(s, phase) | |
| 495 | - | body = fmt.Sprintf(`i=0; while [ "$i" -lt 100 ]; do set -- $(stty size); [ "$2" = "%d" ] && break; i=$((i+1)); sleep 0.01; done; set -- $(stty size); [ "$2" = "%d" ] && printf '\033[2J\033[Hvev perf snapshot-output-resize %%s\n'`, cols, cols) | |
| 496 | - | markerSeparator = " && " | |
| 495 | + | body = fmt.Sprintf(`printf '\033[2J\033[Hvev perf snapshot-output-resize %s\n'`, s.ID) | |
| 497 | 496 | case "attach_restore_tab_switch": | |
| 498 | - | body = `"$VEV_PERF_BIN" cmd next-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'` | |
| 497 | + | body = fmt.Sprintf(`"$VEV_PERF_BIN" cmd next-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'`, s.ID) | |
| 499 | 498 | if phase == "warmup" { | |
| 500 | - | body = `"$VEV_PERF_BIN" cmd new-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'` | |
| 499 | + | body = fmt.Sprintf(`"$VEV_PERF_BIN" cmd new-tab && "$VEV_PERF_BIN" cmd previous-tab && printf 'vev perf attach-restore-tab-switch %s\n'`, s.ID) | |
| 501 | 500 | } | |
| 502 | 501 | markerSeparator = " && " | |
| 503 | 502 | } | |
| 504 | - | return []byte(fmt.Sprintf(body+markerSeparator+"printf '%s\\n'\n", s.ID, marker)) | |
| 505 | - | } | |
| 506 | - | ||
| 507 | - | func resizeWorkloadGeometry(s scenario, phase string) (uint16, uint16) { | |
| 508 | - | if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") { | |
| 509 | - | cols, rows, _ := workloadResize(s, sequence) | |
| 510 | - | return cols, rows | |
| 511 | - | } | |
| 512 | - | return 120, 40 | |
| 503 | + | return []byte(body + markerSeparator + fmt.Sprintf("printf '%s\\n'\n", marker)) | |
| 513 | 504 | } | |
M cmd/vev-perf-harness/runner_test.go +36 -8
| @@ -253,8 +253,8 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) { | |||
| 253 | 253 | s := scenario{ID: "route", Transport: tc.transport.ID} | |
| 254 | 254 | client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport) | |
| 255 | 255 | peer := routeRoleArgs(s, processMapping{Role: tc.peer, Run: 2}, tc.transport) | |
| 256 | - | if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1:perf-route-002"}) { | |
| 257 | - | t.Fatalf("client did not use public remote attach: %q", client.Args) | |
| 256 | + | if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) { | |
| 257 | + | t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args) | |
| 258 | 258 | } | |
| 259 | 259 | if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) { | |
| 260 | 260 | t.Fatalf("ssh peer command=%q", peer.Args) | |
| @@ -321,7 +321,7 @@ func TestWorkloadInputShapes(t *testing.T) { | |||
| 321 | 321 | `printf '\033[38;2;220;80;40mgreen'`, | |
| 322 | 322 | `printf '\033[38;2;80;220;120ms'`, | |
| 323 | 323 | }}, | |
| 324 | - | {"interactive_flood", []string{"while [ $i -lt 128 ]"}}, | |
| 324 | + | {"interactive_flood", []string{"printf 'vev perf output\\n'"}}, | |
| 325 | 325 | {"snapshot_output_resize", []string{`\033[2J`, "vev perf snapshot-output-resize"}}, | |
| 326 | 326 | {"attach_restore_tab_switch", []string{`"$VEV_PERF_BIN" cmd next-tab`, `"$VEV_PERF_BIN" cmd previous-tab`}}, | |
| 327 | 327 | } | |
| @@ -358,6 +358,14 @@ func TestWorkloadInputShapes(t *testing.T) { | |||
| 358 | 358 | t.Fatalf("inactive output truecolor runs = %d, want 3: %q", count, inactive) | |
| 359 | 359 | } | |
| 360 | 360 | ||
| 361 | + | interactive := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1")) | |
| 362 | + | if count := strings.Count(interactive, `printf 'vev perf output\n'`); count != 128 { | |
| 363 | + | t.Fatalf("interactive flood printf commands = %d, want 128: %q", count, interactive) | |
| 364 | + | } | |
| 365 | + | if strings.Contains(interactive, "i=0") || strings.Contains(interactive, "while [") { | |
| 366 | + | t.Fatalf("interactive flood uses shell-specific loop syntax: %q", interactive) | |
| 367 | + | } | |
| 368 | + | ||
| 361 | 369 | warmup := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "warmup")) | |
| 362 | 370 | if count := strings.Count(warmup, `"$VEV_PERF_BIN" cmd new-tab`); count != 1 { | |
| 363 | 371 | t.Fatalf("attach warmup new-tab commands = %d, want 1: %q", count, warmup) | |
| @@ -373,6 +381,26 @@ func TestWorkloadInputShapes(t *testing.T) { | |||
| 373 | 381 | } | |
| 374 | 382 | } | |
| 375 | 383 | ||
| 384 | + | func TestWorkloadInputResizeCommandsAreComplete(t *testing.T) { | |
| 385 | + | for _, tt := range []struct { | |
| 386 | + | workload string | |
| 387 | + | want string | |
| 388 | + | }{ | |
| 389 | + | {"resize_sweep", `printf 'vev perf resize-sweep s\n'; printf '__VEV_HARNESS_s_r1_measured-1__\n'` + "\n"}, | |
| 390 | + | {"snapshot_output_resize", `printf '\033[2J\033[Hvev perf snapshot-output-resize s\n'; printf '__VEV_HARNESS_s_r1_measured-1__\n'` + "\n"}, | |
| 391 | + | } { | |
| 392 | + | t.Run(tt.workload, func(t *testing.T) { | |
| 393 | + | got := string(workloadInput(scenario{ID: "s", Workload: tt.workload}, 1, "measured-1")) | |
| 394 | + | if got != tt.want { | |
| 395 | + | t.Fatalf("workload input = %q, want complete command %q", got, tt.want) | |
| 396 | + | } | |
| 397 | + | if strings.Contains(got, "%!") { | |
| 398 | + | t.Fatalf("workload input contains fmt diagnostic: %q", got) | |
| 399 | + | } | |
| 400 | + | }) | |
| 401 | + | } | |
| 402 | + | } | |
| 403 | + | ||
| 376 | 404 | func TestWorkloadResize(t *testing.T) { | |
| 377 | 405 | for _, tt := range []struct { | |
| 378 | 406 | workload string | |
| @@ -457,7 +485,7 @@ func (l resizeOrderingLauncher) Launch(m processMapping, _ roleCommand) (launche | |||
| 457 | 485 | return &fakeProcess{}, nil | |
| 458 | 486 | } | |
| 459 | 487 | ||
| 460 | - | func TestResizeBoundaryPrecedesResizeAndMarkerWaitsForShellGeometry(t *testing.T) { | |
| 488 | + | func TestResizeBoundaryPrecedesWorkloadWriteAndMarker(t *testing.T) { | |
| 461 | 489 | dir := t.TempDir() | |
| 462 | 490 | order := []string{} | |
| 463 | 491 | client := &resizeOrderingProcess{order: &order} | |
| @@ -476,13 +504,13 @@ func TestResizeBoundaryPrecedesResizeAndMarkerWaitsForShellGeometry(t *testing.T | |||
| 476 | 504 | t.Fatalf("first resize event order = %q, want %q", order[:len(wantOrder)], wantOrder) | |
| 477 | 505 | } | |
| 478 | 506 | first := string(client.inputs[0]) | |
| 479 | - | for _, want := range []string{`set -- $(stty size)`, `[ "$2" = "120" ]`, `sleep 0.01`, `&& printf '__VEV_HARNESS_resize_r1_measured-2__\n'`} { | |
| 507 | + | for _, want := range []string{`printf 'vev perf resize-sweep resize\n'`, `printf '__VEV_HARNESS_resize_r1_measured-2__\n'`} { | |
| 480 | 508 | if !strings.Contains(first, want) { | |
| 481 | - | t.Errorf("resize input has no bounded expected-column gate %q: %q", want, first) | |
| 509 | + | t.Errorf("resize input has no observable workload or marker %q: %q", want, first) | |
| 482 | 510 | } | |
| 483 | 511 | } | |
| 484 | - | if strings.Contains(first, `"40 120"`) { | |
| 485 | - | t.Fatalf("resize input incorrectly gates on client rows that shell chrome does not expose: %q", first) | |
| 512 | + | if strings.Contains(first, "while [") || strings.Contains(first, "%!") { | |
| 513 | + | t.Fatalf("resize input contains unsupported loop or fmt diagnostic: %q", first) | |
| 486 | 514 | } | |
| 487 | 515 | } | |
| 488 | 516 | ||
M docs/performance.md +185 -59
| @@ -31,10 +31,14 @@ injected monotonic clock; producers supply no timestamp. The harness sets `VEV_P | |||
| 31 | 31 | `VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every | |
| 32 | 32 | launched role, identifying its exclusive trace path and process/scenario/run | |
| 33 | 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. | |
| 34 | + | process share that observer. A spawned daemon is deliberately stripped of | |
| 35 | + | these variables so it cannot append to a role's trace. The harness clock is a | |
| 36 | + | separate monotonic domain and is the sole clock for input-injection → terminal- | |
| 37 | + | bytes-successfully-flushed end-to-end samples. Cross-process ticks are never | |
| 38 | + | ordered or subtracted; records correlate only by scenario/run/sequence/request/ | |
| 39 | + | epoch IDs. A `component=observer`, `kind=transport_diagnostic`, `valid=false` | |
| 40 | + | record explicitly reports bounded-observer loss; spans crossing that gap are | |
| 41 | + | excluded, while an unmatched end without such a gap remains an error. | |
| 38 | 42 | ||
| 39 | 43 | The named process-local spans are capture, compose, diff, queue wait, | |
| 40 | 44 | ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end | |
| @@ -42,77 +46,197 @@ marks are paired within one process and summarized by component/adapter with | |||
| 42 | 46 | raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments, | |
| 43 | 47 | retransmits, pending, ACK RTT) remain separate from duration calculations. | |
| 44 | 48 | ||
| 45 | - | ### Proxy ANSI baseline | |
| 49 | + | ### Historical proxy ANSI baseline | |
| 46 | 50 | ||
| 47 | - | The current proxy path transports ANSI and applies it through the local proxy | |
| 48 | - | VT parser. Build the fixed baseline binary and harness, then verify the harness | |
| 49 | - | and parser fixtures: | |
| 51 | + | Before the structured path, proxied state-bearing screen updates transported ANSI | |
| 52 | + | and were applied through the local proxy VT parser. In the candidate, proxied | |
| 53 | + | state-bearing frames are `MsgScreenUpdate`; this parser benchmark remains a | |
| 54 | + | historical diagnostic. Ordinary local output remains the ANSI `MsgOutput` | |
| 55 | + | guardrail. | |
| 56 | + | ||
| 57 | + | The binaries were built from clean, exact commits in temporary clones so their | |
| 58 | + | provenance is embedded and checkable: | |
| 50 | 59 | ||
| 51 | 60 | ```sh | |
| 52 | - | go build -o /tmp/vev-proxy-baseline . | |
| 53 | - | go build -o /tmp/vev-perf-harness ./cmd/vev-perf-harness | |
| 54 | - | go test ./cmd/vev-perf-harness -count=1 | |
| 55 | - | go test ./internal/usecase/daemon -run '^TestProxyANSIBenchmarkFixtures$' -count=1 | |
| 56 | - | go test ./internal/usecase/daemon -run '^$' \ | |
| 57 | - | -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 | |
| 61 | + | GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev /tmp/vev-perf-baseline-1f2c802-clone | |
| 62 | + | GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-baseline-1f2c802-clone checkout --detach 1f2c802d0707785cab979134db6a95645250a82c | |
| 63 | + | GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-candidate-2b48abf9-clone | |
| 64 | + | GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-candidate-2b48abf9-clone checkout --detach 2b48abf9b481c3dbdc371ba13c15cf06051d9df0 | |
| 65 | + | (cd /tmp/vev-perf-baseline-1f2c802-clone && go build -o /tmp/vev-proxy-baseline-1f2c802 .) | |
| 66 | + | (cd /tmp/vev-perf-candidate-2b48abf9-clone && go build -o /tmp/vev-proxy-candidate-2b48abf9 .) | |
| 67 | + | GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-harness-d479fe9-clone | |
| 68 | + | GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-harness-d479fe9-clone checkout --detach d479fe99581cf1988efba7906914c9ddd8653d7d | |
| 69 | + | (cd /tmp/vev-perf-harness-d479fe9-clone && go build -o /tmp/vev-perf-harness-d479fe9 ./cmd/vev-perf-harness) | |
| 70 | + | go version -m /tmp/vev-proxy-baseline-1f2c802 | |
| 71 | + | go version -m /tmp/vev-proxy-candidate-2b48abf9 | |
| 72 | + | go version -m /tmp/vev-perf-harness-d479fe9 | |
| 58 | 73 | ``` | |
| 59 | 74 | ||
| 60 | - | The pre-change parser results below are medians of ten repetitions on | |
| 61 | - | Linux/amd64 with an AMD Ryzen 9 7900X3D. The fixed | |
| 62 | - | `/tmp/vev-proxy-baseline` binary was built from exact Phase 1 commit | |
| 63 | - | `1f2c802d0707785cab979134db6a95645250a82c`. Runtime production code remains | |
| 64 | - | based on `919bcd18c428ef90abfda6c2381ba1cfbb06b0dd` because Phase 1 changes | |
| 65 | - | only benchmarks, harness code, tests, and documentation. The benchmark command | |
| 66 | - | above ran with Go 1.26.5. | |
| 75 | + | The baseline benchmark below is a measured median of ten repetitions on | |
| 76 | + | Linux/amd64 with an AMD Ryzen 9 7900X3D, using Go 1.26.5 and commit | |
| 77 | + | `1f2c802d0707785cab979134db6a95645250a82c`: | |
| 78 | + | ||
| 79 | + | ```sh | |
| 80 | + | cd /tmp/vev-perf-baseline-clone | |
| 81 | + | go test ./internal/usecase/daemon -run '^$' \ | |
| 82 | + | -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 \ | |
| 83 | + | | tee /tmp/vev-perf-results/baseline-1f2c802-proxy-ansi.txt | |
| 84 | + | ``` | |
| 67 | 85 | ||
| 68 | 86 | | ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op | | |
| 69 | 87 | | --- | ---: | ---: | ---: | ---: | | |
| 70 | - | | absolute-position one-cell | 91.06 | 16 | 1 | 9 | | |
| 71 | - | | 120-column full line | 4,659.5 | 129 | 1 | 126 | | |
| 72 | - | | fragmented truecolor styled line | 5,861 | 450 | 1 | 419 | | |
| 73 | - | | full-width 40-row scroll | 233,271.5 | 5,472 | 1 | 4,880 | | |
| 88 | + | | absolute-position one-cell | 93.31 | 16 | 1 | 9 | | |
| 89 | + | | 120-column full line | 4,777 | 130 | 1 | 126 | | |
| 90 | + | | fragmented truecolor styled line | 6,119.5 | 450 | 1 | 419 | | |
| 91 | + | | full-width 40-row scroll | 238,709 | 5,477.5 | 1 | 4,880 | | |
| 74 | 92 | ||
| 75 | 93 | Keep the ordinary local ANSI renderer as a separate guardrail: | |
| 76 | 94 | ||
| 77 | 95 | ```sh | |
| 78 | - | go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 | |
| 96 | + | cd /tmp/vev-perf-candidate-2b48abf9-clone | |
| 97 | + | go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 \ | |
| 98 | + | | tee /tmp/vev-perf-results/candidate-2b48abf9-renderer.txt | |
| 99 | + | ``` | |
| 100 | + | ||
| 101 | + | The candidate guardrail completed with Go 1.26.5; its ten-repetition output is | |
| 102 | + | retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`. | |
| 103 | + | ||
| 104 | + | ### Comparable proxy pipeline benchmark | |
| 105 | + | ||
| 106 | + | `BenchmarkProxyPipeline` is a local comparison harness, not a network or | |
| 107 | + | public-CLI result. Its four fixtures—one-cell, full-line, styled-line, and | |
| 108 | + | full-width scroll—use semantically paired initial and mutated `renderer.Frame` | |
| 109 | + | values. Snapshot/full-paint setup is completed before the timed steady-state | |
| 110 | + | loop; the loop applies the same mutation and consecutive state transitions on | |
| 111 | + | both sides. | |
| 112 | + | ||
| 113 | + | The `ansi-pipeline` slice measures `Renderer.Prepare`/bytes, | |
| 114 | + | `MarshalOutput`, a real benchmark sender that performs the simulated transport, | |
| 115 | + | `UnmarshalOutput`, `proxyANSIApplyState.apply`, and `Commit`. The | |
| 116 | + | `structured-pipeline` slice measures `structuredOutputStream.prepare` (including | |
| 117 | + | `MarshalScreenUpdate`), a real benchmark sender that performs the simulated | |
| 118 | + | transport, `UnmarshalScreenUpdate`, `proxyScreenState.Apply`, damage | |
| 119 | + | acknowledgement, and `prepared.send` commit. Both slices are local and exclude | |
| 120 | + | network effects. `wirebytes/op` is the complete message payload size excluding | |
| 121 | + | connection framing; unlike the historical table above, it includes the | |
| 122 | + | `MsgOutput` or `MsgScreenUpdate` payload header. `B/op` is the benchmark | |
| 123 | + | allocator metric; explicitly, `B/op != wirebytes/op`. `spans/op` is reported | |
| 124 | + | separately for structured updates. Run it with: | |
| 125 | + | ||
| 126 | + | ```sh | |
| 127 | + | cd /tmp/vev-perf-candidate-2b48abf9-clone | |
| 128 | + | go test ./internal/usecase/daemon -run '^$' \ | |
| 129 | + | -bench '^BenchmarkProxyPipeline$' -benchmem -count=10 \ | |
| 130 | + | | tee /tmp/vev-perf-results/candidate-2b48abf9-proxy-pipeline.txt | |
| 79 | 131 | ``` | |
| 80 | 132 | ||
| 133 | + | Measured candidate medians from that command (Go 1.26.5, Linux/amd64): | |
| 134 | + | ||
| 135 | + | | fixture | ANSI ns/op | structured ns/op | ANSI B/op | structured B/op | ANSI wirebytes/op | structured wirebytes/op | | |
| 136 | + | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | |
| 137 | + | | one-cell | 530.55 | 1,016.5 | 192 | 888 | 37 | 71 | | |
| 138 | + | | full-line | 8,637.5 | 22,187.5 | 674 | 19,928 | 154 | 190 | | |
| 139 | + | | styled-line | 10,565.5 | 25,682 | 1,652 | 24,952 | 388 | 432 | | |
| 140 | + | | full-width-scroll | 55,713.5 | 73,858 | 760 | 19,944 | 177 | 196 | | |
| 141 | + | ||
| 142 | + | The component diagnostics completed ten repetitions with separate | |
| 143 | + | commands; their raw output is retained under `/tmp/vev-perf-results/`: | |
| 144 | + | ||
| 145 | + | ```sh | |
| 146 | + | cd /tmp/vev-perf-candidate-2b48abf9-clone | |
| 147 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 \ | |
| 148 | + | | tee /tmp/vev-perf-results/candidate-2b48abf9-proxy-ansi.txt | |
| 149 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkStructuredOutputPrepare$' -benchmem -count=10 \ | |
| 150 | + | | tee /tmp/vev-perf-results/candidate-2b48abf9-structured-prepare.txt | |
| 151 | + | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkProxyScreenStateApply$' -benchmem -count=10 \ | |
| 152 | + | | tee /tmp/vev-perf-results/candidate-2b48abf9-screen-apply.txt | |
| 153 | + | ``` | |
| 154 | + | ||
| 155 | + | Their medians are respectively 107.45/5,107/6,561.5/251,126.5 ns/op for | |
| 156 | + | ANSI one-cell/full-line/styled-line/scroll rows as reported in the raw file; | |
| 157 | + | structured prepare medians are 673/16,928/23,220.5/61,424.5 ns/op and decoded | |
| 158 | + | apply medians are 53.29/1,225/1,234/3,659.5 ns/op. These remain component | |
| 159 | + | diagnostics, not head-to-head pipeline comparisons. | |
| 160 | + | ||
| 161 | + | The 35-scenario public-CLI harness A/B matrix remains the adoption gate. This | |
| 162 | + | local pipeline benchmark does not replace that gate and this document records | |
| 163 | + | no unmeasured result or conclusion. | |
| 164 | + | ||
| 81 | 165 | ### Public-CLI transport runs | |
| 82 | 166 | ||
| 83 | - | Use a 10-second warmup, a 30-second measured interval, and ten independent | |
| 84 | - | repetitions. This representative matrix covers one-cell active output, | |
| 85 | - | 120-column full-line output, fragmented truecolor-styled output, sustained | |
| 86 | - | interactive flood, resize sweep, snapshot/output/resize, and attach/tab-switch | |
| 87 | - | across local, SSH stdio, UDP baseline, UDP 25 ms, and UDP 1% loss (35 | |
| 88 | - | scenarios): | |
| 167 | + | The adoption gate requires a 10-second warmup, a 30-second measured interval, | |
| 168 | + | and ten independent repetitions for each of seven workloads × five transports | |
| 169 | + | (35 scenarios), for both exact baseline and candidate binaries. This exact | |
| 170 | + | command template was executed for every scenario (the two 35-scenario sets | |
| 171 | + | were run separately to avoid oversubscribing the host): | |
| 89 | 172 | ||
| 90 | 173 | ```sh | |
| 91 | - | rm -rf /tmp/vev-proxy-selected | |
| 92 | - | mkdir -m 700 /tmp/vev-proxy-selected | |
| 93 | - | for workload in \ | |
| 94 | - | active_output all_output inactive_output interactive_flood resize_sweep \ | |
| 95 | - | snapshot_output_resize attach_restore_tab_switch | |
| 96 | - | do | |
| 97 | - | for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct | |
| 98 | - | do | |
| 99 | - | scenario="1x4-${workload}-${transport}" | |
| 100 | - | /tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \ | |
| 101 | - | --manifest testdata/perf/manifest.json --scenario "$scenario" \ | |
| 102 | - | --out "/tmp/vev-proxy-selected/$scenario" \ | |
| 103 | - | --warmup 10s --duration 30s --repetitions 10 | |
| 174 | + | for bin in baseline candidate; do | |
| 175 | + | case "$bin" in | |
| 176 | + | baseline) vev_bin=/tmp/vev-proxy-baseline-1f2c802 ;; | |
| 177 | + | candidate) vev_bin=/tmp/vev-proxy-candidate-2b48abf9 ;; | |
| 178 | + | esac | |
| 179 | + | for workload in active_output all_output inactive_output interactive_flood resize_sweep \ | |
| 180 | + | snapshot_output_resize attach_restore_tab_switch; do | |
| 181 | + | for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct; do | |
| 182 | + | scenario="1x4-${workload}-${transport}" | |
| 183 | + | /tmp/vev-perf-harness-d479fe9 \ | |
| 184 | + | --vev-bin "$vev_bin" \ | |
| 185 | + | --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \ | |
| 186 | + | --scenario "$scenario" \ | |
| 187 | + | --out "/tmp/vev-perf-results/public-final-${bin}-matrix/$scenario" \ | |
| 188 | + | --warmup 10s --duration 30s --repetitions 10 | |
| 189 | + | done | |
| 104 | 190 | done | |
| 105 | 191 | done | |
| 106 | 192 | ``` | |
| 107 | 193 | ||
| 108 | - | `all_output` and `inactive_output` provide full-line and styled payload shapes; | |
| 109 | - | these runs do not claim to activate every pane in their manifest topologies. | |
| 110 | - | Run the complete canonical matrix only when full-matrix evidence is required: | |
| 194 | + | The measured outputs are retained only under `/tmp/vev-perf-results/`. Each | |
| 195 | + | binary completed 13/35 scenarios with ten runs; 22 scenarios stopped before a | |
| 196 | + | summary. The common completed results below are harness end-to-end medians | |
| 197 | + | from `summary.json` (p50/p95, ns): | |
| 198 | + | ||
| 199 | + | | scenario | baseline samples | candidate samples | baseline p50/p95 | candidate p50/p95 | | |
| 200 | + | | --- | ---: | ---: | ---: | ---: | | |
| 201 | + | | 1x4-active_output-local | 300 | 300 | 16,913,252 / 17,407,463 | 16,922,803 / 17,444,022 | | |
| 202 | + | | 1x4-active_output-ssh_stdio | 300 | 300 | 17,204,192 / 17,621,643 | 17,209,503 / 17,521,432 | | |
| 203 | + | | 1x4-active_output-udp_baseline | 300 | 300 | 17,065,232 / 17,715,522 | 17,032,283 / 17,729,992 | | |
| 204 | + | | 1x4-active_output-udp_25ms | 290 | 290 | 43,067,851 / 69,676,961 | 43,086,281 / 69,753,290 | | |
| 205 | + | | 1x4-all_output-local | 300 | 300 | 16,977,662 / 17,352,912 | 16,958,053 / 17,326,013 | | |
| 206 | + | | 1x4-all_output-ssh_stdio | 300 | 300 | 17,250,583 / 17,541,982 | 17,284,843 / 17,643,873 | | |
| 207 | + | | 1x4-all_output-udp_baseline | 300 | 300 | 19,826,321 / 20,920,741 | 20,017,971 / 20,717,372 | | |
| 208 | + | | 1x4-all_output-udp_25ms | 290 | 290 | 48,814,889 / 70,035,730 | 48,740,189 / 70,268,490 | | |
| 209 | + | | 1x4-inactive_output-local | 300 | 300 | 17,278,873 / 17,705,613 | 17,279,933 / 17,690,672 | | |
| 210 | + | | 1x4-inactive_output-ssh_stdio | 300 | 300 | 17,140,253 / 17,731,882 | 17,142,882 / 17,781,913 | | |
| 211 | + | | 1x4-inactive_output-udp_baseline | 300 | 300 | 21,030,321 / 23,221,780 | 20,795,161 / 23,274,900 | | |
| 212 | + | | 1x4-inactive_output-udp_25ms | 280 | 280 | 89,857,641 / 104,327,126 | 89,843,102 / 102,844,306 | | |
| 213 | + | | 1x4-attach_restore_tab_switch-local | 300 | 300 | 7,812,797 / 8,501,746 | 7,725,516 / 8,593,246 | | |
| 214 | + | ||
| 215 | + | `1x4-active_output-ssh_stdio` now completes for both binaries, so the former | |
| 216 | + | trace-correlation blocker is fixed. The remaining failed scenarios are | |
| 217 | + | retained as stderr evidence: all five `interactive_flood`, all five | |
| 218 | + | `resize_sweep`, all five `snapshot_output_resize`, the four non-local | |
| 219 | + | `attach_restore_tab_switch` transports, and the three `udp_loss_1pct` | |
| 220 | + | output scenarios. They report terminal-start/readiness failures or missing | |
| 221 | + | receive span pairs; they are not converted into samples. | |
| 222 | + | ||
| 223 | + | The balanced gate is **blocked, not passed**: only 13/35 scenarios have valid | |
| 224 | + | A/B summaries, so the 5% gate and adoption cannot be declared. The completed | |
| 225 | + | common p50 values happened to stay within ±1.12%, but that is not a gate | |
| 226 | + | result and does not substitute for the 22 missing scenarios. | |
| 227 | + | ||
| 228 | + | `summary.json` provenance was checked for every completed result: | |
| 229 | + | `git_sha=d479fe99581cf1988efba7906914c9ddd8653d7d`, with | |
| 230 | + | `vev_git_sha=1f2c802d0707785cab979134db6a95645250a82c` for baseline and | |
| 231 | + | `vev_git_sha=2b48abf9b481c3dbdc371ba13c15cf06051d9df0` for candidate. The | |
| 232 | + | complete 252-scenario canonical matrix was not run because the representative | |
| 233 | + | gate was incomplete. Its exact command remains: | |
| 111 | 234 | ||
| 112 | 235 | ```sh | |
| 113 | - | rm -rf /tmp/vev-proxy-matrix | |
| 114 | - | /tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \ | |
| 115 | - | --manifest testdata/perf/manifest.json --out /tmp/vev-proxy-matrix \ | |
| 236 | + | rm -rf /tmp/vev-perf-results/public-full-baseline | |
| 237 | + | /tmp/vev-perf-harness-d479fe9 --vev-bin /tmp/vev-proxy-baseline-1f2c802 \ | |
| 238 | + | --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \ | |
| 239 | + | --out /tmp/vev-perf-results/public-full-baseline \ | |
| 116 | 240 | --warmup 10s --duration 30s --repetitions 10 | |
| 117 | 241 | ``` | |
| 118 | 242 | ||
| @@ -124,14 +248,16 @@ contain `manifest.json`, `raw-harness.jsonl`, per-process JSONL, `runs.json`, an | |||
| 124 | 248 | `summary.json`. | |
| 125 | 249 | ||
| 126 | 250 | `summary.json` records the harness source as `git_sha`, the measured vev binary | |
| 127 | - | source as `vev_git_sha`, and the measurement parameters; | |
| 128 | - | `runs.json`, run manifests, and JSONL identify scenario, run, process, component, | |
| 129 | - | and correlation IDs. Preserve those files with the benchmark output and record | |
| 130 | - | `go version -m /tmp/vev-proxy-baseline`; do not commit result directories. CPU | |
| 131 | - | and allocation evidence comes from Go microbenchmarks and process-local span | |
| 132 | - | durations. End-to-end latency, terminal flush, and byte evidence comes from the | |
| 133 | - | public-CLI harness. Reset, snapshot, and span counters for structured output are | |
| 134 | - | candidate-only until that output exists. | |
| 251 | + | source as `vev_git_sha`, and the measurement parameters. `runs.json`, run | |
| 252 | + | manifests, and JSONL identify scenario, run, process, component, and correlation | |
| 253 | + | IDs. Preserve those files with the benchmark output and record | |
| 254 | + | `go version -m /tmp/vev-proxy-baseline-1f2c802`, | |
| 255 | + | `go version -m /tmp/vev-proxy-candidate-2b48abf9`, and | |
| 256 | + | `go version -m /tmp/vev-perf-harness-d479fe9`; do not commit result directories. | |
| 257 | + | CPU and allocation evidence comes from Go microbenchmarks and process-local | |
| 258 | + | span durations. End-to-end latency, terminal flush, and byte evidence comes | |
| 259 | + | from the public-CLI harness. Reset, snapshot, and span counters for structured | |
| 260 | + | output are candidate-only until that output exists. | |
| 135 | 261 | ||
| 136 | 262 | Reject results with missing input/flush boundaries, unmatched IDs, bad | |
| 137 | 263 | correlation, nonmonotonic same-process sequence, negative same-domain spans, | |
M internal/adapters/sshstdio/transport.go +3 -2
| @@ -21,6 +21,7 @@ import ( | |||
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | const ( | |
| 24 | + | maxFrameLen = 16 << 20 | |
| 24 | 25 | frameHeaderLen = 4 | |
| 25 | 26 | sshCloseTimeout = 3 * time.Second | |
| 26 | 27 | ) | |
| @@ -77,7 +78,7 @@ type transport struct { | |||
| 77 | 78 | func (t *transport) Send(f ports.Frame) error { | |
| 78 | 79 | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |
| 79 | 80 | n := 1 + len(f.Payload) | |
| 80 | - | if n > ports.MaxFrameLen { | |
| 81 | + | if n > maxFrameLen { | |
| 81 | 82 | end(false) | |
| 82 | 83 | return ErrFrameTooLarge | |
| 83 | 84 | } | |
| @@ -108,7 +109,7 @@ func (t *transport) Recv() (ports.Frame, error) { | |||
| 108 | 109 | end(false) | |
| 109 | 110 | return ports.Frame{}, ErrZeroLengthFrame | |
| 110 | 111 | } | |
| 111 | - | if n > ports.MaxFrameLen { | |
| 112 | + | if n > maxFrameLen { | |
| 112 | 113 | end(false) | |
| 113 | 114 | return ports.Frame{}, ErrFrameTooLarge | |
| 114 | 115 | } | |
M internal/adapters/sshstdio/transport_test.go +0 -35
| @@ -3,7 +3,6 @@ package sshstdio | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | - | "encoding/binary" | |
| 7 | 6 | "errors" | |
| 8 | 7 | "io" | |
| 9 | 8 | "log/slog" | |
| @@ -165,40 +164,6 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) { | |||
| 165 | 164 | } | |
| 166 | 165 | } | |
| 167 | 166 | ||
| 168 | - | func TestTransportRejectsFramesOverSharedMaximum(t *testing.T) { | |
| 169 | - | boundaryPayload := make([]byte, ports.MaxFrameLen-1) | |
| 170 | - | boundaryWire := &bytes.Buffer{} | |
| 171 | - | boundarySend := NewTransport(nil, boundaryWire, nil) | |
| 172 | - | if err := boundarySend.Send(ports.Frame{Type: ports.MsgOutput, Payload: boundaryPayload}); err != nil { | |
| 173 | - | t.Fatalf("boundary Send error = %v", err) | |
| 174 | - | } | |
| 175 | - | if got := binary.BigEndian.Uint32(boundaryWire.Bytes()[:frameHeaderLen]); got != ports.MaxFrameLen { | |
| 176 | - | t.Fatalf("boundary frame length = %d, want %d", got, ports.MaxFrameLen) | |
| 177 | - | } | |
| 178 | - | boundaryRecv := NewTransport(bytes.NewReader(boundaryWire.Bytes()), io.Discard, nil) | |
| 179 | - | boundaryFrame, boundaryErr := boundaryRecv.Recv() | |
| 180 | - | if boundaryErr != nil { | |
| 181 | - | t.Fatalf("boundary Recv error = %v", boundaryErr) | |
| 182 | - | } | |
| 183 | - | if boundaryFrame.Type != ports.MsgOutput || len(boundaryFrame.Payload) != len(boundaryPayload) { | |
| 184 | - | t.Fatalf("boundary frame = type %d, payload %d bytes; want type %d, payload %d bytes", boundaryFrame.Type, len(boundaryFrame.Payload), ports.MsgOutput, len(boundaryPayload)) | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | send := NewTransport(nil, io.Discard, nil) | |
| 188 | - | err := send.Send(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, ports.MaxFrameLen)}) | |
| 189 | - | if !errors.Is(err, ErrFrameTooLarge) { | |
| 190 | - | t.Fatalf("Send error = %v, want ErrFrameTooLarge", err) | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | var header [frameHeaderLen]byte | |
| 194 | - | binary.BigEndian.PutUint32(header[:], ports.MaxFrameLen+1) | |
| 195 | - | recv := NewTransport(bytes.NewReader(header[:]), io.Discard, nil) | |
| 196 | - | _, err = recv.Recv() | |
| 197 | - | if !errors.Is(err, ErrFrameTooLarge) { | |
| 198 | - | t.Fatalf("Recv error = %v, want ErrFrameTooLarge", err) | |
| 199 | - | } | |
| 200 | - | } | |
| 201 | - | ||
| 202 | 167 | func TestDialContextCanceledBeforeStart(t *testing.T) { | |
| 203 | 168 | ctx, cancel := context.WithCancel(context.Background()) | |
| 204 | 169 | cancel() | |
M internal/app/spawn.go +16 -0
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "os" | |
| 10 | 10 | "os/exec" | |
| 11 | 11 | "path/filepath" | |
| 12 | + | "strings" | |
| 12 | 13 | "syscall" | |
| 13 | 14 | "time" | |
| 14 | 15 | ||
| @@ -269,6 +270,7 @@ func realSpawn() error { | |||
| 269 | 270 | ||
| 270 | 271 | var stderr bytes.Buffer | |
| 271 | 272 | cmd := exec.Command(exePath, "--daemon-launcher") | |
| 273 | + | cmd.Env = withoutPerformanceTraceEnv(os.Environ()) | |
| 272 | 274 | cmd.Dir = platform.DirOrHome("") | |
| 273 | 275 | cmd.Stdin = devNull | |
| 274 | 276 | cmd.Stdout = devNull | |
| @@ -283,6 +285,19 @@ func realSpawn() error { | |||
| 283 | 285 | return nil | |
| 284 | 286 | } | |
| 285 | 287 | ||
| 288 | + | func withoutPerformanceTraceEnv(env []string) []string { | |
| 289 | + | filtered := make([]string, 0, len(env)) | |
| 290 | + | for _, entry := range env { | |
| 291 | + | name, _, _ := strings.Cut(entry, "=") | |
| 292 | + | switch name { | |
| 293 | + | case "VEV_PERF_TRACE", "VEV_PERF_PROCESS_ID", "VEV_PERF_SCENARIO", "VEV_PERF_RUN": | |
| 294 | + | continue | |
| 295 | + | } | |
| 296 | + | filtered = append(filtered, entry) | |
| 297 | + | } | |
| 298 | + | return filtered | |
| 299 | + | } | |
| 300 | + | ||
| 286 | 301 | // runDaemonLauncher is the intermediate half of the double-fork. It starts | |
| 287 | 302 | // the long-lived daemon in a new session and exits immediately; realSpawn | |
| 288 | 303 | // waits for this exit so the daemon is no longer descended from the client. | |
| @@ -299,6 +314,7 @@ func runDaemonLauncher() error { | |||
| 299 | 314 | defer func() { _ = devNull.Close() }() | |
| 300 | 315 | ||
| 301 | 316 | cmd := exec.Command(exePath, "--daemon") | |
| 317 | + | cmd.Env = withoutPerformanceTraceEnv(os.Environ()) | |
| 302 | 318 | cmd.Dir = platform.DirOrHome("") | |
| 303 | 319 | cmd.Stdin = devNull | |
| 304 | 320 | cmd.Stdout = devNull | |
M internal/app/spawn_test.go +17 -1
| @@ -23,6 +23,7 @@ const ( | |||
| 23 | 23 | spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE" | |
| 24 | 24 | spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE" | |
| 25 | 25 | spawnTestReleaseFileEnv = "VEV_SPAWN_TEST_RELEASE_FILE" | |
| 26 | + | spawnTestTraceFileEnv = "VEV_SPAWN_TEST_TRACE_FILE" | |
| 26 | 27 | ) | |
| 27 | 28 | ||
| 28 | 29 | func TestMain(m *testing.M) { | |
| @@ -55,6 +56,11 @@ func TestMain(m *testing.M) { | |||
| 55 | 56 | if path == "" { | |
| 56 | 57 | os.Exit(2) | |
| 57 | 58 | } | |
| 59 | + | if tracePath := os.Getenv(spawnTestTraceFileEnv); tracePath != "" { | |
| 60 | + | if err := os.WriteFile(tracePath, []byte(os.Getenv("VEV_PERF_TRACE")), 0o600); err != nil { | |
| 61 | + | os.Exit(2) | |
| 62 | + | } | |
| 63 | + | } | |
| 58 | 64 | if err := writeProcessRecord(path); err != nil { | |
| 59 | 65 | os.Exit(2) | |
| 60 | 66 | } | |
| @@ -66,14 +72,17 @@ func TestMain(m *testing.M) { | |||
| 66 | 72 | os.Exit(m.Run()) | |
| 67 | 73 | } | |
| 68 | 74 | ||
| 69 | - | func TestRealSpawnWaitsForLauncherExit(t *testing.T) { | |
| 75 | + | func TestRealSpawnWaitsForLauncherExitAndDoesNotPropagateTrace(t *testing.T) { | |
| 70 | 76 | dir := t.TempDir() | |
| 71 | 77 | childFile := filepath.Join(dir, "daemon.pid") | |
| 72 | 78 | launcherFile := filepath.Join(dir, "launcher.pid") | |
| 73 | 79 | releaseFile := filepath.Join(dir, "release-launcher") | |
| 80 | + | traceFile := filepath.Join(dir, "daemon.trace-env") | |
| 74 | 81 | t.Setenv(spawnTestChildFileEnv, childFile) | |
| 75 | 82 | t.Setenv(spawnTestLauncherFileEnv, launcherFile) | |
| 76 | 83 | t.Setenv(spawnTestReleaseFileEnv, releaseFile) | |
| 84 | + | t.Setenv(spawnTestTraceFileEnv, traceFile) | |
| 85 | + | t.Setenv("VEV_PERF_TRACE", "parent-trace.jsonl") | |
| 77 | 86 | ||
| 78 | 87 | // Register cleanup before starting either subprocess. The release marker | |
| 79 | 88 | // unblocks a launcher even when setup or an assertion fails, and PID files | |
| @@ -122,6 +131,13 @@ func TestRealSpawnWaitsForLauncherExit(t *testing.T) { | |||
| 122 | 131 | if err := syscall.Kill(daemonPID, 0); err != nil { | |
| 123 | 132 | t.Fatalf("daemon exited with its launcher: %v", err) | |
| 124 | 133 | } | |
| 134 | + | traceEnv, err := os.ReadFile(traceFile) | |
| 135 | + | if err != nil { | |
| 136 | + | t.Fatalf("read daemon trace environment: %v", err) | |
| 137 | + | } | |
| 138 | + | if got := string(traceEnv); got != "" { | |
| 139 | + | t.Fatalf("daemon inherited VEV_PERF_TRACE=%q", got) | |
| 140 | + | } | |
| 125 | 141 | } | |
| 126 | 142 | ||
| 127 | 143 | func waitForProcessRecord(t *testing.T, path string) int { | |
M internal/ports/runtime_observer.go +26 -22
| @@ -14,27 +14,30 @@ const RuntimeMarkSchema uint16 = 1 | |||
| 14 | 14 | type RuntimeMarkKind string | |
| 15 | 15 | ||
| 16 | 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" | |
| 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 | + | RuntimeScreenSnapshot RuntimeMarkKind = "screen_snapshot" | |
| 38 | + | RuntimeScreenDelta RuntimeMarkKind = "screen_delta" | |
| 39 | + | RuntimeScreenResetRequested RuntimeMarkKind = "screen_reset_requested" | |
| 40 | + | RuntimeTransportDiagnostic RuntimeMarkKind = "transport_diagnostic" | |
| 38 | 41 | ) | |
| 39 | 42 | ||
| 40 | 43 | // RuntimeMark is a process-local observation record. Tick is intentionally | |
| @@ -221,7 +224,8 @@ func RuntimeMarkValid(mark RuntimeMark) bool { | |||
| 221 | 224 | RuntimeDiffStart, RuntimeDiffEnd, RuntimeQueueEnqueued, RuntimeQueueDequeued, | |
| 222 | 225 | RuntimeACKBlockedStart, RuntimeACKBlockedEnd, RuntimeEmitStart, RuntimeEmitEnd, | |
| 223 | 226 | RuntimeAdapterSendStart, RuntimeAdapterSendEnd, RuntimeAdapterReceiveStart, | |
| 224 | - | RuntimeAdapterReceiveEnd, RuntimeTerminalFlushed, RuntimeTransportDiagnostic: | |
| 227 | + | RuntimeAdapterReceiveEnd, RuntimeTerminalFlushed, RuntimeScreenSnapshot, | |
| 228 | + | RuntimeScreenDelta, RuntimeScreenResetRequested, RuntimeTransportDiagnostic: | |
| 225 | 229 | return true | |
| 226 | 230 | default: | |
| 227 | 231 | return false | |
M internal/ports/runtime_observer_test.go +22 -0
| @@ -108,6 +108,28 @@ func TestSerializedRuntimeObserverReportsBoundedQueueLoss(t *testing.T) { | |||
| 108 | 108 | } | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | + | func TestP4RuntimeScreenMarkKindsValidateSchema1(t *testing.T) { | |
| 112 | + | if RuntimeMarkSchema != 1 { | |
| 113 | + | t.Fatalf("RuntimeMarkSchema = %d, want 1", RuntimeMarkSchema) | |
| 114 | + | } | |
| 115 | + | for _, tc := range []struct { | |
| 116 | + | kind RuntimeMarkKind | |
| 117 | + | want string | |
| 118 | + | }{ | |
| 119 | + | {RuntimeScreenSnapshot, "screen_snapshot"}, | |
| 120 | + | {RuntimeScreenDelta, "screen_delta"}, | |
| 121 | + | {RuntimeScreenResetRequested, "screen_reset_requested"}, | |
| 122 | + | } { | |
| 123 | + | if string(tc.kind) != tc.want { | |
| 124 | + | t.Fatalf("screen mark kind = %q, want %q", tc.kind, tc.want) | |
| 125 | + | } | |
| 126 | + | mark := NewRuntimeMark("daemon", tc.kind, 17, true) | |
| 127 | + | if !RuntimeMarkValid(mark) { | |
| 128 | + | t.Fatalf("screen mark kind %q did not validate: %+v", tc.kind, mark) | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | ||
| 111 | 133 | func TestRuntimeObserverRequiredSpanKinds(t *testing.T) { | |
| 112 | 134 | pairs := []struct { | |
| 113 | 135 | start RuntimeMarkKind | |
M internal/ports/wire.go +0 -753
| @@ -10,7 +10,6 @@ import ( | |||
| 10 | 10 | "encoding/binary" | |
| 11 | 11 | "errors" | |
| 12 | 12 | "math" | |
| 13 | - | "unicode/utf8" | |
| 14 | 13 | ||
| 15 | 14 | "github.com/bnema/vev/internal/domain" | |
| 16 | 15 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -1076,755 +1075,3 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||
| 1076 | 1075 | } | |
| 1077 | 1076 | return Sessions{Sessions: sessions}, nil | |
| 1078 | 1077 | } | |
| 1079 | - | ||
| 1080 | - | // ScreenUpdateKind selects whether an update replaces the complete screen or | |
| 1081 | - | // applies a delta to the previously committed screen. | |
| 1082 | - | type ScreenUpdateKind uint8 | |
| 1083 | - | ||
| 1084 | - | const ( | |
| 1085 | - | ScreenUpdateSnapshot ScreenUpdateKind = 1 | |
| 1086 | - | ScreenUpdateDelta ScreenUpdateKind = 2 | |
| 1087 | - | ) | |
| 1088 | - | ||
| 1089 | - | // ScreenCursor is the absolute cursor state carried by each screen update. | |
| 1090 | - | type ScreenCursor struct { | |
| 1091 | - | Row, Col uint16 | |
| 1092 | - | Style uint8 | |
| 1093 | - | Visible bool | |
| 1094 | - | StyleSet bool | |
| 1095 | - | } | |
| 1096 | - | ||
| 1097 | - | // ScreenScroll describes a full-width upward scroll applied before spans. | |
| 1098 | - | type ScreenScroll struct { | |
| 1099 | - | Top, Height, Count uint16 | |
| 1100 | - | } | |
| 1101 | - | ||
| 1102 | - | // ScreenSpan identifies owned cells beginning at a horizontal position. | |
| 1103 | - | type ScreenSpan struct { | |
| 1104 | - | Y, X uint16 | |
| 1105 | - | Cells []renderer.Cell | |
| 1106 | - | } | |
| 1107 | - | ||
| 1108 | - | // ScreenUpdate is the semantic structured screen message exchanged by proxy | |
| 1109 | - | // daemons. Wire style tables and run IDs remain private to its codec. | |
| 1110 | - | type ScreenUpdate struct { | |
| 1111 | - | BaseStateNum uint64 | |
| 1112 | - | NewStateNum uint64 | |
| 1113 | - | EchoAck uint64 | |
| 1114 | - | Kind ScreenUpdateKind | |
| 1115 | - | Size domain.Size | |
| 1116 | - | Cursor ScreenCursor | |
| 1117 | - | Scroll *ScreenScroll | |
| 1118 | - | Spans []ScreenSpan | |
| 1119 | - | } | |
| 1120 | - | ||
| 1121 | - | var ( | |
| 1122 | - | // ErrInvalidScreenUpdate reports a malformed or non-canonical screen | |
| 1123 | - | // update. It is deliberately shared by marshal and unmarshal validation. | |
| 1124 | - | ErrInvalidScreenUpdate = errors.New("ports: invalid screen update") | |
| 1125 | - | // ErrScreenUpdateTooLarge reports a payload that cannot fit in a frame. | |
| 1126 | - | ErrScreenUpdateTooLarge = errors.New("ports: screen update too large") | |
| 1127 | - | ) | |
| 1128 | - | ||
| 1129 | - | const ( | |
| 1130 | - | // MaxScreenCells bounds the area of a screen update. | |
| 1131 | - | MaxScreenCells = 1 << 18 | |
| 1132 | - | ||
| 1133 | - | screenHeaderLen = 40 | |
| 1134 | - | screenStyleLen = 18 | |
| 1135 | - | // MaxScreenSpans bounds the number of spans in a screen update. | |
| 1136 | - | MaxScreenSpans = 4096 | |
| 1137 | - | ||
| 1138 | - | // The frame length includes MsgScreenUpdate's type byte. The codec takes | |
| 1139 | - | // only the payload, so one byte is reserved here. | |
| 1140 | - | screenPayloadLimit = MaxFrameLen - 1 | |
| 1141 | - | ||
| 1142 | - | screenFlagCursorVisible = 1 << 0 | |
| 1143 | - | screenFlagCursorStyleSet = 1 << 1 | |
| 1144 | - | screenKnownCursorFlags = screenFlagCursorVisible | screenFlagCursorStyleSet | |
| 1145 | - | ||
| 1146 | - | styleBoldBit = 1 << 0 | |
| 1147 | - | styleItalicBit = 1 << 1 | |
| 1148 | - | styleInverseBit = 1 << 2 | |
| 1149 | - | styleDimBit = 1 << 3 | |
| 1150 | - | styleUnderlineBit = 1 << 4 | |
| 1151 | - | styleBlinkBit = 1 << 5 | |
| 1152 | - | styleStrikethroughBit = 1 << 6 | |
| 1153 | - | styleForegroundRGBBit = 1 << 7 | |
| 1154 | - | styleBackgroundRGBBit = 1 << 8 | |
| 1155 | - | styleUnderlineColorBit = 1 << 9 | |
| 1156 | - | styleUnderlineColorRGBBit = 1 << 10 | |
| 1157 | - | styleKnownBits = (1 << 11) - 1 | |
| 1158 | - | ) | |
| 1159 | - | ||
| 1160 | - | // MarshalScreenUpdate encodes a canonical structured screen update. | |
| 1161 | - | func MarshalScreenUpdate(m ScreenUpdate) ([]byte, error) { | |
| 1162 | - | if err := validateScreenUpdate(m); err != nil { | |
| 1163 | - | return nil, err | |
| 1164 | - | } | |
| 1165 | - | ||
| 1166 | - | styles := make([]renderer.Style, 0) | |
| 1167 | - | styleIDs := make(map[renderer.Style]uint16) | |
| 1168 | - | for _, span := range m.Spans { | |
| 1169 | - | for _, cell := range span.Cells { | |
| 1170 | - | style := canonicalScreenStyle(cell.Style) | |
| 1171 | - | if _, ok := styleIDs[style]; ok { | |
| 1172 | - | continue | |
| 1173 | - | } | |
| 1174 | - | if len(styles) == math.MaxUint16 { | |
| 1175 | - | return nil, ErrInvalidScreenUpdate | |
| 1176 | - | } | |
| 1177 | - | styleIDs[style] = uint16(len(styles)) | |
| 1178 | - | styles = append(styles, style) | |
| 1179 | - | } | |
| 1180 | - | } | |
| 1181 | - | w := screenWriter{b: make([]byte, 0, screenHeaderLen+len(styles)*screenStyleLen)} | |
| 1182 | - | w.u64(m.BaseStateNum) | |
| 1183 | - | w.u64(m.NewStateNum) | |
| 1184 | - | w.u64(m.EchoAck) | |
| 1185 | - | w.u8(uint8(m.Kind)) | |
| 1186 | - | w.u16(uint16(m.Size.Cols)) | |
| 1187 | - | w.u16(uint16(m.Size.Rows)) | |
| 1188 | - | w.u16(m.Cursor.Row) | |
| 1189 | - | w.u16(m.Cursor.Col) | |
| 1190 | - | w.u8(m.Cursor.Style) | |
| 1191 | - | var cursorFlags uint8 | |
| 1192 | - | if m.Cursor.Visible { | |
| 1193 | - | cursorFlags |= screenFlagCursorVisible | |
| 1194 | - | } | |
| 1195 | - | if m.Cursor.StyleSet { | |
| 1196 | - | cursorFlags |= screenFlagCursorStyleSet | |
| 1197 | - | } | |
| 1198 | - | w.u8(cursorFlags) | |
| 1199 | - | if m.Scroll != nil { | |
| 1200 | - | w.u8(1) | |
| 1201 | - | } else { | |
| 1202 | - | w.u8(0) | |
| 1203 | - | } | |
| 1204 | - | w.u16(uint16(len(styles))) | |
| 1205 | - | w.u16(uint16(len(m.Spans))) | |
| 1206 | - | if m.Scroll != nil { | |
| 1207 | - | w.u16(m.Scroll.Top) | |
| 1208 | - | w.u16(m.Scroll.Height) | |
| 1209 | - | w.u16(m.Scroll.Count) | |
| 1210 | - | } | |
| 1211 | - | for _, style := range styles { | |
| 1212 | - | appendScreenStyle(&w, style) | |
| 1213 | - | } | |
| 1214 | - | for _, span := range m.Spans { | |
| 1215 | - | w.u16(span.Y) | |
| 1216 | - | w.u16(span.X) | |
| 1217 | - | w.u16(uint16(len(span.Cells))) | |
| 1218 | - | runs := screenRunCount(span.Cells) | |
| 1219 | - | w.u16(uint16(runs)) | |
| 1220 | - | for start := 0; start < len(span.Cells); { | |
| 1221 | - | style := canonicalScreenStyle(span.Cells[start].Style) | |
| 1222 | - | id := styleIDs[style] | |
| 1223 | - | end := start + 1 | |
| 1224 | - | for end < len(span.Cells) && styleIDs[canonicalScreenStyle(span.Cells[end].Style)] == id { | |
| 1225 | - | end++ | |
| 1226 | - | } | |
| 1227 | - | w.u16(id) | |
| 1228 | - | w.u16(uint16(end - start)) | |
| 1229 | - | for _, cell := range span.Cells[start:end] { | |
| 1230 | - | if cell.Continuation { | |
| 1231 | - | w.uvarint(0) | |
| 1232 | - | } else { | |
| 1233 | - | w.uvarint(uint64(cell.Rune) + 1) | |
| 1234 | - | } | |
| 1235 | - | } | |
| 1236 | - | start = end | |
| 1237 | - | } | |
| 1238 | - | } | |
| 1239 | - | if w.tooLarge { | |
| 1240 | - | return nil, ErrScreenUpdateTooLarge | |
| 1241 | - | } | |
| 1242 | - | return w.b, nil | |
| 1243 | - | } | |
| 1244 | - | ||
| 1245 | - | // UnmarshalScreenUpdate strictly decodes one screen-update payload. All | |
| 1246 | - | // decoded slices own their data; no input-backed slice is retained. | |
| 1247 | - | func UnmarshalScreenUpdate(data []byte) (ScreenUpdate, error) { | |
| 1248 | - | if len(data) > screenPayloadLimit { | |
| 1249 | - | return ScreenUpdate{}, ErrScreenUpdateTooLarge | |
| 1250 | - | } | |
| 1251 | - | if len(data) < screenHeaderLen { | |
| 1252 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1253 | - | } | |
| 1254 | - | r := screenReader{b: data} | |
| 1255 | - | var m ScreenUpdate | |
| 1256 | - | var err error | |
| 1257 | - | if m.BaseStateNum, err = r.u64(); err != nil { | |
| 1258 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1259 | - | } | |
| 1260 | - | if m.NewStateNum, err = r.u64(); err != nil { | |
| 1261 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1262 | - | } | |
| 1263 | - | if m.EchoAck, err = r.u64(); err != nil { | |
| 1264 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1265 | - | } | |
| 1266 | - | kind, err := r.u8() | |
| 1267 | - | if err != nil || (ScreenUpdateKind(kind) != ScreenUpdateSnapshot && ScreenUpdateKind(kind) != ScreenUpdateDelta) { | |
| 1268 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1269 | - | } | |
| 1270 | - | m.Kind = ScreenUpdateKind(kind) | |
| 1271 | - | cols, err := r.u16() | |
| 1272 | - | if err != nil { | |
| 1273 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1274 | - | } | |
| 1275 | - | rows, err := r.u16() | |
| 1276 | - | if err != nil || cols == 0 || rows == 0 || !screenAreaWithinLimit(domain.Size{Cols: int(cols), Rows: int(rows)}) { | |
| 1277 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1278 | - | } | |
| 1279 | - | m.Size = domain.Size{Cols: int(cols), Rows: int(rows)} | |
| 1280 | - | if m.Cursor.Row, err = r.u16(); err != nil { | |
| 1281 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1282 | - | } | |
| 1283 | - | if m.Cursor.Col, err = r.u16(); err != nil { | |
| 1284 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1285 | - | } | |
| 1286 | - | if m.Cursor.Style, err = r.u8(); err != nil { | |
| 1287 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1288 | - | } | |
| 1289 | - | cursorFlags, err := r.u8() | |
| 1290 | - | if err != nil || cursorFlags&^uint8(screenKnownCursorFlags) != 0 || m.Cursor.Row >= rows || m.Cursor.Col >= cols { | |
| 1291 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1292 | - | } | |
| 1293 | - | m.Cursor.Visible = cursorFlags&screenFlagCursorVisible != 0 | |
| 1294 | - | m.Cursor.StyleSet = cursorFlags&screenFlagCursorStyleSet != 0 | |
| 1295 | - | if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) { | |
| 1296 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1297 | - | } | |
| 1298 | - | scrollPresent, err := r.u8() | |
| 1299 | - | if err != nil || scrollPresent > 1 { | |
| 1300 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1301 | - | } | |
| 1302 | - | styleCount, err := r.u16() | |
| 1303 | - | if err != nil || uint64(styleCount) > uint64(len(r.b))/screenStyleLen { | |
| 1304 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1305 | - | } | |
| 1306 | - | spanCount, err := r.u16() | |
| 1307 | - | if err != nil || spanCount > MaxScreenSpans || uint64(spanCount) > uint64(len(r.b))/8 { | |
| 1308 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1309 | - | } | |
| 1310 | - | if scrollPresent != 0 { | |
| 1311 | - | if m.Kind != ScreenUpdateDelta || len(r.b) < 6 { | |
| 1312 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1313 | - | } | |
| 1314 | - | m.Scroll = new(ScreenScroll) | |
| 1315 | - | if m.Scroll.Top, err = r.u16(); err != nil { | |
| 1316 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1317 | - | } | |
| 1318 | - | if m.Scroll.Height, err = r.u16(); err != nil { | |
| 1319 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1320 | - | } | |
| 1321 | - | if m.Scroll.Count, err = r.u16(); err != nil { | |
| 1322 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1323 | - | } | |
| 1324 | - | if !validScreenScroll(*m.Scroll, rows) { | |
| 1325 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1326 | - | } | |
| 1327 | - | } else { | |
| 1328 | - | m.Scroll = nil | |
| 1329 | - | } | |
| 1330 | - | // styleCount was checked against the bytes still available before this | |
| 1331 | - | // allocation. Each style is copied field-by-field below. | |
| 1332 | - | styles := make([]renderer.Style, styleCount) | |
| 1333 | - | seenStyles := make(map[renderer.Style]struct{}, styleCount) | |
| 1334 | - | for i := range styles { | |
| 1335 | - | style, ok := readScreenStyle(&r) | |
| 1336 | - | if !ok { | |
| 1337 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1338 | - | } | |
| 1339 | - | if _, duplicate := seenStyles[style]; duplicate { | |
| 1340 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1341 | - | } | |
| 1342 | - | seenStyles[style] = struct{}{} | |
| 1343 | - | styles[i] = style | |
| 1344 | - | } | |
| 1345 | - | m.Spans = make([]ScreenSpan, spanCount) | |
| 1346 | - | usedStyles := make([]bool, styleCount) | |
| 1347 | - | nextStyleID := uint16(0) | |
| 1348 | - | var previousEnd uint32 | |
| 1349 | - | var previousY uint16 | |
| 1350 | - | for i := range m.Spans { | |
| 1351 | - | y, errY := r.u16() | |
| 1352 | - | x, errX := r.u16() | |
| 1353 | - | cellCount, errCells := r.u16() | |
| 1354 | - | runCount, errRuns := r.u16() | |
| 1355 | - | if errY != nil || errX != nil || errCells != nil || errRuns != nil || cellCount == 0 || runCount == 0 || runCount > cellCount || uint64(runCount) > uint64(len(r.b))/4 { | |
| 1356 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1357 | - | } | |
| 1358 | - | if y >= rows || x >= cols || uint64(x)+uint64(cellCount) > uint64(cols) { | |
| 1359 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1360 | - | } | |
| 1361 | - | if i > 0 { | |
| 1362 | - | if y < previousY || (y == previousY && uint32(x) < previousEnd) { | |
| 1363 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1364 | - | } | |
| 1365 | - | } | |
| 1366 | - | // Ordered non-overlapping spans have at most one cell per screen cell, | |
| 1367 | - | // so the area check above bounds aggregate decoded cells before each | |
| 1368 | - | // per-span allocation. Every cell has at least one token and every run | |
| 1369 | - | // has a four-byte descriptor; check both before allocating the slice. | |
| 1370 | - | minimum := uint64(runCount)*4 + uint64(cellCount) | |
| 1371 | - | if minimum > uint64(len(r.b)) { | |
| 1372 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1373 | - | } | |
| 1374 | - | cells := make([]renderer.Cell, cellCount) | |
| 1375 | - | filled := 0 | |
| 1376 | - | previousStyleID := uint16(math.MaxUint16) | |
| 1377 | - | for range runCount { | |
| 1378 | - | styleID, e1 := r.u16() | |
| 1379 | - | runCells, e2 := r.u16() | |
| 1380 | - | if e1 != nil || e2 != nil || runCells == 0 || styleID >= styleCount || styleID == previousStyleID || uint32(filled)+uint32(runCells) > uint32(cellCount) { | |
| 1381 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1382 | - | } | |
| 1383 | - | if !usedStyles[styleID] { | |
| 1384 | - | if styleID != nextStyleID { | |
| 1385 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1386 | - | } | |
| 1387 | - | nextStyleID++ | |
| 1388 | - | usedStyles[styleID] = true | |
| 1389 | - | } | |
| 1390 | - | previousStyleID = styleID | |
| 1391 | - | for range runCells { | |
| 1392 | - | token, ok := r.uvarint() | |
| 1393 | - | if !ok { | |
| 1394 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1395 | - | } | |
| 1396 | - | if token == 0 { | |
| 1397 | - | cells[filled] = renderer.Cell{Style: styles[styleID], Continuation: true} | |
| 1398 | - | } else { | |
| 1399 | - | runeValue, valid := screenRune(token) | |
| 1400 | - | if !valid { | |
| 1401 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1402 | - | } | |
| 1403 | - | cells[filled] = renderer.Cell{Rune: runeValue, Style: styles[styleID]} | |
| 1404 | - | } | |
| 1405 | - | filled++ | |
| 1406 | - | } | |
| 1407 | - | } | |
| 1408 | - | if filled != int(cellCount) { | |
| 1409 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1410 | - | } | |
| 1411 | - | m.Spans[i] = ScreenSpan{Y: y, X: x, Cells: cells} | |
| 1412 | - | previousY = y | |
| 1413 | - | previousEnd = uint32(x) + uint32(cellCount) | |
| 1414 | - | } | |
| 1415 | - | if len(r.b) != 0 || nextStyleID != styleCount { | |
| 1416 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 1417 | - | } | |
| 1418 | - | if err := validateScreenUpdate(m); err != nil { | |
| 1419 | - | return ScreenUpdate{}, err | |
| 1420 | - | } | |
| 1421 | - | return m, nil | |
| 1422 | - | } | |
| 1423 | - | ||
| 1424 | - | // ValidateScreenUpdate validates the shared semantic and canonical screen | |
| 1425 | - | // update contract used by the wire codec and proxy consumers. | |
| 1426 | - | func ValidateScreenUpdate(m ScreenUpdate) error { | |
| 1427 | - | return validateScreenUpdate(m) | |
| 1428 | - | } | |
| 1429 | - | ||
| 1430 | - | func validateScreenUpdate(m ScreenUpdate) error { | |
| 1431 | - | switch m.Kind { | |
| 1432 | - | case ScreenUpdateSnapshot: | |
| 1433 | - | if m.BaseStateNum != 0 || m.NewStateNum == 0 { | |
| 1434 | - | return ErrInvalidScreenUpdate | |
| 1435 | - | } | |
| 1436 | - | case ScreenUpdateDelta: | |
| 1437 | - | if m.BaseStateNum == 0 || m.BaseStateNum == math.MaxUint64 || m.NewStateNum != m.BaseStateNum+1 { | |
| 1438 | - | return ErrInvalidScreenUpdate | |
| 1439 | - | } | |
| 1440 | - | default: | |
| 1441 | - | return ErrInvalidScreenUpdate | |
| 1442 | - | } | |
| 1443 | - | if m.Size.Cols <= 0 || m.Size.Rows <= 0 || m.Size.Cols > math.MaxUint16 || m.Size.Rows > math.MaxUint16 || | |
| 1444 | - | !screenAreaWithinLimit(m.Size) || m.Cursor.Row >= uint16(m.Size.Rows) || m.Cursor.Col >= uint16(m.Size.Cols) { | |
| 1445 | - | return ErrInvalidScreenUpdate | |
| 1446 | - | } | |
| 1447 | - | if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) { | |
| 1448 | - | return ErrInvalidScreenUpdate | |
| 1449 | - | } | |
| 1450 | - | if m.Kind == ScreenUpdateSnapshot && m.Scroll != nil { | |
| 1451 | - | return ErrInvalidScreenUpdate | |
| 1452 | - | } | |
| 1453 | - | if m.Scroll != nil && !validScreenScroll(*m.Scroll, uint16(m.Size.Rows)) { | |
| 1454 | - | return ErrInvalidScreenUpdate | |
| 1455 | - | } | |
| 1456 | - | if len(m.Spans) > MaxScreenSpans || len(m.Spans) > math.MaxUint16 || (m.Kind == ScreenUpdateSnapshot && len(m.Spans) == 0) { | |
| 1457 | - | return ErrInvalidScreenUpdate | |
| 1458 | - | } | |
| 1459 | - | return validateScreenSpans(m) | |
| 1460 | - | } | |
| 1461 | - | ||
| 1462 | - | func screenAreaWithinLimit(size domain.Size) bool { | |
| 1463 | - | return size.Cols > 0 && size.Rows > 0 && size.Rows <= MaxScreenCells/size.Cols | |
| 1464 | - | } | |
| 1465 | - | ||
| 1466 | - | func validScreenScroll(scroll ScreenScroll, rows uint16) bool { | |
| 1467 | - | return scroll.Height != 0 && scroll.Count != 0 && scroll.Count < scroll.Height && | |
| 1468 | - | scroll.Top < rows && uint64(scroll.Top)+uint64(scroll.Height) <= uint64(rows) | |
| 1469 | - | } | |
| 1470 | - | ||
| 1471 | - | func validateScreenSpans(m ScreenUpdate) error { | |
| 1472 | - | cols, rows := uint32(m.Size.Cols), uint32(m.Size.Rows) | |
| 1473 | - | var previousY uint16 | |
| 1474 | - | var previousEnd uint32 | |
| 1475 | - | for i, span := range m.Spans { | |
| 1476 | - | if len(span.Cells) == 0 || len(span.Cells) > math.MaxUint16 || uint32(span.Y) >= rows || uint32(span.X) >= cols || uint64(span.X)+uint64(len(span.Cells)) > uint64(cols) { | |
| 1477 | - | return ErrInvalidScreenUpdate | |
| 1478 | - | } | |
| 1479 | - | if i > 0 && (span.Y < previousY || (span.Y == previousY && uint32(span.X) < previousEnd)) { | |
| 1480 | - | return ErrInvalidScreenUpdate | |
| 1481 | - | } | |
| 1482 | - | for _, cell := range span.Cells { | |
| 1483 | - | if (cell.Continuation && cell.Rune != 0) || (!cell.Continuation && !utf8.ValidRune(cell.Rune)) { | |
| 1484 | - | return ErrInvalidScreenUpdate | |
| 1485 | - | } | |
| 1486 | - | if err := validateScreenStyle(cell.Style); err != nil { | |
| 1487 | - | return err | |
| 1488 | - | } | |
| 1489 | - | } | |
| 1490 | - | previousY = span.Y | |
| 1491 | - | previousEnd = uint32(span.X) + uint32(len(span.Cells)) | |
| 1492 | - | } | |
| 1493 | - | if m.Kind == ScreenUpdateSnapshot { | |
| 1494 | - | if len(m.Spans) != m.Size.Rows { | |
| 1495 | - | return ErrInvalidScreenUpdate | |
| 1496 | - | } | |
| 1497 | - | for y, span := range m.Spans { | |
| 1498 | - | if int(span.Y) != y || span.X != 0 || len(span.Cells) != m.Size.Cols { | |
| 1499 | - | return ErrInvalidScreenUpdate | |
| 1500 | - | } | |
| 1501 | - | } | |
| 1502 | - | } | |
| 1503 | - | return nil | |
| 1504 | - | } | |
| 1505 | - | ||
| 1506 | - | func validateScreenStyle(style renderer.Style) error { | |
| 1507 | - | if style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 || style.UnderlineStyle > renderer.UnderlineDashed { | |
| 1508 | - | return ErrInvalidScreenUpdate | |
| 1509 | - | } | |
| 1510 | - | if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > math.MaxUint8 || style.ForegroundRGB != (renderer.RGB{})) { | |
| 1511 | - | return ErrInvalidScreenUpdate | |
| 1512 | - | } | |
| 1513 | - | if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > math.MaxUint8 || style.BackgroundRGB != (renderer.RGB{})) { | |
| 1514 | - | return ErrInvalidScreenUpdate | |
| 1515 | - | } | |
| 1516 | - | if style.HasUnderlineColorRGB && style.HasUnderlineColor { | |
| 1517 | - | return ErrInvalidScreenUpdate | |
| 1518 | - | } else if style.HasUnderlineColorRGB { | |
| 1519 | - | // The indexed value is inactive when RGB is selected. | |
| 1520 | - | } else if style.HasUnderlineColor { | |
| 1521 | - | if style.UnderlineColor < 0 || style.UnderlineColor > math.MaxUint8 || style.UnderlineColorRGB != (renderer.RGB{}) { | |
| 1522 | - | return ErrInvalidScreenUpdate | |
| 1523 | - | } | |
| 1524 | - | } else if style.UnderlineColorRGB != (renderer.RGB{}) { | |
| 1525 | - | return ErrInvalidScreenUpdate | |
| 1526 | - | } | |
| 1527 | - | return nil | |
| 1528 | - | } | |
| 1529 | - | ||
| 1530 | - | func canonicalScreenStyle(style renderer.Style) renderer.Style { | |
| 1531 | - | if style.HasForegroundRGB { | |
| 1532 | - | style.Foreground = -1 | |
| 1533 | - | } else { | |
| 1534 | - | style.ForegroundRGB = renderer.RGB{} | |
| 1535 | - | } | |
| 1536 | - | if style.HasBackgroundRGB { | |
| 1537 | - | style.Background = -1 | |
| 1538 | - | } else { | |
| 1539 | - | style.BackgroundRGB = renderer.RGB{} | |
| 1540 | - | } | |
| 1541 | - | if style.HasUnderlineColorRGB { | |
| 1542 | - | style.UnderlineColor = -1 | |
| 1543 | - | } else if style.HasUnderlineColor { | |
| 1544 | - | style.UnderlineColorRGB = renderer.RGB{} | |
| 1545 | - | } else { | |
| 1546 | - | style.UnderlineColor = -1 | |
| 1547 | - | style.UnderlineColorRGB = renderer.RGB{} | |
| 1548 | - | } | |
| 1549 | - | return style | |
| 1550 | - | } | |
| 1551 | - | ||
| 1552 | - | func screenStyleBits(style renderer.Style) uint16 { | |
| 1553 | - | var bits uint16 | |
| 1554 | - | if style.Bold { | |
| 1555 | - | bits |= styleBoldBit | |
| 1556 | - | } | |
| 1557 | - | if style.Italic { | |
| 1558 | - | bits |= styleItalicBit | |
| 1559 | - | } | |
| 1560 | - | if style.Inverse { | |
| 1561 | - | bits |= styleInverseBit | |
| 1562 | - | } | |
| 1563 | - | if style.Attrs&renderer.AttrDim != 0 { | |
| 1564 | - | bits |= styleDimBit | |
| 1565 | - | } | |
| 1566 | - | if style.Attrs&renderer.AttrUnderline != 0 { | |
| 1567 | - | bits |= styleUnderlineBit | |
| 1568 | - | } | |
| 1569 | - | if style.Attrs&renderer.AttrBlink != 0 { | |
| 1570 | - | bits |= styleBlinkBit | |
| 1571 | - | } | |
| 1572 | - | if style.Attrs&renderer.AttrStrikethrough != 0 { | |
| 1573 | - | bits |= styleStrikethroughBit | |
| 1574 | - | } | |
| 1575 | - | if style.HasForegroundRGB { | |
| 1576 | - | bits |= styleForegroundRGBBit | |
| 1577 | - | } | |
| 1578 | - | if style.HasBackgroundRGB { | |
| 1579 | - | bits |= styleBackgroundRGBBit | |
| 1580 | - | } | |
| 1581 | - | if style.HasUnderlineColor { | |
| 1582 | - | bits |= styleUnderlineColorBit | |
| 1583 | - | } | |
| 1584 | - | if style.HasUnderlineColorRGB { | |
| 1585 | - | bits |= styleUnderlineColorRGBBit | |
| 1586 | - | } | |
| 1587 | - | return bits | |
| 1588 | - | } | |
| 1589 | - | ||
| 1590 | - | func appendScreenStyle(w *screenWriter, style renderer.Style) { | |
| 1591 | - | style = canonicalScreenStyle(style) | |
| 1592 | - | w.u16(screenStyleBits(style)) | |
| 1593 | - | w.u16(screenColorIndex(style.Foreground)) | |
| 1594 | - | w.u16(screenColorIndex(style.Background)) | |
| 1595 | - | w.u16(screenColorIndex(style.UnderlineColor)) | |
| 1596 | - | w.u8(style.ForegroundRGB.R) | |
| 1597 | - | w.u8(style.ForegroundRGB.G) | |
| 1598 | - | w.u8(style.ForegroundRGB.B) | |
| 1599 | - | w.u8(style.BackgroundRGB.R) | |
| 1600 | - | w.u8(style.BackgroundRGB.G) | |
| 1601 | - | w.u8(style.BackgroundRGB.B) | |
| 1602 | - | w.u8(style.UnderlineColorRGB.R) | |
| 1603 | - | w.u8(style.UnderlineColorRGB.G) | |
| 1604 | - | w.u8(style.UnderlineColorRGB.B) | |
| 1605 | - | w.u8(uint8(style.UnderlineStyle)) | |
| 1606 | - | } | |
| 1607 | - | ||
| 1608 | - | func screenColorIndex(index int) uint16 { | |
| 1609 | - | if index < 0 { | |
| 1610 | - | return math.MaxUint16 | |
| 1611 | - | } | |
| 1612 | - | return uint16(index) | |
| 1613 | - | } | |
| 1614 | - | ||
| 1615 | - | func screenColorValue(index uint16) int { | |
| 1616 | - | if index == math.MaxUint16 { | |
| 1617 | - | return -1 | |
| 1618 | - | } | |
| 1619 | - | return int(index) | |
| 1620 | - | } | |
| 1621 | - | ||
| 1622 | - | func readScreenStyle(r *screenReader) (renderer.Style, bool) { | |
| 1623 | - | bits, ok := r.u16ok() | |
| 1624 | - | if !ok || bits&^uint16(styleKnownBits) != 0 { | |
| 1625 | - | return renderer.Style{}, false | |
| 1626 | - | } | |
| 1627 | - | foregroundIndex, ok := r.u16ok() | |
| 1628 | - | if !ok { | |
| 1629 | - | return renderer.Style{}, false | |
| 1630 | - | } | |
| 1631 | - | backgroundIndex, ok := r.u16ok() | |
| 1632 | - | if !ok { | |
| 1633 | - | return renderer.Style{}, false | |
| 1634 | - | } | |
| 1635 | - | underlineIndex, ok := r.u16ok() | |
| 1636 | - | if !ok { | |
| 1637 | - | return renderer.Style{}, false | |
| 1638 | - | } | |
| 1639 | - | fgRGB, ok := r.rgb() | |
| 1640 | - | if !ok { | |
| 1641 | - | return renderer.Style{}, false | |
| 1642 | - | } | |
| 1643 | - | bgRGB, ok := r.rgb() | |
| 1644 | - | if !ok { | |
| 1645 | - | return renderer.Style{}, false | |
| 1646 | - | } | |
| 1647 | - | underlineRGB, ok := r.rgb() | |
| 1648 | - | if !ok { | |
| 1649 | - | return renderer.Style{}, false | |
| 1650 | - | } | |
| 1651 | - | underlineStyle, ok := r.u8ok() | |
| 1652 | - | if !ok || underlineStyle > uint8(renderer.UnderlineDashed) { | |
| 1653 | - | return renderer.Style{}, false | |
| 1654 | - | } | |
| 1655 | - | style := renderer.Style{ | |
| 1656 | - | Bold: bits&styleBoldBit != 0, | |
| 1657 | - | Italic: bits&styleItalicBit != 0, | |
| 1658 | - | Inverse: bits&styleInverseBit != 0, | |
| 1659 | - | UnderlineStyle: renderer.UnderlineStyle(underlineStyle), | |
| 1660 | - | Foreground: screenColorValue(foregroundIndex), | |
| 1661 | - | Background: screenColorValue(backgroundIndex), | |
| 1662 | - | HasForegroundRGB: bits&styleForegroundRGBBit != 0, | |
| 1663 | - | ForegroundRGB: fgRGB, | |
| 1664 | - | HasBackgroundRGB: bits&styleBackgroundRGBBit != 0, | |
| 1665 | - | BackgroundRGB: bgRGB, | |
| 1666 | - | HasUnderlineColor: bits&styleUnderlineColorBit != 0, | |
| 1667 | - | UnderlineColor: screenColorValue(underlineIndex), | |
| 1668 | - | HasUnderlineColorRGB: bits&styleUnderlineColorRGBBit != 0, | |
| 1669 | - | UnderlineColorRGB: underlineRGB, | |
| 1670 | - | } | |
| 1671 | - | if style.HasUnderlineColor && style.HasUnderlineColorRGB { | |
| 1672 | - | return renderer.Style{}, false | |
| 1673 | - | } | |
| 1674 | - | style.Attrs = 0 | |
| 1675 | - | if bits&styleDimBit != 0 { | |
| 1676 | - | style.Attrs |= renderer.AttrDim | |
| 1677 | - | } | |
| 1678 | - | if bits&styleUnderlineBit != 0 { | |
| 1679 | - | style.Attrs |= renderer.AttrUnderline | |
| 1680 | - | } | |
| 1681 | - | if bits&styleBlinkBit != 0 { | |
| 1682 | - | style.Attrs |= renderer.AttrBlink | |
| 1683 | - | } | |
| 1684 | - | if bits&styleStrikethroughBit != 0 { | |
| 1685 | - | style.Attrs |= renderer.AttrStrikethrough | |
| 1686 | - | } | |
| 1687 | - | if style.HasForegroundRGB { | |
| 1688 | - | if foregroundIndex != math.MaxUint16 { | |
| 1689 | - | return renderer.Style{}, false | |
| 1690 | - | } | |
| 1691 | - | style.Foreground = -1 | |
| 1692 | - | } else if (foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8) || fgRGB != (renderer.RGB{}) { | |
| 1693 | - | return renderer.Style{}, false | |
| 1694 | - | } | |
| 1695 | - | if style.HasBackgroundRGB { | |
| 1696 | - | if backgroundIndex != math.MaxUint16 { | |
| 1697 | - | return renderer.Style{}, false | |
| 1698 | - | } | |
| 1699 | - | style.Background = -1 | |
| 1700 | - | } else if (backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8) || bgRGB != (renderer.RGB{}) { | |
| 1701 | - | return renderer.Style{}, false | |
| 1702 | - | } | |
| 1703 | - | if style.HasUnderlineColorRGB { | |
| 1704 | - | if underlineIndex != math.MaxUint16 { | |
| 1705 | - | return renderer.Style{}, false | |
| 1706 | - | } | |
| 1707 | - | style.UnderlineColor = -1 | |
| 1708 | - | } else if style.HasUnderlineColor { | |
| 1709 | - | if underlineIndex == math.MaxUint16 || underlineIndex > math.MaxUint8 || underlineRGB != (renderer.RGB{}) { | |
| 1710 | - | return renderer.Style{}, false | |
| 1711 | - | } | |
| 1712 | - | } else if underlineIndex != math.MaxUint16 || underlineRGB != (renderer.RGB{}) { | |
| 1713 | - | return renderer.Style{}, false | |
| 1714 | - | } | |
| 1715 | - | return style, true | |
| 1716 | - | } | |
| 1717 | - | ||
| 1718 | - | func screenRunCount(cells []renderer.Cell) int { | |
| 1719 | - | if len(cells) == 0 { | |
| 1720 | - | return 0 | |
| 1721 | - | } | |
| 1722 | - | n := 1 | |
| 1723 | - | previous := canonicalScreenStyle(cells[0].Style) | |
| 1724 | - | for _, cell := range cells[1:] { | |
| 1725 | - | style := canonicalScreenStyle(cell.Style) | |
| 1726 | - | if style != previous { | |
| 1727 | - | n++ | |
| 1728 | - | previous = style | |
| 1729 | - | } | |
| 1730 | - | } | |
| 1731 | - | return n | |
| 1732 | - | } | |
| 1733 | - | ||
| 1734 | - | func screenRune(token uint64) (rune, bool) { | |
| 1735 | - | if token == 0 || token > uint64(utf8.MaxRune)+1 { | |
| 1736 | - | return 0, false | |
| 1737 | - | } | |
| 1738 | - | r := rune(token - 1) | |
| 1739 | - | return r, utf8.ValidRune(r) | |
| 1740 | - | } | |
| 1741 | - | ||
| 1742 | - | type screenWriter struct { | |
| 1743 | - | b []byte | |
| 1744 | - | tooLarge bool | |
| 1745 | - | } | |
| 1746 | - | ||
| 1747 | - | func (w *screenWriter) append(p ...byte) { | |
| 1748 | - | if w.tooLarge || len(w.b) > screenPayloadLimit-len(p) { | |
| 1749 | - | w.tooLarge = true | |
| 1750 | - | return | |
| 1751 | - | } | |
| 1752 | - | w.b = append(w.b, p...) | |
| 1753 | - | } | |
| 1754 | - | func (w *screenWriter) u8(v uint8) { w.append(v) } | |
| 1755 | - | func (w *screenWriter) u16(v uint16) { w.append(byte(v>>8), byte(v)) } | |
| 1756 | - | func (w *screenWriter) u64(v uint64) { | |
| 1757 | - | var b [8]byte | |
| 1758 | - | binary.BigEndian.PutUint64(b[:], v) | |
| 1759 | - | w.append(b[:]...) | |
| 1760 | - | } | |
| 1761 | - | func (w *screenWriter) uvarint(v uint64) { | |
| 1762 | - | var b [10]byte | |
| 1763 | - | n := binary.PutUvarint(b[:], v) | |
| 1764 | - | w.append(b[:n]...) | |
| 1765 | - | } | |
| 1766 | - | ||
| 1767 | - | type screenReader struct{ b []byte } | |
| 1768 | - | ||
| 1769 | - | func (r *screenReader) take(n int) ([]byte, bool) { | |
| 1770 | - | if n < 0 || len(r.b) < n { | |
| 1771 | - | return nil, false | |
| 1772 | - | } | |
| 1773 | - | p := r.b[:n] | |
| 1774 | - | r.b = r.b[n:] | |
| 1775 | - | return p, true | |
| 1776 | - | } | |
| 1777 | - | func (r *screenReader) u8() (uint8, error) { | |
| 1778 | - | v, ok := r.u8ok() | |
| 1779 | - | if !ok { | |
| 1780 | - | return 0, ErrInvalidScreenUpdate | |
| 1781 | - | } | |
| 1782 | - | return v, nil | |
| 1783 | - | } | |
| 1784 | - | func (r *screenReader) u8ok() (uint8, bool) { | |
| 1785 | - | p, ok := r.take(1) | |
| 1786 | - | if !ok { | |
| 1787 | - | return 0, false | |
| 1788 | - | } | |
| 1789 | - | return p[0], true | |
| 1790 | - | } | |
| 1791 | - | func (r *screenReader) u16() (uint16, error) { | |
| 1792 | - | p, ok := r.take(2) | |
| 1793 | - | if !ok { | |
| 1794 | - | return 0, ErrInvalidScreenUpdate | |
| 1795 | - | } | |
| 1796 | - | return binary.BigEndian.Uint16(p), nil | |
| 1797 | - | } | |
| 1798 | - | func (r *screenReader) u16ok() (uint16, bool) { | |
| 1799 | - | p, ok := r.take(2) | |
| 1800 | - | if !ok { | |
| 1801 | - | return 0, false | |
| 1802 | - | } | |
| 1803 | - | return binary.BigEndian.Uint16(p), true | |
| 1804 | - | } | |
| 1805 | - | func (r *screenReader) u64() (uint64, error) { | |
| 1806 | - | p, ok := r.take(8) | |
| 1807 | - | if !ok { | |
| 1808 | - | return 0, ErrInvalidScreenUpdate | |
| 1809 | - | } | |
| 1810 | - | return binary.BigEndian.Uint64(p), nil | |
| 1811 | - | } | |
| 1812 | - | func (r *screenReader) rgb() (renderer.RGB, bool) { | |
| 1813 | - | p, ok := r.take(3) | |
| 1814 | - | if !ok { | |
| 1815 | - | return renderer.RGB{}, false | |
| 1816 | - | } | |
| 1817 | - | return renderer.RGB{R: p[0], G: p[1], B: p[2]}, true | |
| 1818 | - | } | |
| 1819 | - | func (r *screenReader) uvarint() (uint64, bool) { | |
| 1820 | - | v, n := binary.Uvarint(r.b) | |
| 1821 | - | if n <= 0 { | |
| 1822 | - | return 0, false | |
| 1823 | - | } | |
| 1824 | - | var canonical [binary.MaxVarintLen64]byte | |
| 1825 | - | if binary.PutUvarint(canonical[:], v) != n { | |
| 1826 | - | return 0, false | |
| 1827 | - | } | |
| 1828 | - | r.b = r.b[n:] | |
| 1829 | - | return v, true | |
| 1830 | - | } | |
A internal/ports/wire_screen.go +755 -0
| @@ -0,0 +1,755 @@ | |||
| 1 | + | package ports | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/binary" | |
| 5 | + | "errors" | |
| 6 | + | "math" | |
| 7 | + | "unicode/utf8" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/pkg/renderer" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ScreenUpdateKind selects whether an update replaces the complete screen or | |
| 14 | + | // applies a delta to the previously committed screen. | |
| 15 | + | type ScreenUpdateKind uint8 | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | ScreenUpdateSnapshot ScreenUpdateKind = 1 | |
| 19 | + | ScreenUpdateDelta ScreenUpdateKind = 2 | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | // ScreenCursor is the absolute cursor state carried by each screen update. | |
| 23 | + | type ScreenCursor struct { | |
| 24 | + | Row, Col uint16 | |
| 25 | + | Style uint8 | |
| 26 | + | Visible bool | |
| 27 | + | StyleSet bool | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // ScreenScroll describes a full-width upward scroll applied before spans. | |
| 31 | + | type ScreenScroll struct { | |
| 32 | + | Top, Height, Count uint16 | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ScreenSpan identifies owned cells beginning at a horizontal position. | |
| 36 | + | type ScreenSpan struct { | |
| 37 | + | Y, X uint16 | |
| 38 | + | Cells []renderer.Cell | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // ScreenUpdate is the semantic structured screen message exchanged by proxy | |
| 42 | + | // daemons. Wire style tables and run IDs remain private to its codec. | |
| 43 | + | type ScreenUpdate struct { | |
| 44 | + | BaseStateNum uint64 | |
| 45 | + | NewStateNum uint64 | |
| 46 | + | EchoAck uint64 | |
| 47 | + | Kind ScreenUpdateKind | |
| 48 | + | Size domain.Size | |
| 49 | + | Cursor ScreenCursor | |
| 50 | + | Scroll *ScreenScroll | |
| 51 | + | Spans []ScreenSpan | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | var ( | |
| 55 | + | // ErrInvalidScreenUpdate reports a malformed or non-canonical screen | |
| 56 | + | // update. It is deliberately shared by marshal and unmarshal validation. | |
| 57 | + | ErrInvalidScreenUpdate = errors.New("ports: invalid screen update") | |
| 58 | + | // ErrScreenUpdateTooLarge reports a payload that cannot fit in a frame. | |
| 59 | + | ErrScreenUpdateTooLarge = errors.New("ports: screen update too large") | |
| 60 | + | ) | |
| 61 | + | ||
| 62 | + | const ( | |
| 63 | + | maxScreenCells = 1 << 18 | |
| 64 | + | ||
| 65 | + | screenHeaderLen = 40 | |
| 66 | + | screenStyleLen = 18 | |
| 67 | + | screenSpanLimit = 4096 | |
| 68 | + | ||
| 69 | + | // The frame length includes MsgScreenUpdate's type byte. The codec takes | |
| 70 | + | // only the payload, so one byte is reserved here. | |
| 71 | + | screenPayloadLimit = MaxFrameLen - 1 | |
| 72 | + | ||
| 73 | + | screenFlagCursorVisible = 1 << 0 | |
| 74 | + | screenFlagCursorStyleSet = 1 << 1 | |
| 75 | + | screenKnownCursorFlags = screenFlagCursorVisible | screenFlagCursorStyleSet | |
| 76 | + | ||
| 77 | + | styleBoldBit = 1 << 0 | |
| 78 | + | styleItalicBit = 1 << 1 | |
| 79 | + | styleInverseBit = 1 << 2 | |
| 80 | + | styleDimBit = 1 << 3 | |
| 81 | + | styleUnderlineBit = 1 << 4 | |
| 82 | + | styleBlinkBit = 1 << 5 | |
| 83 | + | styleStrikethroughBit = 1 << 6 | |
| 84 | + | styleForegroundRGBBit = 1 << 7 | |
| 85 | + | styleBackgroundRGBBit = 1 << 8 | |
| 86 | + | styleUnderlineColorBit = 1 << 9 | |
| 87 | + | styleUnderlineColorRGBBit = 1 << 10 | |
| 88 | + | styleKnownBits = (1 << 11) - 1 | |
| 89 | + | ) | |
| 90 | + | ||
| 91 | + | // MarshalScreenUpdate encodes a canonical structured screen update. | |
| 92 | + | func MarshalScreenUpdate(m ScreenUpdate) ([]byte, error) { | |
| 93 | + | if err := validateScreenUpdate(m); err != nil { | |
| 94 | + | return nil, err | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | styles := make([]renderer.Style, 0) | |
| 98 | + | styleIDs := make(map[renderer.Style]uint16) | |
| 99 | + | for _, span := range m.Spans { | |
| 100 | + | for _, cell := range span.Cells { | |
| 101 | + | style := canonicalScreenStyle(cell.Style) | |
| 102 | + | if _, ok := styleIDs[style]; ok { | |
| 103 | + | continue | |
| 104 | + | } | |
| 105 | + | if len(styles) == math.MaxUint16 { | |
| 106 | + | return nil, ErrInvalidScreenUpdate | |
| 107 | + | } | |
| 108 | + | styleIDs[style] = uint16(len(styles)) | |
| 109 | + | styles = append(styles, style) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | w := screenWriter{b: make([]byte, 0, screenHeaderLen+len(styles)*screenStyleLen)} | |
| 113 | + | w.u64(m.BaseStateNum) | |
| 114 | + | w.u64(m.NewStateNum) | |
| 115 | + | w.u64(m.EchoAck) | |
| 116 | + | w.u8(uint8(m.Kind)) | |
| 117 | + | w.u16(uint16(m.Size.Cols)) | |
| 118 | + | w.u16(uint16(m.Size.Rows)) | |
| 119 | + | w.u16(m.Cursor.Row) | |
| 120 | + | w.u16(m.Cursor.Col) | |
| 121 | + | w.u8(m.Cursor.Style) | |
| 122 | + | var cursorFlags uint8 | |
| 123 | + | if m.Cursor.Visible { | |
| 124 | + | cursorFlags |= screenFlagCursorVisible | |
| 125 | + | } | |
| 126 | + | if m.Cursor.StyleSet { | |
| 127 | + | cursorFlags |= screenFlagCursorStyleSet | |
| 128 | + | } | |
| 129 | + | w.u8(cursorFlags) | |
| 130 | + | if m.Scroll != nil { | |
| 131 | + | w.u8(1) | |
| 132 | + | } else { | |
| 133 | + | w.u8(0) | |
| 134 | + | } | |
| 135 | + | w.u16(uint16(len(styles))) | |
| 136 | + | w.u16(uint16(len(m.Spans))) | |
| 137 | + | if m.Scroll != nil { | |
| 138 | + | w.u16(m.Scroll.Top) | |
| 139 | + | w.u16(m.Scroll.Height) | |
| 140 | + | w.u16(m.Scroll.Count) | |
| 141 | + | } | |
| 142 | + | for _, style := range styles { | |
| 143 | + | appendScreenStyle(&w, style) | |
| 144 | + | } | |
| 145 | + | for _, span := range m.Spans { | |
| 146 | + | w.u16(span.Y) | |
| 147 | + | w.u16(span.X) | |
| 148 | + | w.u16(uint16(len(span.Cells))) | |
| 149 | + | runs := screenRunCount(span.Cells) | |
| 150 | + | w.u16(uint16(runs)) | |
| 151 | + | for start := 0; start < len(span.Cells); { | |
| 152 | + | style := canonicalScreenStyle(span.Cells[start].Style) | |
| 153 | + | id := styleIDs[style] | |
| 154 | + | end := start + 1 | |
| 155 | + | for end < len(span.Cells) && styleIDs[canonicalScreenStyle(span.Cells[end].Style)] == id { | |
| 156 | + | end++ | |
| 157 | + | } | |
| 158 | + | w.u16(id) | |
| 159 | + | w.u16(uint16(end - start)) | |
| 160 | + | for _, cell := range span.Cells[start:end] { | |
| 161 | + | if cell.Continuation { | |
| 162 | + | w.uvarint(0) | |
| 163 | + | } else { | |
| 164 | + | w.uvarint(uint64(cell.Rune) + 1) | |
| 165 | + | } | |
| 166 | + | } | |
| 167 | + | start = end | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | if w.tooLarge { | |
| 171 | + | return nil, ErrScreenUpdateTooLarge | |
| 172 | + | } | |
| 173 | + | return w.b, nil | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | // UnmarshalScreenUpdate strictly decodes one screen-update payload. All | |
| 177 | + | // decoded slices own their data; no input-backed slice is retained. | |
| 178 | + | func UnmarshalScreenUpdate(data []byte) (ScreenUpdate, error) { | |
| 179 | + | if len(data) > screenPayloadLimit { | |
| 180 | + | return ScreenUpdate{}, ErrScreenUpdateTooLarge | |
| 181 | + | } | |
| 182 | + | if len(data) < screenHeaderLen { | |
| 183 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 184 | + | } | |
| 185 | + | r := screenReader{b: data} | |
| 186 | + | var m ScreenUpdate | |
| 187 | + | var err error | |
| 188 | + | if m.BaseStateNum, err = r.u64(); err != nil { | |
| 189 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 190 | + | } | |
| 191 | + | if m.NewStateNum, err = r.u64(); err != nil { | |
| 192 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 193 | + | } | |
| 194 | + | if m.EchoAck, err = r.u64(); err != nil { | |
| 195 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 196 | + | } | |
| 197 | + | kind, err := r.u8() | |
| 198 | + | if err != nil || (ScreenUpdateKind(kind) != ScreenUpdateSnapshot && ScreenUpdateKind(kind) != ScreenUpdateDelta) { | |
| 199 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 200 | + | } | |
| 201 | + | m.Kind = ScreenUpdateKind(kind) | |
| 202 | + | cols, err := r.u16() | |
| 203 | + | if err != nil { | |
| 204 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 205 | + | } | |
| 206 | + | rows, err := r.u16() | |
| 207 | + | if err != nil || cols == 0 || rows == 0 || !screenAreaWithinLimit(domain.Size{Cols: int(cols), Rows: int(rows)}) { | |
| 208 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 209 | + | } | |
| 210 | + | m.Size = domain.Size{Cols: int(cols), Rows: int(rows)} | |
| 211 | + | if m.Cursor.Row, err = r.u16(); err != nil { | |
| 212 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 213 | + | } | |
| 214 | + | if m.Cursor.Col, err = r.u16(); err != nil { | |
| 215 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 216 | + | } | |
| 217 | + | if m.Cursor.Style, err = r.u8(); err != nil { | |
| 218 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 219 | + | } | |
| 220 | + | cursorFlags, err := r.u8() | |
| 221 | + | if err != nil || cursorFlags&^uint8(screenKnownCursorFlags) != 0 || m.Cursor.Row >= rows || m.Cursor.Col >= cols { | |
| 222 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 223 | + | } | |
| 224 | + | m.Cursor.Visible = cursorFlags&screenFlagCursorVisible != 0 | |
| 225 | + | m.Cursor.StyleSet = cursorFlags&screenFlagCursorStyleSet != 0 | |
| 226 | + | if !m.Cursor.StyleSet && m.Cursor.Style != 0 || m.Cursor.StyleSet && m.Cursor.Style > 6 { | |
| 227 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 228 | + | } | |
| 229 | + | scrollPresent, err := r.u8() | |
| 230 | + | if err != nil || scrollPresent > 1 { | |
| 231 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 232 | + | } | |
| 233 | + | styleCount, err := r.u16() | |
| 234 | + | if err != nil || uint64(styleCount) > uint64(len(r.b))/screenStyleLen { | |
| 235 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 236 | + | } | |
| 237 | + | spanCount, err := r.u16() | |
| 238 | + | if err != nil || spanCount > screenSpanLimit || uint64(spanCount) > uint64(len(r.b))/8 { | |
| 239 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 240 | + | } | |
| 241 | + | if scrollPresent != 0 { | |
| 242 | + | if m.Kind != ScreenUpdateDelta || len(r.b) < 6 { | |
| 243 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 244 | + | } | |
| 245 | + | m.Scroll = new(ScreenScroll) | |
| 246 | + | if m.Scroll.Top, err = r.u16(); err != nil { | |
| 247 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 248 | + | } | |
| 249 | + | if m.Scroll.Height, err = r.u16(); err != nil { | |
| 250 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 251 | + | } | |
| 252 | + | if m.Scroll.Count, err = r.u16(); err != nil { | |
| 253 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 254 | + | } | |
| 255 | + | if !validScreenScroll(*m.Scroll, rows) { | |
| 256 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 257 | + | } | |
| 258 | + | } else { | |
| 259 | + | m.Scroll = nil | |
| 260 | + | } | |
| 261 | + | // styleCount was checked against the bytes still available before this | |
| 262 | + | // allocation. Each style is copied field-by-field below. | |
| 263 | + | styles := make([]renderer.Style, styleCount) | |
| 264 | + | seenStyles := make(map[renderer.Style]struct{}, styleCount) | |
| 265 | + | for i := range styles { | |
| 266 | + | style, ok := readScreenStyle(&r) | |
| 267 | + | if !ok { | |
| 268 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 269 | + | } | |
| 270 | + | if _, duplicate := seenStyles[style]; duplicate { | |
| 271 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 272 | + | } | |
| 273 | + | seenStyles[style] = struct{}{} | |
| 274 | + | styles[i] = style | |
| 275 | + | } | |
| 276 | + | m.Spans = make([]ScreenSpan, spanCount) | |
| 277 | + | usedStyles := make([]bool, styleCount) | |
| 278 | + | nextStyleID := uint16(0) | |
| 279 | + | var previousEnd uint32 | |
| 280 | + | var previousY uint16 | |
| 281 | + | for i := range m.Spans { | |
| 282 | + | y, errY := r.u16() | |
| 283 | + | x, errX := r.u16() | |
| 284 | + | cellCount, errCells := r.u16() | |
| 285 | + | runCount, errRuns := r.u16() | |
| 286 | + | if errY != nil || errX != nil || errCells != nil || errRuns != nil || cellCount == 0 || runCount == 0 || runCount > cellCount || uint64(runCount) > uint64(len(r.b))/4 { | |
| 287 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 288 | + | } | |
| 289 | + | if y >= rows || x >= cols || uint64(x)+uint64(cellCount) > uint64(cols) { | |
| 290 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 291 | + | } | |
| 292 | + | if i > 0 { | |
| 293 | + | if y < previousY || (y == previousY && uint32(x) < previousEnd) { | |
| 294 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 295 | + | } | |
| 296 | + | } | |
| 297 | + | // Ordered non-overlapping spans have at most one cell per screen cell, | |
| 298 | + | // so the area check above bounds aggregate decoded cells before each | |
| 299 | + | // per-span allocation. Every cell has at least one token and every run | |
| 300 | + | // has a four-byte descriptor; check both before allocating the slice. | |
| 301 | + | minimum := uint64(runCount)*4 + uint64(cellCount) | |
| 302 | + | if minimum > uint64(len(r.b)) { | |
| 303 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 304 | + | } | |
| 305 | + | cells := make([]renderer.Cell, cellCount) | |
| 306 | + | filled := 0 | |
| 307 | + | previousStyleID := uint16(math.MaxUint16) | |
| 308 | + | for range runCount { | |
| 309 | + | styleID, e1 := r.u16() | |
| 310 | + | runCells, e2 := r.u16() | |
| 311 | + | if e1 != nil || e2 != nil || runCells == 0 || styleID >= styleCount || styleID == previousStyleID || uint32(filled)+uint32(runCells) > uint32(cellCount) { | |
| 312 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 313 | + | } | |
| 314 | + | if !usedStyles[styleID] { | |
| 315 | + | if styleID != nextStyleID { | |
| 316 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 317 | + | } | |
| 318 | + | nextStyleID++ | |
| 319 | + | usedStyles[styleID] = true | |
| 320 | + | } | |
| 321 | + | previousStyleID = styleID | |
| 322 | + | for range runCells { | |
| 323 | + | token, ok := r.uvarint() | |
| 324 | + | if !ok { | |
| 325 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 326 | + | } | |
| 327 | + | if token == 0 { | |
| 328 | + | cells[filled] = renderer.Cell{Style: styles[styleID], Continuation: true} | |
| 329 | + | } else { | |
| 330 | + | runeValue, valid := screenRune(token) | |
| 331 | + | if !valid { | |
| 332 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 333 | + | } | |
| 334 | + | cells[filled] = renderer.Cell{Rune: runeValue, Style: styles[styleID]} | |
| 335 | + | } | |
| 336 | + | filled++ | |
| 337 | + | } | |
| 338 | + | } | |
| 339 | + | if filled != int(cellCount) { | |
| 340 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 341 | + | } | |
| 342 | + | m.Spans[i] = ScreenSpan{Y: y, X: x, Cells: cells} | |
| 343 | + | previousY = y | |
| 344 | + | previousEnd = uint32(x) + uint32(cellCount) | |
| 345 | + | } | |
| 346 | + | if len(r.b) != 0 || nextStyleID != styleCount { | |
| 347 | + | return ScreenUpdate{}, ErrInvalidScreenUpdate | |
| 348 | + | } | |
| 349 | + | if err := validateScreenUpdate(m); err != nil { | |
| 350 | + | return ScreenUpdate{}, err | |
| 351 | + | } | |
| 352 | + | return m, nil | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func validateScreenUpdate(m ScreenUpdate) error { | |
| 356 | + | if m.Kind == ScreenUpdateSnapshot { | |
| 357 | + | if m.BaseStateNum != 0 || m.NewStateNum == 0 { | |
| 358 | + | return ErrInvalidScreenUpdate | |
| 359 | + | } | |
| 360 | + | } else if m.Kind == ScreenUpdateDelta { | |
| 361 | + | if m.BaseStateNum == 0 || m.BaseStateNum == math.MaxUint64 || m.NewStateNum != m.BaseStateNum+1 { | |
| 362 | + | return ErrInvalidScreenUpdate | |
| 363 | + | } | |
| 364 | + | } else { | |
| 365 | + | return ErrInvalidScreenUpdate | |
| 366 | + | } | |
| 367 | + | if (m.Kind != ScreenUpdateSnapshot && m.Kind != ScreenUpdateDelta) || | |
| 368 | + | m.Size.Cols <= 0 || m.Size.Rows <= 0 || m.Size.Cols > math.MaxUint16 || m.Size.Rows > math.MaxUint16 || | |
| 369 | + | !screenAreaWithinLimit(m.Size) || m.Cursor.Row >= uint16(m.Size.Rows) || m.Cursor.Col >= uint16(m.Size.Cols) { | |
| 370 | + | return ErrInvalidScreenUpdate | |
| 371 | + | } | |
| 372 | + | if !m.Cursor.StyleSet && m.Cursor.Style != 0 || m.Cursor.StyleSet && m.Cursor.Style > 6 { | |
| 373 | + | return ErrInvalidScreenUpdate | |
| 374 | + | } | |
| 375 | + | if m.Kind == ScreenUpdateSnapshot && m.Scroll != nil { | |
| 376 | + | return ErrInvalidScreenUpdate | |
| 377 | + | } | |
| 378 | + | if m.Scroll != nil && !validScreenScroll(*m.Scroll, uint16(m.Size.Rows)) { | |
| 379 | + | return ErrInvalidScreenUpdate | |
| 380 | + | } | |
| 381 | + | if len(m.Spans) > screenSpanLimit || len(m.Spans) > math.MaxUint16 || (m.Kind == ScreenUpdateSnapshot && len(m.Spans) == 0) { | |
| 382 | + | return ErrInvalidScreenUpdate | |
| 383 | + | } | |
| 384 | + | return validateScreenSpans(m) | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func screenAreaWithinLimit(size domain.Size) bool { | |
| 388 | + | return size.Cols > 0 && size.Rows > 0 && size.Rows <= maxScreenCells/size.Cols | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func validScreenScroll(scroll ScreenScroll, rows uint16) bool { | |
| 392 | + | return scroll.Height != 0 && scroll.Count != 0 && scroll.Count < scroll.Height && | |
| 393 | + | scroll.Top < rows && uint64(scroll.Top)+uint64(scroll.Height) <= uint64(rows) | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func validateScreenSpans(m ScreenUpdate) error { | |
| 397 | + | cols, rows := uint32(m.Size.Cols), uint32(m.Size.Rows) | |
| 398 | + | var previousY uint16 | |
| 399 | + | var previousEnd uint32 | |
| 400 | + | for i, span := range m.Spans { | |
| 401 | + | if len(span.Cells) == 0 || len(span.Cells) > math.MaxUint16 || uint32(span.Y) >= rows || uint32(span.X) >= cols || uint64(span.X)+uint64(len(span.Cells)) > uint64(cols) { | |
| 402 | + | return ErrInvalidScreenUpdate | |
| 403 | + | } | |
| 404 | + | if i > 0 && (span.Y < previousY || (span.Y == previousY && uint32(span.X) < previousEnd)) { | |
| 405 | + | return ErrInvalidScreenUpdate | |
| 406 | + | } | |
| 407 | + | for _, cell := range span.Cells { | |
| 408 | + | if cell.Continuation && cell.Rune != 0 || !cell.Continuation && !utf8.ValidRune(cell.Rune) { | |
| 409 | + | return ErrInvalidScreenUpdate | |
| 410 | + | } | |
| 411 | + | if err := validateScreenStyle(cell.Style); err != nil { | |
| 412 | + | return err | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | previousY = span.Y | |
| 416 | + | previousEnd = uint32(span.X) + uint32(len(span.Cells)) | |
| 417 | + | } | |
| 418 | + | if m.Kind == ScreenUpdateSnapshot { | |
| 419 | + | if len(m.Spans) != m.Size.Rows { | |
| 420 | + | return ErrInvalidScreenUpdate | |
| 421 | + | } | |
| 422 | + | for y, span := range m.Spans { | |
| 423 | + | if int(span.Y) != y || span.X != 0 || len(span.Cells) != m.Size.Cols { | |
| 424 | + | return ErrInvalidScreenUpdate | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | return nil | |
| 429 | + | } | |
| 430 | + | ||
| 431 | + | func validateScreenStyle(style renderer.Style) error { | |
| 432 | + | if style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 || style.UnderlineStyle > renderer.UnderlineDashed { | |
| 433 | + | return ErrInvalidScreenUpdate | |
| 434 | + | } | |
| 435 | + | if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > math.MaxUint8 || style.ForegroundRGB != (renderer.RGB{})) { | |
| 436 | + | return ErrInvalidScreenUpdate | |
| 437 | + | } | |
| 438 | + | if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > math.MaxUint8 || style.BackgroundRGB != (renderer.RGB{})) { | |
| 439 | + | return ErrInvalidScreenUpdate | |
| 440 | + | } | |
| 441 | + | if style.HasUnderlineColorRGB && style.HasUnderlineColor { | |
| 442 | + | return ErrInvalidScreenUpdate | |
| 443 | + | } else if style.HasUnderlineColorRGB { | |
| 444 | + | // The indexed value is inactive when RGB is selected. | |
| 445 | + | } else if style.HasUnderlineColor { | |
| 446 | + | if style.UnderlineColor < 0 || style.UnderlineColor > math.MaxUint8 || style.UnderlineColorRGB != (renderer.RGB{}) { | |
| 447 | + | return ErrInvalidScreenUpdate | |
| 448 | + | } | |
| 449 | + | } else if style.UnderlineColorRGB != (renderer.RGB{}) { | |
| 450 | + | return ErrInvalidScreenUpdate | |
| 451 | + | } | |
| 452 | + | return nil | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | func canonicalScreenStyle(style renderer.Style) renderer.Style { | |
| 456 | + | if style.HasForegroundRGB { | |
| 457 | + | style.Foreground = -1 | |
| 458 | + | } else { | |
| 459 | + | style.ForegroundRGB = renderer.RGB{} | |
| 460 | + | } | |
| 461 | + | if style.HasBackgroundRGB { | |
| 462 | + | style.Background = -1 | |
| 463 | + | } else { | |
| 464 | + | style.BackgroundRGB = renderer.RGB{} | |
| 465 | + | } | |
| 466 | + | if style.HasUnderlineColorRGB { | |
| 467 | + | style.UnderlineColor = -1 | |
| 468 | + | } else if style.HasUnderlineColor { | |
| 469 | + | style.UnderlineColorRGB = renderer.RGB{} | |
| 470 | + | } else { | |
| 471 | + | style.UnderlineColor = -1 | |
| 472 | + | style.UnderlineColorRGB = renderer.RGB{} | |
| 473 | + | } | |
| 474 | + | return style | |
| 475 | + | } | |
| 476 | + | ||
| 477 | + | func screenStyleBits(style renderer.Style) uint16 { | |
| 478 | + | var bits uint16 | |
| 479 | + | if style.Bold { | |
| 480 | + | bits |= styleBoldBit | |
| 481 | + | } | |
| 482 | + | if style.Italic { | |
| 483 | + | bits |= styleItalicBit | |
| 484 | + | } | |
| 485 | + | if style.Inverse { | |
| 486 | + | bits |= styleInverseBit | |
| 487 | + | } | |
| 488 | + | if style.Attrs&renderer.AttrDim != 0 { | |
| 489 | + | bits |= styleDimBit | |
| 490 | + | } | |
| 491 | + | if style.Attrs&renderer.AttrUnderline != 0 { | |
| 492 | + | bits |= styleUnderlineBit | |
| 493 | + | } | |
| 494 | + | if style.Attrs&renderer.AttrBlink != 0 { | |
| 495 | + | bits |= styleBlinkBit | |
| 496 | + | } | |
| 497 | + | if style.Attrs&renderer.AttrStrikethrough != 0 { | |
| 498 | + | bits |= styleStrikethroughBit | |
| 499 | + | } | |
| 500 | + | if style.HasForegroundRGB { | |
| 501 | + | bits |= styleForegroundRGBBit | |
| 502 | + | } | |
| 503 | + | if style.HasBackgroundRGB { | |
| 504 | + | bits |= styleBackgroundRGBBit | |
| 505 | + | } | |
| 506 | + | if style.HasUnderlineColor { | |
| 507 | + | bits |= styleUnderlineColorBit | |
| 508 | + | } | |
| 509 | + | if style.HasUnderlineColorRGB { | |
| 510 | + | bits |= styleUnderlineColorRGBBit | |
| 511 | + | } | |
| 512 | + | return bits | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | func appendScreenStyle(w *screenWriter, style renderer.Style) { | |
| 516 | + | style = canonicalScreenStyle(style) | |
| 517 | + | w.u16(screenStyleBits(style)) | |
| 518 | + | w.u16(screenColorIndex(style.Foreground)) | |
| 519 | + | w.u16(screenColorIndex(style.Background)) | |
| 520 | + | w.u16(screenColorIndex(style.UnderlineColor)) | |
| 521 | + | w.u8(style.ForegroundRGB.R) | |
| 522 | + | w.u8(style.ForegroundRGB.G) | |
| 523 | + | w.u8(style.ForegroundRGB.B) | |
| 524 | + | w.u8(style.BackgroundRGB.R) | |
| 525 | + | w.u8(style.BackgroundRGB.G) | |
| 526 | + | w.u8(style.BackgroundRGB.B) | |
| 527 | + | w.u8(style.UnderlineColorRGB.R) | |
| 528 | + | w.u8(style.UnderlineColorRGB.G) | |
| 529 | + | w.u8(style.UnderlineColorRGB.B) | |
| 530 | + | w.u8(uint8(style.UnderlineStyle)) | |
| 531 | + | } | |
| 532 | + | ||
| 533 | + | func screenColorIndex(index int) uint16 { | |
| 534 | + | if index < 0 { | |
| 535 | + | return math.MaxUint16 | |
| 536 | + | } | |
| 537 | + | return uint16(index) | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | func screenColorValue(index uint16) int { | |
| 541 | + | if index == math.MaxUint16 { | |
| 542 | + | return -1 | |
| 543 | + | } | |
| 544 | + | return int(index) | |
| 545 | + | } | |
| 546 | + | ||
| 547 | + | func readScreenStyle(r *screenReader) (renderer.Style, bool) { | |
| 548 | + | bits, ok := r.u16ok() | |
| 549 | + | if !ok || bits&^uint16(styleKnownBits) != 0 { | |
| 550 | + | return renderer.Style{}, false | |
| 551 | + | } | |
| 552 | + | foregroundIndex, ok := r.u16ok() | |
| 553 | + | if !ok { | |
| 554 | + | return renderer.Style{}, false | |
| 555 | + | } | |
| 556 | + | backgroundIndex, ok := r.u16ok() | |
| 557 | + | if !ok { | |
| 558 | + | return renderer.Style{}, false | |
| 559 | + | } | |
| 560 | + | underlineIndex, ok := r.u16ok() | |
| 561 | + | if !ok { | |
| 562 | + | return renderer.Style{}, false | |
| 563 | + | } | |
| 564 | + | fgRGB, ok := r.rgb() | |
| 565 | + | if !ok { | |
| 566 | + | return renderer.Style{}, false | |
| 567 | + | } | |
| 568 | + | bgRGB, ok := r.rgb() | |
| 569 | + | if !ok { | |
| 570 | + | return renderer.Style{}, false | |
| 571 | + | } | |
| 572 | + | underlineRGB, ok := r.rgb() | |
| 573 | + | if !ok { | |
| 574 | + | return renderer.Style{}, false | |
| 575 | + | } | |
| 576 | + | underlineStyle, ok := r.u8ok() | |
| 577 | + | if !ok || underlineStyle > uint8(renderer.UnderlineDashed) { | |
| 578 | + | return renderer.Style{}, false | |
| 579 | + | } | |
| 580 | + | style := renderer.Style{ | |
| 581 | + | Bold: bits&styleBoldBit != 0, | |
| 582 | + | Italic: bits&styleItalicBit != 0, | |
| 583 | + | Inverse: bits&styleInverseBit != 0, | |
| 584 | + | UnderlineStyle: renderer.UnderlineStyle(underlineStyle), | |
| 585 | + | Foreground: screenColorValue(foregroundIndex), | |
| 586 | + | Background: screenColorValue(backgroundIndex), | |
| 587 | + | HasForegroundRGB: bits&styleForegroundRGBBit != 0, | |
| 588 | + | ForegroundRGB: fgRGB, | |
| 589 | + | HasBackgroundRGB: bits&styleBackgroundRGBBit != 0, | |
| 590 | + | BackgroundRGB: bgRGB, | |
| 591 | + | HasUnderlineColor: bits&styleUnderlineColorBit != 0, | |
| 592 | + | UnderlineColor: screenColorValue(underlineIndex), | |
| 593 | + | HasUnderlineColorRGB: bits&styleUnderlineColorRGBBit != 0, | |
| 594 | + | UnderlineColorRGB: underlineRGB, | |
| 595 | + | } | |
| 596 | + | if style.HasUnderlineColor && style.HasUnderlineColorRGB { | |
| 597 | + | return renderer.Style{}, false | |
| 598 | + | } | |
| 599 | + | style.Attrs = 0 | |
| 600 | + | if bits&styleDimBit != 0 { | |
| 601 | + | style.Attrs |= renderer.AttrDim | |
| 602 | + | } | |
| 603 | + | if bits&styleUnderlineBit != 0 { | |
| 604 | + | style.Attrs |= renderer.AttrUnderline | |
| 605 | + | } | |
| 606 | + | if bits&styleBlinkBit != 0 { | |
| 607 | + | style.Attrs |= renderer.AttrBlink | |
| 608 | + | } | |
| 609 | + | if bits&styleStrikethroughBit != 0 { | |
| 610 | + | style.Attrs |= renderer.AttrStrikethrough | |
| 611 | + | } | |
| 612 | + | if style.HasForegroundRGB { | |
| 613 | + | if foregroundIndex != math.MaxUint16 { | |
| 614 | + | return renderer.Style{}, false | |
| 615 | + | } | |
| 616 | + | style.Foreground = -1 | |
| 617 | + | } else if foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8 || fgRGB != (renderer.RGB{}) { | |
| 618 | + | return renderer.Style{}, false | |
| 619 | + | } | |
| 620 | + | if style.HasBackgroundRGB { | |
| 621 | + | if backgroundIndex != math.MaxUint16 { | |
| 622 | + | return renderer.Style{}, false | |
| 623 | + | } | |
| 624 | + | style.Background = -1 | |
| 625 | + | } else if backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8 || bgRGB != (renderer.RGB{}) { | |
| 626 | + | return renderer.Style{}, false | |
| 627 | + | } | |
| 628 | + | if style.HasUnderlineColorRGB { | |
| 629 | + | if underlineIndex != math.MaxUint16 { | |
| 630 | + | return renderer.Style{}, false | |
| 631 | + | } | |
| 632 | + | style.UnderlineColor = -1 | |
| 633 | + | } else if style.HasUnderlineColor { | |
| 634 | + | if underlineIndex == math.MaxUint16 || underlineIndex > math.MaxUint8 || underlineRGB != (renderer.RGB{}) { | |
| 635 | + | return renderer.Style{}, false | |
| 636 | + | } | |
| 637 | + | } else if underlineIndex != math.MaxUint16 || underlineRGB != (renderer.RGB{}) { | |
| 638 | + | return renderer.Style{}, false | |
| 639 | + | } | |
| 640 | + | return style, true | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | func screenRunCount(cells []renderer.Cell) int { | |
| 644 | + | if len(cells) == 0 { | |
| 645 | + | return 0 | |
| 646 | + | } | |
| 647 | + | n := 1 | |
| 648 | + | previous := canonicalScreenStyle(cells[0].Style) | |
| 649 | + | for _, cell := range cells[1:] { | |
| 650 | + | style := canonicalScreenStyle(cell.Style) | |
| 651 | + | if style != previous { | |
| 652 | + | n++ | |
| 653 | + | previous = style | |
| 654 | + | } | |
| 655 | + | } | |
| 656 | + | return n | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | func screenRune(token uint64) (rune, bool) { | |
| 660 | + | if token == 0 || token > uint64(utf8.MaxRune)+1 { | |
| 661 | + | return 0, false | |
| 662 | + | } | |
| 663 | + | r := rune(token - 1) | |
| 664 | + | return r, utf8.ValidRune(r) | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | type screenWriter struct { | |
| 668 | + | b []byte | |
| 669 | + | tooLarge bool | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | func (w *screenWriter) append(p ...byte) { | |
| 673 | + | if w.tooLarge || len(w.b) > screenPayloadLimit-len(p) { | |
| 674 | + | w.tooLarge = true | |
| 675 | + | return | |
| 676 | + | } | |
| 677 | + | w.b = append(w.b, p...) | |
| 678 | + | } | |
| 679 | + | func (w *screenWriter) u8(v uint8) { w.append(v) } | |
| 680 | + | func (w *screenWriter) u16(v uint16) { w.append(byte(v>>8), byte(v)) } | |
| 681 | + | func (w *screenWriter) u64(v uint64) { | |
| 682 | + | var b [8]byte | |
| 683 | + | binary.BigEndian.PutUint64(b[:], v) | |
| 684 | + | w.append(b[:]...) | |
| 685 | + | } | |
| 686 | + | func (w *screenWriter) uvarint(v uint64) { | |
| 687 | + | var b [10]byte | |
| 688 | + | n := binary.PutUvarint(b[:], v) | |
| 689 | + | w.append(b[:n]...) | |
| 690 | + | } | |
| 691 | + | ||
| 692 | + | type screenReader struct{ b []byte } | |
| 693 | + | ||
| 694 | + | func (r *screenReader) take(n int) ([]byte, bool) { | |
| 695 | + | if n < 0 || len(r.b) < n { | |
| 696 | + | return nil, false | |
| 697 | + | } | |
| 698 | + | p := r.b[:n] | |
| 699 | + | r.b = r.b[n:] | |
| 700 | + | return p, true | |
| 701 | + | } | |
| 702 | + | func (r *screenReader) u8() (uint8, error) { | |
| 703 | + | v, ok := r.u8ok() | |
| 704 | + | if !ok { | |
| 705 | + | return 0, ErrInvalidScreenUpdate | |
| 706 | + | } | |
| 707 | + | return v, nil | |
| 708 | + | } | |
| 709 | + | func (r *screenReader) u8ok() (uint8, bool) { | |
| 710 | + | p, ok := r.take(1) | |
| 711 | + | if !ok { | |
| 712 | + | return 0, false | |
| 713 | + | } | |
| 714 | + | return p[0], true | |
| 715 | + | } | |
| 716 | + | func (r *screenReader) u16() (uint16, error) { | |
| 717 | + | p, ok := r.take(2) | |
| 718 | + | if !ok { | |
| 719 | + | return 0, ErrInvalidScreenUpdate | |
| 720 | + | } | |
| 721 | + | return binary.BigEndian.Uint16(p), nil | |
| 722 | + | } | |
| 723 | + | func (r *screenReader) u16ok() (uint16, bool) { | |
| 724 | + | p, ok := r.take(2) | |
| 725 | + | if !ok { | |
| 726 | + | return 0, false | |
| 727 | + | } | |
| 728 | + | return binary.BigEndian.Uint16(p), true | |
| 729 | + | } | |
| 730 | + | func (r *screenReader) u64() (uint64, error) { | |
| 731 | + | p, ok := r.take(8) | |
| 732 | + | if !ok { | |
| 733 | + | return 0, ErrInvalidScreenUpdate | |
| 734 | + | } | |
| 735 | + | return binary.BigEndian.Uint64(p), nil | |
| 736 | + | } | |
| 737 | + | func (r *screenReader) rgb() (renderer.RGB, bool) { | |
| 738 | + | p, ok := r.take(3) | |
| 739 | + | if !ok { | |
| 740 | + | return renderer.RGB{}, false | |
| 741 | + | } | |
| 742 | + | return renderer.RGB{R: p[0], G: p[1], B: p[2]}, true | |
| 743 | + | } | |
| 744 | + | func (r *screenReader) uvarint() (uint64, bool) { | |
| 745 | + | v, n := binary.Uvarint(r.b) | |
| 746 | + | if n <= 0 { | |
| 747 | + | return 0, false | |
| 748 | + | } | |
| 749 | + | var canonical [binary.MaxVarintLen64]byte | |
| 750 | + | if binary.PutUvarint(canonical[:], v) != n { | |
| 751 | + | return 0, false | |
| 752 | + | } | |
| 753 | + | r.b = r.b[n:] | |
| 754 | + | return v, true | |
| 755 | + | } | |
M internal/ports/wire_screen_test.go +18 -32
| @@ -11,17 +11,19 @@ import ( | |||
| 11 | 11 | "github.com/bnema/vev/pkg/renderer" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | - | type screenGoldenFixture struct { | |
| 14 | + | func screenGoldenMessages() []struct { | |
| 15 | 15 | name string | |
| 16 | 16 | msg ScreenUpdate | |
| 17 | 17 | hex string | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | func screenGoldenMessages() []screenGoldenFixture { | |
| 18 | + | } { | |
| 21 | 19 | defaultCell := func(r rune) renderer.Cell { | |
| 22 | 20 | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 23 | 21 | } | |
| 24 | - | return []screenGoldenFixture{ | |
| 22 | + | return []struct { | |
| 23 | + | name string | |
| 24 | + | msg ScreenUpdate | |
| 25 | + | hex string | |
| 26 | + | }{ | |
| 25 | 27 | { | |
| 26 | 28 | name: "initial 1x1 snapshot", | |
| 27 | 29 | msg: ScreenUpdate{ | |
| @@ -78,18 +80,9 @@ func screenGoldenMessages() []screenGoldenFixture { | |||
| 78 | 80 | } | |
| 79 | 81 | } | |
| 80 | 82 | ||
| 81 | - | func screenGoldenMessage(name string) screenGoldenFixture { | |
| 82 | - | for _, fixture := range screenGoldenMessages() { | |
| 83 | - | if fixture.name == name { | |
| 84 | - | return fixture | |
| 85 | - | } | |
| 86 | - | } | |
| 87 | - | panic("unknown screen golden fixture: " + name) | |
| 88 | - | } | |
| 89 | - | ||
| 90 | 83 | func TestScreenUpdateStateNumbers(t *testing.T) { | |
| 91 | - | snapshot := screenGoldenMessage("initial 1x1 snapshot").msg | |
| 92 | - | delta := screenGoldenMessage("cursor-only delta").msg | |
| 84 | + | snapshot := screenGoldenMessages()[0].msg | |
| 85 | + | delta := screenGoldenMessages()[3].msg | |
| 93 | 86 | valid := []ScreenUpdate{ | |
| 94 | 87 | snapshot, | |
| 95 | 88 | func() ScreenUpdate { | |
| @@ -212,7 +205,7 @@ func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) { | |||
| 212 | 205 | } | |
| 213 | 206 | ||
| 214 | 207 | func TestScreenUpdateHostileFields(t *testing.T) { | |
| 215 | - | golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex) | |
| 208 | + | golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex) | |
| 216 | 209 | tests := []struct { | |
| 217 | 210 | name string | |
| 218 | 211 | mut func([]byte) | |
| @@ -225,7 +218,7 @@ func TestScreenUpdateHostileFields(t *testing.T) { | |||
| 225 | 218 | {"style ID mismatch", func(b []byte) { b[66], b[67] = 0, 1 }}, | |
| 226 | 219 | {"run cell mismatch", func(b []byte) { b[68], b[69] = 0, 2 }}, | |
| 227 | 220 | {"invalid Unicode", func(b []byte) { b[len(b)-1] = 0x81 }}, | |
| 228 | - | {"truncated final varint", func(b []byte) { b[len(b)-1] = 0x80 }}, | |
| 221 | + | {"overflow varint", func(b []byte) { b[len(b)-1] = 0x80 }}, | |
| 229 | 222 | } | |
| 230 | 223 | for _, tt := range tests { | |
| 231 | 224 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -255,13 +248,9 @@ func TestScreenUpdateHostileFields(t *testing.T) { | |||
| 255 | 248 | func TestScreenUpdateSpanAndShapeValidation(t *testing.T) { | |
| 256 | 249 | cell := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |
| 257 | 250 | base := ScreenUpdate{ | |
| 258 | - | BaseStateNum: 1, NewStateNum: 2, | |
| 259 | 251 | Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 2}, Cursor: ScreenCursor{Visible: true}, | |
| 260 | 252 | Spans: []ScreenSpan{{Y: 0, X: 0, Cells: []renderer.Cell{cell}}, {Y: 1, X: 0, Cells: []renderer.Cell{cell}}}, | |
| 261 | 253 | } | |
| 262 | - | if err := ValidateScreenUpdate(base); err != nil { | |
| 263 | - | t.Fatalf("test base is invalid: %v", err) | |
| 264 | - | } | |
| 265 | 254 | tests := []struct { | |
| 266 | 255 | name string | |
| 267 | 256 | edit func(*ScreenUpdate) | |
| @@ -288,19 +277,19 @@ func TestScreenUpdateSpanAndShapeValidation(t *testing.T) { | |||
| 288 | 277 | if _, err := MarshalScreenUpdate(tooWide); !errors.Is(err, ErrInvalidScreenUpdate) { | |
| 289 | 278 | t.Fatalf("out-of-bounds span err = %v", err) | |
| 290 | 279 | } | |
| 291 | - | if _, err := MarshalScreenUpdate(ScreenUpdate{BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{{Rune: rune(0xd800)}}}}}); !errors.Is(err, ErrInvalidScreenUpdate) { | |
| 280 | + | if _, err := MarshalScreenUpdate(ScreenUpdate{Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{{Rune: rune(0xd800)}}}}}); !errors.Is(err, ErrInvalidScreenUpdate) { | |
| 292 | 281 | t.Fatalf("invalid rune err = %v", err) | |
| 293 | 282 | } | |
| 294 | - | if _, err := MarshalScreenUpdate(ScreenUpdate{NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 2, Rows: 2}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell, cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) { | |
| 283 | + | if _, err := MarshalScreenUpdate(ScreenUpdate{Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 2, Rows: 2}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell, cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) { | |
| 295 | 284 | t.Fatalf("incomplete snapshot err = %v", err) | |
| 296 | 285 | } | |
| 297 | - | if _, err := MarshalScreenUpdate(ScreenUpdate{BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{StyleSet: true, Style: 7}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) { | |
| 286 | + | if _, err := MarshalScreenUpdate(ScreenUpdate{Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{StyleSet: true, Style: 7}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) { | |
| 298 | 287 | t.Fatalf("cursor style err = %v", err) | |
| 299 | 288 | } | |
| 300 | 289 | } | |
| 301 | 290 | ||
| 302 | 291 | func TestScreenUpdateOwnership(t *testing.T) { | |
| 303 | - | golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex) | |
| 292 | + | golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex) | |
| 304 | 293 | decoded, err := UnmarshalScreenUpdate(golden) | |
| 305 | 294 | if err != nil { | |
| 306 | 295 | t.Fatal(err) | |
| @@ -311,7 +300,7 @@ func TestScreenUpdateOwnership(t *testing.T) { | |||
| 311 | 300 | if err != nil { | |
| 312 | 301 | t.Fatal(err) | |
| 313 | 302 | } | |
| 314 | - | if !bytes.Equal(want, mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)) { | |
| 303 | + | if !bytes.Equal(want, mustDecodeHex(t, screenGoldenMessages()[0].hex)) { | |
| 315 | 304 | t.Fatal("decoded screen retained transport payload") | |
| 316 | 305 | } | |
| 317 | 306 | } | |
| @@ -320,8 +309,8 @@ func TestScreenUpdateLimits(t *testing.T) { | |||
| 320 | 309 | if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) { | |
| 321 | 310 | t.Fatalf("oversize err = %v", err) | |
| 322 | 311 | } | |
| 323 | - | tooMany := screenGoldenMessage("initial 1x1 snapshot").msg | |
| 324 | - | tooMany.Spans = make([]ScreenSpan, MaxScreenSpans+1) | |
| 312 | + | tooMany := screenGoldenMessages()[0].msg | |
| 313 | + | tooMany.Spans = make([]ScreenSpan, screenSpanLimit+1) | |
| 325 | 314 | if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) { | |
| 326 | 315 | t.Fatalf("span limit err = %v", err) | |
| 327 | 316 | } | |
| @@ -400,9 +389,6 @@ func FuzzScreenUpdate(f *testing.F) { | |||
| 400 | 389 | if err != nil { | |
| 401 | 390 | t.Fatal(err) | |
| 402 | 391 | } | |
| 403 | - | if !bytes.Equal(encoded, data) { | |
| 404 | - | t.Fatalf("re-encoded accepted input = %x, want %x", encoded, data) | |
| 405 | - | } | |
| 406 | 392 | roundtrip, err := UnmarshalScreenUpdate(encoded) | |
| 407 | 393 | if err != nil { | |
| 408 | 394 | t.Fatal(err) | |
M internal/ports/wire_test.go +0 -4
| @@ -625,10 +625,6 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) { | |||
| 625 | 625 | } | |
| 626 | 626 | ||
| 627 | 627 | assertAllPrefixesFail(t, got[:24], UnmarshalOutput) | |
| 628 | - | withTrailingData := append(append([]byte(nil), got...), 0xff) | |
| 629 | - | trailing, err := UnmarshalOutput(withTrailingData) | |
| 630 | - | require.NoError(t, err) | |
| 631 | - | require.Equal(t, append(append([]byte(nil), msg.Data...), 0xff), trailing.Data) | |
| 632 | 628 | } | |
| 633 | 629 | ||
| 634 | 630 | func TestAckGoldenAndRoundTrip(t *testing.T) { | |
M internal/usecase/daemon/attachment_transition.go +1 -3
| @@ -195,9 +195,7 @@ func (d *Daemon) publishAttachmentTransition(req attachmentTransitionRequest) (a | |||
| 195 | 195 | // still owns the activation barrier (and therefore sendMu). | |
| 196 | 196 | func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) { | |
| 197 | 197 | ac := result.published.ac | |
| 198 | - | if ac.output != nil { | |
| 199 | - | ac.output.rebase() | |
| 200 | - | } | |
| 198 | + | ac.rebaseOutput() | |
| 201 | 199 | ac.captureFrames = nil | |
| 202 | 200 | if sess, ok := localSession(result.published.sess); ok { | |
| 203 | 201 | d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false) | |
M internal/usecase/daemon/client.go +55 -7
| @@ -36,9 +36,12 @@ type attachedClient struct { | |||
| 36 | 36 | tr ports.Transport | |
| 37 | 37 | transportIncarnation uint64 | |
| 38 | 38 | output *outputStateStream | |
| 39 | - | overlays *overlayRuntime | |
| 40 | - | overlayOnce sync.Once | |
| 41 | - | clientID [16]byte | |
| 39 | + | // screenOutput is allocated only for proxied attachments and shares output's | |
| 40 | + | // state-number/ACK window. Both streams are serialized by sendMu. | |
| 41 | + | screenOutput *structuredOutputStream | |
| 42 | + | overlays *overlayRuntime | |
| 43 | + | overlayOnce sync.Once | |
| 44 | + | clientID [16]byte | |
| 42 | 45 | // roleGeneration is the wire-facing capability generation. roleEffects is | |
| 43 | 46 | // the linearization gate for every role-bound observable operation. A role | |
| 44 | 47 | // transition freezes and drains this gate before changing either generation | |
| @@ -62,6 +65,9 @@ type attachedClient struct { | |||
| 62 | 65 | pipelineCache composeCacheInput | |
| 63 | 66 | pipelineScratch composeCacheInput | |
| 64 | 67 | renderScratch renderCaptureScratch // only touched while sendMu is held | |
| 68 | + | // proxyCapture retains the last captured proxy frame for damage-aware | |
| 69 | + | // updates. It is attachment-owned and only touched while sendMu is held. | |
| 70 | + | proxyCapture proxyCapture | |
| 65 | 71 | // sessionMeta is the last authoritative metadata snapshot successfully sent | |
| 66 | 72 | // on a proxied attachment. It is only read or written while sendMu is held. | |
| 67 | 73 | sessionMeta ports.SessionMeta | |
| @@ -291,6 +297,46 @@ func (ac *attachedClient) ackOutputState(state uint64) { | |||
| 291 | 297 | ac.output.ack(state) | |
| 292 | 298 | } | |
| 293 | 299 | ||
| 300 | + | // ensureScreenOutput is called while sendMu owns the attachment. Production | |
| 301 | + | // constructors allocate this at attachment creation; the lazy path keeps | |
| 302 | + | // hand-built test attachments faithful when they set proxied after creation. | |
| 303 | + | func (ac *attachedClient) ensureScreenOutput() *structuredOutputStream { | |
| 304 | + | if ac == nil || !ac.proxied || ac.output == nil { | |
| 305 | + | return nil | |
| 306 | + | } | |
| 307 | + | if ac.screenOutput == nil { | |
| 308 | + | ac.screenOutput = newStructuredOutputStream(ac.output) | |
| 309 | + | } | |
| 310 | + | return ac.screenOutput | |
| 311 | + | } | |
| 312 | + | ||
| 313 | + | // discardProxyCapture abandons a candidate that was not emitted. The next | |
| 314 | + | // capture clones the authoritative screen once, avoiding replaying pending | |
| 315 | + | // scroll damage onto an already-captured failed candidate. | |
| 316 | + | func (ac *attachedClient) discardProxyCapture() { | |
| 317 | + | if ac != nil && ac.proxied { | |
| 318 | + | ac.proxyCapture = proxyCapture{} | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | // rebaseOutput resets both wire representations while retaining the shared | |
| 323 | + | // state-number chain. Callers hold sendMu (or the activation barrier). | |
| 324 | + | func (ac *attachedClient) rebaseOutput() { | |
| 325 | + | if ac == nil { | |
| 326 | + | return | |
| 327 | + | } | |
| 328 | + | if ac.proxied { | |
| 329 | + | ac.ensureScreenOutput() | |
| 330 | + | } | |
| 331 | + | if ac.output != nil { | |
| 332 | + | ac.output.rebase() | |
| 333 | + | } | |
| 334 | + | if ac.screenOutput != nil { | |
| 335 | + | ac.screenOutput.rebase() | |
| 336 | + | } | |
| 337 | + | ac.proxyCapture = proxyCapture{} | |
| 338 | + | } | |
| 339 | + | ||
| 294 | 340 | var errTransportReplaced = errors.New("client transport was replaced") | |
| 295 | 341 | ||
| 296 | 342 | // sendExpectedTransport writes only when expected is still the attachment's | |
| @@ -519,15 +565,19 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||
| 519 | 565 | if opts.resumeCapable { | |
| 520 | 566 | resumeToken = d.nextResumeTokenLocked() | |
| 521 | 567 | } | |
| 568 | + | output := newOutputStateStream(opts.maxOutputInFlight) | |
| 522 | 569 | ac := &attachedClient{ | |
| 523 | 570 | tr: tr, | |
| 524 | - | output: newOutputStateStream(opts.maxOutputInFlight), | |
| 571 | + | output: output, | |
| 525 | 572 | size: sz, | |
| 526 | 573 | clientID: opts.clientID, | |
| 527 | 574 | resumeCapable: opts.resumeCapable, | |
| 528 | 575 | resumeToken: resumeToken, | |
| 529 | 576 | proxied: opts.proxied, | |
| 530 | 577 | } | |
| 578 | + | if opts.proxied { | |
| 579 | + | ac.screenOutput = newStructuredOutputStream(output) | |
| 580 | + | } | |
| 531 | 581 | ac.initOverlays() | |
| 532 | 582 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | |
| 533 | 583 | return ac | |
| @@ -642,9 +692,7 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool { | |||
| 642 | 692 | if token.ac.renderStages.handoffRebase != nil { | |
| 643 | 693 | token.ac.renderStages.handoffRebase() | |
| 644 | 694 | } | |
| 645 | - | if token.ac.output != nil { | |
| 646 | - | token.ac.output.rebase() | |
| 647 | - | } | |
| 695 | + | token.ac.rebaseOutput() | |
| 648 | 696 | // Captures belong to the old session even when pane-local IDs happen | |
| 649 | 697 | // to be reused by the destination. | |
| 650 | 698 | token.ac.captureFrames = nil | |
M internal/usecase/daemon/client_frame_routing.go +1 -1
| @@ -265,7 +265,7 @@ func (d *Daemon) resetActiveOutput(token attachmentRoleToken) bool { | |||
| 265 | 265 | ac.sendMu.Unlock() | |
| 266 | 266 | return false | |
| 267 | 267 | } | |
| 268 | - | ac.output.rebase() | |
| 268 | + | ac.rebaseOutput() | |
| 269 | 269 | ac.sendMu.Unlock() | |
| 270 | 270 | ||
| 271 | 271 | rc := token.sess.core().coordinator.Load() | |
M internal/usecase/daemon/output_state_stream.go +9 -2
| @@ -85,9 +85,16 @@ func (p *preparedOutput) commitNoSend() { | |||
| 85 | 85 | ||
| 86 | 86 | func (p *preparedOutput) commit() { | |
| 87 | 87 | p.draw.Commit() | |
| 88 | - | if p.reset { | |
| 89 | - | p.stream.forceSnapshot = false | |
| 88 | + | p.stream.forceSnapshot = false | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (s *outputStateStream) frame(data []byte, reset bool, echoAck uint64) ports.Frame { | |
| 92 | + | s.next++ | |
| 93 | + | base := s.next - 1 | |
| 94 | + | if reset { | |
| 95 | + | base = 0 | |
| 90 | 96 | } | |
| 97 | + | return frameOutputState(data, base, s.next, echoAck) | |
| 91 | 98 | } | |
| 92 | 99 | ||
| 93 | 100 | func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame { | |
M internal/usecase/daemon/output_state_stream_test.go +7 -60
| @@ -41,11 +41,7 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T | |||
| 41 | 41 | fillOutputStateRows(initial, []string{"abc"}) | |
| 42 | 42 | first, err := stream.prepare(initial, nil, true) | |
| 43 | 43 | require.NoError(t, err) | |
| 44 | - | var firstFrame ports.Frame | |
| 45 | - | require.NoError(t, first.send(first.data, 0, func(frame ports.Frame) error { | |
| 46 | - | firstFrame = frame | |
| 47 | - | return nil | |
| 48 | - | })) | |
| 44 | + | require.NoError(t, first.send(first.data, 0, func(ports.Frame) error { return nil })) | |
| 49 | 45 | require.Equal(t, uint64(1), stream.next) | |
| 50 | 46 | ||
| 51 | 47 | changed := initial.Clone() | |
| @@ -57,11 +53,7 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T | |||
| 57 | 53 | require.Empty(t, probe.Bytes(), "preparation must not advance the renderer shadow") | |
| 58 | 54 | ||
| 59 | 55 | sendErr := errors.New("send failed") | |
| 60 | - | var failedFrame ports.Frame | |
| 61 | - | require.ErrorIs(t, pending.send(pending.data, 0, func(frame ports.Frame) error { | |
| 62 | - | failedFrame = frame | |
| 63 | - | return sendErr | |
| 64 | - | }), sendErr) | |
| 56 | + | require.ErrorIs(t, pending.send(pending.data, 0, func(ports.Frame) error { return sendErr }), sendErr) | |
| 65 | 57 | require.Equal(t, uint64(1), stream.next, "failed send must not advance the state chain") | |
| 66 | 58 | pending.commitNoSend() | |
| 67 | 59 | probe, err = stream.renderer.Prepare(initial, nil, false) | |
| @@ -79,18 +71,6 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T | |||
| 79 | 71 | require.NoError(t, err) | |
| 80 | 72 | require.Zero(t, out.BaseStateNum, "retry after ambiguous send failure must be dependency-free") | |
| 81 | 73 | require.Equal(t, uint64(2), out.NewStateNum, "state numbers remain monotonic across rebases") | |
| 82 | - | require.Equal(t, "\x1b[1;1Hxbc\x1b[0m", string(out.Data)) | |
| 83 | - | ||
| 84 | - | beforeFailure := vt.NewScreen(3, 1) | |
| 85 | - | mustApplyOutput(t, beforeFailure, firstFrame) | |
| 86 | - | afterFailure := vt.NewScreen(3, 1) | |
| 87 | - | mustApplyOutput(t, afterFailure, firstFrame) | |
| 88 | - | mustApplyOutput(t, afterFailure, failedFrame) | |
| 89 | - | mustApplyOutput(t, beforeFailure, sent) | |
| 90 | - | mustApplyOutput(t, afterFailure, sent) | |
| 91 | - | for _, screen := range []*vt.Screen{beforeFailure, afterFailure} { | |
| 92 | - | require.Equal(t, "xbc", outputStateRow(screen.Frame.Row(0))) | |
| 93 | - | } | |
| 94 | 74 | require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once") | |
| 95 | 75 | require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error { | |
| 96 | 76 | t.Fatal("completed output sent twice") | |
| @@ -102,29 +82,6 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T | |||
| 102 | 82 | require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow") | |
| 103 | 83 | } | |
| 104 | 84 | ||
| 105 | - | func TestPreparedDeltaCommitPreservesRebaseSnapshot(t *testing.T) { | |
| 106 | - | stream := newOutputStateStream() | |
| 107 | - | initial := renderer.NewFrame(1, 1) | |
| 108 | - | initial.Set(0, 0, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()}) | |
| 109 | - | first, err := stream.prepare(initial, nil, true) | |
| 110 | - | require.NoError(t, err) | |
| 111 | - | require.NoError(t, first.send(first.data, 0, func(ports.Frame) error { return nil })) | |
| 112 | - | ||
| 113 | - | changed := initial.Clone() | |
| 114 | - | changed.Set(0, 0, renderer.Cell{Rune: 'b', Style: renderer.DefaultStyle()}) | |
| 115 | - | prepared, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |
| 116 | - | require.NoError(t, err) | |
| 117 | - | require.False(t, prepared.reset) | |
| 118 | - | ||
| 119 | - | stream.rebase() | |
| 120 | - | prepared.commitNoSend() | |
| 121 | - | require.True(t, stream.forceSnapshot, "committing a pre-rebase delta must not clear the rebase snapshot") | |
| 122 | - | ||
| 123 | - | next, err := stream.prepare(changed, nil, false) | |
| 124 | - | require.NoError(t, err) | |
| 125 | - | require.True(t, next.reset, "the next output after rebase must remain a snapshot") | |
| 126 | - | } | |
| 127 | - | ||
| 128 | 85 | func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) { | |
| 129 | 86 | stream := newOutputStateStream() | |
| 130 | 87 | frame := renderer.NewFrame(3, 1) | |
| @@ -179,8 +136,8 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T | |||
| 179 | 136 | } | |
| 180 | 137 | } | |
| 181 | 138 | ||
| 182 | - | func renderCommitted(stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) { | |
| 183 | - | prepared, err := stream.prepare(frame, damage, reset) | |
| 139 | + | func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) { | |
| 140 | + | prepared, err := s.prepare(frame, damage, reset) | |
| 184 | 141 | if err != nil { | |
| 185 | 142 | return nil, err | |
| 186 | 143 | } | |
| @@ -190,21 +147,11 @@ func renderCommitted(stream *outputStateStream, frame renderer.Frame, damage []r | |||
| 190 | 147 | ||
| 191 | 148 | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | |
| 192 | 149 | t.Helper() | |
| 193 | - | prepared, err := stream.prepare(frame, damage, reset) | |
| 194 | - | if err != nil || len(prepared.data) == 0 { | |
| 195 | - | if err == nil { | |
| 196 | - | prepared.commitNoSend() | |
| 197 | - | } | |
| 198 | - | return ports.Frame{}, false, err | |
| 199 | - | } | |
| 200 | - | var output ports.Frame | |
| 201 | - | if err := prepared.send(prepared.data, echoAck, func(frame ports.Frame) error { | |
| 202 | - | output = frame | |
| 203 | - | return nil | |
| 204 | - | }); err != nil { | |
| 150 | + | data, err := stream.render(frame, damage, reset) | |
| 151 | + | if err != nil || len(data) == 0 { | |
| 205 | 152 | return ports.Frame{}, false, err | |
| 206 | 153 | } | |
| 207 | - | return output, true, nil | |
| 154 | + | return stream.frame(data, reset, echoAck), true, nil | |
| 208 | 155 | } | |
| 209 | 156 | ||
| 210 | 157 | // A resize is a state reset, but it must not turn subsequent idle or damaged | |
M internal/usecase/daemon/performance_benchmark_test.go +324 -20
| @@ -18,6 +18,7 @@ import ( | |||
| 18 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | 19 | snapcodec "github.com/bnema/vev/internal/usecase/snapshot" | |
| 20 | 20 | "github.com/bnema/vev/pkg/renderer" | |
| 21 | + | "github.com/bnema/vev/pkg/vt" | |
| 21 | 22 | ) | |
| 22 | 23 | ||
| 23 | 24 | func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) { | |
| @@ -214,9 +215,9 @@ func TestProxyANSIBenchmarkFixtures(t *testing.T) { | |||
| 214 | 215 | ||
| 215 | 216 | for _, fixture := range fixtures { | |
| 216 | 217 | t.Run(fixture.name, func(t *testing.T) { | |
| 217 | - | proxy := newProxyANSIBenchmarkSession(t) | |
| 218 | + | state := newProxyANSIApplyState(t) | |
| 218 | 219 | ||
| 219 | - | ack, reset, changed := proxy.applyOutputForGeneration(proxy.linkGeneration, ports.Output{ | |
| 220 | + | ack, reset, changed := state.apply(ports.Output{ | |
| 220 | 221 | BaseStateNum: 0, | |
| 221 | 222 | NewStateNum: 1, | |
| 222 | 223 | Data: fixture.data, | |
| @@ -225,22 +226,55 @@ func TestProxyANSIBenchmarkFixtures(t *testing.T) { | |||
| 225 | 226 | require.Equal(t, uint64(1), ack) | |
| 226 | 227 | require.False(t, reset) | |
| 227 | 228 | require.True(t, changed) | |
| 228 | - | proxy.mu.Lock() | |
| 229 | - | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, proxy.contentSize) | |
| 230 | - | proxy.mu.Unlock() | |
| 229 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, domain.Size{Cols: state.screen.Frame.Width, Rows: state.screen.Frame.Height}) | |
| 231 | 230 | }) | |
| 232 | 231 | } | |
| 233 | 232 | } | |
| 234 | 233 | ||
| 235 | - | func newProxyANSIBenchmarkSession(t testing.TB) *proxySession { | |
| 234 | + | // proxyANSIApplyState is deliberately a test-only copy of the removed ANSI | |
| 235 | + | // relay state: each operation copies transport bytes, feeds vt.Screen.Write, | |
| 236 | + | // and advances a dependent state chain. It keeps this historical benchmark | |
| 237 | + | // independent of structured snapshots and renderer-frame cloning. | |
| 238 | + | type proxyANSIApplyState struct { | |
| 239 | + | mu sync.Mutex | |
| 240 | + | screen *vt.Screen | |
| 241 | + | appliedState uint64 | |
| 242 | + | awaitingReset bool | |
| 243 | + | } | |
| 244 | + | ||
| 245 | + | func newProxyANSIApplyState(t testing.TB) *proxyANSIApplyState { | |
| 236 | 246 | t.Helper() | |
| 237 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "benchmark", Name: "ansi"}, domain.Size{Cols: 120, Rows: 40 + tabChromeRows}) | |
| 238 | - | require.NoError(t, err) | |
| 239 | - | proxy.mu.Lock() | |
| 240 | - | contentSize := proxy.contentSize | |
| 241 | - | proxy.mu.Unlock() | |
| 242 | - | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, contentSize) | |
| 243 | - | return proxy | |
| 247 | + | return &proxyANSIApplyState{screen: vt.NewScreen(120, 40)} | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset, changed bool) { | |
| 251 | + | data := append([]byte(nil), out.Data...) | |
| 252 | + | s.mu.Lock() | |
| 253 | + | defer s.mu.Unlock() | |
| 254 | + | if out.NewStateNum == 0 { | |
| 255 | + | s.screen.Write(data) | |
| 256 | + | return 0, false, len(data) != 0 | |
| 257 | + | } | |
| 258 | + | if out.BaseStateNum == 0 { | |
| 259 | + | if !s.awaitingReset && out.NewStateNum <= s.appliedState { | |
| 260 | + | return 0, false, false | |
| 261 | + | } | |
| 262 | + | s.screen = vt.NewScreen(s.screen.Frame.Width, s.screen.Frame.Height) | |
| 263 | + | s.screen.Write(data) | |
| 264 | + | s.appliedState = out.NewStateNum | |
| 265 | + | s.awaitingReset = false | |
| 266 | + | return out.NewStateNum, false, true | |
| 267 | + | } | |
| 268 | + | if s.awaitingReset || out.BaseStateNum != s.appliedState || out.NewStateNum != out.BaseStateNum+1 { | |
| 269 | + | if !s.awaitingReset { | |
| 270 | + | s.awaitingReset = true | |
| 271 | + | requestReset = true | |
| 272 | + | } | |
| 273 | + | return 0, requestReset, false | |
| 274 | + | } | |
| 275 | + | s.screen.Write(data) | |
| 276 | + | s.appliedState = out.NewStateNum | |
| 277 | + | return out.NewStateNum, false, len(data) != 0 | |
| 244 | 278 | } | |
| 245 | 279 | ||
| 246 | 280 | type proxyANSIBenchmarkFixture struct { | |
| @@ -272,6 +306,277 @@ func proxyANSIBenchmarkFixtures() []proxyANSIBenchmarkFixture { | |||
| 272 | 306 | } | |
| 273 | 307 | } | |
| 274 | 308 | ||
| 309 | + | type proxyPipelineFixture struct { | |
| 310 | + | name string | |
| 311 | + | newFrame func() renderer.Frame | |
| 312 | + | damage []renderer.Damage | |
| 313 | + | mutate func(renderer.Frame, int) | |
| 314 | + | } | |
| 315 | + | ||
| 316 | + | func proxyPipelineFixtures() []proxyPipelineFixture { | |
| 317 | + | const width, height = 120, 40 | |
| 318 | + | return []proxyPipelineFixture{ | |
| 319 | + | { | |
| 320 | + | name: "one-cell", | |
| 321 | + | newFrame: func() renderer.Frame { return renderer.NewFrame(width, height) }, | |
| 322 | + | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 59, Y: 19, Width: 1, Height: 1}}, | |
| 323 | + | mutate: func(frame renderer.Frame, i int) { | |
| 324 | + | cell := frame.At(59, 19) | |
| 325 | + | cell.Rune = rune('a' + i%26) | |
| 326 | + | frame.Set(59, 19, cell) | |
| 327 | + | }, | |
| 328 | + | }, | |
| 329 | + | { | |
| 330 | + | name: "full-line", | |
| 331 | + | newFrame: func() renderer.Frame { return renderer.NewFrame(width, height) }, | |
| 332 | + | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}}, | |
| 333 | + | mutate: func(frame renderer.Frame, i int) { | |
| 334 | + | for x := range frame.Width { | |
| 335 | + | frame.Set(x, 0, renderer.Cell{Rune: rune('a' + (x+i)%26), Style: renderer.DefaultStyle()}) | |
| 336 | + | } | |
| 337 | + | }, | |
| 338 | + | }, | |
| 339 | + | { | |
| 340 | + | name: "styled-line", | |
| 341 | + | newFrame: func() renderer.Frame { return renderer.NewFrame(width, height) }, | |
| 342 | + | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}}, | |
| 343 | + | mutate: func(frame renderer.Frame, i int) { | |
| 344 | + | for fragment := range 12 { | |
| 345 | + | style := renderer.DefaultStyle() | |
| 346 | + | style.Bold = fragment%2 == 0 | |
| 347 | + | style.HasForegroundRGB = true | |
| 348 | + | style.ForegroundRGB = renderer.RGB{R: uint8(fragment * 17), G: uint8(fragment * 13), B: uint8(fragment * 7)} | |
| 349 | + | for x := fragment * 10; x < (fragment+1)*10; x++ { | |
| 350 | + | frame.Set(x, 0, renderer.Cell{Rune: rune('a' + (fragment+i)%26), Style: style}) | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | }, | |
| 354 | + | }, | |
| 355 | + | { | |
| 356 | + | name: "full-width-scroll", | |
| 357 | + | newFrame: func() renderer.Frame { | |
| 358 | + | frame := renderer.NewFrame(width, height) | |
| 359 | + | for y := range height { | |
| 360 | + | for x, r := range performanceFullWidthRow(width, 0, y) { | |
| 361 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 362 | + | } | |
| 363 | + | } | |
| 364 | + | return frame | |
| 365 | + | }, | |
| 366 | + | damage: []renderer.Damage{ | |
| 367 | + | {Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: width, Height: height, Count: 1}, | |
| 368 | + | {Kind: renderer.DamageText, X: 0, Y: height - 1, Width: width, Height: 1}, | |
| 369 | + | }, | |
| 370 | + | mutate: func(frame renderer.Frame, i int) { | |
| 371 | + | frame.ScrollUp(0, frame.Height-1, 1) | |
| 372 | + | for x := range frame.Width { | |
| 373 | + | frame.Set(x, frame.Height-1, renderer.Cell{Rune: rune('a' + i%26), Style: renderer.DefaultStyle()}) | |
| 374 | + | } | |
| 375 | + | }, | |
| 376 | + | }, | |
| 377 | + | } | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | func TestProxyPipelineFixturesProduceEquivalentFrames(t *testing.T) { | |
| 381 | + | for _, fixture := range proxyPipelineFixtures() { | |
| 382 | + | t.Run(fixture.name, func(t *testing.T) { | |
| 383 | + | ansiFrame := fixture.newFrame() | |
| 384 | + | structuredFrame := ansiFrame.Clone() | |
| 385 | + | ansiRenderer := renderer.New(renderer.Capabilities{}) | |
| 386 | + | ansiState := newProxyANSIApplyState(t) | |
| 387 | + | ansiSender := proxyPipelineANSISender(ansiState) | |
| 388 | + | initialANSI, err := ansiRenderer.Prepare(ansiFrame, nil, true) | |
| 389 | + | require.NoError(t, err) | |
| 390 | + | require.NoError(t, ansiSender(ports.Frame{ | |
| 391 | + | Type: ports.MsgOutput, | |
| 392 | + | Payload: ports.MarshalOutput(ports.Output{ | |
| 393 | + | BaseStateNum: 0, | |
| 394 | + | NewStateNum: 1, | |
| 395 | + | Data: initialANSI.Bytes(), | |
| 396 | + | }), | |
| 397 | + | })) | |
| 398 | + | initialANSI.Commit() | |
| 399 | + | ||
| 400 | + | structuredState := newProxyScreenState(frameSize(structuredFrame)) | |
| 401 | + | structuredStream := newStructuredOutputStream(newOutputStateStream()) | |
| 402 | + | initialStructured, err := structuredStream.prepare(structuredFrame, nil, cursorOut{}, true, 0) | |
| 403 | + | require.NoError(t, err) | |
| 404 | + | require.NoError(t, initialStructured.send(proxyPipelineStructuredSender(structuredState))) | |
| 405 | + | require.Equal(t, uint64(1), ansiState.appliedState) | |
| 406 | + | require.Equal(t, uint64(1), structuredState.stateNum) | |
| 407 | + | requireProxyPipelineFramesEqual(t, ansiState.screen.Frame, structuredState.frame) | |
| 408 | + | ||
| 409 | + | for i := 0; i < 3; i++ { | |
| 410 | + | fixture.mutate(ansiFrame, i) | |
| 411 | + | fixture.mutate(structuredFrame, i) | |
| 412 | + | ansiDraw, err := ansiRenderer.Prepare(ansiFrame, fixture.damage, false) | |
| 413 | + | require.NoError(t, err) | |
| 414 | + | next := ansiState.appliedState + 1 | |
| 415 | + | require.NoError(t, ansiSender(ports.Frame{ | |
| 416 | + | Type: ports.MsgOutput, | |
| 417 | + | Payload: ports.MarshalOutput(ports.Output{ | |
| 418 | + | BaseStateNum: ansiState.appliedState, | |
| 419 | + | NewStateNum: next, | |
| 420 | + | Data: ansiDraw.Bytes(), | |
| 421 | + | }), | |
| 422 | + | })) | |
| 423 | + | ansiDraw.Commit() | |
| 424 | + | ||
| 425 | + | structuredDraw, err := structuredStream.prepare(structuredFrame, fixture.damage, cursorOut{}, false, 0) | |
| 426 | + | require.NoError(t, err) | |
| 427 | + | require.NoError(t, structuredDraw.send(proxyPipelineStructuredSender(structuredState))) | |
| 428 | + | require.Equal(t, next, ansiState.appliedState) | |
| 429 | + | require.Equal(t, next, structuredState.stateNum) | |
| 430 | + | requireProxyPipelineFramesEqual(t, ansiState.screen.Frame, structuredState.frame) | |
| 431 | + | requireProxyPipelineFramesEqual(t, ansiFrame, ansiState.screen.Frame) | |
| 432 | + | requireProxyPipelineFramesEqual(t, structuredFrame, structuredState.frame) | |
| 433 | + | } | |
| 434 | + | }) | |
| 435 | + | } | |
| 436 | + | } | |
| 437 | + | ||
| 438 | + | func requireProxyPipelineFramesEqual(t *testing.T, want, got renderer.Frame) { | |
| 439 | + | t.Helper() | |
| 440 | + | require.Equal(t, want.Width, got.Width) | |
| 441 | + | require.Equal(t, want.Height, got.Height) | |
| 442 | + | for y := range want.Height { | |
| 443 | + | for x := range want.Width { | |
| 444 | + | require.Truef(t, want.At(x, y).Equal(got.At(x, y)), "cell %d,%d differs: want %+v got %+v", x, y, want.At(x, y), got.At(x, y)) | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func proxyPipelineANSISender(state *proxyANSIApplyState) func(ports.Frame) error { | |
| 450 | + | return func(frame ports.Frame) error { | |
| 451 | + | if frame.Type != ports.MsgOutput { | |
| 452 | + | return fmt.Errorf("ANSI benchmark sender received frame type %d", frame.Type) | |
| 453 | + | } | |
| 454 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 455 | + | if err != nil { | |
| 456 | + | return err | |
| 457 | + | } | |
| 458 | + | ack, reset, changed := state.apply(out) | |
| 459 | + | if ack != out.NewStateNum || reset || !changed { | |
| 460 | + | return fmt.Errorf("ANSI benchmark apply = ack %d, reset %t, changed %t; want ack %d", ack, reset, changed, out.NewStateNum) | |
| 461 | + | } | |
| 462 | + | return nil | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | func proxyPipelineStructuredSender(state *proxyScreenState) func(ports.Frame) error { | |
| 467 | + | return func(frame ports.Frame) error { | |
| 468 | + | if frame.Type != ports.MsgScreenUpdate { | |
| 469 | + | return fmt.Errorf("structured benchmark sender received frame type %d", frame.Type) | |
| 470 | + | } | |
| 471 | + | update, err := ports.UnmarshalScreenUpdate(frame.Payload) | |
| 472 | + | if err != nil { | |
| 473 | + | return err | |
| 474 | + | } | |
| 475 | + | if err := state.Apply(update); err != nil { | |
| 476 | + | return err | |
| 477 | + | } | |
| 478 | + | generation := state.generation | |
| 479 | + | if !state.AcknowledgeDamage(generation) { | |
| 480 | + | return fmt.Errorf("structured benchmark damage acknowledgement failed at generation %d", generation) | |
| 481 | + | } | |
| 482 | + | return nil | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | func BenchmarkProxyPipeline(b *testing.B) { | |
| 487 | + | for _, fixture := range proxyPipelineFixtures() { | |
| 488 | + | b.Run(fixture.name, func(b *testing.B) { | |
| 489 | + | b.Run("ansi-pipeline", func(b *testing.B) { | |
| 490 | + | benchmarkProxyANSIPipeline(b, fixture) | |
| 491 | + | }) | |
| 492 | + | b.Run("structured-pipeline", func(b *testing.B) { | |
| 493 | + | benchmarkProxyStructuredPipeline(b, fixture) | |
| 494 | + | }) | |
| 495 | + | }) | |
| 496 | + | } | |
| 497 | + | } | |
| 498 | + | ||
| 499 | + | func benchmarkProxyANSIPipeline(b *testing.B, fixture proxyPipelineFixture) { | |
| 500 | + | b.Helper() | |
| 501 | + | frame := fixture.newFrame() | |
| 502 | + | ansiRenderer := renderer.New(renderer.Capabilities{}) | |
| 503 | + | ansiState := newProxyANSIApplyState(b) | |
| 504 | + | ansiSender := proxyPipelineANSISender(ansiState) | |
| 505 | + | initial, err := ansiRenderer.Prepare(frame, nil, true) | |
| 506 | + | if err != nil { | |
| 507 | + | b.Fatal(err) | |
| 508 | + | } | |
| 509 | + | if err := ansiSender(ports.Frame{ | |
| 510 | + | Type: ports.MsgOutput, | |
| 511 | + | Payload: ports.MarshalOutput(ports.Output{ | |
| 512 | + | BaseStateNum: 0, | |
| 513 | + | NewStateNum: 1, | |
| 514 | + | Data: initial.Bytes(), | |
| 515 | + | }), | |
| 516 | + | }); err != nil { | |
| 517 | + | b.Fatal(err) | |
| 518 | + | } | |
| 519 | + | initial.Commit() | |
| 520 | + | ||
| 521 | + | var wireBytes int | |
| 522 | + | b.ReportAllocs() | |
| 523 | + | b.ResetTimer() | |
| 524 | + | for i := 0; i < b.N; i++ { | |
| 525 | + | fixture.mutate(frame, i) | |
| 526 | + | draw, err := ansiRenderer.Prepare(frame, fixture.damage, false) | |
| 527 | + | if err != nil { | |
| 528 | + | b.Fatal(err) | |
| 529 | + | } | |
| 530 | + | next := ansiState.appliedState + 1 | |
| 531 | + | payload := ports.MarshalOutput(ports.Output{ | |
| 532 | + | BaseStateNum: ansiState.appliedState, | |
| 533 | + | NewStateNum: next, | |
| 534 | + | Data: draw.Bytes(), | |
| 535 | + | }) | |
| 536 | + | if err := ansiSender(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil { | |
| 537 | + | b.Fatal(err) | |
| 538 | + | } | |
| 539 | + | draw.Commit() | |
| 540 | + | wireBytes += len(payload) | |
| 541 | + | } | |
| 542 | + | b.StopTimer() | |
| 543 | + | b.ReportMetric(float64(wireBytes)/float64(b.N), "wirebytes/op") | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | func benchmarkProxyStructuredPipeline(b *testing.B, fixture proxyPipelineFixture) { | |
| 547 | + | b.Helper() | |
| 548 | + | frame := fixture.newFrame() | |
| 549 | + | stream := newStructuredOutputStream(newOutputStateStream()) | |
| 550 | + | state := newProxyScreenState(frameSize(frame)) | |
| 551 | + | sender := proxyPipelineStructuredSender(state) | |
| 552 | + | initial, err := stream.prepare(frame, nil, cursorOut{}, true, 0) | |
| 553 | + | if err != nil { | |
| 554 | + | b.Fatal(err) | |
| 555 | + | } | |
| 556 | + | if err := initial.send(sender); err != nil { | |
| 557 | + | b.Fatal(err) | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | var wireBytes, spans int | |
| 561 | + | b.ReportAllocs() | |
| 562 | + | b.ResetTimer() | |
| 563 | + | for i := 0; i < b.N; i++ { | |
| 564 | + | fixture.mutate(frame, i) | |
| 565 | + | prepared, err := stream.prepare(frame, fixture.damage, cursorOut{}, false, 0) | |
| 566 | + | if err != nil { | |
| 567 | + | b.Fatal(err) | |
| 568 | + | } | |
| 569 | + | wireBytes += len(prepared.data) | |
| 570 | + | spans += len(prepared.update.Spans) | |
| 571 | + | if err := prepared.send(sender); err != nil { | |
| 572 | + | b.Fatal(err) | |
| 573 | + | } | |
| 574 | + | } | |
| 575 | + | b.StopTimer() | |
| 576 | + | b.ReportMetric(float64(wireBytes)/float64(b.N), "wirebytes/op") | |
| 577 | + | b.ReportMetric(float64(spans)/float64(b.N), "spans/op") | |
| 578 | + | } | |
| 579 | + | ||
| 275 | 580 | func TestPerformanceFixtureCounters(t *testing.T) { | |
| 276 | 581 | fixture := newPerformanceFixture(t, performanceConfig{}) | |
| 277 | 582 | ||
| @@ -435,25 +740,24 @@ var daemonSnapshotTopologies = []daemonHistoryTopology{ | |||
| 435 | 740 | {name: "1tab-4panes", tabs: 1, panes: 4}, | |
| 436 | 741 | } | |
| 437 | 742 | ||
| 743 | + | // BenchmarkProxyANSIApply is a component diagnostic for proxy ANSI parsing and | |
| 744 | + | // apply only; it is not a pipeline comparison with structured output. | |
| 438 | 745 | func BenchmarkProxyANSIApply(b *testing.B) { | |
| 439 | 746 | for _, fixture := range proxyANSIBenchmarkFixtures() { | |
| 440 | 747 | b.Run(fixture.name, func(b *testing.B) { | |
| 441 | - | proxy := newProxyANSIBenchmarkSession(b) | |
| 442 | - | generation := proxy.linkGeneration | |
| 443 | - | state := uint64(0) | |
| 748 | + | state := newProxyANSIApplyState(b) | |
| 444 | 749 | b.ReportAllocs() | |
| 445 | 750 | b.SetBytes(int64(len(fixture.data))) | |
| 446 | 751 | for b.Loop() { | |
| 447 | - | next := state + 1 | |
| 448 | - | ack, reset, changed := proxy.applyOutputForGeneration(generation, ports.Output{ | |
| 449 | - | BaseStateNum: state, | |
| 752 | + | next := state.appliedState + 1 | |
| 753 | + | ack, reset, changed := state.apply(ports.Output{ | |
| 754 | + | BaseStateNum: state.appliedState, | |
| 450 | 755 | NewStateNum: next, | |
| 451 | 756 | Data: fixture.data, | |
| 452 | 757 | }) | |
| 453 | 758 | if ack != next || reset || !changed { | |
| 454 | 759 | b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next) | |
| 455 | 760 | } | |
| 456 | - | state = next | |
| 457 | 761 | } | |
| 458 | 762 | b.ReportMetric(float64(len(fixture.data)), "ansibytes/op") | |
| 459 | 763 | }) | |
M internal/usecase/daemon/proxied_contract_test.go +81 -11
| @@ -41,16 +41,18 @@ func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) { | |||
| 41 | 41 | require.Equal(t, uint16(0), meta.Active) | |
| 42 | 42 | require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs) | |
| 43 | 43 | ||
| 44 | - | outputFrame := awaitFrame(t, sends, ports.MsgOutput) | |
| 45 | - | output, err := ports.UnmarshalOutput(outputFrame.Payload) | |
| 44 | + | screenFrame := awaitFrame(t, sends, ports.MsgScreenUpdate) | |
| 45 | + | update, err := ports.UnmarshalScreenUpdate(screenFrame.Payload) | |
| 46 | 46 | require.NoError(t, err) | |
| 47 | - | require.Zero(t, output.BaseStateNum) | |
| 48 | - | ||
| 49 | - | client := vt.NewScreen(contentSize.Cols, contentSize.Rows) | |
| 50 | - | client.Write(output.Data) | |
| 51 | - | require.Equal(t, contentSize.Rows, client.Frame.Height) | |
| 52 | - | for _, row := range frameRows(client.Frame) { | |
| 53 | - | require.NotContains(t, row, "remote-work", "proxied output must not contain remote session chrome") | |
| 47 | + | require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind) | |
| 48 | + | require.Zero(t, update.BaseStateNum) | |
| 49 | + | require.Equal(t, contentSize, update.Size) | |
| 50 | + | require.Len(t, update.Spans, contentSize.Rows, "structured snapshots must carry every full row") | |
| 51 | + | for y, span := range update.Spans { | |
| 52 | + | require.Equal(t, uint16(y), span.Y) | |
| 53 | + | require.Zero(t, span.X) | |
| 54 | + | require.Len(t, span.Cells, contentSize.Cols) | |
| 55 | + | require.NotContains(t, rowText(span.Cells), "remote-work", "proxied screen must not contain remote session chrome") | |
| 54 | 56 | } | |
| 55 | 57 | ||
| 56 | 58 | sess := firstSession(d) | |
| @@ -102,6 +104,64 @@ func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) { | |||
| 102 | 104 | handlers.Wait() | |
| 103 | 105 | } | |
| 104 | 106 | ||
| 107 | + | func TestDesiredCapturedCursorPublishesVisibleAbsoluteCursor(t *testing.T) { | |
| 108 | + | visible := desiredCapturedCursor(capturedCursorInputs{ | |
| 109 | + | row: 2, col: 3, style: 0, visible: true, renderable: true, | |
| 110 | + | content: domain.Rect{X: 4, Y: 5, Width: 8, Height: 4}, | |
| 111 | + | }, 1) | |
| 112 | + | require.Equal(t, cursorOut{valid: true, row: 8, col: 7, style: 1, hasStyle: true}, visible) | |
| 113 | + | ||
| 114 | + | hidden := desiredCapturedCursor(capturedCursorInputs{ | |
| 115 | + | row: 2, col: 3, visible: false, renderable: true, | |
| 116 | + | content: domain.Rect{X: 4, Y: 5, Width: 8, Height: 4}, | |
| 117 | + | }, 1) | |
| 118 | + | require.Equal(t, cursorOut{hidden: true}, hidden) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) { | |
| 122 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 123 | + | client, sends := newCapturingTransport(t) | |
| 124 | + | proxy, ac := newAttachedProxyFixture(t, d, client, newProxyTestTransport()) | |
| 125 | + | ac.proxied = true | |
| 126 | + | ac.screenOutput = newStructuredOutputStream(ac.output) | |
| 127 | + | require.True(t, applyTestScreenText(proxy, 0, 0, "remote")) | |
| 128 | + | ||
| 129 | + | ac.sendMu.Lock() | |
| 130 | + | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true}) | |
| 131 | + | require.True(t, ok) | |
| 132 | + | composed := composeFrame(*state, composeCacheInput{}) | |
| 133 | + | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |
| 134 | + | ||
| 135 | + | metadata := awaitTestValue(t, sends, "proxied remote metadata was not sent") | |
| 136 | + | require.Equal(t, ports.MsgSessionMeta, metadata.Type) | |
| 137 | + | meta, err := ports.UnmarshalSessionMeta(metadata.Payload) | |
| 138 | + | require.NoError(t, err) | |
| 139 | + | require.Equal(t, "work", meta.SessionName) | |
| 140 | + | require.Equal(t, ports.MsgScreenUpdate, awaitTestValue(t, sends, "proxied remote screen was not sent").Type) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestProxiedPaintPublishesAbsoluteVisibleCursorStyle(t *testing.T) { | |
| 144 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 145 | + | ac.proxied = true | |
| 146 | + | pane := sess.activeTab().focusedPane() | |
| 147 | + | pane.mu.Lock() | |
| 148 | + | pane.screen.Write([]byte("\x1b[3;6H\x1b[0 q")) | |
| 149 | + | pane.mu.Unlock() | |
| 150 | + | ||
| 151 | + | d.paint(sess, ac, true, nil) | |
| 152 | + | require.Equal(t, ports.MsgSessionMeta, awaitTestValue(t, sends, "proxied paint did not emit session metadata").Type) | |
| 153 | + | frame := awaitTestValue(t, sends, "proxied paint did not emit structured screen update") | |
| 154 | + | require.Equal(t, ports.MsgScreenUpdate, frame.Type) | |
| 155 | + | update, err := ports.UnmarshalScreenUpdate(frame.Payload) | |
| 156 | + | require.NoError(t, err) | |
| 157 | + | require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind) | |
| 158 | + | require.True(t, update.Cursor.Visible) | |
| 159 | + | require.Equal(t, uint16(2), update.Cursor.Row) | |
| 160 | + | require.Equal(t, uint16(5), update.Cursor.Col) | |
| 161 | + | require.True(t, update.Cursor.StyleSet) | |
| 162 | + | require.Zero(t, update.Cursor.Style) | |
| 163 | + | } | |
| 164 | + | ||
| 105 | 165 | func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) { | |
| 106 | 166 | paneFrame := renderer.NewFrame(8, 2) | |
| 107 | 167 | fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"}) | |
| @@ -131,6 +191,15 @@ func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) { | |||
| 131 | 191 | require.Equal(t, 1, ordinary.cursor.row) | |
| 132 | 192 | } | |
| 133 | 193 | ||
| 194 | + | func TestProxyResizeUpdatesAttachmentViewport(t *testing.T) { | |
| 195 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 196 | + | client, _ := newCapturingTransport(t) | |
| 197 | + | proxy, ac := newAttachedProxyFixture(t, d, client, newProxyTestTransport()) | |
| 198 | + | ||
| 199 | + | d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 24, Rows: 10}) | |
| 200 | + | require.Equal(t, domain.Size{Cols: 24, Rows: 10}, ac.size) | |
| 201 | + | } | |
| 202 | + | ||
| 134 | 203 | func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) { | |
| 135 | 204 | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| 136 | 205 | ac.proxied = true | |
| @@ -206,9 +275,10 @@ func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) { | |||
| 206 | 275 | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |
| 207 | 276 | })) | |
| 208 | 277 | ||
| 209 | - | frame := awaitFrame(t, sends, ports.MsgOutput) | |
| 210 | - | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 278 | + | frame := awaitFrame(t, sends, ports.MsgScreenUpdate) | |
| 279 | + | out, err := ports.UnmarshalScreenUpdate(frame.Payload) | |
| 211 | 280 | require.NoError(t, err) | |
| 281 | + | require.Equal(t, ports.ScreenUpdateSnapshot, out.Kind) | |
| 212 | 282 | require.Zero(t, out.BaseStateNum) | |
| 213 | 283 | ac.sendMu.Lock() | |
| 214 | 284 | require.Equal(t, ac.output.maxOutstanding, ac.output.acked, "reset must retire the previously full output window") | |
M internal/usecase/daemon/proxy_input_test.go +8 -2
| @@ -351,7 +351,7 @@ func TestProxyAttachedCommandCorrelatesInterleavedWrongAndLateResults(t *testing | |||
| 351 | 351 | _ = awaitTestValue(t, clock.timers, "first command did not arm timeout") | |
| 352 | 352 | ||
| 353 | 353 | generation := proxy.linkGeneration | |
| 354 | - | result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})}) | |
| 354 | + | result, err := d.handleLinkFrame(proxy, generation, proxyHandshakeSnapshot()) | |
| 355 | 355 | require.NoError(t, err) | |
| 356 | 356 | require.Equal(t, proxyLinkResume, result) | |
| 357 | 357 | require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type) | |
| @@ -499,7 +499,7 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T) | |||
| 499 | 499 | request := requireProxyCommandRequest(t, link) | |
| 500 | 500 | _ = awaitTestValue(t, clock.timers, "command did not arm timeout") | |
| 501 | 501 | oldGeneration := proxy.linkGeneration | |
| 502 | - | newGeneration, _ := proxy.installTransport(newProxyTestTransport()) | |
| 502 | + | newGeneration, _ := proxy.installTransport(newProxyTestTransport(), false) | |
| 503 | 503 | require.Greater(t, newGeneration, oldGeneration) | |
| 504 | 504 | require.Error(t, awaitTestValue(t, done, "replacement did not cancel command")) | |
| 505 | 505 | _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true})) | |
| @@ -602,6 +602,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t | |||
| 602 | 602 | transport := newProxyTestTransport() | |
| 603 | 603 | transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 604 | 604 | transport.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 605 | + | transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 605 | 606 | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 606 | 607 | dialer := portsmocks.NewMockDialer(t) | |
| 607 | 608 | var daemonLockAvailable, sourceLockAvailable bool | |
| @@ -660,6 +661,7 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) | |||
| 660 | 661 | transport := newProxyTestTransport() | |
| 661 | 662 | transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 662 | 663 | transport.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 664 | + | transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 663 | 665 | d.remoteDialerFactory = newProxyConstructionFactory(transport) | |
| 664 | 666 | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 665 | 667 | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | |
| @@ -806,6 +808,7 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) { | |||
| 806 | 808 | remote := newProxyTestTransport() | |
| 807 | 809 | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 808 | 810 | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 811 | + | remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 809 | 812 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |
| 810 | 813 | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 811 | 814 | ||
| @@ -898,6 +901,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) { | |||
| 898 | 901 | remote := newProxyTestTransport() | |
| 899 | 902 | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 900 | 903 | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 904 | + | remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 901 | 905 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |
| 902 | 906 | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 903 | 907 | token := roleEffectForTest(t, local.attachmentToken(ac, client)) | |
| @@ -936,6 +940,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) | |||
| 936 | 940 | remote := newProxyTestTransport() | |
| 937 | 941 | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 938 | 942 | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 943 | + | remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 939 | 944 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |
| 940 | 945 | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 941 | 946 | ||
| @@ -992,6 +997,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) { | |||
| 992 | 997 | remote := newProxyTestTransport() | |
| 993 | 998 | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 994 | 999 | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 1000 | + | remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 995 | 1001 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | |
| 996 | 1002 | d.remoteTransportMode = ports.RemoteTransportUDP | |
| 997 | 1003 | ||
M internal/usecase/daemon/proxy_lifecycle_test.go +31 -3
| @@ -146,6 +146,30 @@ func newRegisteredLifecycleProxy(t *testing.T, clock ports.Clock) (*Daemon, *pro | |||
| 146 | 146 | return d, proxy, transport | |
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | + | func TestNewProxySessionUsesProxyScreenSizeLimit(t *testing.T) { | |
| 150 | + | for _, test := range []struct { | |
| 151 | + | name string | |
| 152 | + | size domain.Size | |
| 153 | + | valid bool | |
| 154 | + | }{ | |
| 155 | + | {name: "largest valid content", size: domain.Size{Cols: 512, Rows: 514}, valid: true}, | |
| 156 | + | {name: "one cell over content limit", size: domain.Size{Cols: 512, Rows: 515}}, | |
| 157 | + | {name: "1000x1000", size: domain.Size{Cols: 1000, Rows: 1000}}, | |
| 158 | + | } { | |
| 159 | + | t.Run(test.name, func(t *testing.T) { | |
| 160 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, test.size) | |
| 161 | + | if !test.valid { | |
| 162 | + | require.Error(t, err) | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | require.NoError(t, err) | |
| 166 | + | require.Equal(t, contentSize(test.size, false), proxy.contentSize) | |
| 167 | + | require.Equal(t, test.size.Cols, proxy.screen.frame.Width) | |
| 168 | + | require.Equal(t, test.size.Rows-tabChromeRows, proxy.screen.frame.Height) | |
| 169 | + | }) | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 149 | 173 | func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) { | |
| 150 | 174 | clock := newProxyLifecycleClock() | |
| 151 | 175 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |
| @@ -397,6 +421,7 @@ func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) { | |||
| 397 | 421 | _ = requireProxyHello(t, resumed) | |
| 398 | 422 | resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume|ports.CapabilityProxied)} | |
| 399 | 423 | resumed.recv <- proxyRecv{frame: proxyMeta(proxy.key.Name)} | |
| 424 | + | resumed.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 400 | 425 | require.Eventually(t, func() bool { | |
| 401 | 426 | proxy.mu.Lock() | |
| 402 | 427 | defer proxy.mu.Unlock() | |
| @@ -1160,6 +1185,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) { | |||
| 1160 | 1185 | second := newProxyTestTransport() | |
| 1161 | 1186 | second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityProxied)} | |
| 1162 | 1187 | second.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 1188 | + | second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 1163 | 1189 | factory := newProxyConstructionFactory(first, second) | |
| 1164 | 1190 | d := newProxyTestDaemon(t, factory, nil) | |
| 1165 | 1191 | ||
| @@ -1186,6 +1212,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) { | |||
| 1186 | 1212 | ||
| 1187 | 1213 | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 1188 | 1214 | first.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 1215 | + | first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 1189 | 1216 | leaderResult := awaitTestValue(t, leader, "leader construction did not finish") | |
| 1190 | 1217 | waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish") | |
| 1191 | 1218 | require.NoError(t, leaderResult.err) | |
| @@ -1227,6 +1254,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) { | |||
| 1227 | 1254 | ||
| 1228 | 1255 | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 1229 | 1256 | first.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 1257 | + | first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 1230 | 1258 | leaderResult := awaitTestValue(t, leader, "leader construction did not finish") | |
| 1231 | 1259 | require.NoError(t, leaderResult.err) | |
| 1232 | 1260 | stopProxy(t, leaderResult.proxy) | |
| @@ -1408,7 +1436,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) { | |||
| 1408 | 1436 | d, p, clock := newProxyLifecycleFixture(t) | |
| 1409 | 1437 | transport := newProxyTestTransport() | |
| 1410 | 1438 | t.Cleanup(func() { _ = transport.Close() }) | |
| 1411 | - | generation, _ := p.installTransport(transport) | |
| 1439 | + | generation, _ := p.installTransport(transport, false) | |
| 1412 | 1440 | token, timer := armWarmTimer(t, d, p, clock) | |
| 1413 | 1441 | ||
| 1414 | 1442 | require.True(t, d.markProxyReplaced(p, generation, transport)) | |
| @@ -1432,7 +1460,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) { | |||
| 1432 | 1460 | d, p, clock := newProxyLifecycleFixture(t) | |
| 1433 | 1461 | transport := newProxyTestTransport() | |
| 1434 | 1462 | t.Cleanup(func() { _ = transport.Close() }) | |
| 1435 | - | generation, _ := p.installTransport(transport) | |
| 1463 | + | generation, _ := p.installTransport(transport, false) | |
| 1436 | 1464 | ||
| 1437 | 1465 | require.True(t, d.markProxyReplaced(p, generation, transport)) | |
| 1438 | 1466 | timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer") | |
| @@ -1449,7 +1477,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) { | |||
| 1449 | 1477 | d, p, _ := newProxyLifecycleFixture(t) | |
| 1450 | 1478 | transport := newProxyTestTransport() | |
| 1451 | 1479 | t.Cleanup(func() { _ = transport.Close() }) | |
| 1452 | - | generation, _ := p.installTransport(transport) | |
| 1480 | + | generation, _ := p.installTransport(transport, false) | |
| 1453 | 1481 | ||
| 1454 | 1482 | require.False(t, d.markProxyReplaced(p, generation-1, transport)) | |
| 1455 | 1483 | require.False(t, proxyExpired(p)) | |
M internal/usecase/daemon/proxy_link.go +113 -24
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -80,7 +81,18 @@ func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent | |||
| 80 | 81 | return err | |
| 81 | 82 | } | |
| 82 | 83 | ||
| 83 | - | generation, previous := p.installTransport(transport) | |
| 84 | + | p.mu.Lock() | |
| 85 | + | resumeToken := p.resumeToken | |
| 86 | + | clientID := p.clientID | |
| 87 | + | size := p.contentSize | |
| 88 | + | stateFloor := p.appliedState | |
| 89 | + | p.mu.Unlock() | |
| 90 | + | resume := intent == ports.IntentResume | |
| 91 | + | if !resume { | |
| 92 | + | resumeToken = 0 | |
| 93 | + | stateFloor = 0 | |
| 94 | + | } | |
| 95 | + | generation, previous := p.installTransport(transport, resume) | |
| 84 | 96 | if previous != nil && previous != transport { | |
| 85 | 97 | _ = previous.Close() | |
| 86 | 98 | } | |
| @@ -91,15 +103,6 @@ func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent | |||
| 91 | 103 | _ = transport.Close() | |
| 92 | 104 | } | |
| 93 | 105 | }() | |
| 94 | - | ||
| 95 | - | p.mu.Lock() | |
| 96 | - | resumeToken := p.resumeToken | |
| 97 | - | clientID := p.clientID | |
| 98 | - | size := p.contentSize | |
| 99 | - | p.mu.Unlock() | |
| 100 | - | if intent != ports.IntentResume { | |
| 101 | - | resumeToken = 0 | |
| 102 | - | } | |
| 103 | 106 | hello := ports.Hello{ | |
| 104 | 107 | Version: ports.ProtocolVersion, | |
| 105 | 108 | Intent: intent, | |
| @@ -114,13 +117,23 @@ func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent | |||
| 114 | 117 | return fmt.Errorf("proxy session: send hello: %w", err) | |
| 115 | 118 | } | |
| 116 | 119 | ||
| 117 | - | welcome, meta, err := recvProxyHandshake(ctx, transport, p.key.Name) | |
| 120 | + | welcome, meta, snapshot, err := recvProxyHandshake(ctx, transport, p.key.Name, size, stateFloor) | |
| 118 | 121 | if err != nil { | |
| 119 | 122 | return err | |
| 120 | 123 | } | |
| 121 | 124 | if err := ctx.Err(); err != nil { | |
| 122 | 125 | return err | |
| 123 | 126 | } | |
| 127 | + | ack, reset, _ := p.applyScreenUpdateForGeneration(generation, snapshot) | |
| 128 | + | if reset || ack != snapshot.NewStateNum { | |
| 129 | + | return errors.New("proxy session: initial screen snapshot was not applied") | |
| 130 | + | } | |
| 131 | + | if err := p.sendGeneration(generation, ports.Frame{ | |
| 132 | + | Type: ports.MsgAck, | |
| 133 | + | Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}), | |
| 134 | + | }); err != nil { | |
| 135 | + | return fmt.Errorf("proxy session: send initial screen ACK: %w", err) | |
| 136 | + | } | |
| 124 | 137 | ||
| 125 | 138 | p.mu.Lock() | |
| 126 | 139 | if p.linkGeneration != generation || p.transport != transport { | |
| @@ -143,25 +156,43 @@ func proxyOutputWindow(transport ports.Transport) uint8 { | |||
| 143 | 156 | return maxUnackedOutputStates | |
| 144 | 157 | } | |
| 145 | 158 | ||
| 146 | - | func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string) (ports.Welcome, ports.SessionMeta, error) { | |
| 159 | + | func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string, size domain.Size, stateFloor uint64) (ports.Welcome, ports.SessionMeta, ports.ScreenUpdate, error) { | |
| 147 | 160 | frame, err := recvProxyHandshakeFrame(ctx, transport) | |
| 148 | 161 | if err != nil { | |
| 149 | - | return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive welcome: %w", err) | |
| 162 | + | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive welcome: %w", err) | |
| 150 | 163 | } | |
| 151 | 164 | welcome, err := validateProxyWelcome(frame, sessionName) | |
| 152 | 165 | if err != nil { | |
| 153 | - | return ports.Welcome{}, ports.SessionMeta{}, err | |
| 166 | + | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, err | |
| 154 | 167 | } | |
| 155 | 168 | ||
| 156 | 169 | frame, err = recvProxyHandshakeFrame(ctx, transport) | |
| 157 | 170 | if err != nil { | |
| 158 | - | return ports.Welcome{}, ports.SessionMeta{}, fmt.Errorf("proxy session: receive initial metadata: %w", err) | |
| 171 | + | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive initial metadata: %w", err) | |
| 159 | 172 | } | |
| 160 | 173 | meta, err := validateProxyInitialMeta(frame, sessionName) | |
| 161 | 174 | if err != nil { | |
| 162 | - | return ports.Welcome{}, ports.SessionMeta{}, err | |
| 175 | + | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, err | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | frame, err = recvProxyHandshakeFrame(ctx, transport) | |
| 179 | + | if err != nil { | |
| 180 | + | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive initial screen snapshot: %w", err) | |
| 181 | + | } | |
| 182 | + | if frame.Type != ports.MsgScreenUpdate { | |
| 183 | + | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: third server frame is %d, want ScreenUpdate", frame.Type) | |
| 184 | + | } | |
| 185 | + | snapshot, err := ports.UnmarshalScreenUpdate(frame.Payload) | |
| 186 | + | if err != nil { | |
| 187 | + | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: decode initial screen snapshot: %w", err) | |
| 188 | + | } | |
| 189 | + | if snapshot.Kind != ports.ScreenUpdateSnapshot || snapshot.BaseStateNum != 0 || snapshot.NewStateNum <= stateFloor { | |
| 190 | + | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, errors.New("proxy session: initial screen frame is not a moving base-zero snapshot") | |
| 163 | 191 | } | |
| 164 | - | return welcome, meta, nil | |
| 192 | + | if snapshot.Size != size { | |
| 193 | + | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: initial screen geometry mismatch: got %dx%d, want %dx%d", snapshot.Size.Cols, snapshot.Size.Rows, size.Cols, size.Rows) | |
| 194 | + | } | |
| 195 | + | return welcome, meta, snapshot, nil | |
| 165 | 196 | } | |
| 166 | 197 | ||
| 167 | 198 | func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) { | |
| @@ -221,7 +252,7 @@ func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (po | |||
| 221 | 252 | } | |
| 222 | 253 | } | |
| 223 | 254 | ||
| 224 | - | func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) { | |
| 255 | + | func (p *proxySession) installTransport(transport ports.Transport, resume bool) (uint64, ports.Transport) { | |
| 225 | 256 | // Link replacement participates in the same sender serialization as every | |
| 226 | 257 | // frame. A request can therefore only be published wholly before the | |
| 227 | 258 | // generation changes or wholly after it. | |
| @@ -231,12 +262,18 @@ func (p *proxySession) installTransport(transport ports.Transport) (uint64, port | |||
| 231 | 262 | previous := p.transport | |
| 232 | 263 | p.linkGeneration++ | |
| 233 | 264 | generation := p.linkGeneration | |
| 234 | - | // State numbers belong to one remote output stream. A replacement link | |
| 235 | - | // must prove its state with a base-zero paint before an incremental frame | |
| 236 | - | // can advance this proxy. | |
| 237 | - | p.resetOutputStateLocked() | |
| 265 | + | if resume { | |
| 266 | + | // Resume keeps the applied remote state as the handshake floor, but the | |
| 267 | + | // replacement must prove that state with a fresh base-zero snapshot. | |
| 268 | + | p.screenReady = false | |
| 269 | + | p.resetRequested = false | |
| 270 | + | } else { | |
| 271 | + | p.resetOutputStateLocked() | |
| 272 | + | } | |
| 238 | 273 | p.transport = transport | |
| 239 | - | p.linkState = ports.LinkStateConnected | |
| 274 | + | // Authentication is not complete until the authoritative screen snapshot | |
| 275 | + | // has been applied and acknowledged. | |
| 276 | + | p.linkState = ports.LinkStateProbing | |
| 240 | 277 | p.mu.Unlock() | |
| 241 | 278 | p.failPendingCommandsLocked(errProxyCommandGeneration) | |
| 242 | 279 | return generation, previous | |
| @@ -540,6 +577,46 @@ func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generat | |||
| 540 | 577 | } | |
| 541 | 578 | } | |
| 542 | 579 | ||
| 580 | + | // handleProxySideEffect forwards a stateless remote effect to the exact local | |
| 581 | + | // attachment that currently owns this proxy. Role admission and transport | |
| 582 | + | // incarnation checks make a stale handoff a harmless drop rather than a send | |
| 583 | + | // to the client's next session. | |
| 584 | + | func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error { | |
| 585 | + | if p == nil || !p.currentLinkGeneration(generation) { | |
| 586 | + | return nil | |
| 587 | + | } | |
| 588 | + | p.sessionCore.mu.Lock() | |
| 589 | + | ac := p.client | |
| 590 | + | p.sessionCore.mu.Unlock() | |
| 591 | + | if ac == nil || ac.output == nil { | |
| 592 | + | return nil | |
| 593 | + | } | |
| 594 | + | expected := ac.transportSnapshot() | |
| 595 | + | if expected.transport == nil { | |
| 596 | + | return nil | |
| 597 | + | } | |
| 598 | + | token := attachmentToken(p, ac, expected.transport) | |
| 599 | + | if token.role != attachmentActive || token.sess != p { | |
| 600 | + | return nil | |
| 601 | + | } | |
| 602 | + | ticket, admitted := ac.beginRoleEffect(token) | |
| 603 | + | if !admitted { | |
| 604 | + | return nil | |
| 605 | + | } | |
| 606 | + | defer ticket.End() | |
| 607 | + | if !p.currentLinkGeneration(generation) { | |
| 608 | + | return nil | |
| 609 | + | } | |
| 610 | + | frame := ac.output.sideEffect(out.Data, ac.echoAck.Load()) | |
| 611 | + | if err := ac.sendExpectedTransportForRole(expected, frame, ticket); err != nil { | |
| 612 | + | if errors.Is(err, errAttachmentTransition) { | |
| 613 | + | return nil | |
| 614 | + | } | |
| 615 | + | return fmt.Errorf("proxy session: side-effect send: %w: %w", errProxyLinkSend, err) | |
| 616 | + | } | |
| 617 | + | return nil | |
| 618 | + | } | |
| 619 | + | ||
| 543 | 620 | // handleLinkFrame returns proxyLinkResume to mean "continue this transport"; | |
| 544 | 621 | // the other values are terminal actions. | |
| 545 | 622 | func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports.Frame) (proxyLinkResult, error) { | |
| @@ -549,7 +626,19 @@ func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports | |||
| 549 | 626 | if err != nil { | |
| 550 | 627 | return proxyLinkStop, err | |
| 551 | 628 | } | |
| 552 | - | if err := d.handleProxyOutput(p, generation, out); err != nil { | |
| 629 | + | if out.BaseStateNum != 0 || out.NewStateNum != 0 { | |
| 630 | + | return proxyLinkStop, fmt.Errorf("proxy session: state-bearing MsgOutput is not valid on a proxied link") | |
| 631 | + | } | |
| 632 | + | if err := d.handleProxySideEffect(p, generation, out); err != nil { | |
| 633 | + | return proxyLinkStop, err | |
| 634 | + | } | |
| 635 | + | return proxyLinkResume, nil | |
| 636 | + | case ports.MsgScreenUpdate: | |
| 637 | + | update, err := ports.UnmarshalScreenUpdate(frame.Payload) | |
| 638 | + | if err != nil { | |
| 639 | + | return proxyLinkStop, err | |
| 640 | + | } | |
| 641 | + | if err := d.handleProxyScreenUpdate(p, generation, update); err != nil { | |
| 553 | 642 | return proxyLinkStop, err | |
| 554 | 643 | } | |
| 555 | 644 | return proxyLinkResume, nil | |
M internal/usecase/daemon/proxy_output.go +63 -66
| @@ -4,78 +4,76 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/ports" | |
| 7 | - | "github.com/bnema/vev/pkg/vt" | |
| 8 | 7 | ) | |
| 9 | 8 | ||
| 10 | - | // applyOutput applies one decoded remote output frame for the current link. | |
| 11 | - | // Link readers use applyOutputForGeneration to bind processing to the exact | |
| 12 | - | // transport generation that received the frame. | |
| 13 | - | func (p *proxySession) applyOutput(out ports.Output) (ack uint64, requestReset, changed bool) { | |
| 14 | - | p.mu.Lock() | |
| 15 | - | generation := p.linkGeneration | |
| 16 | - | p.mu.Unlock() | |
| 17 | - | return p.applyOutputForGeneration(generation, out) | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | // applyOutputForGeneration applies one decoded remote output frame while | |
| 21 | - | // holding only the proxy leaf lock. The supplied link generation prevents a | |
| 22 | - | // receive goroutine from an old transport advancing the replacement link's VT | |
| 23 | - | // state. | |
| 24 | - | func (p *proxySession) applyOutputForGeneration(generation uint64, out ports.Output) (ack uint64, requestReset, changed bool) { | |
| 25 | - | // Screen.Write may retain a partial escape sequence, so do not retain a | |
| 26 | - | // transport-owned payload. | |
| 27 | - | data := append([]byte(nil), out.Data...) | |
| 28 | - | ||
| 29 | - | p.mu.Lock() | |
| 30 | - | defer p.mu.Unlock() | |
| 31 | - | if p.linkGeneration != generation { | |
| 9 | + | // applyScreenUpdateForGeneration applies one semantic screen update while | |
| 10 | + | // holding only the proxy leaf lock. A link generation fence prevents a stale | |
| 11 | + | // receive goroutine from advancing a replacement link's screen. | |
| 12 | + | func (p *proxySession) applyScreenUpdateForGeneration(generation uint64, update ports.ScreenUpdate) (ack uint64, requestReset, changed bool) { | |
| 13 | + | if p == nil { | |
| 32 | 14 | return 0, false, false | |
| 33 | 15 | } | |
| 34 | - | if p.screen == nil { | |
| 16 | + | p.mu.Lock() | |
| 17 | + | defer p.mu.Unlock() | |
| 18 | + | if p.linkGeneration != generation || p.screen == nil { | |
| 35 | 19 | return 0, false, false | |
| 36 | 20 | } | |
| 37 | - | if out.NewStateNum == 0 { | |
| 38 | - | p.screen.Write(data) | |
| 39 | - | return 0, false, len(data) != 0 | |
| 21 | + | ||
| 22 | + | // The wire codec validates all semantic bounds. Geometry is still checked | |
| 23 | + | // here because it is attachment state, not merely a wire property: a | |
| 24 | + | // remote paint for the wrong content rectangle cannot be applied locally. | |
| 25 | + | if update.Size != p.contentSize { | |
| 26 | + | return 0, p.requestScreenResetLocked(), false | |
| 40 | 27 | } | |
| 41 | - | if out.BaseStateNum == 0 { | |
| 42 | - | // A retransmitted full paint must not clobber a newer screen or regress | |
| 43 | - | // the applied state. Only a reset this proxy actually asked for may | |
| 44 | - | // replay a state it has already applied. | |
| 45 | - | if !p.awaitingReset && out.NewStateNum <= p.appliedState { | |
| 46 | - | return 0, false, false | |
| 28 | + | if update.Kind == ports.ScreenUpdateSnapshot { | |
| 29 | + | // A base-zero paint may only move the consumer forward. This floor is | |
| 30 | + | // retained across resume and resize, so resetRequested never authorizes | |
| 31 | + | // replaying a stale snapshot. | |
| 32 | + | if update.NewStateNum <= p.appliedState { | |
| 33 | + | return 0, p.requestScreenResetLocked(), false | |
| 47 | 34 | } | |
| 48 | - | p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height) | |
| 49 | - | p.screen.Write(data) | |
| 50 | - | p.appliedState = out.NewStateNum | |
| 51 | - | p.awaitingReset = false | |
| 52 | - | return out.NewStateNum, false, true | |
| 53 | - | } | |
| 54 | - | // Stateful frames are consecutive by construction. A duplicate or an | |
| 55 | - | // otherwise non-monotonic state is invalid just like a missing dependency. | |
| 56 | - | if p.awaitingReset || out.BaseStateNum != p.appliedState || out.NewStateNum != out.BaseStateNum+1 { | |
| 57 | - | if !p.awaitingReset { | |
| 58 | - | p.awaitingReset = true | |
| 59 | - | requestReset = true | |
| 35 | + | before := p.screen.cursorOut | |
| 36 | + | if err := p.screen.Apply(update); err != nil { | |
| 37 | + | return 0, p.requestScreenResetLocked(), false | |
| 60 | 38 | } | |
| 61 | - | return 0, requestReset, false | |
| 39 | + | p.appliedState = update.NewStateNum | |
| 40 | + | p.screenReady = true | |
| 41 | + | p.resetRequested = false | |
| 42 | + | return update.NewStateNum, false, update.Kind == ports.ScreenUpdateSnapshot || before != update.Cursor || len(update.Spans) != 0 | |
| 62 | 43 | } | |
| 63 | - | p.screen.Write(data) | |
| 64 | - | p.appliedState = out.NewStateNum | |
| 65 | - | return out.NewStateNum, false, len(data) != 0 | |
| 44 | + | ||
| 45 | + | if !p.screenReady || p.resetRequested || update.BaseStateNum != p.appliedState || update.NewStateNum != update.BaseStateNum+1 { | |
| 46 | + | return 0, p.requestScreenResetLocked(), false | |
| 47 | + | } | |
| 48 | + | before := p.screen.cursorOut | |
| 49 | + | if err := p.screen.Apply(update); err != nil { | |
| 50 | + | return 0, p.requestScreenResetLocked(), false | |
| 51 | + | } | |
| 52 | + | p.appliedState = update.NewStateNum | |
| 53 | + | return update.NewStateNum, false, update.Scroll != nil || len(update.Spans) != 0 || before != update.Cursor | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (p *proxySession) requestScreenResetLocked() bool { | |
| 57 | + | if p.resetRequested { | |
| 58 | + | p.screenReady = false | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | p.screenReady = false | |
| 62 | + | p.resetRequested = true | |
| 63 | + | return true | |
| 66 | 64 | } | |
| 67 | 65 | ||
| 68 | - | // handleProxyOutput completes output handling after applyOutputForGeneration | |
| 69 | - | // has released proxy.mu. In particular, transport I/O and local render | |
| 70 | - | // invalidation must never run under the VT lock. | |
| 71 | - | func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error { | |
| 72 | - | ack, requestReset, changed := p.applyOutputForGeneration(generation, out) | |
| 66 | + | // handleProxyScreenUpdate completes screen handling after apply has released | |
| 67 | + | // proxy.mu. Transport I/O and render invalidation never run under that lock. | |
| 68 | + | func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, update ports.ScreenUpdate) error { | |
| 69 | + | marks := d.newRuntimeMarkBatch() | |
| 70 | + | ack, requestReset, changed := p.applyScreenUpdateForGeneration(generation, update) | |
| 73 | 71 | if ack != 0 { | |
| 74 | 72 | if err := p.sendGeneration(generation, ports.Frame{ | |
| 75 | 73 | Type: ports.MsgAck, | |
| 76 | 74 | Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}), | |
| 77 | 75 | }); err != nil { | |
| 78 | - | return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err) | |
| 76 | + | return fmt.Errorf("proxy screen ACK: %w: %w", errProxyLinkSend, err) | |
| 79 | 77 | } | |
| 80 | 78 | } | |
| 81 | 79 | if requestReset { | |
| @@ -83,9 +81,13 @@ func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports | |||
| 83 | 81 | Type: ports.MsgOutputResetRequest, | |
| 84 | 82 | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |
| 85 | 83 | }); err != nil { | |
| 86 | - | return fmt.Errorf("proxy output reset request: %w: %w", errProxyLinkSend, err) | |
| 84 | + | return fmt.Errorf("proxy screen reset request: %w: %w", errProxyLinkSend, err) | |
| 87 | 85 | } | |
| 86 | + | marks.diagnostic(ports.RuntimeScreenResetRequested, 0, 0) | |
| 88 | 87 | } | |
| 88 | + | // The reset send released proxy sendMu and apply released proxy.mu before | |
| 89 | + | // observer I/O; a blocked sink cannot hold either protocol ownership lock. | |
| 90 | + | marks.flush() | |
| 89 | 91 | if !changed || !p.currentLinkGeneration(generation) { | |
| 90 | 92 | return nil | |
| 91 | 93 | } | |
| @@ -97,18 +99,13 @@ func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports | |||
| 97 | 99 | return nil | |
| 98 | 100 | } | |
| 99 | 101 | ||
| 100 | - | // resetOutputState discards the dependency chain after a local content resize. | |
| 101 | - | // The next incremental remote frame is rejected and triggers a request for an | |
| 102 | - | // authoritative base-zero paint. | |
| 103 | - | func (p *proxySession) resetOutputState() { | |
| 104 | - | p.mu.Lock() | |
| 105 | - | defer p.mu.Unlock() | |
| 106 | - | p.resetOutputStateLocked() | |
| 107 | - | } | |
| 108 | - | ||
| 109 | 102 | func (p *proxySession) resetOutputStateLocked() { | |
| 110 | 103 | p.appliedState = 0 | |
| 111 | - | p.awaitingReset = false | |
| 104 | + | p.screenReady = false | |
| 105 | + | p.resetRequested = false | |
| 106 | + | if p.screen != nil { | |
| 107 | + | p.screen.stateNum = 0 | |
| 108 | + | } | |
| 112 | 109 | } | |
| 113 | 110 | ||
| 114 | 111 | func (p *proxySession) currentLinkGeneration(generation uint64) bool { | |
M internal/usecase/daemon/proxy_output_test.go +460 -252
| @@ -12,10 +12,13 @@ import ( | |||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | + | "github.com/bnema/vev/pkg/renderer" | |
| 15 | 16 | "github.com/stretchr/testify/mock" | |
| 16 | 17 | "github.com/stretchr/testify/require" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 20 | + | var proxyHandshakeState atomic.Uint64 | |
| 21 | + | ||
| 19 | 22 | type proxyRecv struct { | |
| 20 | 23 | frame ports.Frame | |
| 21 | 24 | err error | |
| @@ -95,6 +98,28 @@ func (t *proxyTestTransport) Recv() (ports.Frame, error) { | |||
| 95 | 98 | } | |
| 96 | 99 | } | |
| 97 | 100 | ||
| 101 | + | func applyTestScreenText(p *proxySession, row, col int, text string) bool { | |
| 102 | + | p.mu.Lock() | |
| 103 | + | generation := p.linkGeneration | |
| 104 | + | size := p.contentSize | |
| 105 | + | p.mu.Unlock() | |
| 106 | + | frame := renderer.NewFrame(size.Cols, size.Rows) | |
| 107 | + | for x, r := range []rune(text) { | |
| 108 | + | if col+x < frame.Width { | |
| 109 | + | frame.Set(col+x, row, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | spans := make([]ports.ScreenSpan, size.Rows) | |
| 113 | + | for y := range size.Rows { | |
| 114 | + | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: append([]renderer.Cell(nil), frame.Row(y)...)} | |
| 115 | + | } | |
| 116 | + | _, _, changed := p.applyScreenUpdateForGeneration(generation, ports.ScreenUpdate{ | |
| 117 | + | NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: size, | |
| 118 | + | Cursor: ports.ScreenCursor{Row: uint16(row), Col: uint16(col), Visible: true}, Spans: spans, | |
| 119 | + | }) | |
| 120 | + | return changed | |
| 121 | + | } | |
| 122 | + | ||
| 98 | 123 | func (t *proxyTestTransport) Close() error { | |
| 99 | 124 | t.closeOnce.Do(func() { close(t.closed) }) | |
| 100 | 125 | return nil | |
| @@ -109,6 +134,26 @@ func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame { | |||
| 109 | 134 | })} | |
| 110 | 135 | } | |
| 111 | 136 | ||
| 137 | + | func proxyHandshakeSnapshot() ports.Frame { | |
| 138 | + | const cols, rows = 80, 22 | |
| 139 | + | spans := make([]ports.ScreenSpan, rows) | |
| 140 | + | for y := range rows { | |
| 141 | + | cells := make([]renderer.Cell, cols) | |
| 142 | + | for x := range cells { | |
| 143 | + | cells[x] = renderer.BlankCell() | |
| 144 | + | } | |
| 145 | + | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cells} | |
| 146 | + | } | |
| 147 | + | payload, err := ports.MarshalScreenUpdate(ports.ScreenUpdate{ | |
| 148 | + | NewStateNum: proxyHandshakeState.Add(1), Kind: ports.ScreenUpdateSnapshot, Size: domain.Size{Cols: cols, Rows: rows}, | |
| 149 | + | Cursor: ports.ScreenCursor{Visible: true}, Spans: spans, | |
| 150 | + | }) | |
| 151 | + | if err != nil { | |
| 152 | + | panic(err) | |
| 153 | + | } | |
| 154 | + | return ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload} | |
| 155 | + | } | |
| 156 | + | ||
| 112 | 157 | func proxyMeta(name string) ports.Frame { | |
| 113 | 158 | payload, err := ports.MarshalSessionMeta(ports.SessionMeta{ | |
| 114 | 159 | SessionName: name, | |
| @@ -208,6 +253,7 @@ func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) { | |||
| 208 | 253 | tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)} | |
| 209 | 254 | require.Empty(t, proxySessionSnapshot(d), "Welcome alone must not publish a proxy") | |
| 210 | 255 | tr.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 256 | + | tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 211 | 257 | ||
| 212 | 258 | created := awaitTestValue(t, result, "proxy handshake did not complete") | |
| 213 | 259 | require.NoError(t, created.err) | |
| @@ -252,9 +298,11 @@ func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) { | |||
| 252 | 298 | firstTransport := newProxyTestTransport() | |
| 253 | 299 | firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)} | |
| 254 | 300 | firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)} | |
| 301 | + | firstTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 255 | 302 | secondTransport := newProxyTestTransport() | |
| 256 | 303 | secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)} | |
| 257 | 304 | secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)} | |
| 305 | + | secondTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 258 | 306 | factory := portsmocks.NewMockRemoteDialerFactory(t) | |
| 259 | 307 | for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} { | |
| 260 | 308 | dialer := portsmocks.NewMockDialer(t) | |
| @@ -339,6 +387,7 @@ func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) { | |||
| 339 | 387 | first := newProxyTestTransport() | |
| 340 | 388 | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)} | |
| 341 | 389 | first.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 390 | + | first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 342 | 391 | second := newProxyTestTransport() | |
| 343 | 392 | factory := proxyFactoryFor(t, key, first, second) | |
| 344 | 393 | // The resume backoff is a ports.Clock deadline, so it is fired rather than | |
| @@ -361,6 +410,7 @@ func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) { | |||
| 361 | 410 | require.Equal(t, firstHello.ClientID, resumeHello.ClientID) | |
| 362 | 411 | second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)} | |
| 363 | 412 | second.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 413 | + | second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 364 | 414 | second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}} | |
| 365 | 415 | ||
| 366 | 416 | select { | |
| @@ -396,9 +446,10 @@ func TestProxySenderSerializesAllTransportWrites(t *testing.T) { | |||
| 396 | 446 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 397 | 447 | gate := make(chan struct{}) | |
| 398 | 448 | entered := make(chan struct{}, 1) | |
| 399 | - | tr := newProxyTestTransportWithSendGate(gate, entered, 1) | |
| 449 | + | tr := newProxyTestTransportWithSendGate(gate, entered, 2) | |
| 400 | 450 | tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 401 | 451 | tr.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 452 | + | tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |
| 402 | 453 | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{}) | |
| 403 | 454 | proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | |
| 404 | 455 | require.NoError(t, err) | |
| @@ -424,6 +475,20 @@ func TestProxySenderSerializesAllTransportWrites(t *testing.T) { | |||
| 424 | 475 | stopProxy(t, proxy) | |
| 425 | 476 | } | |
| 426 | 477 | ||
| 478 | + | func TestProxyHandshakeRejectsMissingInitialScreenSnapshotWithoutPublication(t *testing.T) { | |
| 479 | + | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 480 | + | tr := newProxyTestTransport() | |
| 481 | + | tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |
| 482 | + | tr.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |
| 483 | + | tr.recv <- proxyRecv{err: io.EOF} | |
| 484 | + | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{}) | |
| 485 | + | ||
| 486 | + | proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | |
| 487 | + | require.Error(t, err) | |
| 488 | + | require.Nil(t, proxy) | |
| 489 | + | require.Empty(t, proxySessionSnapshot(d)) | |
| 490 | + | } | |
| 491 | + | ||
| 427 | 492 | func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) { | |
| 428 | 493 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |
| 429 | 494 | tr := newProxyTestTransport() | |
| @@ -454,164 +519,319 @@ func newProxyOutputSession(t *testing.T) (*Daemon, *proxySession, *proxyTestTran | |||
| 454 | 519 | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24}) | |
| 455 | 520 | require.NoError(t, err) | |
| 456 | 521 | transport := newProxyTestTransport() | |
| 457 | - | generation, _ := proxy.installTransport(transport) | |
| 522 | + | generation, _ := proxy.installTransport(transport, false) | |
| 458 | 523 | return newProxyTestDaemon(t, nil, stubClock{}), proxy, transport, generation | |
| 459 | 524 | } | |
| 460 | 525 | ||
| 461 | - | func proxyScreenText(p *proxySession) string { | |
| 462 | - | p.mu.Lock() | |
| 463 | - | defer p.mu.Unlock() | |
| 464 | - | return string([]rune{p.screen.Frame.At(0, 0).Rune, p.screen.Frame.At(1, 0).Rune}) | |
| 465 | - | } | |
| 526 | + | func TestProxyScreenResumeAndFreshLinksRequireMovingSnapshots(t *testing.T) { | |
| 527 | + | d, proxy, first, generation := newProxyOutputSession(t) | |
| 528 | + | snapshot, err := ports.UnmarshalScreenUpdate(proxyHandshakeSnapshot().Payload) | |
| 529 | + | require.NoError(t, err) | |
| 530 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | |
| 531 | + | _ = awaitTestValue(t, first.sent, "initial screen was not acknowledged") | |
| 532 | + | proxy.mu.Lock() | |
| 533 | + | applied := proxy.appliedState | |
| 534 | + | proxy.mu.Unlock() | |
| 466 | 535 | ||
| 467 | - | func proxyOutputState(p *proxySession) (uint64, bool) { | |
| 468 | - | p.mu.Lock() | |
| 469 | - | defer p.mu.Unlock() | |
| 470 | - | return p.appliedState, p.awaitingReset | |
| 536 | + | second := newProxyTestTransport() | |
| 537 | + | generation, _ = proxy.installTransport(second, true) | |
| 538 | + | proxy.mu.Lock() | |
| 539 | + | require.Equal(t, applied, proxy.appliedState, "resume must preserve the applied state floor") | |
| 540 | + | require.False(t, proxy.screenReady, "resume must wait for its replacement snapshot") | |
| 541 | + | proxy.mu.Unlock() | |
| 542 | + | gap := snapshot | |
| 543 | + | gap.Kind = ports.ScreenUpdateDelta | |
| 544 | + | gap.BaseStateNum, gap.NewStateNum = applied, applied+1 | |
| 545 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap)) | |
| 546 | + | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, second.sent, "resume delta did not request a reset").Type) | |
| 547 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap)) | |
| 548 | + | select { | |
| 549 | + | case frame := <-second.sent: | |
| 550 | + | t.Fatalf("duplicate resume gap sent %v", frame.Type) | |
| 551 | + | default: | |
| 552 | + | } | |
| 553 | + | moving := snapshot | |
| 554 | + | moving.NewStateNum = applied + 1 | |
| 555 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, moving)) | |
| 556 | + | require.Equal(t, ports.MsgAck, awaitTestValue(t, second.sent, "resume snapshot was not acknowledged").Type) | |
| 557 | + | ||
| 558 | + | third := newProxyTestTransport() | |
| 559 | + | generation, _ = proxy.installTransport(third, false) | |
| 560 | + | proxy.mu.Lock() | |
| 561 | + | require.Zero(t, proxy.appliedState, "fresh link must clear the state floor") | |
| 562 | + | require.False(t, proxy.screenReady) | |
| 563 | + | proxy.mu.Unlock() | |
| 564 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap)) | |
| 565 | + | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, third.sent, "fresh delta did not request a reset").Type) | |
| 566 | + | fresh := snapshot | |
| 567 | + | fresh.NewStateNum = 1 | |
| 568 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, fresh)) | |
| 569 | + | require.Equal(t, ports.MsgAck, awaitTestValue(t, third.sent, "fresh state-one snapshot was not acknowledged").Type) | |
| 570 | + | proxy.mu.Lock() | |
| 571 | + | require.Equal(t, uint64(1), proxy.appliedState) | |
| 572 | + | proxy.mu.Unlock() | |
| 471 | 573 | } | |
| 472 | 574 | ||
| 473 | - | func TestProxyOutputStateMachine(t *testing.T) { | |
| 474 | - | tests := []struct { | |
| 475 | - | name string | |
| 476 | - | initial *ports.Output | |
| 477 | - | output ports.Output | |
| 478 | - | wantAck uint64 | |
| 479 | - | wantReset bool | |
| 480 | - | wantChanged bool | |
| 481 | - | wantState uint64 | |
| 482 | - | wantAwaiting bool | |
| 483 | - | wantScreen string | |
| 484 | - | }{ | |
| 485 | - | { | |
| 486 | - | name: "base zero initial reset", | |
| 487 | - | output: ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}, | |
| 488 | - | wantAck: 1, | |
| 489 | - | wantChanged: true, | |
| 490 | - | wantState: 1, | |
| 491 | - | wantScreen: "A ", | |
| 492 | - | }, | |
| 493 | - | { | |
| 494 | - | name: "matching increment", | |
| 495 | - | initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}, | |
| 496 | - | output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}, | |
| 497 | - | wantAck: 2, | |
| 498 | - | wantChanged: true, | |
| 499 | - | wantState: 2, | |
| 500 | - | wantScreen: "AB", | |
| 501 | - | }, | |
| 502 | - | { | |
| 503 | - | name: "duplicate old state requests reset", | |
| 504 | - | initial: &ports.Output{BaseStateNum: 0, NewStateNum: 2, Data: []byte("A")}, | |
| 505 | - | output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}, | |
| 506 | - | wantReset: true, | |
| 507 | - | wantState: 2, | |
| 508 | - | wantAwaiting: true, | |
| 509 | - | wantScreen: "A ", | |
| 510 | - | }, | |
| 511 | - | { | |
| 512 | - | name: "gap requests reset", | |
| 513 | - | initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}, | |
| 514 | - | output: ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("B")}, | |
| 515 | - | wantReset: true, | |
| 516 | - | wantState: 1, | |
| 517 | - | wantAwaiting: true, | |
| 518 | - | wantScreen: "A ", | |
| 519 | - | }, | |
| 520 | - | { | |
| 521 | - | name: "stateless side effect does not advance state", | |
| 522 | - | output: ports.Output{NewStateNum: 0, Data: []byte("!")}, | |
| 523 | - | wantChanged: true, | |
| 524 | - | wantScreen: "! ", | |
| 525 | - | }, | |
| 526 | - | { | |
| 527 | - | name: "stale base zero is ignored", | |
| 528 | - | initial: &ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")}, | |
| 529 | - | output: ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("B")}, | |
| 530 | - | wantState: 5, | |
| 531 | - | wantScreen: "A ", | |
| 532 | - | }, | |
| 533 | - | { | |
| 534 | - | name: "duplicate base zero is ignored", | |
| 535 | - | initial: &ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")}, | |
| 536 | - | output: ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("B")}, | |
| 537 | - | wantState: 5, | |
| 538 | - | wantScreen: "A ", | |
| 539 | - | }, | |
| 575 | + | func TestProxyScreenStaleSnapshotsAndApplyFailuresRequestOneResetWithoutMutation(t *testing.T) { | |
| 576 | + | newProxy := func(t *testing.T) (*Daemon, *proxySession, uint64) { | |
| 577 | + | t.Helper() | |
| 578 | + | d, proxy, _, generation := newProxyOutputSession(t) | |
| 579 | + | return d, proxy, generation | |
| 540 | 580 | } | |
| 541 | - | for _, tt := range tests { | |
| 542 | - | t.Run(tt.name, func(t *testing.T) { | |
| 543 | - | _, proxy, _, generation := newProxyOutputSession(t) | |
| 544 | - | if tt.initial != nil { | |
| 545 | - | _, _, _ = proxy.applyOutputForGeneration(generation, *tt.initial) | |
| 546 | - | } | |
| 547 | 581 | ||
| 548 | - | ack, reset, changed := proxy.applyOutput(tt.output) | |
| 549 | - | require.Equal(t, tt.wantAck, ack) | |
| 550 | - | require.Equal(t, tt.wantReset, reset) | |
| 551 | - | require.Equal(t, tt.wantChanged, changed) | |
| 552 | - | state, awaiting := proxyOutputState(proxy) | |
| 553 | - | require.Equal(t, tt.wantState, state) | |
| 554 | - | require.Equal(t, tt.wantAwaiting, awaiting) | |
| 555 | - | require.Equal(t, tt.wantScreen, proxyScreenText(proxy)) | |
| 556 | - | }) | |
| 557 | - | } | |
| 558 | - | } | |
| 582 | + | t.Run("stale snapshot while not ready", func(t *testing.T) { | |
| 583 | + | d, proxy, generation := newProxy(t) | |
| 584 | + | snapshot := screenSnapshotForProxy(t, proxy, 7) | |
| 585 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | |
| 586 | + | proxy.mu.Lock() | |
| 587 | + | before := proxy.screen.frame.Clone() | |
| 588 | + | proxy.screenReady = false | |
| 589 | + | proxy.resetRequested = false | |
| 590 | + | proxy.mu.Unlock() | |
| 591 | + | ||
| 592 | + | stale := snapshot | |
| 593 | + | stale.NewStateNum = 7 | |
| 594 | + | ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, stale) | |
| 595 | + | require.Zero(t, ack) | |
| 596 | + | require.True(t, reset, "a stale snapshot must request a reset while not ready") | |
| 597 | + | require.False(t, changed) | |
| 598 | + | proxy.mu.Lock() | |
| 599 | + | require.Equal(t, uint64(7), proxy.appliedState) | |
| 600 | + | require.False(t, proxy.screenReady) | |
| 601 | + | require.True(t, proxy.resetRequested) | |
| 602 | + | require.Equal(t, before, proxy.screen.frame) | |
| 603 | + | proxy.mu.Unlock() | |
| 604 | + | ||
| 605 | + | ack, reset, changed = proxy.applyScreenUpdateForGeneration(generation, stale) | |
| 606 | + | require.Zero(t, ack) | |
| 607 | + | require.False(t, reset, "an already-requested reset must be deduplicated") | |
| 608 | + | require.False(t, changed) | |
| 609 | + | }) | |
| 559 | 610 | ||
| 560 | - | // TestProxyOutputAwaitedResetAcceptsReplayedState fences the stale base-zero | |
| 561 | - | // guard: a reset this proxy asked for must still be applied even when the | |
| 562 | - | // remote numbers it at or below the state already applied. | |
| 563 | - | func TestProxyOutputAwaitedResetAcceptsReplayedState(t *testing.T) { | |
| 564 | - | _, proxy, _, generation := newProxyOutputSession(t) | |
| 565 | - | _, _, _ = proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")}) | |
| 566 | - | _, requestReset, _ := proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 9, NewStateNum: 10, Data: []byte("x")}) | |
| 567 | - | require.True(t, requestReset) | |
| 611 | + | t.Run("snapshot apply failure", func(t *testing.T) { | |
| 612 | + | d, proxy, generation := newProxy(t) | |
| 613 | + | initial := screenSnapshotForProxy(t, proxy, 7) | |
| 614 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial)) | |
| 615 | + | proxy.mu.Lock() | |
| 616 | + | before := proxy.screen.frame.Clone() | |
| 617 | + | proxy.mu.Unlock() | |
| 618 | + | invalid := initial | |
| 619 | + | invalid.NewStateNum = 8 | |
| 620 | + | invalid.Spans = invalid.Spans[:len(invalid.Spans)-1] | |
| 621 | + | ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, invalid) | |
| 622 | + | require.Zero(t, ack) | |
| 623 | + | require.True(t, reset) | |
| 624 | + | require.False(t, changed) | |
| 625 | + | proxy.mu.Lock() | |
| 626 | + | require.Equal(t, uint64(7), proxy.appliedState) | |
| 627 | + | require.False(t, proxy.screenReady) | |
| 628 | + | require.True(t, proxy.resetRequested) | |
| 629 | + | require.Equal(t, before, proxy.screen.frame) | |
| 630 | + | proxy.mu.Unlock() | |
| 631 | + | _, reset, _ = proxy.applyScreenUpdateForGeneration(generation, invalid) | |
| 632 | + | require.False(t, reset, "snapshot Apply failure reset was not deduplicated") | |
| 633 | + | }) | |
| 568 | 634 | ||
| 569 | - | ack, reset, changed := proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("B")}) | |
| 570 | - | require.Equal(t, uint64(3), ack) | |
| 571 | - | require.False(t, reset) | |
| 572 | - | require.True(t, changed) | |
| 573 | - | state, awaiting := proxyOutputState(proxy) | |
| 574 | - | require.Equal(t, uint64(3), state) | |
| 575 | - | require.False(t, awaiting) | |
| 576 | - | require.Equal(t, "B ", proxyScreenText(proxy)) | |
| 635 | + | t.Run("delta apply failure", func(t *testing.T) { | |
| 636 | + | d, proxy, generation := newProxy(t) | |
| 637 | + | initial := screenSnapshotForProxy(t, proxy, 7) | |
| 638 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial)) | |
| 639 | + | proxy.mu.Lock() | |
| 640 | + | before := proxy.screen.frame.Clone() | |
| 641 | + | size := proxy.contentSize | |
| 642 | + | proxy.mu.Unlock() | |
| 643 | + | invalid := ports.ScreenUpdate{ | |
| 644 | + | Kind: ports.ScreenUpdateDelta, BaseStateNum: 7, NewStateNum: 8, Size: size, | |
| 645 | + | Spans: []ports.ScreenSpan{{Y: uint16(size.Rows), Cells: []renderer.Cell{{Rune: 'x'}}}}, | |
| 646 | + | } | |
| 647 | + | ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, invalid) | |
| 648 | + | require.Zero(t, ack) | |
| 649 | + | require.True(t, reset) | |
| 650 | + | require.False(t, changed) | |
| 651 | + | proxy.mu.Lock() | |
| 652 | + | require.Equal(t, uint64(7), proxy.appliedState) | |
| 653 | + | require.False(t, proxy.screenReady) | |
| 654 | + | require.True(t, proxy.resetRequested) | |
| 655 | + | require.Equal(t, before, proxy.screen.frame) | |
| 656 | + | proxy.mu.Unlock() | |
| 657 | + | _, reset, _ = proxy.applyScreenUpdateForGeneration(generation, invalid) | |
| 658 | + | require.False(t, reset, "delta Apply failure reset was not deduplicated") | |
| 659 | + | }) | |
| 577 | 660 | } | |
| 578 | 661 | ||
| 579 | - | // TestProxyLinkReplySendFailureResumesInsteadOfStopping separates a broken | |
| 580 | - | // write from a broken remote: the frame was well formed, so the proxy link is | |
| 581 | - | // resumable and only this transport incarnation is retired. | |
| 582 | - | func TestProxyLinkReplySendFailureResumesInsteadOfStopping(t *testing.T) { | |
| 583 | - | tests := []struct { | |
| 584 | - | name string | |
| 585 | - | frame ports.Frame | |
| 662 | + | func screenSnapshotForProxy(t *testing.T, proxy *proxySession, state uint64) ports.ScreenUpdate { | |
| 663 | + | t.Helper() | |
| 664 | + | proxy.mu.Lock() | |
| 665 | + | size := proxy.contentSize | |
| 666 | + | proxy.mu.Unlock() | |
| 667 | + | snapshot := blankSnapshot(size) | |
| 668 | + | snapshot.NewStateNum = state | |
| 669 | + | return snapshot | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | func TestProxyScreenUpdate(t *testing.T) { | |
| 673 | + | t.Run("initial snapshot", func(t *testing.T) { | |
| 674 | + | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 675 | + | snapshot := screenSnapshotForProxy(t, proxy, 1) | |
| 676 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | |
| 677 | + | require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial screen was not acknowledged").Type) | |
| 678 | + | proxy.mu.Lock() | |
| 679 | + | require.Equal(t, uint64(1), proxy.appliedState) | |
| 680 | + | require.True(t, proxy.screenReady) | |
| 681 | + | require.False(t, proxy.resetRequested) | |
| 682 | + | proxy.mu.Unlock() | |
| 683 | + | }) | |
| 684 | + | ||
| 685 | + | t.Run("matching delta", func(t *testing.T) { | |
| 686 | + | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 687 | + | snapshot := screenSnapshotForProxy(t, proxy, 1) | |
| 688 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | |
| 689 | + | _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged") | |
| 690 | + | ||
| 691 | + | delta := ports.ScreenUpdate{ | |
| 692 | + | Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2, | |
| 693 | + | Size: snapshot.Size, Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("delta")}}, | |
| 694 | + | } | |
| 695 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, delta)) | |
| 696 | + | require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "screen delta was not acknowledged").Type) | |
| 697 | + | proxy.mu.Lock() | |
| 698 | + | require.Equal(t, uint64(2), proxy.appliedState) | |
| 699 | + | require.Equal(t, 'd', proxy.screen.frame.At(0, 0).Rune) | |
| 700 | + | proxy.mu.Unlock() | |
| 701 | + | }) | |
| 702 | + | ||
| 703 | + | for _, tc := range []struct { | |
| 704 | + | name string | |
| 705 | + | base uint64 | |
| 706 | + | new uint64 | |
| 586 | 707 | }{ | |
| 587 | - | { | |
| 588 | - | name: "pong reply", | |
| 589 | - | frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}, | |
| 590 | - | }, | |
| 591 | - | { | |
| 592 | - | name: "output ack", | |
| 593 | - | frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})}, | |
| 594 | - | }, | |
| 595 | - | { | |
| 596 | - | name: "output reset request", | |
| 597 | - | frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 7, NewStateNum: 8, Data: []byte("A")})}, | |
| 598 | - | }, | |
| 708 | + | {name: "duplicate", base: 0, new: 1}, | |
| 709 | + | {name: "gap", base: 3, new: 4}, | |
| 710 | + | } { | |
| 711 | + | t.Run(tc.name+" requests exactly one reset", func(t *testing.T) { | |
| 712 | + | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 713 | + | snapshot := screenSnapshotForProxy(t, proxy, 1) | |
| 714 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | |
| 715 | + | _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged") | |
| 716 | + | ||
| 717 | + | update := ports.ScreenUpdate{ | |
| 718 | + | Kind: ports.ScreenUpdateDelta, BaseStateNum: tc.base, NewStateNum: tc.new, | |
| 719 | + | Size: snapshot.Size, | |
| 720 | + | } | |
| 721 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, update)) | |
| 722 | + | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "screen mismatch did not request reset").Type) | |
| 723 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, update)) | |
| 724 | + | select { | |
| 725 | + | case frame := <-transport.sent: | |
| 726 | + | t.Fatalf("duplicate %s sent %v", tc.name, frame.Type) | |
| 727 | + | default: | |
| 728 | + | } | |
| 729 | + | proxy.mu.Lock() | |
| 730 | + | require.Equal(t, uint64(1), proxy.appliedState) | |
| 731 | + | require.False(t, proxy.screenReady) | |
| 732 | + | require.True(t, proxy.resetRequested) | |
| 733 | + | proxy.mu.Unlock() | |
| 734 | + | }) | |
| 599 | 735 | } | |
| 600 | - | for _, tt := range tests { | |
| 601 | - | t.Run(tt.name, func(t *testing.T) { | |
| 736 | + | ||
| 737 | + | t.Run("awaited base-zero snapshot advances the floor", func(t *testing.T) { | |
| 738 | + | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 739 | + | initial := screenSnapshotForProxy(t, proxy, 5) | |
| 740 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial)) | |
| 741 | + | _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged") | |
| 742 | + | ||
| 743 | + | gap := ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, BaseStateNum: 9, NewStateNum: 10, Size: initial.Size} | |
| 744 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap)) | |
| 745 | + | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "screen gap did not request reset").Type) | |
| 746 | + | ||
| 747 | + | moving := initial | |
| 748 | + | moving.NewStateNum = 6 | |
| 749 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, moving)) | |
| 750 | + | require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "awaited snapshot was not acknowledged").Type) | |
| 751 | + | proxy.mu.Lock() | |
| 752 | + | require.Equal(t, uint64(6), proxy.appliedState) | |
| 753 | + | require.True(t, proxy.screenReady) | |
| 754 | + | require.False(t, proxy.resetRequested) | |
| 755 | + | proxy.mu.Unlock() | |
| 756 | + | }) | |
| 757 | + | ||
| 758 | + | t.Run("stale generation is ignored", func(t *testing.T) { | |
| 759 | + | _, proxy, transport, oldGeneration := newProxyOutputSession(t) | |
| 760 | + | replacement := newProxyTestTransport() | |
| 761 | + | newGeneration, _ := proxy.installTransport(replacement, false) | |
| 762 | + | require.NotEqual(t, oldGeneration, newGeneration) | |
| 763 | + | ack, reset, changed := proxy.applyScreenUpdateForGeneration(oldGeneration, screenSnapshotForProxy(t, proxy, 1)) | |
| 764 | + | require.Zero(t, ack) | |
| 765 | + | require.False(t, reset) | |
| 766 | + | require.False(t, changed) | |
| 767 | + | proxy.mu.Lock() | |
| 768 | + | require.Zero(t, proxy.appliedState) | |
| 769 | + | proxy.mu.Unlock() | |
| 770 | + | select { | |
| 771 | + | case frame := <-transport.sent: | |
| 772 | + | t.Fatalf("stale screen update sent %v", frame.Type) | |
| 773 | + | default: | |
| 774 | + | } | |
| 775 | + | }) | |
| 776 | + | ||
| 777 | + | t.Run("ACK and reset send failures are resumable", func(t *testing.T) { | |
| 778 | + | t.Run("ACK", func(t *testing.T) { | |
| 602 | 779 | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 603 | - | t.Cleanup(func() { _ = transport.Close() }) | |
| 604 | 780 | transport.sendFails.Store(true) | |
| 605 | - | transport.recv <- proxyRecv{frame: tt.frame} | |
| 781 | + | snapshot := screenSnapshotForProxy(t, proxy, 1) | |
| 782 | + | payload, err := ports.MarshalScreenUpdate(snapshot) | |
| 783 | + | require.NoError(t, err) | |
| 784 | + | transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload}} | |
| 785 | + | require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, generation, transport)) | |
| 786 | + | }) | |
| 606 | 787 | ||
| 607 | - | result := d.runProxyTransport(context.Background(), proxy, generation, transport) | |
| 608 | - | require.Equal(t, proxyLinkResume, result, "a failed reply must retire the transport, not the proxy link") | |
| 788 | + | t.Run("reset", func(t *testing.T) { | |
| 789 | + | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 790 | + | transport.sendFails.Store(true) | |
| 791 | + | proxy.mu.Lock() | |
| 792 | + | size := proxy.contentSize | |
| 793 | + | proxy.mu.Unlock() | |
| 794 | + | gap := ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2, Size: size} | |
| 795 | + | payload, err := ports.MarshalScreenUpdate(gap) | |
| 796 | + | require.NoError(t, err) | |
| 797 | + | transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload}} | |
| 798 | + | require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, generation, transport)) | |
| 609 | 799 | }) | |
| 610 | - | } | |
| 800 | + | }) | |
| 801 | + | ||
| 802 | + | t.Run("blocked send releases proxy.mu", func(t *testing.T) { | |
| 803 | + | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 804 | + | gate := make(chan struct{}) | |
| 805 | + | entered := make(chan struct{}, 1) | |
| 806 | + | transport.sendGate = gate | |
| 807 | + | transport.sendEntered = entered | |
| 808 | + | snapshot := screenSnapshotForProxy(t, proxy, 1) | |
| 809 | + | result := make(chan error, 1) | |
| 810 | + | go func() { | |
| 811 | + | result <- d.handleProxyScreenUpdate(proxy, generation, snapshot) | |
| 812 | + | }() | |
| 813 | + | awaitTestCompletion(t, entered, "screen ACK sender did not enter transport") | |
| 814 | + | ||
| 815 | + | unlocked := make(chan struct{}) | |
| 816 | + | go func() { | |
| 817 | + | proxy.mu.Lock() | |
| 818 | + | currentTransport := proxy.transport | |
| 819 | + | proxy.mu.Unlock() | |
| 820 | + | require.Same(t, transport, currentTransport) | |
| 821 | + | close(unlocked) | |
| 822 | + | }() | |
| 823 | + | select { | |
| 824 | + | case <-unlocked: | |
| 825 | + | case <-time.After(time.Second): | |
| 826 | + | t.Fatal("proxy.mu was held while screen send was blocked") | |
| 827 | + | } | |
| 828 | + | require.NoError(t, transport.Close()) | |
| 829 | + | require.Error(t, awaitTestValue(t, result, "blocked screen send did not return")) | |
| 830 | + | }) | |
| 611 | 831 | } | |
| 612 | 832 | ||
| 613 | - | // TestProxyLinkProtocolErrorStillStops fences the sentinel so it cannot widen | |
| 614 | - | // into "every error is resumable". | |
| 833 | + | // TestProxyLinkProtocolErrorStillStops fences malformed MsgOutput rejection | |
| 834 | + | // separately from resumable structured screen reply failures. | |
| 615 | 835 | func TestProxyLinkProtocolErrorStillStops(t *testing.T) { | |
| 616 | 836 | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 617 | 837 | t.Cleanup(func() { _ = transport.Close() }) | |
| @@ -620,130 +840,118 @@ func TestProxyLinkProtocolErrorStillStops(t *testing.T) { | |||
| 620 | 840 | require.Equal(t, proxyLinkStop, d.runProxyTransport(context.Background(), proxy, generation, transport)) | |
| 621 | 841 | } | |
| 622 | 842 | ||
| 623 | - | func TestProxyOutputMismatchSendsOneResetUntilBaseZero(t *testing.T) { | |
| 624 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 625 | - | require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})) | |
| 626 | - | require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial output was not acknowledged").Type) | |
| 627 | - | ||
| 628 | - | mismatch := ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("discard")} | |
| 629 | - | require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch)) | |
| 630 | - | reset := awaitTestValue(t, transport.sent, "output mismatch did not request reset") | |
| 631 | - | require.Equal(t, ports.MsgOutputResetRequest, reset.Type) | |
| 632 | - | require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch)) | |
| 633 | - | select { | |
| 634 | - | case frame := <-transport.sent: | |
| 635 | - | t.Fatalf("second mismatch sent %v, want no frame", frame.Type) | |
| 636 | - | default: | |
| 637 | - | } | |
| 638 | - | ||
| 639 | - | require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("R")})) | |
| 640 | - | ack := awaitTestValue(t, transport.sent, "reset output was not acknowledged") | |
| 641 | - | require.Equal(t, ports.MsgAck, ack.Type) | |
| 642 | - | state, awaiting := proxyOutputState(proxy) | |
| 643 | - | require.Equal(t, uint64(5), state) | |
| 644 | - | require.False(t, awaiting) | |
| 645 | - | require.Equal(t, "R ", proxyScreenText(proxy)) | |
| 843 | + | func proxySideEffectFrame(data []byte) ports.Frame { | |
| 844 | + | return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | |
| 845 | + | BaseStateNum: 0, | |
| 846 | + | NewStateNum: 0, | |
| 847 | + | Data: data, | |
| 848 | + | })} | |
| 646 | 849 | } | |
| 647 | 850 | ||
| 648 | - | func TestProxyOutputMalformedOrStaleCannotAdvanceState(t *testing.T) { | |
| 649 | - | t.Run("malformed", func(t *testing.T) { | |
| 650 | - | d, proxy, _, generation := newProxyOutputSession(t) | |
| 651 | - | result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}) | |
| 652 | - | require.Equal(t, proxyLinkStop, result) | |
| 653 | - | require.Error(t, err) | |
| 654 | - | state, awaiting := proxyOutputState(proxy) | |
| 655 | - | require.Zero(t, state) | |
| 656 | - | require.False(t, awaiting) | |
| 657 | - | }) | |
| 851 | + | func TestProxySideEffectForwardsOSC52ToCurrentThinClient(t *testing.T) { | |
| 852 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 853 | + | remote := newProxyTestTransport() | |
| 854 | + | thin := newProxyTestTransport() | |
| 855 | + | proxy, ac := newAttachedProxyFixture(t, d, thin, remote) | |
| 856 | + | ac.echoAck.Store(17) | |
| 658 | 857 | ||
| 659 | - | t.Run("stale generation", func(t *testing.T) { | |
| 660 | - | d, proxy, transport, oldGeneration := newProxyOutputSession(t) | |
| 661 | - | newGeneration, _ := proxy.installTransport(newProxyTestTransport()) | |
| 662 | - | require.NotEqual(t, oldGeneration, newGeneration) | |
| 663 | - | require.NoError(t, d.handleProxyOutput(proxy, oldGeneration, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})) | |
| 664 | - | state, awaiting := proxyOutputState(proxy) | |
| 665 | - | require.Zero(t, state) | |
| 666 | - | require.False(t, awaiting) | |
| 667 | - | require.Equal(t, " ", proxyScreenText(proxy)) | |
| 668 | - | select { | |
| 669 | - | case frame := <-transport.sent: | |
| 670 | - | t.Fatalf("stale output sent %v", frame.Type) | |
| 671 | - | default: | |
| 672 | - | } | |
| 673 | - | }) | |
| 674 | - | } | |
| 858 | + | const osc52 = "\x1b]52;c;YQ==\a" | |
| 859 | + | result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte(osc52))) | |
| 860 | + | require.Equal(t, proxyLinkResume, result) | |
| 861 | + | require.NoError(t, err) | |
| 675 | 862 | ||
| 676 | - | func TestProxyOutputAckFailureStillDoesNotApplyStaleState(t *testing.T) { | |
| 677 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 678 | - | transport.sendGate = make(chan struct{}) | |
| 679 | - | require.NoError(t, transport.Close()) | |
| 680 | - | err := d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}) | |
| 681 | - | require.Error(t, err) | |
| 682 | - | state, _ := proxyOutputState(proxy) | |
| 683 | - | require.Equal(t, uint64(1), state, "ACK failure follows a successfully applied state") | |
| 863 | + | forwarded := awaitTestValue(t, thin.sent, "proxy OSC52 side effect was not forwarded") | |
| 864 | + | require.Equal(t, ports.MsgOutput, forwarded.Type) | |
| 865 | + | out, err := ports.UnmarshalOutput(forwarded.Payload) | |
| 866 | + | require.NoError(t, err) | |
| 867 | + | require.Zero(t, out.BaseStateNum) | |
| 868 | + | require.Zero(t, out.NewStateNum) | |
| 869 | + | require.Equal(t, uint64(17), out.EchoAck) | |
| 870 | + | require.Equal(t, []byte(osc52), out.Data) | |
| 871 | + | select { | |
| 872 | + | case frame := <-thin.sent: | |
| 873 | + | t.Fatalf("proxy OSC52 side effect forwarded more than once: %v", frame.Type) | |
| 874 | + | default: | |
| 875 | + | } | |
| 876 | + | proxy.mu.Lock() | |
| 877 | + | require.Zero(t, proxy.appliedState) | |
| 878 | + | require.False(t, proxy.resetRequested) | |
| 879 | + | require.True(t, proxy.screenReady, "side effect must not alter proxy screen readiness") | |
| 880 | + | proxy.mu.Unlock() | |
| 881 | + | select { | |
| 882 | + | case frame := <-remote.sent: | |
| 883 | + | t.Fatalf("side effect unexpectedly replied on proxy link: %v", frame.Type) | |
| 884 | + | default: | |
| 885 | + | } | |
| 684 | 886 | } | |
| 685 | 887 | ||
| 686 | - | func TestProxyOutputReconnectRequiresBaseZero(t *testing.T) { | |
| 687 | - | d, proxy, first, generation := newProxyOutputSession(t) | |
| 688 | - | require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})) | |
| 689 | - | _ = awaitTestValue(t, first.sent, "initial output was not acknowledged") // ACK | |
| 690 | - | ||
| 691 | - | second := newProxyTestTransport() | |
| 692 | - | generation, _ = proxy.installTransport(second) | |
| 693 | - | require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")})) | |
| 694 | - | frame := awaitTestValue(t, second.sent, "reconnected output did not request reset") | |
| 695 | - | require.Equal(t, ports.MsgOutputResetRequest, frame.Type) | |
| 696 | - | state, awaiting := proxyOutputState(proxy) | |
| 697 | - | require.Zero(t, state) | |
| 698 | - | require.True(t, awaiting) | |
| 699 | - | require.Equal(t, "A ", proxyScreenText(proxy)) | |
| 700 | - | ||
| 701 | - | require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 7, Data: []byte("R")})) | |
| 702 | - | frame = awaitTestValue(t, second.sent, "reset reconnected output was not acknowledged") | |
| 703 | - | require.Equal(t, ports.MsgAck, frame.Type) | |
| 704 | - | state, awaiting = proxyOutputState(proxy) | |
| 705 | - | require.Equal(t, uint64(7), state) | |
| 706 | - | require.False(t, awaiting) | |
| 707 | - | } | |
| 708 | - | ||
| 709 | - | func TestProxyOutputResizeRequiresBaseZero(t *testing.T) { | |
| 710 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 711 | - | require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})) | |
| 712 | - | _ = awaitTestValue(t, transport.sent, "initial output was not acknowledged") // ACK | |
| 888 | + | func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) { | |
| 889 | + | tests := []struct { | |
| 890 | + | name string | |
| 891 | + | frame ports.Frame | |
| 892 | + | }{ | |
| 893 | + | {name: "state bearing", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("screen")})}}, | |
| 894 | + | {name: "malformed", frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}, | |
| 895 | + | } | |
| 896 | + | for _, tt := range tests { | |
| 897 | + | t.Run(tt.name, func(t *testing.T) { | |
| 898 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 899 | + | remote := newProxyTestTransport() | |
| 900 | + | thin := newProxyTestTransport() | |
| 901 | + | proxy, _ := newAttachedProxyFixture(t, d, thin, remote) | |
| 713 | 902 | ||
| 714 | - | proxy.resetOutputState() | |
| 715 | - | require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")})) | |
| 716 | - | frame := awaitTestValue(t, transport.sent, "resize mismatch did not request reset") | |
| 717 | - | require.Equal(t, ports.MsgOutputResetRequest, frame.Type) | |
| 718 | - | state, awaiting := proxyOutputState(proxy) | |
| 719 | - | require.Zero(t, state) | |
| 720 | - | require.True(t, awaiting) | |
| 721 | - | require.Equal(t, "A ", proxyScreenText(proxy)) | |
| 903 | + | result, err := d.handleLinkFrame(proxy, 1, tt.frame) | |
| 904 | + | require.Equal(t, proxyLinkStop, result) | |
| 905 | + | require.Error(t, err) | |
| 906 | + | select { | |
| 907 | + | case frame := <-thin.sent: | |
| 908 | + | t.Fatalf("invalid proxy output forwarded: %v", frame.Type) | |
| 909 | + | default: | |
| 910 | + | } | |
| 911 | + | }) | |
| 912 | + | } | |
| 722 | 913 | } | |
| 723 | 914 | ||
| 724 | - | func TestProxyOutputConcurrentCancelDoesNotHoldProxyLockDuringSend(t *testing.T) { | |
| 725 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 726 | - | gate := make(chan struct{}) | |
| 727 | - | entered := make(chan struct{}, 1) | |
| 728 | - | transport.sendGate = gate | |
| 729 | - | transport.sendEntered = entered | |
| 915 | + | func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) { | |
| 916 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 917 | + | remote := newProxyTestTransport() | |
| 918 | + | thin := newProxyTestTransport() | |
| 919 | + | proxy, ac := newAttachedProxyFixture(t, d, thin, remote) | |
| 920 | + | frame := proxySideEffectFrame([]byte("effect")) | |
| 730 | 921 | ||
| 731 | - | result := make(chan error, 1) | |
| 732 | - | go func() { | |
| 733 | - | result <- d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}) | |
| 734 | - | }() | |
| 735 | - | awaitTestCompletion(t, entered, "output sender did not enter transport") | |
| 922 | + | result, err := d.handleLinkFrame(proxy, 0, frame) | |
| 923 | + | require.Equal(t, proxyLinkResume, result) | |
| 924 | + | require.NoError(t, err) | |
| 925 | + | select { | |
| 926 | + | case forwarded := <-thin.sent: | |
| 927 | + | t.Fatalf("stale proxy generation forwarded side effect: %v", forwarded.Type) | |
| 928 | + | default: | |
| 929 | + | } | |
| 736 | 930 | ||
| 737 | - | locked := make(chan struct{}) | |
| 738 | - | go func() { | |
| 739 | - | _, _ = proxyOutputState(proxy) | |
| 740 | - | close(locked) | |
| 741 | - | }() | |
| 931 | + | token := attachmentToken(proxy, ac, ac.transport()) | |
| 932 | + | ticket, admitted := ac.beginRoleEffect(token) | |
| 933 | + | require.True(t, admitted) | |
| 934 | + | ticket.End() | |
| 935 | + | ac.roleGeneration.Add(1) | |
| 936 | + | result, err = d.handleLinkFrame(proxy, 1, frame) | |
| 937 | + | require.Equal(t, proxyLinkResume, result) | |
| 938 | + | require.NoError(t, err) | |
| 742 | 939 | select { | |
| 743 | - | case <-locked: | |
| 744 | - | case <-time.After(time.Second): | |
| 745 | - | t.Fatal("proxy.mu was held while transport Send was blocked") | |
| 940 | + | case forwarded := <-thin.sent: | |
| 941 | + | t.Fatalf("stale attachment role forwarded side effect: %v", forwarded.Type) | |
| 942 | + | default: | |
| 746 | 943 | } | |
| 747 | - | require.NoError(t, transport.Close()) | |
| 748 | - | require.Error(t, awaitTestValue(t, result, "blocked output send did not return")) | |
| 944 | + | } | |
| 945 | + | ||
| 946 | + | func TestProxySideEffectSendFailureResumesLink(t *testing.T) { | |
| 947 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 948 | + | remote := newProxyTestTransport() | |
| 949 | + | thin := newProxyTestTransport() | |
| 950 | + | thin.sendFails.Store(true) | |
| 951 | + | proxy, _ := newAttachedProxyFixture(t, d, thin, remote) | |
| 952 | + | err := d.handleProxySideEffect(proxy, 1, ports.Output{Data: []byte("effect")}) | |
| 953 | + | require.ErrorContains(t, err, "link reply send failed") | |
| 954 | + | remote.recv <- proxyRecv{frame: proxySideEffectFrame([]byte("effect"))} | |
| 955 | + | ||
| 956 | + | require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, 1, remote)) | |
| 749 | 957 | } | |
M internal/usecase/daemon/proxy_render.go +40 -11
| @@ -9,6 +9,14 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | const proxyRenderPaneID layout.PaneID = "proxy-content" | |
| 11 | 11 | ||
| 12 | + | type proxyCapture struct { | |
| 13 | + | screen *proxyScreenState | |
| 14 | + | frame renderer.Frame | |
| 15 | + | damage []renderer.Damage | |
| 16 | + | generation uint64 | |
| 17 | + | cursor capturedCursorInputs | |
| 18 | + | } | |
| 19 | + | ||
| 12 | 20 | // capturePrimary snapshots the remote VT while holding only proxy.mu. The | |
| 13 | 21 | // snapshot is then composed by the ordinary local frame pipeline, which owns | |
| 14 | 22 | // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore | |
| @@ -24,22 +32,33 @@ func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCapture | |||
| 24 | 32 | return nil, false | |
| 25 | 33 | } | |
| 26 | 34 | screen := p.screen | |
| 27 | - | frame := screen.Frame.Clone() | |
| 35 | + | capture := &ac.proxyCapture | |
| 28 | 36 | damage := screen.CaptureDamage() | |
| 29 | - | cursorStyle, hasCursorStyle := screen.CursorStyle() | |
| 30 | - | cursor := capturedCursorInputs{ | |
| 31 | - | row: screen.CursorRow(), | |
| 32 | - | col: screen.CursorCol(), | |
| 33 | - | style: cursorStyle, | |
| 34 | - | hasStyle: hasCursorStyle, | |
| 35 | - | visible: screen.CursorVisible(), | |
| 37 | + | if capture.screen != screen { | |
| 38 | + | // A replacement screen has no relationship to the retained frame. | |
| 39 | + | capture.frame = renderer.Frame{} | |
| 40 | + | } | |
| 41 | + | if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil { | |
| 42 | + | screen.CaptureInto(&capture.frame) | |
| 43 | + | } | |
| 44 | + | capture.screen = screen | |
| 45 | + | capture.generation = damage.Generation | |
| 46 | + | capture.damage = append(capture.damage[:0], damage.Damage...) | |
| 47 | + | frame := capture.frame | |
| 48 | + | capture.cursor = capturedCursorInputs{ | |
| 49 | + | row: int(screen.cursorOut.Row), | |
| 50 | + | col: int(screen.cursorOut.Col), | |
| 51 | + | style: int(screen.cursorOut.Style), | |
| 52 | + | hasStyle: screen.cursorOut.StyleSet, | |
| 53 | + | visible: screen.cursorOut.Visible, | |
| 36 | 54 | renderable: frame.Width > 0 && frame.Height > 0, | |
| 37 | 55 | content: domain.Rect{Width: frame.Width, Height: frame.Height}, | |
| 38 | 56 | } | |
| 57 | + | cursor := capture.cursor | |
| 58 | + | paneDamage := append([]renderer.Damage(nil), capture.damage...) | |
| 39 | 59 | p.mu.Unlock() | |
| 40 | 60 | ||
| 41 | 61 | content := domain.Rect{Width: frame.Width, Height: frame.Height} | |
| 42 | - | paneDamage := append([]renderer.Damage(nil), damage.Damage...) | |
| 43 | 62 | if uncertainDamage(paneDamage, frame.Width, frame.Height) { | |
| 44 | 63 | paneDamage = []renderer.Damage{renderer.FullRedraw()} | |
| 45 | 64 | } | |
| @@ -98,6 +117,9 @@ func (d *Daemon) resizeProxyForLease(p *proxySession, ac *attachedClient, lease | |||
| 98 | 117 | if changed, _ := p.resize(size); !changed { | |
| 99 | 118 | return | |
| 100 | 119 | } | |
| 120 | + | ac.sendMu.Lock() | |
| 121 | + | ac.size = size | |
| 122 | + | ac.sendMu.Unlock() | |
| 101 | 123 | if rc := attachmentRenderCoordinator(p); rc != nil { | |
| 102 | 124 | rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"}) | |
| 103 | 125 | return | |
| @@ -118,15 +140,22 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) { | |||
| 118 | 140 | return false, false | |
| 119 | 141 | } | |
| 120 | 142 | content := contentSize(size, false) | |
| 143 | + | if !validProxyScreenSize(content) { | |
| 144 | + | return false, false | |
| 145 | + | } | |
| 121 | 146 | p.mu.Lock() | |
| 122 | 147 | if p.screen == nil || p.contentSize == content { | |
| 123 | 148 | available := p.screen != nil | |
| 124 | 149 | p.mu.Unlock() | |
| 125 | 150 | return false, available | |
| 126 | 151 | } | |
| 127 | - | p.screen.Resize(content.Cols, content.Rows) | |
| 152 | + | p.screen.ResizePlaceholder(content) | |
| 128 | 153 | p.contentSize = content | |
| 129 | - | p.resetOutputStateLocked() | |
| 154 | + | // A resize invalidates the local replay chain, but the last applied state | |
| 155 | + | // remains the moving snapshot floor for the replacement geometry. | |
| 156 | + | p.screenReady = false | |
| 157 | + | p.resetRequested = false | |
| 158 | + | p.screen.stateNum = 0 | |
| 130 | 159 | generation := p.linkGeneration | |
| 131 | 160 | p.mu.Unlock() | |
| 132 | 161 | ||
M internal/usecase/daemon/proxy_render_test.go +170 -3
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "io" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| @@ -12,13 +13,50 @@ import ( | |||
| 12 | 13 | "github.com/stretchr/testify/require" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 16 | + | func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) { | |
| 17 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 4, Rows: 3}) | |
| 18 | + | require.NoError(t, err) | |
| 19 | + | proxy.mu.Lock() | |
| 20 | + | generation := proxy.linkGeneration | |
| 21 | + | snapshot := screenSnapshot(4, 3, "abcd", "efgh", "ijkl") | |
| 22 | + | require.NoError(t, proxy.screen.Apply(snapshot)) | |
| 23 | + | proxy.screenReady = true | |
| 24 | + | proxy.appliedState = snapshot.NewStateNum | |
| 25 | + | proxy.mu.Unlock() | |
| 26 | + | ||
| 27 | + | ac := &attachedClient{} | |
| 28 | + | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{}) | |
| 29 | + | require.True(t, ok) | |
| 30 | + | require.Equal(t, "abcdefghijkl", proxyFrameText(state.panes[0].frame)) | |
| 31 | + | commitDamageReceipts(state.receipts) | |
| 32 | + | ||
| 33 | + | proxy.mu.Lock() | |
| 34 | + | delta := ports.ScreenUpdate{ | |
| 35 | + | Kind: ports.ScreenUpdateDelta, | |
| 36 | + | BaseStateNum: 1, | |
| 37 | + | NewStateNum: 2, | |
| 38 | + | Size: domain.Size{Cols: 4, Rows: 3}, | |
| 39 | + | Spans: []ports.ScreenSpan{{Y: 1, X: 1, Cells: cells("Z")}}, | |
| 40 | + | Cursor: ports.ScreenCursor{Visible: true}, | |
| 41 | + | } | |
| 42 | + | require.NoError(t, proxy.screen.Apply(delta)) | |
| 43 | + | proxy.mu.Unlock() | |
| 44 | + | ||
| 45 | + | cellsBefore := &ac.proxyCapture.frame.Cells[0] | |
| 46 | + | state, ok = proxy.capturePrimary(ac, primaryCaptureRequest{}) | |
| 47 | + | require.True(t, ok) | |
| 48 | + | require.Equal(t, "abcdeZghijkl", proxyFrameText(state.panes[0].frame)) | |
| 49 | + | require.Equal(t, cellsBefore, &ac.proxyCapture.frame.Cells[0], "safe span damage must update the retained frame") | |
| 50 | + | require.Equal(t, generation, proxy.linkGeneration) | |
| 51 | + | } | |
| 52 | + | ||
| 15 | 53 | func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) { | |
| 16 | 54 | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6}) | |
| 17 | 55 | require.NoError(t, err) | |
| 18 | 56 | proxy.mu.Lock() | |
| 19 | 57 | proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}} | |
| 20 | 58 | proxy.mu.Unlock() | |
| 21 | - | _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2;3Hremote")}) | |
| 59 | + | changed := applyTestScreenText(proxy, 1, 2, "remote") | |
| 22 | 60 | require.True(t, changed) | |
| 23 | 61 | ||
| 24 | 62 | state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{ | |
| @@ -45,7 +83,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) { | |||
| 45 | 83 | proxy.mu.Lock() | |
| 46 | 84 | proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}} | |
| 47 | 85 | proxy.mu.Unlock() | |
| 48 | - | _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")}) | |
| 86 | + | changed := applyTestScreenText(proxy, 0, 0, "remote") | |
| 49 | 87 | require.True(t, changed) | |
| 50 | 88 | ||
| 51 | 89 | d := newTestDaemon(t, nil, stubClock{}) | |
| @@ -72,7 +110,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) { | |||
| 72 | 110 | proxy.mu.Lock() | |
| 73 | 111 | proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}} | |
| 74 | 112 | proxy.mu.Unlock() | |
| 75 | - | _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")}) | |
| 113 | + | changed := applyTestScreenText(proxy, 0, 0, "remote") | |
| 76 | 114 | require.True(t, changed) | |
| 77 | 115 | d.mu.Lock() | |
| 78 | 116 | require.True(t, d.registerSessionLocked(proxy)) | |
| @@ -100,6 +138,113 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) { | |||
| 100 | 138 | require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to local session did not paint").Type) | |
| 101 | 139 | } | |
| 102 | 140 | ||
| 141 | + | func TestProxyResizeRejectsOversizedGeometryBeforeMutationOrForwarding(t *testing.T) { | |
| 142 | + | for _, size := range []domain.Size{ | |
| 143 | + | {Cols: 512, Rows: 515}, | |
| 144 | + | {Cols: 1000, Rows: 1000}, | |
| 145 | + | } { | |
| 146 | + | t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) { | |
| 147 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24}) | |
| 148 | + | require.NoError(t, err) | |
| 149 | + | transport := newProxyTestTransport() | |
| 150 | + | proxy.mu.Lock() | |
| 151 | + | proxy.transport = transport | |
| 152 | + | proxy.linkGeneration = 1 | |
| 153 | + | initial := proxy.contentSize | |
| 154 | + | proxy.mu.Unlock() | |
| 155 | + | ||
| 156 | + | changed, sent := proxy.resize(size) | |
| 157 | + | require.False(t, changed) | |
| 158 | + | require.False(t, sent) | |
| 159 | + | proxy.mu.Lock() | |
| 160 | + | require.Equal(t, initial, proxy.contentSize) | |
| 161 | + | proxy.mu.Unlock() | |
| 162 | + | select { | |
| 163 | + | case frame := <-transport.sent: | |
| 164 | + | t.Fatalf("oversized resize was forwarded: %#v", frame) | |
| 165 | + | default: | |
| 166 | + | } | |
| 167 | + | }) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func TestProxyResizeForLeaseRejectsOversizedGeometryBeforeMutationOrPaint(t *testing.T) { | |
| 172 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 173 | + | client, clientSent := newCapturingTransport(t) | |
| 174 | + | remote := newProxyTestTransport() | |
| 175 | + | proxy, ac := newAttachedProxyFixture(t, d, client, remote) | |
| 176 | + | initialContent := proxy.contentSize | |
| 177 | + | initialViewport := ac.size | |
| 178 | + | ||
| 179 | + | d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 1000, Rows: 1000}) | |
| 180 | + | ||
| 181 | + | proxy.mu.Lock() | |
| 182 | + | require.Equal(t, initialContent, proxy.contentSize) | |
| 183 | + | proxy.mu.Unlock() | |
| 184 | + | require.Equal(t, initialViewport, ac.size) | |
| 185 | + | select { | |
| 186 | + | case frame := <-clientSent: | |
| 187 | + | t.Fatalf("oversized resize painted the client: %#v", frame) | |
| 188 | + | default: | |
| 189 | + | } | |
| 190 | + | select { | |
| 191 | + | case frame := <-remote.sent: | |
| 192 | + | t.Fatalf("oversized resize was forwarded: %#v", frame) | |
| 193 | + | default: | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func TestProxyResizeKeepsPlaceholderUntilSnapshot(t *testing.T) { | |
| 198 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 4, Rows: 4}) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | transport := newProxyTestTransport() | |
| 201 | + | proxy.mu.Lock() | |
| 202 | + | initial := screenSnapshot(4, 2, "abcd", "efgh") | |
| 203 | + | initial.NewStateNum = 7 | |
| 204 | + | require.NoError(t, proxy.screen.Apply(initial)) | |
| 205 | + | proxy.transport = transport | |
| 206 | + | proxy.linkGeneration = 1 | |
| 207 | + | proxy.screenReady = true | |
| 208 | + | proxy.appliedState = initial.NewStateNum | |
| 209 | + | proxy.mu.Unlock() | |
| 210 | + | ||
| 211 | + | changed, sent := proxy.resize(domain.Size{Cols: 3, Rows: 5}) | |
| 212 | + | require.True(t, changed) | |
| 213 | + | require.True(t, sent) | |
| 214 | + | awaitTestValue(t, transport.sent, "proxy resize did not reach remote transport") | |
| 215 | + | ||
| 216 | + | proxy.mu.Lock() | |
| 217 | + | require.Equal(t, domain.Size{Cols: 3, Rows: 3}, proxy.contentSize) | |
| 218 | + | require.Equal(t, "abcefg ", proxyFrameText(proxy.screen.frame)) | |
| 219 | + | require.Equal(t, uint64(7), proxy.appliedState, "resize must preserve the moving snapshot floor") | |
| 220 | + | require.False(t, proxy.screenReady) | |
| 221 | + | require.False(t, proxy.resetRequested) | |
| 222 | + | require.Zero(t, proxy.screen.stateNum) | |
| 223 | + | proxy.mu.Unlock() | |
| 224 | + | ||
| 225 | + | _, requestReset, changed := proxy.applyScreenUpdateForGeneration(1, ports.ScreenUpdate{ | |
| 226 | + | Kind: ports.ScreenUpdateDelta, | |
| 227 | + | BaseStateNum: 7, | |
| 228 | + | NewStateNum: 8, | |
| 229 | + | Size: domain.Size{Cols: 3, Rows: 3}, | |
| 230 | + | Cursor: ports.ScreenCursor{Visible: true}, | |
| 231 | + | }) | |
| 232 | + | require.True(t, requestReset) | |
| 233 | + | require.False(t, changed) | |
| 234 | + | ||
| 235 | + | moving := screenSnapshot(3, 3, "123", "456", "789") | |
| 236 | + | moving.NewStateNum = 8 | |
| 237 | + | _, requestReset, changed = proxy.applyScreenUpdateForGeneration(1, moving) | |
| 238 | + | require.False(t, requestReset) | |
| 239 | + | require.True(t, changed) | |
| 240 | + | proxy.mu.Lock() | |
| 241 | + | require.Equal(t, uint64(8), proxy.appliedState) | |
| 242 | + | require.Greater(t, proxy.appliedState, uint64(7)) | |
| 243 | + | require.True(t, proxy.screenReady) | |
| 244 | + | require.False(t, proxy.resetRequested) | |
| 245 | + | proxy.mu.Unlock() | |
| 246 | + | } | |
| 247 | + | ||
| 103 | 248 | func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) { | |
| 104 | 249 | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8}) | |
| 105 | 250 | require.NoError(t, err) | |
| @@ -107,6 +252,7 @@ func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) { | |||
| 107 | 252 | proxy.mu.Lock() | |
| 108 | 253 | proxy.transport = transport | |
| 109 | 254 | proxy.linkGeneration = 1 | |
| 255 | + | proxy.screenReady = true | |
| 110 | 256 | proxy.mu.Unlock() | |
| 111 | 257 | ||
| 112 | 258 | changed, sent := proxy.resize(domain.Size{Cols: 20, Rows: 10}) | |
| @@ -141,6 +287,7 @@ func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport, | |||
| 141 | 287 | proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}} | |
| 142 | 288 | proxy.transport = remoteTr | |
| 143 | 289 | proxy.linkGeneration = 1 | |
| 290 | + | proxy.screenReady = true | |
| 144 | 291 | proxy.mu.Unlock() | |
| 145 | 292 | d.mu.Lock() | |
| 146 | 293 | require.True(t, d.registerSessionLocked(proxy)) | |
| @@ -162,6 +309,26 @@ func newFailingTransport(t *testing.T) *portsmocks.MockTransport { | |||
| 162 | 309 | return tr | |
| 163 | 310 | } | |
| 164 | 311 | ||
| 312 | + | func TestProxyStructuredSendFailureRetainsProxyDamage(t *testing.T) { | |
| 313 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 314 | + | clientTr := newFailingTransport(t) | |
| 315 | + | proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport()) | |
| 316 | + | ac.proxied = true | |
| 317 | + | ac.screenOutput = newStructuredOutputStream(ac.output) | |
| 318 | + | require.True(t, applyTestScreenText(proxy, 0, 0, "pending")) | |
| 319 | + | ||
| 320 | + | ac.sendMu.Lock() | |
| 321 | + | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true}) | |
| 322 | + | require.True(t, ok) | |
| 323 | + | composed := composeFrame(*state, composeCacheInput{}) | |
| 324 | + | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |
| 325 | + | ||
| 326 | + | proxy.mu.Lock() | |
| 327 | + | damage := proxy.screen.CaptureDamage() | |
| 328 | + | proxy.mu.Unlock() | |
| 329 | + | require.NotEmpty(t, damage.Damage, "a failed structured send must leave proxy damage pending") | |
| 330 | + | } | |
| 331 | + | ||
| 165 | 332 | // TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails covers the split | |
| 166 | 333 | // between "the local content rectangle moved" and "the remote was told". The | |
| 167 | 334 | // local VT has already been resized, so the client owes a repaint either way. | |
M internal/usecase/daemon/proxy_screen_state.go +115 -11
| @@ -3,13 +3,18 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "math" | |
| 6 | + | "unicode/utf8" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/vev/internal/domain" | |
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | - | const maxProxyScreenDamage = 1024 | |
| 13 | + | const ( | |
| 14 | + | maxProxyScreenCells = 1 << 18 | |
| 15 | + | maxProxyScreenDamage = 1024 | |
| 16 | + | maxProxyScreenSpans = 4096 | |
| 17 | + | ) | |
| 13 | 18 | ||
| 14 | 19 | type proxyScreenDamageCapture struct { | |
| 15 | 20 | Damage []renderer.Damage | |
| @@ -73,19 +78,93 @@ func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error { | |||
| 73 | 78 | } | |
| 74 | 79 | ||
| 75 | 80 | func (s *proxyScreenState) validateUpdate(update ports.ScreenUpdate) error { | |
| 76 | - | if err := ports.ValidateScreenUpdate(update); err != nil { | |
| 77 | - | return fmt.Errorf("proxy screen: %w", err) | |
| 81 | + | if update.Kind != ports.ScreenUpdateSnapshot && update.Kind != ports.ScreenUpdateDelta { | |
| 82 | + | return fmt.Errorf("proxy screen: unknown update kind %d", update.Kind) | |
| 83 | + | } | |
| 84 | + | if !validProxyScreenSize(update.Size) { | |
| 85 | + | return fmt.Errorf("proxy screen: invalid size %dx%d", update.Size.Cols, update.Size.Rows) | |
| 78 | 86 | } | |
| 79 | 87 | if update.Kind == ports.ScreenUpdateDelta { | |
| 80 | 88 | if s.frame.Width != update.Size.Cols || s.frame.Height != update.Size.Rows { | |
| 81 | 89 | return fmt.Errorf("proxy screen: delta size %dx%d does not match %dx%d", update.Size.Cols, update.Size.Rows, s.frame.Width, s.frame.Height) | |
| 82 | 90 | } | |
| 91 | + | } else if update.Scroll != nil { | |
| 92 | + | return fmt.Errorf("proxy screen: snapshot cannot contain scroll") | |
| 93 | + | } | |
| 94 | + | if update.Cursor.Row >= uint16(update.Size.Rows) || update.Cursor.Col >= uint16(update.Size.Cols) { | |
| 95 | + | return fmt.Errorf("proxy screen: cursor %d,%d outside %dx%d", update.Cursor.Col, update.Cursor.Row, update.Size.Cols, update.Size.Rows) | |
| 96 | + | } | |
| 97 | + | if !update.Cursor.StyleSet && update.Cursor.Style != 0 { | |
| 98 | + | return fmt.Errorf("proxy screen: cursor style is not set") | |
| 99 | + | } | |
| 100 | + | if update.Cursor.StyleSet && update.Cursor.Style > 6 { | |
| 101 | + | return fmt.Errorf("proxy screen: cursor style is out of range") | |
| 102 | + | } | |
| 103 | + | if len(update.Spans) > maxProxyScreenSpans { | |
| 104 | + | return fmt.Errorf("proxy screen: too many spans") | |
| 105 | + | } | |
| 106 | + | if update.Kind == ports.ScreenUpdateSnapshot { | |
| 107 | + | if len(update.Spans) != update.Size.Rows { | |
| 108 | + | return fmt.Errorf("proxy screen: snapshot must cover every row") | |
| 109 | + | } | |
| 110 | + | for y, span := range update.Spans { | |
| 111 | + | if int(span.Y) != y || span.X != 0 || len(span.Cells) != update.Size.Cols { | |
| 112 | + | return fmt.Errorf("proxy screen: snapshot span %d is not a complete row", y) | |
| 113 | + | } | |
| 114 | + | } | |
| 83 | 115 | } | |
| 84 | - | return s.validateStateNumbers(update) | |
| 116 | + | if err := s.validateStateNumbers(update); err != nil { | |
| 117 | + | return err | |
| 118 | + | } | |
| 119 | + | if scroll := update.Scroll; scroll != nil { | |
| 120 | + | if scroll.Height == 0 || scroll.Count == 0 || scroll.Count >= scroll.Height { | |
| 121 | + | return fmt.Errorf("proxy screen: invalid scroll %d,%d,%d", scroll.Top, scroll.Height, scroll.Count) | |
| 122 | + | } | |
| 123 | + | if uint64(scroll.Top)+uint64(scroll.Height) > uint64(update.Size.Rows) { | |
| 124 | + | return fmt.Errorf("proxy screen: scroll outside screen") | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | var previousY uint16 | |
| 128 | + | var previousEnd uint32 | |
| 129 | + | for i, span := range update.Spans { | |
| 130 | + | if len(span.Cells) == 0 { | |
| 131 | + | return fmt.Errorf("proxy screen: span %d is empty", i) | |
| 132 | + | } | |
| 133 | + | if uint64(span.Y) >= uint64(update.Size.Rows) || uint64(span.X) >= uint64(update.Size.Cols) || | |
| 134 | + | uint64(span.X)+uint64(len(span.Cells)) > uint64(update.Size.Cols) { | |
| 135 | + | return fmt.Errorf("proxy screen: span %d outside screen", i) | |
| 136 | + | } | |
| 137 | + | if i > 0 && (span.Y < previousY || (span.Y == previousY && uint32(span.X) < previousEnd)) { | |
| 138 | + | return fmt.Errorf("proxy screen: spans overlap or are out of order") | |
| 139 | + | } | |
| 140 | + | for _, cell := range span.Cells { | |
| 141 | + | if cell.Continuation && cell.Rune != 0 || !cell.Continuation && !utf8.ValidRune(cell.Rune) { | |
| 142 | + | return fmt.Errorf("proxy screen: invalid cell rune") | |
| 143 | + | } | |
| 144 | + | if !validProxyScreenStyle(cell.Style) { | |
| 145 | + | return fmt.Errorf("proxy screen: invalid cell style") | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | previousY = span.Y | |
| 149 | + | previousEnd = uint32(span.X) + uint32(len(span.Cells)) | |
| 150 | + | } | |
| 151 | + | return nil | |
| 85 | 152 | } | |
| 86 | 153 | ||
| 87 | 154 | func (s *proxyScreenState) validateStateNumbers(update ports.ScreenUpdate) error { | |
| 88 | - | if update.Kind == ports.ScreenUpdateDelta && (s.stateNum == 0 || update.BaseStateNum != s.stateNum) { | |
| 155 | + | if update.NewStateNum == 0 || update.NewStateNum <= update.BaseStateNum { | |
| 156 | + | return fmt.Errorf("proxy screen: invalid state transition %d to %d", update.BaseStateNum, update.NewStateNum) | |
| 157 | + | } | |
| 158 | + | if update.Kind == ports.ScreenUpdateSnapshot { | |
| 159 | + | if update.BaseStateNum != 0 { | |
| 160 | + | return fmt.Errorf("proxy screen: snapshot base state must be zero") | |
| 161 | + | } | |
| 162 | + | return nil | |
| 163 | + | } | |
| 164 | + | if update.BaseStateNum == 0 || update.NewStateNum != update.BaseStateNum+1 { | |
| 165 | + | return fmt.Errorf("proxy screen: delta state transition %d to %d is not consecutive", update.BaseStateNum, update.NewStateNum) | |
| 166 | + | } | |
| 167 | + | if s.stateNum == 0 || update.BaseStateNum != s.stateNum { | |
| 89 | 168 | return fmt.Errorf("proxy screen: delta base state %d does not match %d", update.BaseStateNum, s.stateNum) | |
| 90 | 169 | } | |
| 91 | 170 | return nil | |
| @@ -183,11 +262,9 @@ func (s *proxyScreenState) AcknowledgeDamage(generation uint64) bool { | |||
| 183 | 262 | return true | |
| 184 | 263 | } | |
| 185 | 264 | ||
| 186 | - | // CaptureInto updates dst from the live frame. dst must already contain the | |
| 187 | - | // previous synchronized generation before incremental damage can be replayed; | |
| 188 | - | // callers should initialize it with a full clone or equivalent first capture. | |
| 189 | - | // Safe scroll/span damage is replayed incrementally; snapshots, uncertain | |
| 190 | - | // damage, and dimensions use a complete clone. | |
| 265 | + | // CaptureInto updates dst from the live frame. Safe scroll/span damage is | |
| 266 | + | // replayed incrementally; snapshots, uncertain damage, and dimensions use a | |
| 267 | + | // complete clone. | |
| 191 | 268 | func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) { | |
| 192 | 269 | if s == nil || dst == nil || !validProxyFrame(*s) { | |
| 193 | 270 | return | |
| @@ -266,9 +343,36 @@ func validProxyScreenSize(size domain.Size) bool { | |||
| 266 | 343 | if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 { | |
| 267 | 344 | return false | |
| 268 | 345 | } | |
| 269 | - | return size.Rows <= ports.MaxScreenCells/size.Cols | |
| 346 | + | return size.Rows <= maxProxyScreenCells/size.Cols | |
| 270 | 347 | } | |
| 271 | 348 | ||
| 272 | 349 | func validProxyFrame(s proxyScreenState) bool { | |
| 273 | 350 | return s.frame.Validate() == nil && s.scratch.Validate() == nil | |
| 274 | 351 | } | |
| 352 | + | ||
| 353 | + | func validProxyScreenStyle(style renderer.Style) bool { | |
| 354 | + | if style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 || style.UnderlineStyle > renderer.UnderlineDashed { | |
| 355 | + | return false | |
| 356 | + | } | |
| 357 | + | if style.HasForegroundRGB { | |
| 358 | + | if style.Foreground < -1 || style.Foreground > math.MaxInt16 { | |
| 359 | + | return false | |
| 360 | + | } | |
| 361 | + | } else if style.Foreground < -1 || style.Foreground > math.MaxUint8 || style.ForegroundRGB != (renderer.RGB{}) { | |
| 362 | + | return false | |
| 363 | + | } | |
| 364 | + | if style.HasBackgroundRGB { | |
| 365 | + | if style.Background < -1 || style.Background > math.MaxInt16 { | |
| 366 | + | return false | |
| 367 | + | } | |
| 368 | + | } else if style.Background < -1 || style.Background > math.MaxUint8 || style.BackgroundRGB != (renderer.RGB{}) { | |
| 369 | + | return false | |
| 370 | + | } | |
| 371 | + | if style.HasUnderlineColorRGB { | |
| 372 | + | return !style.HasUnderlineColor && style.UnderlineColor >= -1 && style.UnderlineColor <= math.MaxInt16 | |
| 373 | + | } | |
| 374 | + | if style.HasUnderlineColor { | |
| 375 | + | return style.UnderlineColor >= 0 && style.UnderlineColor <= math.MaxUint8 && style.UnderlineColorRGB == (renderer.RGB{}) | |
| 376 | + | } | |
| 377 | + | return (style.UnderlineColor == 0 || style.UnderlineColor == -1) && style.UnderlineColorRGB == (renderer.RGB{}) | |
| 378 | + | } | |
M internal/usecase/daemon/proxy_screen_state_test.go +7 -19
| @@ -63,20 +63,6 @@ func TestProxyScreenStateInvalidUpdateIsAtomic(t *testing.T) { | |||
| 63 | 63 | t.Fatal("invalid update mutated metadata") | |
| 64 | 64 | } | |
| 65 | 65 | ||
| 66 | - | invalid = ports.ScreenUpdate{ | |
| 67 | - | Kind: ports.ScreenUpdateDelta, | |
| 68 | - | BaseStateNum: 1, | |
| 69 | - | NewStateNum: 2, | |
| 70 | - | Size: domain.Size{Cols: 3, Rows: 2}, | |
| 71 | - | Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true}, | |
| 72 | - | } | |
| 73 | - | if err := s.Apply(invalid); err == nil { | |
| 74 | - | t.Fatal("delta with mismatched dimensions was accepted") | |
| 75 | - | } | |
| 76 | - | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState { | |
| 77 | - | t.Fatal("mismatched-dimension update mutated state") | |
| 78 | - | } | |
| 79 | - | ||
| 80 | 66 | invalid = ports.ScreenUpdate{ | |
| 81 | 67 | Kind: ports.ScreenUpdateDelta, | |
| 82 | 68 | BaseStateNum: 1, | |
| @@ -191,7 +177,7 @@ func TestProxyScreenStateRejectsWireInvalidSemanticUpdatesAtomically(t *testing. | |||
| 191 | 177 | beforeDamage = s.CaptureDamage() | |
| 192 | 178 | invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{ | |
| 193 | 179 | Size: domain.Size{Cols: 2, Rows: 2}, | |
| 194 | - | Spans: make([]ports.ScreenSpan, ports.MaxScreenSpans+1), | |
| 180 | + | Spans: make([]ports.ScreenSpan, maxProxyScreenSpans+1), | |
| 195 | 181 | }) | |
| 196 | 182 | if err := s.Apply(invalidSpans); err == nil { | |
| 197 | 183 | t.Fatal("too many spans were accepted") | |
| @@ -303,8 +289,8 @@ func TestProxyScreenStateCaptureFallsBackAfterSpanThenScroll(t *testing.T) { | |||
| 303 | 289 | t.Fatalf("span-then-scroll damage = %+v", capture.Damage) | |
| 304 | 290 | } | |
| 305 | 291 | s.CaptureInto(&dst) | |
| 306 | - | if !reflect.DeepEqual(dst, s.frame.Clone()) { | |
| 307 | - | t.Fatal("span-then-scroll capture diverged from live frame") | |
| 292 | + | if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want { | |
| 293 | + | t.Fatalf("span-then-scroll capture = %q, want %q", got, want) | |
| 308 | 294 | } | |
| 309 | 295 | } | |
| 310 | 296 | ||
| @@ -333,8 +319,8 @@ func TestProxyScreenStateCaptureFallsBackAfterRepeatedScroll(t *testing.T) { | |||
| 333 | 319 | t.Fatalf("repeated-scroll damage = %+v", capture.Damage) | |
| 334 | 320 | } | |
| 335 | 321 | s.CaptureInto(&dst) | |
| 336 | - | if !reflect.DeepEqual(dst, s.frame.Clone()) { | |
| 337 | - | t.Fatal("repeated-scroll capture diverged from live frame") | |
| 322 | + | if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want { | |
| 323 | + | t.Fatalf("repeated-scroll capture = %q, want %q", got, want) | |
| 338 | 324 | } | |
| 339 | 325 | } | |
| 340 | 326 | ||
| @@ -369,6 +355,8 @@ func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) { | |||
| 369 | 355 | } | |
| 370 | 356 | } | |
| 371 | 357 | ||
| 358 | + | // BenchmarkProxyScreenStateApply is a component diagnostic for applying an | |
| 359 | + | // already-decoded update; it is not a pipeline comparison with ANSI apply. | |
| 372 | 360 | func BenchmarkProxyScreenStateApply(b *testing.B) { | |
| 373 | 361 | size := domain.Size{Cols: 120, Rows: 40} | |
| 374 | 362 | cases := []struct { | |
M internal/usecase/daemon/proxy_session.go +19 -4
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/vev/internal/domain" | |
| 13 | 13 | "github.com/bnema/vev/internal/ports" | |
| 14 | - | "github.com/bnema/vev/pkg/vt" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 17 | 16 | // proxySession is the local attachment identity for one structured remote | |
| @@ -28,8 +27,9 @@ type proxySession struct { | |||
| 28 | 27 | transport ports.Transport | |
| 29 | 28 | resumeToken uint64 | |
| 30 | 29 | appliedState uint64 | |
| 31 | - | awaitingReset bool | |
| 32 | - | screen *vt.Screen | |
| 30 | + | screenReady bool | |
| 31 | + | resetRequested bool | |
| 32 | + | screen *proxyScreenState | |
| 33 | 33 | meta ports.SessionMeta | |
| 34 | 34 | attentionAt []time.Time | |
| 35 | 35 | linkState ports.LinkState | |
| @@ -59,6 +59,18 @@ func (p *proxySession) core() *sessionCore { | |||
| 59 | 59 | ||
| 60 | 60 | func (p *proxySession) isProxy() bool { return true } | |
| 61 | 61 | ||
| 62 | + | func (p *proxySession) sessionMetaSnapshot() (ports.SessionMeta, bool) { | |
| 63 | + | if p == nil { | |
| 64 | + | return ports.SessionMeta{}, false | |
| 65 | + | } | |
| 66 | + | p.mu.Lock() | |
| 67 | + | defer p.mu.Unlock() | |
| 68 | + | if len(p.meta.Tabs) == 0 || int(p.meta.Active) >= len(p.meta.Tabs) { | |
| 69 | + | return ports.SessionMeta{}, false | |
| 70 | + | } | |
| 71 | + | return cloneSessionMeta(p.meta), true | |
| 72 | + | } | |
| 73 | + | ||
| 62 | 74 | // replaceSessionMetaLocked atomically replaces the relayed snapshot while | |
| 63 | 75 | // preserving the first-observed timestamp of tabs that remain attentive. | |
| 64 | 76 | // Callers must validate meta and hold p.mu. | |
| @@ -194,6 +206,9 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi | |||
| 194 | 206 | return nil, fmt.Errorf("proxy session key: %w", err) | |
| 195 | 207 | } | |
| 196 | 208 | content := contentSize(size, false) | |
| 209 | + | if size.Valid() && !validProxyScreenSize(content) { | |
| 210 | + | return nil, fmt.Errorf("proxy session size: invalid content size %dx%d", content.Cols, content.Rows) | |
| 211 | + | } | |
| 197 | 212 | var clientID [16]byte | |
| 198 | 213 | if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil { | |
| 199 | 214 | return nil, fmt.Errorf("generate proxy client id: %w", err) | |
| @@ -206,7 +221,7 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi | |||
| 206 | 221 | }, | |
| 207 | 222 | key: key, | |
| 208 | 223 | generation: 1, | |
| 209 | - | screen: vt.NewScreen(content.Cols, content.Rows), | |
| 224 | + | screen: newProxyScreenState(content), | |
| 210 | 225 | linkState: ports.LinkStateConnected, | |
| 211 | 226 | done: make(chan struct{}), | |
| 212 | 227 | clientID: clientID, | |
M internal/usecase/daemon/render.go +11 -0
| @@ -282,6 +282,17 @@ func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) | |||
| 282 | 282 | } | |
| 283 | 283 | } | |
| 284 | 284 | ||
| 285 | + | // diagnostic records a completed measurement for observer I/O after the | |
| 286 | + | // render transaction releases attachment ownership. | |
| 287 | + | func (b *runtimeMarkBatch) diagnostic(kind ports.RuntimeMarkKind, bytes, fragments uint64) { | |
| 288 | + | if b == nil || b.observer == nil { | |
| 289 | + | return | |
| 290 | + | } | |
| 291 | + | mark := ports.NewRuntimeMark("daemon", kind, bytes, true) | |
| 292 | + | mark.Fragments = fragments | |
| 293 | + | b.marks = append(b.marks, mark) | |
| 294 | + | } | |
| 295 | + | ||
| 285 | 296 | func (b *runtimeMarkBatch) flush() { | |
| 286 | 297 | if b == nil || b.observer == nil { | |
| 287 | 298 | return | |
M internal/usecase/daemon/render_capture.go +1 -2
| @@ -8,7 +8,6 @@ import ( | |||
| 8 | 8 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 9 | 9 | "github.com/bnema/vev/internal/usecase/ui" | |
| 10 | 10 | "github.com/bnema/vev/pkg/renderer" | |
| 11 | - | "github.com/bnema/vev/pkg/vt" | |
| 12 | 11 | ) | |
| 13 | 12 | ||
| 14 | 13 | // capturedRenderState is the immutable hand-off from authoritative daemon | |
| @@ -38,7 +37,7 @@ type renderCaptureScratch struct { | |||
| 38 | 37 | type damageReceipt struct { | |
| 39 | 38 | pane *pane | |
| 40 | 39 | proxy *proxySession | |
| 41 | - | proxyScreen *vt.Screen | |
| 40 | + | proxyScreen *proxyScreenState | |
| 42 | 41 | generation uint64 | |
| 43 | 42 | } | |
| 44 | 43 | ||
M internal/usecase/daemon/render_pipeline.go +60 -19
| @@ -215,7 +215,7 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | |||
| 215 | 215 | if !c.hasStyle { | |
| 216 | 216 | style = 1 | |
| 217 | 217 | } | |
| 218 | - | return cursorOut{row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | |
| 218 | + | return cursorOut{valid: true, row: c.content.Y + contentY + c.row, col: c.content.X + c.col, style: style, hasStyle: true} | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 221 | 221 | func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) { | |
| @@ -430,7 +430,7 @@ func commitDamageReceipts(receipts []damageReceipt) { | |||
| 430 | 430 | } | |
| 431 | 431 | if receipt.proxy != nil { | |
| 432 | 432 | receipt.proxy.mu.Lock() | |
| 433 | - | if receipt.proxy.screen == receipt.proxyScreen { | |
| 433 | + | if receipt.proxy.screen != nil && receipt.proxy.screen == receipt.proxyScreen { | |
| 434 | 434 | receipt.proxy.screen.AcknowledgeDamage(receipt.generation) | |
| 435 | 435 | } | |
| 436 | 436 | receipt.proxy.mu.Unlock() | |
| @@ -473,9 +473,34 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 473 | 473 | } | |
| 474 | 474 | } | |
| 475 | 475 | endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0) | |
| 476 | - | prepared, err := ac.output.prepare(composed.frame, composed.damage, composed.reset) | |
| 476 | + | var ( | |
| 477 | + | preparedANSI *preparedOutput | |
| 478 | + | preparedScreen *preparedStructuredOutput | |
| 479 | + | err error | |
| 480 | + | cursor cursorCandidate | |
| 481 | + | data []byte | |
| 482 | + | ) | |
| 483 | + | if ac.proxied { | |
| 484 | + | screenOutput := ac.ensureScreenOutput() | |
| 485 | + | if screenOutput == nil { | |
| 486 | + | err = errors.New("proxied attachment has no structured output stream") | |
| 487 | + | } else { | |
| 488 | + | preparedScreen, err = screenOutput.prepare(composed.frame, composed.damage, composed.cursor, composed.reset, ac.echoAck.Load()) | |
| 489 | + | if err == nil { | |
| 490 | + | data = preparedScreen.data | |
| 491 | + | } | |
| 492 | + | } | |
| 493 | + | } else { | |
| 494 | + | preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset) | |
| 495 | + | if err == nil { | |
| 496 | + | cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0) | |
| 497 | + | data = append([]byte(nil), preparedANSI.data...) | |
| 498 | + | data = append(data, cursor.data...) | |
| 499 | + | } | |
| 500 | + | } | |
| 477 | 501 | endDiff(0, err == nil) | |
| 478 | 502 | if err != nil { | |
| 503 | + | ac.discardProxyCapture() | |
| 479 | 504 | ac.sendMu.Unlock() | |
| 480 | 505 | d.log.Error("render draw failed", "err", err, "session", entry.core().name) | |
| 481 | 506 | // Without a coordinator reportError repaints synchronously. Suppress only | |
| @@ -498,9 +523,6 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 498 | 523 | d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed") | |
| 499 | 524 | return true | |
| 500 | 525 | } | |
| 501 | - | cursor := ac.prepareCursorTail(composed.cursor, len(prepared.data) > 0) | |
| 502 | - | data := append([]byte(nil), prepared.data...) | |
| 503 | - | data = append(data, cursor.data...) | |
| 504 | 526 | var sendTr ports.Transport | |
| 505 | 527 | var sendErr error | |
| 506 | 528 | // Metadata precedes any output bytes and is published on its own when the | |
| @@ -511,6 +533,13 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 511 | 533 | if ac.proxied { | |
| 512 | 534 | if sess, ok := localSession(entry); ok { | |
| 513 | 535 | sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect) | |
| 536 | + | } else if proxy, ok := entry.(*proxySession); ok { | |
| 537 | + | meta, metaOK := proxy.sessionMetaSnapshot() | |
| 538 | + | if !metaOK { | |
| 539 | + | sendErr = errSessionMetaUnavailable | |
| 540 | + | } else { | |
| 541 | + | sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.roleEffect) | |
| 542 | + | } | |
| 514 | 543 | } | |
| 515 | 544 | } | |
| 516 | 545 | if len(data) > 0 { | |
| @@ -524,17 +553,11 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 524 | 553 | send = async.SendAsync | |
| 525 | 554 | } | |
| 526 | 555 | interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport) | |
| 527 | - | sendErr = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error { | |
| 528 | - | err := send(frame) | |
| 529 | - | if interruptible { | |
| 530 | - | if err != nil { | |
| 531 | - | marks.roleEffect.reportTransportFailure(sendTransport) | |
| 532 | - | } | |
| 533 | - | marks.roleEffect.endTransportSend() | |
| 534 | - | interruptible = false | |
| 535 | - | } | |
| 536 | - | return err | |
| 537 | - | }) | |
| 556 | + | if ac.proxied { | |
| 557 | + | sendErr = preparedScreen.send(send) | |
| 558 | + | } else { | |
| 559 | + | sendErr = preparedANSI.send(data, ac.echoAck.Load(), send) | |
| 560 | + | } | |
| 538 | 561 | if interruptible { | |
| 539 | 562 | if sendErr != nil { | |
| 540 | 563 | marks.roleEffect.reportTransportFailure(sendTransport) | |
| @@ -543,17 +566,32 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 543 | 566 | } | |
| 544 | 567 | } | |
| 545 | 568 | endEmit(uint64(len(data)), sendErr == nil) | |
| 569 | + | if sendErr == nil && ac.proxied { | |
| 570 | + | kind := ports.RuntimeScreenDelta | |
| 571 | + | if preparedScreen.update.Kind == ports.ScreenUpdateSnapshot { | |
| 572 | + | kind = ports.RuntimeScreenSnapshot | |
| 573 | + | } | |
| 574 | + | marks.diagnostic(kind, uint64(len(preparedScreen.data)), uint64(len(preparedScreen.update.Spans))) | |
| 575 | + | } | |
| 546 | 576 | } | |
| 547 | 577 | } | |
| 548 | 578 | if sendErr == nil { | |
| 549 | 579 | if len(data) == 0 { | |
| 550 | - | prepared.commitNoSend() | |
| 580 | + | if ac.proxied { | |
| 581 | + | preparedScreen.commitNoSend() | |
| 582 | + | } else { | |
| 583 | + | preparedANSI.commitNoSend() | |
| 584 | + | } | |
| 551 | 585 | } | |
| 552 | 586 | // Publish only after output preparation and transport emission both | |
| 553 | 587 | // succeed. A cross-session transition may publish concurrently, but its | |
| 554 | 588 | // mandatory first-paint rebase waits for sendMu and therefore follows this | |
| 555 | 589 | // completed output transaction. | |
| 556 | - | ac.lastCursor = cursor.next | |
| 590 | + | if ac.proxied { | |
| 591 | + | ac.lastCursor = composed.cursor | |
| 592 | + | } else { | |
| 593 | + | ac.lastCursor = cursor.next | |
| 594 | + | } | |
| 557 | 595 | ac.pipelineScratch = ac.pipelineCache | |
| 558 | 596 | ac.pipelineCache = composed.cache | |
| 559 | 597 | ||
| @@ -565,6 +603,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||
| 565 | 603 | ac.renderStages.emit() | |
| 566 | 604 | } | |
| 567 | 605 | } | |
| 606 | + | if sendErr != nil { | |
| 607 | + | ac.discardProxyCapture() | |
| 608 | + | } | |
| 568 | 609 | ac.sendMu.Unlock() | |
| 569 | 610 | if sendErr != nil { | |
| 570 | 611 | // A transport failure may invalidate the role gate. Release this render's | |
M internal/usecase/daemon/render_pipeline_cache_test.go +2 -9
| @@ -15,16 +15,9 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ) | |
| 17 | 17 | ||
| 18 | - | type cacheFailTransport struct { | |
| 19 | - | err error | |
| 20 | - | } | |
| 18 | + | type cacheFailTransport struct{} | |
| 21 | 19 | ||
| 22 | - | func (t cacheFailTransport) Send(ports.Frame) error { | |
| 23 | - | if t.err != nil { | |
| 24 | - | return t.err | |
| 25 | - | } | |
| 26 | - | return errors.New("send failed") | |
| 27 | - | } | |
| 20 | + | func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 28 | 21 | func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 29 | 22 | func (cacheFailTransport) Close() error { return nil } | |
| 30 | 23 | ||
M internal/usecase/daemon/render_pipeline_test.go +2 -2
| @@ -371,13 +371,13 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) { | |||
| 371 | 371 | } | |
| 372 | 372 | stream := newOutputStateStream() | |
| 373 | 373 | first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{}) | |
| 374 | - | firstBytes, err := renderCommitted(stream, first.frame, first.damage, first.reset) | |
| 374 | + | firstBytes, err := stream.render(first.frame, first.damage, first.reset) | |
| 375 | 375 | require.NoError(t, err) | |
| 376 | 376 | client := vt.NewScreen(4, 5) | |
| 377 | 377 | client.Write(firstBytes) | |
| 378 | 378 | ||
| 379 | 379 | second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache) | |
| 380 | - | secondBytes, err := renderCommitted(stream, second.frame, second.damage, second.reset) | |
| 380 | + | secondBytes, err := stream.render(second.frame, second.damage, second.reset) | |
| 381 | 381 | require.NoError(t, err) | |
| 382 | 382 | require.Equal(t, tt.wantBytes, string(secondBytes)) | |
| 383 | 383 | client.Write(secondBytes) | |
M internal/usecase/daemon/resume.go +1 -1
| @@ -544,7 +544,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 544 | 544 | // The parked attachment has no session owner and cannot paint. Retire the | |
| 545 | 545 | // abandoned transport's output chain before binding the replacement so the | |
| 546 | 546 | // mandatory first paint cannot be blocked by ACKs that died with the link. | |
| 547 | - | ac.output.rebase() | |
| 547 | + | ac.rebaseOutput() | |
| 548 | 548 | ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight)) | |
| 549 | 549 | ac.replaceTransport(tr) | |
| 550 | 550 | ac.size = sz | |
M internal/usecase/daemon/resume_test.go +4 -24
| @@ -16,7 +16,6 @@ import ( | |||
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | - | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 19 | "github.com/bnema/vev/pkg/vt" | |
| 21 | 20 | ) | |
| 22 | 21 | ||
| @@ -1397,22 +1396,10 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |||
| 1397 | 1396 | ||
| 1398 | 1397 | ac.sendMu.Lock() | |
| 1399 | 1398 | ac.output.next = 5 | |
| 1400 | - | baseFrame := renderer.NewFrame(1, 1) | |
| 1401 | - | baseFrame.Set(0, 0, renderer.Cell{Rune: 'a', Style: renderer.DefaultStyle()}) | |
| 1402 | - | _, err := ac.output.renderer.Draw(baseFrame, nil) | |
| 1403 | - | require.NoError(t, err) | |
| 1404 | - | ||
| 1405 | 1399 | reset := false | |
| 1406 | 1400 | require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint") | |
| 1407 | - | incrementalFrame := baseFrame.Clone() | |
| 1408 | - | incrementalFrame.Set(0, 0, renderer.Cell{Rune: 'i', Style: renderer.DefaultStyle()}) | |
| 1409 | - | prepared, err := ac.output.prepare(incrementalFrame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, reset) | |
| 1410 | - | require.NoError(t, err) | |
| 1411 | - | var f ports.Frame | |
| 1412 | - | require.NoError(t, prepared.send(prepared.data, 0, func(frame ports.Frame) error { | |
| 1413 | - | f = frame | |
| 1414 | - | return nil | |
| 1415 | - | })) | |
| 1401 | + | ||
| 1402 | + | f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0) | |
| 1416 | 1403 | ac.sendMu.Unlock() | |
| 1417 | 1404 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 1418 | 1405 | require.NoError(t, err) | |
| @@ -1422,16 +1409,9 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |||
| 1422 | 1409 | ac.sendMu.Lock() | |
| 1423 | 1410 | reset = true | |
| 1424 | 1411 | require.True(t, reset, "explicit reset should still force full repaint") | |
| 1425 | - | fullFrame := incrementalFrame.Clone() | |
| 1426 | - | full, err := ac.output.prepare(fullFrame, nil, reset) | |
| 1427 | - | require.NoError(t, err) | |
| 1428 | - | var fullOutput ports.Frame | |
| 1429 | - | require.NoError(t, full.send(full.data, 0, func(frame ports.Frame) error { | |
| 1430 | - | fullOutput = frame | |
| 1431 | - | return nil | |
| 1432 | - | })) | |
| 1412 | + | full := ac.output.frame([]byte("explicit full repaint"), reset, 0) | |
| 1433 | 1413 | ac.sendMu.Unlock() | |
| 1434 | - | fullOut, err := ports.UnmarshalOutput(fullOutput.Payload) | |
| 1414 | + | fullOut, err := ports.UnmarshalOutput(full.Payload) | |
| 1435 | 1415 | require.NoError(t, err) | |
| 1436 | 1416 | require.Equal(t, uint64(0), fullOut.BaseStateNum) | |
| 1437 | 1417 | require.Equal(t, uint64(7), fullOut.NewStateNum) | |
M internal/usecase/daemon/role_effect_gate.go +0 -10
| @@ -198,16 +198,6 @@ func (t *roleEffectTicket) endTransportSend() { | |||
| 198 | 198 | // beginRoleEffect is the sole role-bound effect admission point. The gate is | |
| 199 | 199 | // held only long enough to reserve the capability; the effect itself must run | |
| 200 | 200 | // without the gate mutex. | |
| 201 | - | func (ac *attachedClient) transportEffectActive() bool { | |
| 202 | - | if ac == nil { | |
| 203 | - | return false | |
| 204 | - | } | |
| 205 | - | g := &ac.roleEffects | |
| 206 | - | g.mu.Lock() | |
| 207 | - | defer g.mu.Unlock() | |
| 208 | - | return len(g.transportEffects) != 0 | |
| 209 | - | } | |
| 210 | - | ||
| 211 | 201 | func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) { | |
| 212 | 202 | if ac == nil || token.ac != ac { | |
| 213 | 203 | return nil, false | |
A internal/usecase/daemon/screen_output_stream.go +235 -0
| @@ -0,0 +1,235 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // structuredOutputStream is the semantic counterpart of outputStateStream. | |
| 12 | + | // The output state owns the shared state-number and ACK window; this stream | |
| 13 | + | // owns only its planner shadow and the last committed absolute cursor. | |
| 14 | + | // Callers serialize access with attachedClient.sendMu. | |
| 15 | + | type structuredOutputStream struct { | |
| 16 | + | state *outputStateStream | |
| 17 | + | ||
| 18 | + | shadow renderer.Frame | |
| 19 | + | cursorOut cursorOut | |
| 20 | + | ||
| 21 | + | forceSnapshot bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // newStructuredOutputStream accepts the shared output state so ANSI and | |
| 25 | + | // structured output use one dependency chain and one bounded ACK window. | |
| 26 | + | func newStructuredOutputStream(state *outputStateStream) *structuredOutputStream { | |
| 27 | + | return &structuredOutputStream{state: state, forceSnapshot: true} | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // prepare creates an owned structured payload without changing state. | |
| 31 | + | func (s *structuredOutputStream) prepare(frame renderer.Frame, damage []renderer.Damage, cursor cursorOut, reset bool, echoAck uint64) (*preparedStructuredOutput, error) { | |
| 32 | + | if s == nil || s.state == nil { | |
| 33 | + | return nil, errors.New("structured output stream is nil") | |
| 34 | + | } | |
| 35 | + | if err := frame.Validate(); err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | reset = reset || s.forceSnapshot || s.state.next == 0 || s.shadow.Validate() != nil || | |
| 40 | + | s.shadow.Width != frame.Width || s.shadow.Height != frame.Height | |
| 41 | + | candidate, err := renderer.PlanDelta(frame, damage, s.shadow, reset) | |
| 42 | + | if err != nil { | |
| 43 | + | return nil, err | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | plan := candidate.Plan | |
| 47 | + | snapshot := reset || plan.Snapshot | |
| 48 | + | cursorChanged := cursor != s.cursorOut | |
| 49 | + | if !snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 && !cursorChanged { | |
| 50 | + | return &preparedStructuredOutput{ | |
| 51 | + | stream: s, | |
| 52 | + | candidate: candidate, | |
| 53 | + | cursor: cursor, | |
| 54 | + | echoAck: echoAck, | |
| 55 | + | }, nil | |
| 56 | + | } | |
| 57 | + | if s.state.next == ^uint64(0) { | |
| 58 | + | return nil, errors.New("structured output state number exhausted") | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | wireCursor, err := wireScreenCursor(cursor, frame) | |
| 62 | + | if err != nil { | |
| 63 | + | return nil, err | |
| 64 | + | } | |
| 65 | + | update := ports.ScreenUpdate{ | |
| 66 | + | BaseStateNum: s.state.next, | |
| 67 | + | NewStateNum: s.state.next + 1, | |
| 68 | + | EchoAck: echoAck, | |
| 69 | + | Kind: ports.ScreenUpdateDelta, | |
| 70 | + | Size: frameSize(frame), | |
| 71 | + | Cursor: wireCursor, | |
| 72 | + | } | |
| 73 | + | if snapshot { | |
| 74 | + | update.BaseStateNum = 0 | |
| 75 | + | update.Kind = ports.ScreenUpdateSnapshot | |
| 76 | + | update.Spans = make([]ports.ScreenSpan, frame.Height) | |
| 77 | + | for y := range frame.Height { | |
| 78 | + | update.Spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: copyScreenCells(frame.Row(y))} | |
| 79 | + | } | |
| 80 | + | } else { | |
| 81 | + | if plan.Scroll.Height != 0 { | |
| 82 | + | if plan.Scroll.Y < 0 || plan.Scroll.Height <= 0 || plan.Scroll.Count <= 0 || | |
| 83 | + | plan.Scroll.Y+plan.Scroll.Height > frame.Height || plan.Scroll.Count >= plan.Scroll.Height { | |
| 84 | + | return nil, errors.New("structured output: invalid planned scroll") | |
| 85 | + | } | |
| 86 | + | update.Scroll = &ports.ScreenScroll{ | |
| 87 | + | Top: uint16(plan.Scroll.Y), | |
| 88 | + | Height: uint16(plan.Scroll.Height), | |
| 89 | + | Count: uint16(plan.Scroll.Count), | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | update.Spans = make([]ports.ScreenSpan, 0, len(plan.Spans)) | |
| 93 | + | for _, span := range plan.Spans { | |
| 94 | + | if span.Y < 0 || span.X < 0 || span.Width <= 0 || span.Y >= frame.Height || span.X+span.Width > frame.Width { | |
| 95 | + | return nil, errors.New("structured output: invalid planned span") | |
| 96 | + | } | |
| 97 | + | update.Spans = append(update.Spans, ports.ScreenSpan{ | |
| 98 | + | Y: uint16(span.Y), | |
| 99 | + | X: uint16(span.X), | |
| 100 | + | Cells: copyScreenCells(frame.Row(span.Y)[span.X : span.X+span.Width]), | |
| 101 | + | }) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | data, err := ports.MarshalScreenUpdate(update) | |
| 105 | + | if err != nil { | |
| 106 | + | return nil, err | |
| 107 | + | } | |
| 108 | + | return &preparedStructuredOutput{ | |
| 109 | + | stream: s, | |
| 110 | + | candidate: candidate, | |
| 111 | + | cursor: cursor, | |
| 112 | + | update: update, | |
| 113 | + | next: update.NewStateNum, | |
| 114 | + | data: data, | |
| 115 | + | echoAck: echoAck, | |
| 116 | + | snapshot: snapshot, | |
| 117 | + | }, nil | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func frameSize(frame renderer.Frame) domain.Size { | |
| 121 | + | return domain.Size{Cols: frame.Width, Rows: frame.Height} | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func copyScreenCells(cells []renderer.Cell) []renderer.Cell { | |
| 125 | + | return append([]renderer.Cell(nil), cells...) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func wireScreenCursor(cursor cursorOut, frame renderer.Frame) (ports.ScreenCursor, error) { | |
| 129 | + | out := ports.ScreenCursor{Visible: cursor.valid && !cursor.hidden} | |
| 130 | + | if !cursor.valid { | |
| 131 | + | return out, nil | |
| 132 | + | } | |
| 133 | + | if cursor.row < 0 || cursor.row >= frame.Height || cursor.col < 0 || cursor.col >= frame.Width { | |
| 134 | + | return ports.ScreenCursor{}, errors.New("structured output: cursor outside frame") | |
| 135 | + | } | |
| 136 | + | if cursor.hasStyle { | |
| 137 | + | if cursor.style < 0 || cursor.style > 6 { | |
| 138 | + | return ports.ScreenCursor{}, errors.New("structured output: cursor style out of range") | |
| 139 | + | } | |
| 140 | + | out.Style = uint8(cursor.style) | |
| 141 | + | out.StyleSet = true | |
| 142 | + | } else if cursor.style != 0 { | |
| 143 | + | return ports.ScreenCursor{}, errors.New("structured output: cursor style is not set") | |
| 144 | + | } | |
| 145 | + | out.Row = uint16(cursor.row) | |
| 146 | + | out.Col = uint16(cursor.col) | |
| 147 | + | return out, nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | type preparedStructuredOutput struct { | |
| 151 | + | stream *structuredOutputStream | |
| 152 | + | candidate renderer.DeltaCandidate | |
| 153 | + | cursor cursorOut | |
| 154 | + | update ports.ScreenUpdate | |
| 155 | + | next uint64 | |
| 156 | + | echoAck uint64 | |
| 157 | + | data []byte | |
| 158 | + | snapshot bool | |
| 159 | + | completed bool | |
| 160 | + | committed bool | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func (p *preparedStructuredOutput) send(sender func(ports.Frame) error) error { | |
| 164 | + | if p == nil || p.completed { | |
| 165 | + | return nil | |
| 166 | + | } | |
| 167 | + | if len(p.data) == 0 { | |
| 168 | + | p.completed = true | |
| 169 | + | p.commit() | |
| 170 | + | return nil | |
| 171 | + | } | |
| 172 | + | if sender == nil { | |
| 173 | + | p.completed = true | |
| 174 | + | p.stream.forceSnapshot = true | |
| 175 | + | return errors.New("structured output: nil sender") | |
| 176 | + | } | |
| 177 | + | if err := sender(ports.Frame{Type: ports.MsgScreenUpdate, Payload: p.data}); err != nil { | |
| 178 | + | p.completed = true | |
| 179 | + | p.stream.forceSnapshot = true | |
| 180 | + | return err | |
| 181 | + | } | |
| 182 | + | p.completed = true | |
| 183 | + | p.commit() | |
| 184 | + | p.stream.state.next = p.next | |
| 185 | + | return nil | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // commitNoSend commits a prepared noop without adding a state-bearing frame. | |
| 189 | + | // A failed send is already completed, so it cannot accidentally commit after | |
| 190 | + | // the failure and thereby destroy the required snapshot retry. | |
| 191 | + | func (p *preparedStructuredOutput) commitNoSend() { | |
| 192 | + | if p == nil || p.completed { | |
| 193 | + | return | |
| 194 | + | } | |
| 195 | + | p.completed = true | |
| 196 | + | p.commit() | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func (p *preparedStructuredOutput) commit() { | |
| 200 | + | if p == nil || p.committed || p.stream == nil { | |
| 201 | + | return | |
| 202 | + | } | |
| 203 | + | p.candidate.Commit(&p.stream.shadow) | |
| 204 | + | p.stream.cursorOut = p.cursor | |
| 205 | + | p.stream.forceSnapshot = false | |
| 206 | + | p.committed = true | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func (s *structuredOutputStream) ack(state uint64) { | |
| 210 | + | if s != nil && s.state != nil { | |
| 211 | + | s.state.ack(state) | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (s *structuredOutputStream) atCapacity() bool { | |
| 216 | + | return s == nil || s.state == nil || s.state.atCapacity() | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func (s *structuredOutputStream) outstanding() uint64 { | |
| 220 | + | if s == nil || s.state == nil { | |
| 221 | + | return 0 | |
| 222 | + | } | |
| 223 | + | return s.state.outstanding() | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | // rebase discards only the local planner dependency. The shared state number | |
| 227 | + | // is rebased once by attachedClient.rebaseOutput. | |
| 228 | + | func (s *structuredOutputStream) rebase() { | |
| 229 | + | if s == nil { | |
| 230 | + | return | |
| 231 | + | } | |
| 232 | + | s.shadow = renderer.Frame{} | |
| 233 | + | s.cursorOut = cursorOut{} | |
| 234 | + | s.forceSnapshot = true | |
| 235 | + | } | |
A internal/usecase/daemon/screen_output_stream_test.go +374 -0
| @@ -0,0 +1,374 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/ports" | |
| 8 | + | "github.com/bnema/vev/pkg/renderer" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestStructuredOutputStreamSnapshotsThenDeltas(t *testing.T) { | |
| 13 | + | state := newOutputStateStream() | |
| 14 | + | stream := newStructuredOutputStream(state) | |
| 15 | + | frame := structuredTestFrame("abc", "def") | |
| 16 | + | cursor := cursorOut{valid: true} | |
| 17 | + | ||
| 18 | + | first, err := stream.prepare(frame, nil, cursor, true, 7) | |
| 19 | + | require.NoError(t, err) | |
| 20 | + | firstUpdate := decodeStructured(t, first.data) | |
| 21 | + | require.Equal(t, ports.ScreenUpdateSnapshot, firstUpdate.Kind) | |
| 22 | + | require.Zero(t, firstUpdate.BaseStateNum) | |
| 23 | + | require.Equal(t, uint64(1), firstUpdate.NewStateNum) | |
| 24 | + | require.Len(t, firstUpdate.Spans, 2) | |
| 25 | + | for y, span := range firstUpdate.Spans { | |
| 26 | + | require.Equal(t, uint16(y), span.Y) | |
| 27 | + | require.Zero(t, span.X) | |
| 28 | + | require.Len(t, span.Cells, frame.Width) | |
| 29 | + | } | |
| 30 | + | require.NoError(t, first.send(func(got ports.Frame) error { | |
| 31 | + | require.Equal(t, ports.MsgScreenUpdate, got.Type) | |
| 32 | + | gotUpdate, err := ports.UnmarshalScreenUpdate(got.Payload) | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | require.Equal(t, uint64(7), gotUpdate.EchoAck) | |
| 35 | + | return nil | |
| 36 | + | })) | |
| 37 | + | require.Equal(t, uint64(1), state.next) | |
| 38 | + | ||
| 39 | + | changed := frame.Clone() | |
| 40 | + | changed.Set(1, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |
| 41 | + | second, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, X: 1, Y: 0, Width: 1, Height: 1}}, cursor, false, 0) | |
| 42 | + | require.NoError(t, err) | |
| 43 | + | secondUpdate := decodeStructured(t, second.data) | |
| 44 | + | require.Equal(t, ports.ScreenUpdateDelta, secondUpdate.Kind) | |
| 45 | + | require.Equal(t, uint64(1), secondUpdate.BaseStateNum) | |
| 46 | + | require.Equal(t, uint64(2), secondUpdate.NewStateNum) | |
| 47 | + | require.Len(t, secondUpdate.Spans, 1) | |
| 48 | + | require.Equal(t, uint16(1), secondUpdate.Spans[0].X) | |
| 49 | + | require.NoError(t, second.send(func(ports.Frame) error { return nil })) | |
| 50 | + | require.Equal(t, uint64(2), state.next) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func TestStructuredOutputStreamCursorOnlyAndNoopOwnership(t *testing.T) { | |
| 54 | + | state := newOutputStateStream() | |
| 55 | + | stream := newStructuredOutputStream(state) | |
| 56 | + | frame := structuredTestFrame("abc") | |
| 57 | + | cursor := cursorOut{valid: true} | |
| 58 | + | ||
| 59 | + | first, err := stream.prepare(frame, nil, cursor, true, 0) | |
| 60 | + | require.NoError(t, err) | |
| 61 | + | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |
| 62 | + | ||
| 63 | + | moved := cursor | |
| 64 | + | moved.col = 2 | |
| 65 | + | cursorOnly, err := stream.prepare(frame, nil, moved, false, 0) | |
| 66 | + | require.NoError(t, err) | |
| 67 | + | cursorUpdate := decodeStructured(t, cursorOnly.data) | |
| 68 | + | require.Equal(t, ports.ScreenUpdateDelta, cursorUpdate.Kind) | |
| 69 | + | require.Empty(t, cursorUpdate.Spans) | |
| 70 | + | require.Nil(t, cursorUpdate.Scroll) | |
| 71 | + | require.NoError(t, cursorOnly.send(func(ports.Frame) error { return nil })) | |
| 72 | + | require.Equal(t, uint64(2), state.next) | |
| 73 | + | ||
| 74 | + | noop, err := stream.prepare(frame, nil, moved, false, 0) | |
| 75 | + | require.NoError(t, err) | |
| 76 | + | require.Empty(t, noop.data) | |
| 77 | + | require.NoError(t, noop.send(func(ports.Frame) error { | |
| 78 | + | t.Fatal("noop must not send a frame") | |
| 79 | + | return nil | |
| 80 | + | })) | |
| 81 | + | require.Equal(t, uint64(2), state.next) | |
| 82 | + | require.True(t, stream.cursorOut.valid) | |
| 83 | + | require.Equal(t, moved.row, stream.cursorOut.row) | |
| 84 | + | require.Equal(t, moved.col, stream.cursorOut.col) | |
| 85 | + | require.Equal(t, !moved.hidden, !stream.cursorOut.hidden) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func TestStructuredOutputStreamDeltaCommitReusesShadowStorage(t *testing.T) { | |
| 89 | + | state := newOutputStateStream() | |
| 90 | + | stream := newStructuredOutputStream(state) | |
| 91 | + | cursor := cursorOut{valid: true} | |
| 92 | + | frame := structuredTestFrame("abc", "def") | |
| 93 | + | ||
| 94 | + | initial, err := stream.prepare(frame, nil, cursor, true, 0) | |
| 95 | + | require.NoError(t, err) | |
| 96 | + | require.NoError(t, initial.send(func(ports.Frame) error { return nil })) | |
| 97 | + | shadowCells := stream.shadow.Cells | |
| 98 | + | require.NotEmpty(t, shadowCells) | |
| 99 | + | shadowStart := &shadowCells[0] | |
| 100 | + | shadowCap := cap(shadowCells) | |
| 101 | + | ||
| 102 | + | changed := frame.Clone() | |
| 103 | + | changed.Set(1, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |
| 104 | + | delta, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, X: 1, Y: 0, Width: 1, Height: 1}}, cursor, false, 0) | |
| 105 | + | require.NoError(t, err) | |
| 106 | + | require.NoError(t, delta.send(func(ports.Frame) error { return nil })) | |
| 107 | + | ||
| 108 | + | require.Equal(t, shadowStart, &stream.shadow.Cells[0]) | |
| 109 | + | require.Equal(t, shadowCap, cap(stream.shadow.Cells)) | |
| 110 | + | require.Equal(t, changed.At(1, 0), stream.shadow.At(1, 0)) | |
| 111 | + | require.Equal(t, uint64(2), state.next) | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func TestStructuredOutputStreamResetAndResizeUseBaseZero(t *testing.T) { | |
| 115 | + | state := newOutputStateStream() | |
| 116 | + | stream := newStructuredOutputStream(state) | |
| 117 | + | cursor := cursorOut{valid: true} | |
| 118 | + | frame := structuredTestFrame("abc") | |
| 119 | + | ||
| 120 | + | first, err := stream.prepare(frame, nil, cursor, true, 0) | |
| 121 | + | require.NoError(t, err) | |
| 122 | + | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |
| 123 | + | changed := frame.Clone() | |
| 124 | + | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 125 | + | delta, err := stream.prepare(changed, nil, cursor, false, 0) | |
| 126 | + | require.NoError(t, err) | |
| 127 | + | require.NoError(t, delta.send(func(ports.Frame) error { return nil })) | |
| 128 | + | ||
| 129 | + | resized := structuredTestFrame("abcd", "efgh") | |
| 130 | + | reset, err := stream.prepare(resized, nil, cursor, true, 0) | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | resetUpdate := decodeStructured(t, reset.data) | |
| 133 | + | require.Equal(t, ports.ScreenUpdateSnapshot, resetUpdate.Kind) | |
| 134 | + | require.Zero(t, resetUpdate.BaseStateNum) | |
| 135 | + | require.Equal(t, uint64(3), resetUpdate.NewStateNum) | |
| 136 | + | require.Len(t, resetUpdate.Spans, resized.Height) | |
| 137 | + | for _, span := range resetUpdate.Spans { | |
| 138 | + | require.Equal(t, uint16(resized.Width), uint16(len(span.Cells))) | |
| 139 | + | } | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestStructuredOutputStreamFailedSendRetainsTransactionAndRetriesSnapshot(t *testing.T) { | |
| 143 | + | state := newOutputStateStream() | |
| 144 | + | stream := newStructuredOutputStream(state) | |
| 145 | + | cursor := cursorOut{valid: true} | |
| 146 | + | frame := structuredTestFrame("abc") | |
| 147 | + | ||
| 148 | + | first, err := stream.prepare(frame, nil, cursor, true, 0) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |
| 151 | + | before := stream.shadow.Clone() | |
| 152 | + | beforeCursor := stream.cursorOut | |
| 153 | + | ||
| 154 | + | changed := frame.Clone() | |
| 155 | + | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 156 | + | changedCursor := cursorOut{valid: true, row: 0, col: 1, hidden: true, hasStyle: true} | |
| 157 | + | pending, err := stream.prepare(changed, nil, changedCursor, false, 0) | |
| 158 | + | require.NoError(t, err) | |
| 159 | + | sendErr := errors.New("send failed") | |
| 160 | + | require.ErrorIs(t, pending.send(func(ports.Frame) error { return sendErr }), sendErr) | |
| 161 | + | require.Equal(t, uint64(1), state.next) | |
| 162 | + | require.Equal(t, before, stream.shadow) | |
| 163 | + | require.Equal(t, beforeCursor, stream.cursorOut) | |
| 164 | + | require.True(t, stream.forceSnapshot) | |
| 165 | + | ||
| 166 | + | retry, err := stream.prepare(changed, nil, changedCursor, false, 0) | |
| 167 | + | require.NoError(t, err) | |
| 168 | + | retryUpdate := decodeStructured(t, retry.data) | |
| 169 | + | require.Equal(t, ports.ScreenUpdateSnapshot, retryUpdate.Kind) | |
| 170 | + | require.Zero(t, retryUpdate.BaseStateNum) | |
| 171 | + | require.Equal(t, uint64(2), retryUpdate.NewStateNum) | |
| 172 | + | require.Len(t, retryUpdate.Spans, changed.Height) | |
| 173 | + | require.NoError(t, retry.send(func(ports.Frame) error { return nil })) | |
| 174 | + | require.Equal(t, uint64(2), state.next) | |
| 175 | + | require.False(t, stream.forceSnapshot) | |
| 176 | + | require.NoError(t, retry.send(func(ports.Frame) error { | |
| 177 | + | t.Fatal("completed send must not send twice") | |
| 178 | + | return nil | |
| 179 | + | })) | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func TestStructuredOutputStreamComparesCursorValidityAndHidden(t *testing.T) { | |
| 183 | + | state := newOutputStateStream() | |
| 184 | + | stream := newStructuredOutputStream(state) | |
| 185 | + | frame := structuredTestFrame("a") | |
| 186 | + | visible := cursorOut{valid: true, row: 0, col: 0} | |
| 187 | + | first, err := stream.prepare(frame, nil, visible, true, 0) | |
| 188 | + | require.NoError(t, err) | |
| 189 | + | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |
| 190 | + | ||
| 191 | + | hidden := visible | |
| 192 | + | hidden.hidden = true | |
| 193 | + | second, err := stream.prepare(frame, nil, hidden, false, 0) | |
| 194 | + | require.NoError(t, err) | |
| 195 | + | secondUpdate := decodeStructured(t, second.data) | |
| 196 | + | require.Empty(t, secondUpdate.Spans) | |
| 197 | + | require.False(t, secondUpdate.Cursor.Visible) | |
| 198 | + | require.NoError(t, second.send(func(ports.Frame) error { return nil })) | |
| 199 | + | ||
| 200 | + | invalid := hidden | |
| 201 | + | invalid.valid = false | |
| 202 | + | third, err := stream.prepare(frame, nil, invalid, false, 0) | |
| 203 | + | require.NoError(t, err) | |
| 204 | + | thirdUpdate := decodeStructured(t, third.data) | |
| 205 | + | require.False(t, thirdUpdate.Cursor.Visible) | |
| 206 | + | require.NoError(t, third.send(func(ports.Frame) error { return nil })) | |
| 207 | + | ||
| 208 | + | noop, err := stream.prepare(frame, nil, invalid, false, 0) | |
| 209 | + | require.NoError(t, err) | |
| 210 | + | require.Empty(t, noop.data) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | func TestStructuredOutputStreamPreservesExplicitZeroCursorStyle(t *testing.T) { | |
| 214 | + | state := newOutputStateStream() | |
| 215 | + | stream := newStructuredOutputStream(state) | |
| 216 | + | frame := structuredTestFrame("a") | |
| 217 | + | unset := cursorOut{valid: true} | |
| 218 | + | first, err := stream.prepare(frame, nil, unset, true, 0) | |
| 219 | + | require.NoError(t, err) | |
| 220 | + | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |
| 221 | + | ||
| 222 | + | explicit := unset | |
| 223 | + | explicit.hasStyle = true | |
| 224 | + | second, err := stream.prepare(frame, nil, explicit, false, 0) | |
| 225 | + | require.NoError(t, err) | |
| 226 | + | got := decodeStructured(t, second.data) | |
| 227 | + | require.True(t, got.Cursor.StyleSet) | |
| 228 | + | require.Zero(t, got.Cursor.Style) | |
| 229 | + | require.NoError(t, second.send(func(ports.Frame) error { return nil })) | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | func TestStructuredOutputStreamSharesACKWindow(t *testing.T) { | |
| 233 | + | state := newOutputStateStream(1) | |
| 234 | + | stream := newStructuredOutputStream(state) | |
| 235 | + | prepared, err := stream.prepare(structuredTestFrame("a"), nil, cursorOut{valid: true}, true, 0) | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | require.NoError(t, prepared.send(func(ports.Frame) error { return nil })) | |
| 238 | + | require.True(t, stream.atCapacity()) | |
| 239 | + | stream.ack(1) | |
| 240 | + | require.False(t, stream.atCapacity()) | |
| 241 | + | require.Equal(t, uint64(0), stream.outstanding()) | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | // BenchmarkStructuredOutputPrepare is a component diagnostic for structured | |
| 245 | + | // planning, encoding, and commit without decode/apply; it is not a pipeline | |
| 246 | + | // comparison with ANSI apply. | |
| 247 | + | func BenchmarkStructuredOutputPrepare(b *testing.B) { | |
| 248 | + | fixtures := []struct { | |
| 249 | + | name string | |
| 250 | + | frame renderer.Frame | |
| 251 | + | damage []renderer.Damage | |
| 252 | + | mutate func(renderer.Frame, int) | |
| 253 | + | }{ | |
| 254 | + | { | |
| 255 | + | name: "one-cell", | |
| 256 | + | frame: structuredRows(120, 40), | |
| 257 | + | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |
| 258 | + | mutate: func(frame renderer.Frame, i int) { | |
| 259 | + | cell := frame.At(0, 0) | |
| 260 | + | cell.Rune = rune('a' + i%26) | |
| 261 | + | frame.Set(0, 0, cell) | |
| 262 | + | }, | |
| 263 | + | }, | |
| 264 | + | { | |
| 265 | + | name: "full-line", | |
| 266 | + | frame: structuredRows(120, 40), | |
| 267 | + | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 120, Height: 1}}, | |
| 268 | + | mutate: func(frame renderer.Frame, i int) { | |
| 269 | + | for x := range frame.Width { | |
| 270 | + | cell := frame.At(x, 0) | |
| 271 | + | cell.Rune = rune('a' + (x+i)%26) | |
| 272 | + | frame.Set(x, 0, cell) | |
| 273 | + | } | |
| 274 | + | }, | |
| 275 | + | }, | |
| 276 | + | { | |
| 277 | + | name: "styled-fragment", | |
| 278 | + | frame: structuredStyledFrame(120, 40), | |
| 279 | + | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 120, Height: 1}}, | |
| 280 | + | mutate: func(frame renderer.Frame, i int) { | |
| 281 | + | for x := range frame.Width { | |
| 282 | + | cell := frame.At(x, 0) | |
| 283 | + | cell.Rune = rune('a' + (x+i)%26) | |
| 284 | + | frame.Set(x, 0, cell) | |
| 285 | + | } | |
| 286 | + | }, | |
| 287 | + | }, | |
| 288 | + | { | |
| 289 | + | name: "full-screen-scroll", | |
| 290 | + | frame: structuredRows(120, 40), | |
| 291 | + | damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 120, Height: 40, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 39, Width: 120, Height: 1}}, | |
| 292 | + | mutate: func(frame renderer.Frame, i int) { | |
| 293 | + | frame.ScrollUp(0, 39, 1) | |
| 294 | + | for x := range frame.Width { | |
| 295 | + | frame.Set(x, 39, renderer.Cell{Rune: rune('a' + i%26), Style: renderer.DefaultStyle()}) | |
| 296 | + | } | |
| 297 | + | }, | |
| 298 | + | }, | |
| 299 | + | } | |
| 300 | + | for _, fixture := range fixtures { | |
| 301 | + | b.Run(fixture.name, func(b *testing.B) { | |
| 302 | + | stream := newStructuredOutputStream(newOutputStateStream()) | |
| 303 | + | cursor := cursorOut{valid: true} | |
| 304 | + | initial, err := stream.prepare(fixture.frame, nil, cursor, true, 0) | |
| 305 | + | if err != nil { | |
| 306 | + | b.Fatal(err) | |
| 307 | + | } | |
| 308 | + | if err := initial.send(func(ports.Frame) error { return nil }); err != nil { | |
| 309 | + | b.Fatal(err) | |
| 310 | + | } | |
| 311 | + | var wireBytes, spans, snapshots int | |
| 312 | + | b.ResetTimer() | |
| 313 | + | for i := 0; i < b.N; i++ { | |
| 314 | + | fixture.mutate(fixture.frame, i) | |
| 315 | + | prepared, err := stream.prepare(fixture.frame, fixture.damage, cursor, false, 0) | |
| 316 | + | if err != nil { | |
| 317 | + | b.Fatal(err) | |
| 318 | + | } | |
| 319 | + | wireBytes += len(prepared.data) | |
| 320 | + | spans += len(prepared.update.Spans) | |
| 321 | + | if prepared.update.Kind == ports.ScreenUpdateSnapshot { | |
| 322 | + | snapshots++ | |
| 323 | + | } | |
| 324 | + | if err := prepared.send(func(ports.Frame) error { return nil }); err != nil { | |
| 325 | + | b.Fatal(err) | |
| 326 | + | } | |
| 327 | + | } | |
| 328 | + | b.StopTimer() | |
| 329 | + | if snapshots != 0 { | |
| 330 | + | b.Fatalf("unexpected snapshots/op: %d", snapshots) | |
| 331 | + | } | |
| 332 | + | b.ReportMetric(float64(wireBytes)/float64(b.N), "wirebytes/op") | |
| 333 | + | b.ReportMetric(float64(spans)/float64(b.N), "spans/op") | |
| 334 | + | b.ReportMetric(float64(snapshots)/float64(b.N), "snapshots/op") | |
| 335 | + | }) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | func decodeStructured(t *testing.T, data []byte) ports.ScreenUpdate { | |
| 340 | + | t.Helper() | |
| 341 | + | require.NotEmpty(t, data) | |
| 342 | + | update, err := ports.UnmarshalScreenUpdate(data) | |
| 343 | + | require.NoError(t, err) | |
| 344 | + | return update | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func structuredTestFrame(lines ...string) renderer.Frame { | |
| 348 | + | width := len([]rune(lines[0])) | |
| 349 | + | frame := renderer.NewFrame(width, len(lines)) | |
| 350 | + | for y, line := range lines { | |
| 351 | + | for x, r := range []rune(line) { | |
| 352 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 353 | + | } | |
| 354 | + | } | |
| 355 | + | return frame | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func structuredRows(width, height int) renderer.Frame { | |
| 359 | + | frame := renderer.NewFrame(width, height) | |
| 360 | + | for y := range height { | |
| 361 | + | for x := range width { | |
| 362 | + | frame.Set(x, y, renderer.Cell{Rune: rune('0' + y%10), Style: renderer.DefaultStyle()}) | |
| 363 | + | } | |
| 364 | + | } | |
| 365 | + | return frame | |
| 366 | + | } | |
| 367 | + | ||
| 368 | + | func structuredStyledFrame(width, height int) renderer.Frame { | |
| 369 | + | frame := structuredRows(width, height) | |
| 370 | + | for x := range width { | |
| 371 | + | frame.Set(x, 0, renderer.Cell{Rune: 'x', Style: renderer.Style{Bold: x%2 == 0, Foreground: x % 8, Background: (x + 1) % 8}}) | |
| 372 | + | } | |
| 373 | + | return frame | |
| 374 | + | } | |
M internal/usecase/daemon/session_meta.go +9 -0
| @@ -71,6 +71,15 @@ func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected trans | |||
| 71 | 71 | if !ok { | |
| 72 | 72 | return errSessionMetaUnavailable | |
| 73 | 73 | } | |
| 74 | + | return ac.sendSessionMetaSnapshot(meta, expected, ticket) | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // sendSessionMetaSnapshot sends an already captured metadata snapshot while | |
| 78 | + | // the caller holds ac.sendMu. | |
| 79 | + | func (ac *attachedClient) sendSessionMetaSnapshot(meta ports.SessionMeta, expected transportSnapshot, ticket *roleEffectTicket) error { | |
| 80 | + | if ac == nil || !ac.proxied { | |
| 81 | + | return nil | |
| 82 | + | } | |
| 74 | 83 | if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) { | |
| 75 | 84 | return nil | |
| 76 | 85 | } | |
M internal/usecase/daemon/session_meta_test.go +11 -5
| @@ -29,9 +29,12 @@ func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) { | |||
| 29 | 29 | ||
| 30 | 30 | emit("initial") | |
| 31 | 31 | firstMetaFrame := <-sends | |
| 32 | - | firstOutputFrame := <-sends | |
| 32 | + | firstScreenFrame := <-sends | |
| 33 | 33 | require.Equal(t, ports.MsgSessionMeta, firstMetaFrame.Type) | |
| 34 | - | require.Equal(t, ports.MsgOutput, firstOutputFrame.Type) | |
| 34 | + | require.Equal(t, ports.MsgScreenUpdate, firstScreenFrame.Type) | |
| 35 | + | firstScreen, err := ports.UnmarshalScreenUpdate(firstScreenFrame.Payload) | |
| 36 | + | require.NoError(t, err) | |
| 37 | + | require.Equal(t, ports.ScreenUpdateSnapshot, firstScreen.Kind) | |
| 35 | 38 | firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload) | |
| 36 | 39 | require.NoError(t, err) | |
| 37 | 40 | require.Equal(t, "work", firstMeta.SessionName) | |
| @@ -41,9 +44,12 @@ func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) { | |||
| 41 | 44 | sess.mu.Unlock() | |
| 42 | 45 | emit("changed") | |
| 43 | 46 | changedMetaFrame := <-sends | |
| 44 | - | changedOutputFrame := <-sends | |
| 47 | + | changedScreenFrame := <-sends | |
| 45 | 48 | require.Equal(t, ports.MsgSessionMeta, changedMetaFrame.Type) | |
| 46 | - | require.Equal(t, ports.MsgOutput, changedOutputFrame.Type) | |
| 49 | + | require.Equal(t, ports.MsgScreenUpdate, changedScreenFrame.Type) | |
| 50 | + | changedScreen, err := ports.UnmarshalScreenUpdate(changedScreenFrame.Payload) | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | require.Equal(t, ports.ScreenUpdateSnapshot, changedScreen.Kind) | |
| 47 | 53 | changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload) | |
| 48 | 54 | require.NoError(t, err) | |
| 49 | 55 | require.Equal(t, "renamed", changedMeta.Tabs[0].Name) | |
| @@ -63,7 +69,7 @@ func TestProxiedMetadataSendsWithoutOutputBytes(t *testing.T) { | |||
| 63 | 69 | ||
| 64 | 70 | emit(true) | |
| 65 | 71 | require.Equal(t, ports.MsgSessionMeta, (<-sends).Type) | |
| 66 | - | require.Equal(t, ports.MsgOutput, (<-sends).Type) | |
| 72 | + | require.Equal(t, ports.MsgScreenUpdate, (<-sends).Type) | |
| 67 | 73 | ||
| 68 | 74 | sess.mu.Lock() | |
| 69 | 75 | sess.tabs[0].name = "renamed" | |
M internal/usecase/daemon/session_test.go +6 -0
| @@ -299,6 +299,12 @@ func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) { | |||
| 299 | 299 | acA := sess.client | |
| 300 | 300 | sess.mu.Unlock() | |
| 301 | 301 | require.NotNil(t, acA) | |
| 302 | + | // The transport observes Send before the render transaction releases sendMu. | |
| 303 | + | // Wait for that contract boundary before replacing A; otherwise the test can | |
| 304 | + | // intentionally classify the in-flight first paint as an interrupted render. | |
| 305 | + | acA.sendMu.Lock() | |
| 306 | + | require.Same(t, trA, acA.transportSnapshot().transport) | |
| 307 | + | acA.sendMu.Unlock() | |
| 302 | 308 | ||
| 303 | 309 | // Client B attaches to the same session, displacing A. | |
| 304 | 310 | trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24})) | |
M internal/usecase/daemon/snatched_cleanup.go +1 -1
| @@ -187,7 +187,7 @@ func (d *Daemon) deferAttachmentTransitionCleanups(result attachmentTransitionRe | |||
| 187 | 187 | } | |
| 188 | 188 | if token := result.displaced; token.ac != nil && token.transport.transport != nil { | |
| 189 | 189 | blockedRender := result.displacedInterrupted | |
| 190 | - | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) && token.ac.transportEffectActive() { | |
| 190 | + | if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) { | |
| 191 | 191 | blockedRender = !token.ac.sendMu.TryLock() | |
| 192 | 192 | if !blockedRender { | |
| 193 | 193 | token.ac.sendMu.Unlock() | |
M internal/usecase/daemon/snatched_render.go +38 -24
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "errors" | |
| 5 | 5 | ||
| 6 | 6 | "github.com/bnema/vev/internal/domain" | |
| 7 | - | "github.com/bnema/vev/internal/ports" | |
| 8 | 7 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 9 | 8 | "github.com/bnema/vev/internal/usecase/ui" | |
| 10 | 9 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -115,38 +114,53 @@ func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapsho | |||
| 115 | 114 | ||
| 116 | 115 | applied := ac.getAppliedTheme() | |
| 117 | 116 | frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback) | |
| 118 | - | ac.output.rebase() | |
| 119 | - | prepared, err := ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true) | |
| 120 | - | if err != nil { | |
| 121 | - | return err | |
| 117 | + | ac.rebaseOutput() | |
| 118 | + | var data []byte | |
| 119 | + | var cursor cursorCandidate | |
| 120 | + | var preparedANSI *preparedOutput | |
| 121 | + | var preparedScreen *preparedStructuredOutput | |
| 122 | + | var sendErr error | |
| 123 | + | if ac.proxied { | |
| 124 | + | screenOutput := ac.ensureScreenOutput() | |
| 125 | + | if screenOutput == nil { | |
| 126 | + | return errors.New("proxied attachment has no structured output stream") | |
| 127 | + | } | |
| 128 | + | preparedScreen, sendErr = screenOutput.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, cursorOut{hidden: true}, true, ac.echoAck.Load()) | |
| 129 | + | if sendErr == nil { | |
| 130 | + | data = preparedScreen.data | |
| 131 | + | } | |
| 132 | + | } else { | |
| 133 | + | preparedANSI, sendErr = ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true) | |
| 134 | + | if sendErr == nil { | |
| 135 | + | cursor = ac.prepareCursorTail(cursorOut{hidden: true}, true) | |
| 136 | + | data = append(append([]byte(nil), preparedANSI.data...), cursor.data...) | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | if sendErr != nil { | |
| 140 | + | return sendErr | |
| 122 | 141 | } | |
| 123 | - | cursor := ac.prepareCursorTail(cursorOut{hidden: true}, true) | |
| 124 | - | data := append(append([]byte(nil), prepared.data...), cursor.data...) | |
| 125 | 142 | if effect != nil && !effect.beginTransportSend(expected) { | |
| 126 | 143 | return errAttachmentTransition | |
| 127 | 144 | } | |
| 128 | - | effectActive := effect != nil | |
| 129 | - | err = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error { | |
| 130 | - | err := expected.transport.Send(frame) | |
| 131 | - | if effectActive { | |
| 132 | - | if err != nil { | |
| 133 | - | effect.reportTransportFailure(expected) | |
| 134 | - | } | |
| 135 | - | effect.endTransportSend() | |
| 136 | - | effectActive = false | |
| 137 | - | } | |
| 138 | - | return err | |
| 139 | - | }) | |
| 140 | - | if effectActive { | |
| 141 | - | if err != nil { | |
| 145 | + | if ac.proxied { | |
| 146 | + | sendErr = preparedScreen.send(expected.transport.Send) | |
| 147 | + | } else { | |
| 148 | + | sendErr = preparedANSI.send(data, ac.echoAck.Load(), expected.transport.Send) | |
| 149 | + | } | |
| 150 | + | if effect != nil { | |
| 151 | + | if sendErr != nil { | |
| 142 | 152 | effect.reportTransportFailure(expected) | |
| 143 | 153 | } | |
| 144 | 154 | effect.endTransportSend() | |
| 145 | 155 | } | |
| 146 | - | if err == nil { | |
| 147 | - | ac.lastCursor = cursor.next | |
| 156 | + | if sendErr == nil { | |
| 157 | + | if ac.proxied { | |
| 158 | + | ac.lastCursor = cursorOut{hidden: true} | |
| 159 | + | } else { | |
| 160 | + | ac.lastCursor = cursor.next | |
| 161 | + | } | |
| 148 | 162 | } | |
| 149 | - | return err | |
| 163 | + | return sendErr | |
| 150 | 164 | }) | |
| 151 | 165 | if errors.Is(err, errSendTimedOut) { | |
| 152 | 166 | _ = ac.closeCapturedTransport(tr) | |
M internal/usecase/daemon/snatched_render_test.go +21 -8
| @@ -138,10 +138,28 @@ func TestSendSnatchedPanelRebasesOutput(t *testing.T) { | |||
| 138 | 138 | require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden") | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | + | func TestSendSnatchedPanelUsesStructuredSnapshotForProxied(t *testing.T) { | |
| 142 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 143 | + | tr := &closeTrackingTransport{} | |
| 144 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), proxied: true, size: domain.Size{Cols: 80, Rows: 24}} | |
| 145 | + | ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})}) | |
| 146 | + | ac.roleGeneration.Store(1) | |
| 147 | + | ||
| 148 | + | require.NoError(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, "")) | |
| 149 | + | frames := tr.Sends() | |
| 150 | + | require.Len(t, frames, 1) | |
| 151 | + | require.Equal(t, ports.MsgScreenUpdate, frames[0].Type) | |
| 152 | + | update, err := ports.UnmarshalScreenUpdate(frames[0].Payload) | |
| 153 | + | require.NoError(t, err) | |
| 154 | + | require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind) | |
| 155 | + | require.Zero(t, update.BaseStateNum) | |
| 156 | + | require.False(t, update.Cursor.Visible, "structured snatched panel must hide the cursor semantically") | |
| 157 | + | require.Len(t, update.Spans, ac.size.Rows, "structured snapshots must carry every full row") | |
| 158 | + | } | |
| 159 | + | ||
| 141 | 160 | func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) { | |
| 142 | 161 | d := newTestDaemon(t, nil, stubClock{}) | |
| 143 | - | sendErr := errors.New("send failed") | |
| 144 | - | failed := cacheFailTransport{err: sendErr} | |
| 162 | + | failed := cacheFailTransport{} | |
| 145 | 163 | ac := &attachedClient{ | |
| 146 | 164 | tr: failed, | |
| 147 | 165 | output: newOutputStateStream(), | |
| @@ -153,14 +171,9 @@ func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) { | |||
| 153 | 171 | ac.roleGeneration.Store(1) | |
| 154 | 172 | beforeCursor := ac.lastCursor | |
| 155 | 173 | ||
| 156 | - | require.ErrorIs(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""), sendErr) | |
| 174 | + | require.Error(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, "")) | |
| 157 | 175 | require.Equal(t, beforeCursor, ac.lastCursor) | |
| 158 | 176 | require.Equal(t, uint64(7), ac.output.next) | |
| 159 | - | require.True(t, ac.output.forceSnapshot, "failed snatched send must force the next output to be a snapshot") | |
| 160 | - | probeFrame := snatchedPanelFrame(ac.size, ac.getAppliedTheme().Resolved.Styles, "") | |
| 161 | - | probe, err := ac.output.renderer.Prepare(probeFrame, nil, false) | |
| 162 | - | require.NoError(t, err) | |
| 163 | - | require.NotEmpty(t, probe.Bytes(), "failed snatched send must not commit the renderer shadow") | |
| 164 | 177 | ||
| 165 | 178 | healthy := &closeTrackingTransport{} | |
| 166 | 179 | ac.replaceTransport(healthy) | |
A internal/usecase/daemon/structured_diagnostics_test.go +202 -0
| @@ -0,0 +1,202 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "io" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 11 | + | "github.com/stretchr/testify/mock" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing.T) { | |
| 16 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 17 | + | observer := &daemonRuntimeObserver{} | |
| 18 | + | d.runtimeObserver = observer | |
| 19 | + | clientTransport, sent := newCapturingTransport(t) | |
| 20 | + | proxy, ac := newAttachedProxyFixture(t, d, clientTransport, newProxyTestTransport()) | |
| 21 | + | ac.proxied = true | |
| 22 | + | ac.screenOutput = newStructuredOutputStream(ac.output) | |
| 23 | + | require.True(t, applyTestScreenText(proxy, 0, 0, "snapshot")) | |
| 24 | + | ||
| 25 | + | emit := func(reset bool) ports.ScreenUpdate { | |
| 26 | + | t.Helper() | |
| 27 | + | ac.sendMu.Lock() | |
| 28 | + | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: reset}) | |
| 29 | + | require.True(t, ok) | |
| 30 | + | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |
| 31 | + | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |
| 32 | + | frame := awaitTestValue(t, sent, "structured screen update") | |
| 33 | + | if frame.Type == ports.MsgSessionMeta { | |
| 34 | + | frame = awaitTestValue(t, sent, "structured screen update after metadata") | |
| 35 | + | } | |
| 36 | + | require.Equal(t, ports.MsgScreenUpdate, frame.Type) | |
| 37 | + | update, err := ports.UnmarshalScreenUpdate(frame.Payload) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | return update | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | snapshot := emit(true) | |
| 43 | + | marks := structuredScreenMarks(observer) | |
| 44 | + | require.Len(t, marks, 1) | |
| 45 | + | require.Equal(t, ports.RuntimeScreenSnapshot, marks[0].Kind) | |
| 46 | + | require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, snapshot))), marks[0].Bytes) | |
| 47 | + | require.Equal(t, uint64(len(snapshot.Spans)), marks[0].Fragments) | |
| 48 | + | ||
| 49 | + | proxy.mu.Lock() | |
| 50 | + | size := proxy.contentSize | |
| 51 | + | proxy.mu.Unlock() | |
| 52 | + | _, _, changed := proxy.applyScreenUpdateForGeneration(1, ports.ScreenUpdate{ | |
| 53 | + | Kind: ports.ScreenUpdateDelta, BaseStateNum: snapshot.NewStateNum, NewStateNum: snapshot.NewStateNum + 1, | |
| 54 | + | Size: size, Spans: []ports.ScreenSpan{{Y: 0, X: 0, Cells: cells("delta")}}, | |
| 55 | + | }) | |
| 56 | + | require.True(t, changed) | |
| 57 | + | observer.marks = nil | |
| 58 | + | ||
| 59 | + | delta := emit(false) | |
| 60 | + | marks = structuredScreenMarks(observer) | |
| 61 | + | require.Len(t, marks, 1) | |
| 62 | + | require.Equal(t, ports.RuntimeScreenDelta, marks[0].Kind) | |
| 63 | + | require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, delta))), marks[0].Bytes) | |
| 64 | + | require.Equal(t, uint64(len(delta.Spans)), marks[0].Fragments) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestP4StructuredRuntimeDiagnosticsDoNotRecordFailedSend(t *testing.T) { | |
| 68 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 69 | + | observer := &daemonRuntimeObserver{} | |
| 70 | + | d.runtimeObserver = observer | |
| 71 | + | clientTransport := newStructuredDiagnosticFailingTransport(t) | |
| 72 | + | proxy, ac := newAttachedProxyFixture(t, d, clientTransport, newProxyTestTransport()) | |
| 73 | + | ac.proxied = true | |
| 74 | + | ac.screenOutput = newStructuredOutputStream(ac.output) | |
| 75 | + | require.True(t, applyTestScreenText(proxy, 0, 0, "failed")) | |
| 76 | + | ||
| 77 | + | ac.sendMu.Lock() | |
| 78 | + | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true}) | |
| 79 | + | require.True(t, ok) | |
| 80 | + | composed := composeFrame(*state, composeCacheInput{}) | |
| 81 | + | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |
| 82 | + | require.Empty(t, structuredScreenMarks(observer)) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func TestP4StructuredRuntimeDiagnosticsResetOnlyAfterAcceptedRequest(t *testing.T) { | |
| 86 | + | d, proxy, transport, generation := newProxyOutputSession(t) | |
| 87 | + | observer := &daemonRuntimeObserver{} | |
| 88 | + | d.runtimeObserver = observer | |
| 89 | + | invalid := ports.ScreenUpdate{ | |
| 90 | + | Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2, | |
| 91 | + | Size: domain.Size{Cols: 80, Rows: 22}, | |
| 92 | + | } | |
| 93 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid)) | |
| 94 | + | marks := structuredScreenMarks(observer) | |
| 95 | + | require.Len(t, marks, 1) | |
| 96 | + | require.Equal(t, ports.RuntimeScreenResetRequested, marks[0].Kind) | |
| 97 | + | require.Zero(t, marks[0].Bytes) | |
| 98 | + | require.Zero(t, marks[0].Fragments) | |
| 99 | + | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "reset request").Type) | |
| 100 | + | ||
| 101 | + | // The already-requested reset is not sent or measured a second time. | |
| 102 | + | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid)) | |
| 103 | + | require.Len(t, structuredScreenMarks(observer), 1) | |
| 104 | + | ||
| 105 | + | d, proxy, transport, generation = newProxyOutputSession(t) | |
| 106 | + | d.runtimeObserver = &daemonRuntimeObserver{} | |
| 107 | + | transport.sendFails.Store(true) | |
| 108 | + | require.Error(t, d.handleProxyScreenUpdate(proxy, generation, invalid)) | |
| 109 | + | require.Empty(t, structuredScreenMarks(d.runtimeObserver.(*daemonRuntimeObserver))) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLocks(t *testing.T) { | |
| 113 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 114 | + | observer := &blockingStructuredDiagnosticObserver{entered: make(chan struct{}), release: make(chan struct{})} | |
| 115 | + | d.runtimeObserver = observer | |
| 116 | + | clientTransport, sent := newCapturingTransport(t) | |
| 117 | + | proxy, ac := newAttachedProxyFixture(t, d, clientTransport, newProxyTestTransport()) | |
| 118 | + | ac.proxied = true | |
| 119 | + | ac.screenOutput = newStructuredOutputStream(ac.output) | |
| 120 | + | require.True(t, applyTestScreenText(proxy, 0, 0, "locks")) | |
| 121 | + | ||
| 122 | + | done := make(chan struct{}) | |
| 123 | + | go func() { | |
| 124 | + | ac.sendMu.Lock() | |
| 125 | + | state, ok := proxy.capturePrimary(ac, primaryCaptureRequest{reset: true}) | |
| 126 | + | if !ok { | |
| 127 | + | ac.sendMu.Unlock() | |
| 128 | + | close(done) | |
| 129 | + | return | |
| 130 | + | } | |
| 131 | + | composed := composeFrame(*state, composeCacheInput{}) | |
| 132 | + | d.emitFrame(proxy, ac, state, composed) | |
| 133 | + | close(done) | |
| 134 | + | }() | |
| 135 | + | awaitDaemonObserver(t, observer.entered, "structured diagnostic observer") | |
| 136 | + | _ = awaitTestValue(t, sent, "structured screen update") | |
| 137 | + | ||
| 138 | + | attachmentUnlocked := make(chan struct{}) | |
| 139 | + | go func() { | |
| 140 | + | ac.sendMu.Lock() | |
| 141 | + | _ = ac.size | |
| 142 | + | ac.sendMu.Unlock() | |
| 143 | + | close(attachmentUnlocked) | |
| 144 | + | }() | |
| 145 | + | proxyUnlocked := make(chan struct{}) | |
| 146 | + | go func() { | |
| 147 | + | proxy.mu.Lock() | |
| 148 | + | _ = proxy.contentSize | |
| 149 | + | proxy.mu.Unlock() | |
| 150 | + | close(proxyUnlocked) | |
| 151 | + | }() | |
| 152 | + | awaitDaemonObserver(t, attachmentUnlocked, "attachment lock") | |
| 153 | + | awaitDaemonObserver(t, proxyUnlocked, "proxy lock") | |
| 154 | + | close(observer.release) | |
| 155 | + | awaitDaemonObserver(t, done, "structured render") | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func structuredScreenMarks(observer ports.RuntimeObserver) []ports.RuntimeMark { | |
| 159 | + | collector, ok := observer.(*daemonRuntimeObserver) | |
| 160 | + | if !ok { | |
| 161 | + | return nil | |
| 162 | + | } | |
| 163 | + | var marks []ports.RuntimeMark | |
| 164 | + | for _, mark := range collector.marks { | |
| 165 | + | switch mark.Kind { | |
| 166 | + | case ports.RuntimeScreenSnapshot, ports.RuntimeScreenDelta, ports.RuntimeScreenResetRequested: | |
| 167 | + | marks = append(marks, mark) | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | return marks | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | func mustMarshalScreenUpdate(t *testing.T, update ports.ScreenUpdate) []byte { | |
| 174 | + | t.Helper() | |
| 175 | + | data, err := ports.MarshalScreenUpdate(update) | |
| 176 | + | require.NoError(t, err) | |
| 177 | + | return data | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | type blockingStructuredDiagnosticObserver struct { | |
| 181 | + | entered chan struct{} | |
| 182 | + | release chan struct{} | |
| 183 | + | once sync.Once | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (o *blockingStructuredDiagnosticObserver) ObserveRuntime(mark ports.RuntimeMark) { | |
| 187 | + | if mark.Kind != ports.RuntimeScreenSnapshot && mark.Kind != ports.RuntimeScreenDelta { | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | o.once.Do(func() { | |
| 191 | + | close(o.entered) | |
| 192 | + | <-o.release | |
| 193 | + | }) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func newStructuredDiagnosticFailingTransport(t *testing.T) *portsmocks.MockTransport { | |
| 197 | + | t.Helper() | |
| 198 | + | tr := portsmocks.NewMockTransport(t) | |
| 199 | + | tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe() | |
| 200 | + | tr.EXPECT().Close().Return(nil).Maybe() | |
| 201 | + | return tr | |
| 202 | + | } | |
M internal/usecase/daemon/toast_damage_test.go +2 -2
| @@ -33,7 +33,7 @@ func TestComposeFrameToastDamageIsIncrementalAndRestoresOldFootprints(t *testing | |||
| 33 | 33 | require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1}) | |
| 34 | 34 | requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints) | |
| 35 | 35 | requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints) | |
| 36 | - | incremental, err := renderCommitted(stream, updated.frame, updated.damage, updated.reset) | |
| 36 | + | incremental, err := stream.render(updated.frame, updated.damage, updated.reset) | |
| 37 | 37 | require.NoError(t, err) | |
| 38 | 38 | fullRenderer := renderer.New(renderer.Capabilities{}) | |
| 39 | 39 | full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()}) | |
| @@ -126,7 +126,7 @@ func toastDamageState(notices []domain.Notification, damage []renderer.Damage, r | |||
| 126 | 126 | ||
| 127 | 127 | func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) { | |
| 128 | 128 | t.Helper() | |
| 129 | - | data, err := renderCommitted(stream, composed.frame, composed.damage, composed.reset) | |
| 129 | + | data, err := stream.render(composed.frame, composed.damage, composed.reset) | |
| 130 | 130 | require.NoError(t, err) | |
| 131 | 131 | terminal.Write(data) | |
| 132 | 132 | require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame)) | |
M pkg/renderer/damage_plan.go +22 -3
| @@ -11,6 +11,28 @@ const maxPlannedDamageSpans = 4096 | |||
| 11 | 11 | // used for terminal emission. The source damage remains untouched because the | |
| 12 | 12 | // scroll and shadow paths need its original structural information. | |
| 13 | 13 | func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) { | |
| 14 | + | if len(damage) == 1 { | |
| 15 | + | d := damage[0] | |
| 16 | + | if skip != nil && sameDamage(d, *skip) { | |
| 17 | + | return nil, false | |
| 18 | + | } | |
| 19 | + | if d.Kind != DamageText && d.Kind != DamageClear { | |
| 20 | + | return nil, false | |
| 21 | + | } | |
| 22 | + | x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height) | |
| 23 | + | if !ok { | |
| 24 | + | return nil, false | |
| 25 | + | } | |
| 26 | + | if height > maxPlannedDamageSpans { | |
| 27 | + | return nil, true | |
| 28 | + | } | |
| 29 | + | spans := make([]Span, height) | |
| 30 | + | for row := range height { | |
| 31 | + | spans[row] = Span{Y: y + row, X: x, Width: width} | |
| 32 | + | } | |
| 33 | + | return spans, false | |
| 34 | + | } | |
| 35 | + | ||
| 14 | 36 | spans := make([]Span, 0) | |
| 15 | 37 | for _, d := range damage { | |
| 16 | 38 | if skip != nil && sameDamage(d, *skip) { | |
| @@ -30,9 +52,6 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) | |||
| 30 | 52 | spans = append(spans, Span{Y: row, X: x, Width: width}) | |
| 31 | 53 | } | |
| 32 | 54 | } | |
| 33 | - | if len(spans) == 0 { | |
| 34 | - | return nil, false | |
| 35 | - | } | |
| 36 | 55 | ||
| 37 | 56 | slices.SortFunc(spans, func(a, b Span) int { | |
| 38 | 57 | if c := cmp.Compare(a.Y, b.Y); c != 0 { | |
M pkg/renderer/damage_plan_test.go +1 -19
| @@ -11,7 +11,6 @@ func TestBuildDamagePlan(t *testing.T) { | |||
| 11 | 11 | name string | |
| 12 | 12 | frame Frame | |
| 13 | 13 | damage []Damage | |
| 14 | - | skip *Damage | |
| 15 | 14 | want []Span | |
| 16 | 15 | full bool | |
| 17 | 16 | }{ | |
| @@ -73,29 +72,12 @@ func TestBuildDamagePlan(t *testing.T) { | |||
| 73 | 72 | want: nil, | |
| 74 | 73 | full: true, | |
| 75 | 74 | }, | |
| 76 | - | { | |
| 77 | - | name: "exceeding budget across multiple damages requests full redraw", | |
| 78 | - | frame: NewFrame(1, maxPlannedDamageSpans+1), | |
| 79 | - | damage: []Damage{ | |
| 80 | - | {Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans}, | |
| 81 | - | {Kind: DamageText, X: 0, Y: maxPlannedDamageSpans, Width: 1, Height: 1}, | |
| 82 | - | }, | |
| 83 | - | want: nil, | |
| 84 | - | full: true, | |
| 85 | - | }, | |
| 86 | - | { | |
| 87 | - | name: "skips the matching damage", | |
| 88 | - | frame: NewFrame(10, 1), | |
| 89 | - | damage: []Damage{{Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1}}, | |
| 90 | - | skip: &Damage{Kind: DamageText, X: 1, Y: 0, Width: 2, Height: 1}, | |
| 91 | - | want: nil, | |
| 92 | - | }, | |
| 93 | 75 | } | |
| 94 | 76 | ||
| 95 | 77 | for _, tt := range tests { | |
| 96 | 78 | t.Run(tt.name, func(t *testing.T) { | |
| 97 | 79 | original := append([]Damage(nil), tt.damage...) | |
| 98 | - | got, full := buildDamagePlan(tt.frame, tt.damage, tt.skip) | |
| 80 | + | got, full := buildDamagePlan(tt.frame, tt.damage, nil) | |
| 99 | 81 | if full != tt.full || !reflect.DeepEqual(got, tt.want) { | |
| 100 | 82 | t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full) | |
| 101 | 83 | } | |
M pkg/renderer/delta_plan.go +7 -15
| @@ -40,9 +40,6 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta | |||
| 40 | 40 | candidate.Plan.Snapshot = true | |
| 41 | 41 | return candidate, nil | |
| 42 | 42 | } | |
| 43 | - | if err := committed.Validate(); err != nil { | |
| 44 | - | return DeltaCandidate{}, err | |
| 45 | - | } | |
| 46 | 43 | ||
| 47 | 44 | if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 48 | 45 | candidate.Plan = planSingleDamage(frame, damage[0]) | |
| @@ -50,7 +47,7 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta | |||
| 50 | 47 | } | |
| 51 | 48 | ||
| 52 | 49 | if needsFull(damage) { | |
| 53 | - | dirty, full := buildDirtyLinePlan(frame, committed, nil) | |
| 50 | + | dirty, full := buildDirtyLinePlan(frame, committed) | |
| 54 | 51 | if len(dirty) > 0 || full { | |
| 55 | 52 | candidate.Plan.Snapshot = true | |
| 56 | 53 | candidate.Plan.Spans = dirty | |
| @@ -70,7 +67,7 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta | |||
| 70 | 67 | var spans []Span | |
| 71 | 68 | var full bool | |
| 72 | 69 | if len(damage) == 0 { | |
| 73 | - | spans, full = buildDirtyLinePlan(frame, committed, nil) | |
| 70 | + | spans, full = buildDirtyLinePlan(frame, committed) | |
| 74 | 71 | } else { | |
| 75 | 72 | spans, full = buildDamagePlan(frame, damage, skip) | |
| 76 | 73 | } | |
| @@ -82,13 +79,8 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta | |||
| 82 | 79 | if candidate.Plan.Scroll.Height != 0 { | |
| 83 | 80 | candidate.Plan = DeltaPlan{Snapshot: true} | |
| 84 | 81 | } else { | |
| 85 | - | dirty, full := buildDirtyLinePlan(frame, committed, spans[:0]) | |
| 86 | - | if full || len(dirty) == 0 { | |
| 87 | - | candidate.Plan = DeltaPlan{Snapshot: true} | |
| 88 | - | } else { | |
| 89 | - | candidate.Plan.Snapshot = true | |
| 90 | - | candidate.Plan.Spans = dirty | |
| 91 | - | } | |
| 82 | + | candidate.Plan.Snapshot = true | |
| 83 | + | candidate.Plan.Spans = spans | |
| 92 | 84 | } | |
| 93 | 85 | return candidate, nil | |
| 94 | 86 | } | |
| @@ -110,13 +102,13 @@ func planSingleDamage(frame Frame, d Damage) DeltaPlan { | |||
| 110 | 102 | } | |
| 111 | 103 | plan := DeltaPlan{Spans: spans} | |
| 112 | 104 | if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) { | |
| 113 | - | return DeltaPlan{Snapshot: true} | |
| 105 | + | plan.Snapshot = true | |
| 114 | 106 | } | |
| 115 | 107 | return plan | |
| 116 | 108 | } | |
| 117 | 109 | ||
| 118 | - | func buildDirtyLinePlan(frame, committed Frame, spans []Span) ([]Span, bool) { | |
| 119 | - | spans = spans[:0] | |
| 110 | + | func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) { | |
| 111 | + | var spans []Span | |
| 120 | 112 | for y := range frame.Height { | |
| 121 | 113 | frameRow := frame.Row(y) | |
| 122 | 114 | committedRow := committed.Row(y) | |
M pkg/renderer/delta_plan_test.go +6 -34
| @@ -116,14 +116,17 @@ func TestPlanDelta(t *testing.T) { | |||
| 116 | 116 | frame: testFrame("changed!"), | |
| 117 | 117 | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 8, Height: 1}}, | |
| 118 | 118 | committed: NewFrame(8, 1), | |
| 119 | - | want: DeltaPlan{Snapshot: true}, | |
| 119 | + | want: DeltaPlan{Snapshot: true, Spans: []Span{{Y: 0, X: 0, Width: 8}}}, | |
| 120 | 120 | }, | |
| 121 | 121 | { | |
| 122 | - | name: "single broad damage snapshots completely", | |
| 122 | + | name: "single broad damage retains spans for snapshot encoding", | |
| 123 | 123 | frame: testFrame("aaaa", "bbbb"), | |
| 124 | 124 | damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}}, | |
| 125 | 125 | committed: NewFrame(4, 2), | |
| 126 | - | want: DeltaPlan{Snapshot: true}, | |
| 126 | + | want: DeltaPlan{ | |
| 127 | + | Snapshot: true, | |
| 128 | + | Spans: []Span{{Y: 0, X: 0, Width: 4}, {Y: 1, X: 0, Width: 4}}, | |
| 129 | + | }, | |
| 127 | 130 | }, | |
| 128 | 131 | { | |
| 129 | 132 | name: "snapshot cost includes style runs", | |
| @@ -207,34 +210,6 @@ func TestPlanDeltaRejectsInvalidFrame(t *testing.T) { | |||
| 207 | 210 | } | |
| 208 | 211 | } | |
| 209 | 212 | ||
| 210 | - | func TestPlanDeltaRejectsInvalidCommittedFrame(t *testing.T) { | |
| 211 | - | frame := NewFrame(2, 1) | |
| 212 | - | committed := NewFrame(2, 1) | |
| 213 | - | committed.lineOffset = nil | |
| 214 | - | if _, err := PlanDelta(frame, nil, committed, false); err == nil { | |
| 215 | - | t.Fatal("PlanDelta accepted an invalid committed frame") | |
| 216 | - | } | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | func TestSnapshotCommitCopiesCellsOutsideDamage(t *testing.T) { | |
| 220 | - | frame := NewFrame(2, maxPlannedDamageSpans+1) | |
| 221 | - | frame.Set(1, 0, Cell{Rune: 'x', Style: DefaultStyle()}) | |
| 222 | - | committed := NewFrame(2, maxPlannedDamageSpans+1) | |
| 223 | - | damage := []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 1, Height: maxPlannedDamageSpans + 1}} | |
| 224 | - | ||
| 225 | - | candidate, err := PlanDelta(frame, damage, committed, false) | |
| 226 | - | if err != nil { | |
| 227 | - | t.Fatalf("PlanDelta() error = %v", err) | |
| 228 | - | } | |
| 229 | - | if !candidate.Plan.Snapshot || len(candidate.Plan.Spans) != 0 { | |
| 230 | - | t.Fatalf("snapshot plan = %#v, want complete snapshot without partial spans", candidate.Plan) | |
| 231 | - | } | |
| 232 | - | candidate.Commit(&committed) | |
| 233 | - | if got := committed.At(1, 0); !got.Equal(frame.At(1, 0)) { | |
| 234 | - | t.Fatalf("committed cell = %#v, want %#v", got, frame.At(1, 0)) | |
| 235 | - | } | |
| 236 | - | } | |
| 237 | - | ||
| 238 | 213 | func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) { | |
| 239 | 214 | committed := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777") | |
| 240 | 215 | frame := committed.Clone() | |
| @@ -259,9 +234,6 @@ func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) { | |||
| 259 | 234 | t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y)) | |
| 260 | 235 | } | |
| 261 | 236 | } | |
| 262 | - | if err := committed.CheckInvariants(); err != nil { | |
| 263 | - | t.Fatalf("committed frame invariants: %v", err) | |
| 264 | - | } | |
| 265 | 237 | } | |
| 266 | 238 | ||
| 267 | 239 | func fillFrameRow(frame Frame, y int, row string) { | |
M pkg/renderer/output_min_test.go +12 -10
| @@ -264,8 +264,9 @@ func TestRightEdgeRunInvalidatesTracking(t *testing.T) { | |||
| 264 | 264 | func TestWideRunCursorTrackingAndEL(t *testing.T) { | |
| 265 | 265 | t.Run("cursor advances two columns per wide rune", func(t *testing.T) { | |
| 266 | 266 | // Rect A ends with a wide pair (not at the edge); rect B starts one | |
| 267 | - | // column later. The literal space in the expected output bridges the | |
| 268 | - | // damaged regions. | |
| 267 | + | // column later. The gap between them must be crossed with the cursor | |
| 268 | + | // position that reflects the terminal advancing two columns for the | |
| 269 | + | // wide rune — otherwise the relative move would be wrong. | |
| 269 | 270 | r := New(Capabilities{}) | |
| 270 | 271 | base := NewFrame(8, 1) | |
| 271 | 272 | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |
| @@ -285,8 +286,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||
| 285 | 286 | {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1}, | |
| 286 | 287 | } | |
| 287 | 288 | got := drawGolden(t, r, frame, damage) | |
| 288 | - | // The literal space bridges the undamaged gap between the two regions. | |
| 289 | - | want := "\x1b[1;1H你AB CDE\x1b[0m" | |
| 289 | + | // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF. | |
| 290 | + | want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m" | |
| 290 | 291 | if got != want { | |
| 291 | 292 | t.Fatalf("output = %q, want %q", got, want) | |
| 292 | 293 | } | |
| @@ -294,8 +295,9 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||
| 294 | 295 | ||
| 295 | 296 | t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) { | |
| 296 | 297 | // A damage rect that ends on a wide head whose continuation lies | |
| 297 | - | // outside the rect is followed by a literal space that bridges the | |
| 298 | - | // damaged regions. The trailing erase sequence clears the row tail. | |
| 298 | + | // outside the rect makes the terminal advance one column further than | |
| 299 | + | // the rect width accounts for. Tracking must be dropped: the next | |
| 300 | + | // rect must be positioned with absolute CUP, not a relative move. | |
| 299 | 301 | r := New(Capabilities{}) | |
| 300 | 302 | base := NewFrame(8, 1) | |
| 301 | 303 | if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil { | |
| @@ -312,9 +314,9 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) { | |||
| 312 | 314 | {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1}, | |
| 313 | 315 | } | |
| 314 | 316 | got := drawGolden(t, r, frame, damage) | |
| 315 | - | // The expected output preserves the literal-space bridge and trailing | |
| 316 | - | // erase sequence for this split wide pair. | |
| 317 | - | want := "\x1b[1;1HA你 B\x1b[K\x1b[0m" | |
| 317 | + | // After A你 the real cursor is at col 3, not col 2: a relative CUF | |
| 318 | + | // would be off by one, so the second rect must use absolute CUP. | |
| 319 | + | want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m" | |
| 318 | 320 | if got != want { | |
| 319 | 321 | t.Fatalf("output = %q, want %q", got, want) | |
| 320 | 322 | } | |
| @@ -423,7 +425,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) { | |||
| 423 | 425 | if err != nil { | |
| 424 | 426 | t.Fatal(err) | |
| 425 | 427 | } | |
| 426 | - | if got, want := string(out), "\x1b[1;1HA你 B\x1b[K\x1b[0m"; got != want { | |
| 428 | + | if got, want := string(out), "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"; got != want { | |
| 427 | 429 | t.Fatalf("output = %q, want %q", got, want) | |
| 428 | 430 | } | |
| 429 | 431 | } | |
M pkg/renderer/renderer.go +19 -5
| @@ -65,7 +65,24 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||
| 65 | 65 | var err error | |
| 66 | 66 | if !reset && len(r.shadow) != 0 && r.width == frame.Width && r.height == frame.Height && len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) { | |
| 67 | 67 | if err = frame.Validate(); err == nil { | |
| 68 | - | candidate = DeltaCandidate{Plan: planSingleDamage(frame, damage[0]), frame: frame} | |
| 68 | + | d := damage[0] | |
| 69 | + | x, y, width, height, ok := clampRect(frame, d.X, d.Y, d.Width, d.Height) | |
| 70 | + | plan := DeltaPlan{} | |
| 71 | + | if ok { | |
| 72 | + | if height > maxPlannedDamageSpans { | |
| 73 | + | plan.Snapshot = true | |
| 74 | + | } else { | |
| 75 | + | spans := make([]Span, height) | |
| 76 | + | for row := range height { | |
| 77 | + | spans[row] = Span{Y: y + row, X: x, Width: width} | |
| 78 | + | } | |
| 79 | + | if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) { | |
| 80 | + | plan.Snapshot = true | |
| 81 | + | } | |
| 82 | + | plan.Spans = spans | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | candidate = DeltaCandidate{Plan: plan, frame: frame} | |
| 69 | 86 | } | |
| 70 | 87 | } else { | |
| 71 | 88 | candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0) | |
| @@ -79,10 +96,7 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr | |||
| 79 | 96 | return prepared, nil | |
| 80 | 97 | } | |
| 81 | 98 | ||
| 82 | - | buf, ok := bufferPool.Get().(*bytes.Buffer) | |
| 83 | - | if !ok { | |
| 84 | - | buf = new(bytes.Buffer) | |
| 85 | - | } | |
| 99 | + | buf := bufferPool.Get().(*bytes.Buffer) | |
| 86 | 100 | buf.Reset() | |
| 87 | 101 | defer putBuffer(buf) | |
| 88 | 102 | if r.caps.SynchronizedOutput { | |
M pkg/renderer/renderer_test.go +0 -11
| @@ -1289,17 +1289,6 @@ func TestRendererPrepare(t *testing.T) { | |||
| 1289 | 1289 | }, | |
| 1290 | 1290 | want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m", | |
| 1291 | 1291 | }, | |
| 1292 | - | { | |
| 1293 | - | name: "synchronized output framing", | |
| 1294 | - | prepare: func(t *testing.T) PreparedDraw { | |
| 1295 | - | prepared, err := New(Capabilities{SynchronizedOutput: true}).Prepare(fullFrame, fullDamage, false) | |
| 1296 | - | if err != nil { | |
| 1297 | - | t.Fatal(err) | |
| 1298 | - | } | |
| 1299 | - | return prepared | |
| 1300 | - | }, | |
| 1301 | - | want: SyncStartCSI + "\x1b[1;1HABC\x1b[2;1HDEF\x1b[0m" + SyncEndCSI, | |
| 1302 | - | }, | |
| 1303 | 1292 | } | |
| 1304 | 1293 | ||
| 1305 | 1294 | for _, tt := range tests { | |