detached c6e0a78

feat(proxy): stream structured screen updates

commit
c6e0a7889a8544e7a579504cea4a8384612d1f2c
author
brice · · 2026-08-02 10:13 UTC
parent
087e78d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 // and exact correlation domain may pair, and each pair's ticks must retain200 // and exact correlation domain may pair, and each pair's ticks must retain
201 // its own start-before-end ordering.201 // its own start-before-end ordering.
202 starts := map[string]traceRecord{}202 starts := map[string]traceRecord{}
203+ observerGap := false
203 for _, r := range records {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 for _, pair := range spanPairs {214 for _, pair := range spanPairs {
205 key := fmt.Sprintf("%s/%s/%s/%d/%d/%d/%d", r.ProcessID, r.Component, m.Scenario, m.Run, r.Sequence, r.RequestID, r.Epoch)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 if r.Kind == pair.start {216 if r.Kind == pair.start {
@@ -214,6 +224,9 @@ func mergeProcessTraces(mappings []processMapping) ([]span, error) {
214 k := pair.name + "/" + key224 k := pair.name + "/" + key
215 start, ok := starts[k]225 start, ok := starts[k]
216 if !ok {226 if !ok {
227+ if observerGap {
228+ continue
229+ }
217 return nil, errors.New("span end without same-process start")230 return nil, errors.New("span end without same-process start")
218 }231 }
219 if r.Tick < start.Tick {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 func TestHarnessExcludesFailedSpanDurationsWhileValidatingPairing(t *testing.T) {389 func TestHarnessExcludesFailedSpanDurationsWhileValidatingPairing(t *testing.T) {
368 base := func(kind string, tick int64, valid bool) traceRecord {390 base := func(kind string, tick int64, valid bool) traceRecord {
369 return traceRecord{ProcessID: "one", Component: "adapter", Scenario: "s", Run: 1, Sequence: 1, RequestID: 1, Epoch: 1, Kind: kind, Tick: tick, Valid: valid}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 input := string(workloadInput(scenario{ID: "s", Workload: "interactive_flood"}, 1, "measured-1"))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 t.Fatalf("workload is not real PTY shell input with an observable marker: %q", input)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 func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand {382 func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand {
383 session := "perf-" + safeName(s.ID) + fmt.Sprintf("-%03d", m.Run)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 switch m.Role {388 switch m.Role {
386 case "daemon":389 case "daemon":
387 return roleCommand{Args: []string{"--daemon"}}390 return roleCommand{Args: []string{"--daemon"}}
@@ -469,7 +472,7 @@ func workloadResize(s scenario, sequence uint64) (uint16, uint16, bool) {
469 // by cliProcess; it is not a synthetic timestamp.472 // by cliProcess; it is not a synthetic timestamp.
470 func workloadInput(s scenario, run int, phase string) []byte {473 func workloadInput(s scenario, run int, phase string) []byte {
471 marker := fmt.Sprintf("__VEV_HARNESS_%s_r%d_%s__", safeName(s.ID), run, safeName(phase))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 markerSeparator := "; "476 markerSeparator := "; "
474 switch s.Workload {477 switch s.Workload {
475 case "active_output":478 case "active_output":
@@ -477,37 +480,25 @@ func workloadInput(s scenario, run int, phase string) []byte {
477 if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") {480 if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") {
478 cell = '0' + byte(sequence%2)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 case "all_output":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 case "inactive_output":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 case "interactive_flood":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 case "resize_sweep":490 case "resize_sweep":
488- cols, _ := resizeWorkloadGeometry(s, phase)491+ body = fmt.Sprintf("printf 'vev perf resize-sweep %s\\n'", s.ID)
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 case "copy_search":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 case "snapshot_output_resize":494 case "snapshot_output_resize":
494- cols, _ := resizeWorkloadGeometry(s, phase)495+ body = fmt.Sprintf(`printf '\033[2J\033[Hvev perf snapshot-output-resize %s\n'`, s.ID)
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 = " && "
497 case "attach_restore_tab_switch":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 if phase == "warmup" {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 markerSeparator = " && "501 markerSeparator = " && "
503 }502 }
504- return []byte(fmt.Sprintf(body+markerSeparator+"printf '%s\\n'\n", s.ID, marker))503+ return []byte(body + markerSeparator + fmt.Sprintf("printf '%s\\n'\n", 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
513 }504 }
M cmd/vev-perf-harness/runner_test.go +36 -8
@@ -253,8 +253,8 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
253 s := scenario{ID: "route", Transport: tc.transport.ID}253 s := scenario{ID: "route", Transport: tc.transport.ID}
254 client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport)254 client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport)
255 peer := routeRoleArgs(s, processMapping{Role: tc.peer, Run: 2}, tc.transport)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"}) {256+ if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) {
257- t.Fatalf("client did not use public remote attach: %q", client.Args)257+ t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args)
258 }258 }
259 if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {259 if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {
260 t.Fatalf("ssh peer command=%q", peer.Args)260 t.Fatalf("ssh peer command=%q", peer.Args)
@@ -321,7 +321,7 @@ func TestWorkloadInputShapes(t *testing.T) {
321 `printf '\033[38;2;220;80;40mgreen'`,321 `printf '\033[38;2;220;80;40mgreen'`,
322 `printf '\033[38;2;80;220;120ms'`,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 {"snapshot_output_resize", []string{`\033[2J`, "vev perf snapshot-output-resize"}},325 {"snapshot_output_resize", []string{`\033[2J`, "vev perf snapshot-output-resize"}},
326 {"attach_restore_tab_switch", []string{`"$VEV_PERF_BIN" cmd next-tab`, `"$VEV_PERF_BIN" cmd previous-tab`}},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 t.Fatalf("inactive output truecolor runs = %d, want 3: %q", count, inactive)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 warmup := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "warmup"))369 warmup := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "warmup"))
362 if count := strings.Count(warmup, `"$VEV_PERF_BIN" cmd new-tab`); count != 1 {370 if count := strings.Count(warmup, `"$VEV_PERF_BIN" cmd new-tab`); count != 1 {
363 t.Fatalf("attach warmup new-tab commands = %d, want 1: %q", count, warmup)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 func TestWorkloadResize(t *testing.T) {404 func TestWorkloadResize(t *testing.T) {
377 for _, tt := range []struct {405 for _, tt := range []struct {
378 workload string406 workload string
@@ -457,7 +485,7 @@ func (l resizeOrderingLauncher) Launch(m processMapping, _ roleCommand) (launche
457 return &fakeProcess{}, nil485 return &fakeProcess{}, nil
458 }486 }
459 487
460-func TestResizeBoundaryPrecedesResizeAndMarkerWaitsForShellGeometry(t *testing.T) {488+func TestResizeBoundaryPrecedesWorkloadWriteAndMarker(t *testing.T) {
461 dir := t.TempDir()489 dir := t.TempDir()
462 order := []string{}490 order := []string{}
463 client := &resizeOrderingProcess{order: &order}491 client := &resizeOrderingProcess{order: &order}
@@ -476,13 +504,13 @@ func TestResizeBoundaryPrecedesResizeAndMarkerWaitsForShellGeometry(t *testing.T
476 t.Fatalf("first resize event order = %q, want %q", order[:len(wantOrder)], wantOrder)504 t.Fatalf("first resize event order = %q, want %q", order[:len(wantOrder)], wantOrder)
477 }505 }
478 first := string(client.inputs[0])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 if !strings.Contains(first, want) {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"`) {512+ if strings.Contains(first, "while [") || strings.Contains(first, "%!") {
485- t.Fatalf("resize input incorrectly gates on client rows that shell chrome does not expose: %q", 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 `VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every31 `VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every
32 launched role, identifying its exclusive trace path and process/scenario/run32 launched role, identifying its exclusive trace path and process/scenario/run
33 mapping. Components in one OS33 mapping. Components in one OS
34-process share that observer. The harness clock is a separate monotonic domain34+process share that observer. A spawned daemon is deliberately stripped of
35-and is the sole clock for input-injection → terminal-bytes-successfully-flushed35+these variables so it cannot append to a role's trace. The harness clock is a
36-end-to-end samples. Cross-process ticks are never ordered or subtracted;36+separate monotonic domain and is the sole clock for input-injection → terminal-
37-records correlate only by scenario/run/sequence/request/epoch IDs.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 The named process-local spans are capture, compose, diff, queue wait,43 The named process-local spans are capture, compose, diff, queue wait,
40 ACK-blocked interval, emit, adapter send, and adapter receive. Their start/end44 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 raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments,46 raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments,
43 retransmits, pending, ACK RTT) remain separate from duration calculations.47 retransmits, pending, ACK RTT) remain separate from duration calculations.
44 48
45-### Proxy ANSI baseline49+### Historical proxy ANSI baseline
46 50
47-The current proxy path transports ANSI and applies it through the local proxy51+Before the structured path, proxied state-bearing screen updates transported ANSI
48-VT parser. Build the fixed baseline binary and harness, then verify the harness52+and were applied through the local proxy VT parser. In the candidate, proxied
49-and parser fixtures: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 ```sh60 ```sh
52-go build -o /tmp/vev-proxy-baseline .61+GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev /tmp/vev-perf-baseline-1f2c802-clone
53-go build -o /tmp/vev-perf-harness ./cmd/vev-perf-harness62+GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-baseline-1f2c802-clone checkout --detach 1f2c802d0707785cab979134db6a95645250a82c
54-go test ./cmd/vev-perf-harness -count=163+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
55-go test ./internal/usecase/daemon -run '^TestProxyANSIBenchmarkFixtures$' -count=164+GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-candidate-2b48abf9-clone checkout --detach 2b48abf9b481c3dbdc371ba13c15cf06051d9df0
56-go test ./internal/usecase/daemon -run '^$' \65+(cd /tmp/vev-perf-baseline-1f2c802-clone && go build -o /tmp/vev-proxy-baseline-1f2c802 .)
57- -bench '^BenchmarkProxyANSIApply$' -benchmem -count=1066+(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 on75+The baseline benchmark below is a measured median of ten repetitions on
61-Linux/amd64 with an AMD Ryzen 9 7900X3D. The fixed76+Linux/amd64 with an AMD Ryzen 9 7900X3D, using Go 1.26.5 and commit
62-`/tmp/vev-proxy-baseline` binary was built from exact Phase 1 commit77+`1f2c802d0707785cab979134db6a95645250a82c`:
63-`1f2c802d0707785cab979134db6a95645250a82c`. Runtime production code remains78+
64-based on `919bcd18c428ef90abfda6c2381ba1cfbb06b0dd` because Phase 1 changes79+```sh
65-only benchmarks, harness code, tests, and documentation. The benchmark command80+cd /tmp/vev-perf-baseline-clone
66-above ran with Go 1.26.5.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 | ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op |86 | ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op |
69 | --- | ---: | ---: | ---: | ---: |87 | --- | ---: | ---: | ---: | ---: |
70-| absolute-position one-cell | 91.06 | 16 | 1 | 9 |88+| absolute-position one-cell | 93.31 | 16 | 1 | 9 |
71-| 120-column full line | 4,659.5 | 129 | 1 | 126 |89+| 120-column full line | 4,777 | 130 | 1 | 126 |
72-| fragmented truecolor styled line | 5,861 | 450 | 1 | 419 |90+| fragmented truecolor styled line | 6,119.5 | 450 | 1 | 419 |
73-| full-width 40-row scroll | 233,271.5 | 5,472 | 1 | 4,880 |91+| full-width 40-row scroll | 238,709 | 5,477.5 | 1 | 4,880 |
74 92
75 Keep the ordinary local ANSI renderer as a separate guardrail:93 Keep the ordinary local ANSI renderer as a separate guardrail:
76 94
77 ```sh95 ```sh
78-go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=1096+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 ### Public-CLI transport runs165 ### Public-CLI transport runs
82 166
83-Use a 10-second warmup, a 30-second measured interval, and ten independent167+The adoption gate requires a 10-second warmup, a 30-second measured interval,
84-repetitions. This representative matrix covers one-cell active output,168+and ten independent repetitions for each of seven workloads × five transports
85-120-column full-line output, fragmented truecolor-styled output, sustained169+(35 scenarios), for both exact baseline and candidate binaries. This exact
86-interactive flood, resize sweep, snapshot/output/resize, and attach/tab-switch170+command template was executed for every scenario (the two 35-scenario sets
87-across local, SSH stdio, UDP baseline, UDP 25 ms, and UDP 1% loss (35171+were run separately to avoid oversubscribing the host):
88-scenarios):
89 172
90 ```sh173 ```sh
91-rm -rf /tmp/vev-proxy-selected174+for bin in baseline candidate; do
92-mkdir -m 700 /tmp/vev-proxy-selected175+ case "$bin" in
93-for workload in \176+ baseline) vev_bin=/tmp/vev-proxy-baseline-1f2c802 ;;
94- active_output all_output inactive_output interactive_flood resize_sweep \177+ candidate) vev_bin=/tmp/vev-proxy-candidate-2b48abf9 ;;
95- snapshot_output_resize attach_restore_tab_switch178+ esac
96-do179+ for workload in active_output all_output inactive_output interactive_flood resize_sweep \
97- for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct180+ snapshot_output_resize attach_restore_tab_switch; do
98- do181+ for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct; do
99- scenario="1x4-${workload}-${transport}"182+ scenario="1x4-${workload}-${transport}"
100- /tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \183+ /tmp/vev-perf-harness-d479fe9 \
101- --manifest testdata/perf/manifest.json --scenario "$scenario" \184+ --vev-bin "$vev_bin" \
102- --out "/tmp/vev-proxy-selected/$scenario" \185+ --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
103- --warmup 10s --duration 30s --repetitions 10186+ --scenario "$scenario" \
187+ --out "/tmp/vev-perf-results/public-final-${bin}-matrix/$scenario" \
188+ --warmup 10s --duration 30s --repetitions 10
189+ done
104 done190 done
105 done191 done
106 ```192 ```
107 193
108-`all_output` and `inactive_output` provide full-line and styled payload shapes;194+The measured outputs are retained only under `/tmp/vev-perf-results/`. Each
109-these runs do not claim to activate every pane in their manifest topologies.195+binary completed 13/35 scenarios with ten runs; 22 scenarios stopped before a
110-Run the complete canonical matrix only when full-matrix evidence is required: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 ```sh235 ```sh
113-rm -rf /tmp/vev-proxy-matrix236+rm -rf /tmp/vev-perf-results/public-full-baseline
114-/tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \237+/tmp/vev-perf-harness-d479fe9 --vev-bin /tmp/vev-proxy-baseline-1f2c802 \
115- --manifest testdata/perf/manifest.json --out /tmp/vev-proxy-matrix \238+ --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
239+ --out /tmp/vev-perf-results/public-full-baseline \
116 --warmup 10s --duration 30s --repetitions 10240 --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 `summary.json`.248 `summary.json`.
125 249
126 `summary.json` records the harness source as `git_sha`, the measured vev binary250 `summary.json` records the harness source as `git_sha`, the measured vev binary
127-source as `vev_git_sha`, and the measurement parameters;251+source as `vev_git_sha`, and the measurement parameters. `runs.json`, run
128-`runs.json`, run manifests, and JSONL identify scenario, run, process, component,252+manifests, and JSONL identify scenario, run, process, component, and correlation
129-and correlation IDs. Preserve those files with the benchmark output and record253+IDs. Preserve those files with the benchmark output and record
130-`go version -m /tmp/vev-proxy-baseline`; do not commit result directories. CPU254+`go version -m /tmp/vev-proxy-baseline-1f2c802`,
131-and allocation evidence comes from Go microbenchmarks and process-local span255+`go version -m /tmp/vev-proxy-candidate-2b48abf9`, and
132-durations. End-to-end latency, terminal flush, and byte evidence comes from the256+`go version -m /tmp/vev-perf-harness-d479fe9`; do not commit result directories.
133-public-CLI harness. Reset, snapshot, and span counters for structured output are257+CPU and allocation evidence comes from Go microbenchmarks and process-local
134-candidate-only until that output exists.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 Reject results with missing input/flush boundaries, unmatched IDs, bad262 Reject results with missing input/flush boundaries, unmatched IDs, bad
137 correlation, nonmonotonic same-process sequence, negative same-domain spans,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 const (23 const (
24+ maxFrameLen = 16 << 20
24 frameHeaderLen = 425 frameHeaderLen = 4
25 sshCloseTimeout = 3 * time.Second26 sshCloseTimeout = 3 * time.Second
26 )27 )
@@ -77,7 +78,7 @@ type transport struct {
77 func (t *transport) Send(f ports.Frame) error {78 func (t *transport) Send(f ports.Frame) error {
78 end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))79 end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
79 n := 1 + len(f.Payload)80 n := 1 + len(f.Payload)
80- if n > ports.MaxFrameLen {81+ if n > maxFrameLen {
81 end(false)82 end(false)
82 return ErrFrameTooLarge83 return ErrFrameTooLarge
83 }84 }
@@ -108,7 +109,7 @@ func (t *transport) Recv() (ports.Frame, error) {
108 end(false)109 end(false)
109 return ports.Frame{}, ErrZeroLengthFrame110 return ports.Frame{}, ErrZeroLengthFrame
110 }111 }
111- if n > ports.MaxFrameLen {112+ if n > maxFrameLen {
112 end(false)113 end(false)
113 return ports.Frame{}, ErrFrameTooLarge114 return ports.Frame{}, ErrFrameTooLarge
114 }115 }
M internal/adapters/sshstdio/transport_test.go +0 -35
@@ -3,7 +3,6 @@ package sshstdio
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6- "encoding/binary"
7 "errors"6 "errors"
8 "io"7 "io"
9 "log/slog"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 func TestDialContextCanceledBeforeStart(t *testing.T) {167 func TestDialContextCanceledBeforeStart(t *testing.T) {
203 ctx, cancel := context.WithCancel(context.Background())168 ctx, cancel := context.WithCancel(context.Background())
204 cancel()169 cancel()
M internal/app/spawn.go +16 -0
@@ -9,6 +9,7 @@ import (
9 "os"9 "os"
10 "os/exec"10 "os/exec"
11 "path/filepath"11 "path/filepath"
12+ "strings"
12 "syscall"13 "syscall"
13 "time"14 "time"
14 15
@@ -269,6 +270,7 @@ func realSpawn() error {
269 270
270 var stderr bytes.Buffer271 var stderr bytes.Buffer
271 cmd := exec.Command(exePath, "--daemon-launcher")272 cmd := exec.Command(exePath, "--daemon-launcher")
273+ cmd.Env = withoutPerformanceTraceEnv(os.Environ())
272 cmd.Dir = platform.DirOrHome("")274 cmd.Dir = platform.DirOrHome("")
273 cmd.Stdin = devNull275 cmd.Stdin = devNull
274 cmd.Stdout = devNull276 cmd.Stdout = devNull
@@ -283,6 +285,19 @@ func realSpawn() error {
283 return nil285 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 // runDaemonLauncher is the intermediate half of the double-fork. It starts301 // runDaemonLauncher is the intermediate half of the double-fork. It starts
287 // the long-lived daemon in a new session and exits immediately; realSpawn302 // the long-lived daemon in a new session and exits immediately; realSpawn
288 // waits for this exit so the daemon is no longer descended from the client.303 // waits for this exit so the daemon is no longer descended from the client.
@@ -299,6 +314,7 @@ func runDaemonLauncher() error {
299 defer func() { _ = devNull.Close() }()314 defer func() { _ = devNull.Close() }()
300 315
301 cmd := exec.Command(exePath, "--daemon")316 cmd := exec.Command(exePath, "--daemon")
317+ cmd.Env = withoutPerformanceTraceEnv(os.Environ())
302 cmd.Dir = platform.DirOrHome("")318 cmd.Dir = platform.DirOrHome("")
303 cmd.Stdin = devNull319 cmd.Stdin = devNull
304 cmd.Stdout = devNull320 cmd.Stdout = devNull
M internal/app/spawn_test.go +17 -1
@@ -23,6 +23,7 @@ const (
23 spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"23 spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
24 spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"24 spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
25 spawnTestReleaseFileEnv = "VEV_SPAWN_TEST_RELEASE_FILE"25 spawnTestReleaseFileEnv = "VEV_SPAWN_TEST_RELEASE_FILE"
26+ spawnTestTraceFileEnv = "VEV_SPAWN_TEST_TRACE_FILE"
26 )27 )
27 28
28 func TestMain(m *testing.M) {29 func TestMain(m *testing.M) {
@@ -55,6 +56,11 @@ func TestMain(m *testing.M) {
55 if path == "" {56 if path == "" {
56 os.Exit(2)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 if err := writeProcessRecord(path); err != nil {64 if err := writeProcessRecord(path); err != nil {
59 os.Exit(2)65 os.Exit(2)
60 }66 }
@@ -66,14 +72,17 @@ func TestMain(m *testing.M) {
66 os.Exit(m.Run())72 os.Exit(m.Run())
67 }73 }
68 74
69-func TestRealSpawnWaitsForLauncherExit(t *testing.T) {75+func TestRealSpawnWaitsForLauncherExitAndDoesNotPropagateTrace(t *testing.T) {
70 dir := t.TempDir()76 dir := t.TempDir()
71 childFile := filepath.Join(dir, "daemon.pid")77 childFile := filepath.Join(dir, "daemon.pid")
72 launcherFile := filepath.Join(dir, "launcher.pid")78 launcherFile := filepath.Join(dir, "launcher.pid")
73 releaseFile := filepath.Join(dir, "release-launcher")79 releaseFile := filepath.Join(dir, "release-launcher")
80+ traceFile := filepath.Join(dir, "daemon.trace-env")
74 t.Setenv(spawnTestChildFileEnv, childFile)81 t.Setenv(spawnTestChildFileEnv, childFile)
75 t.Setenv(spawnTestLauncherFileEnv, launcherFile)82 t.Setenv(spawnTestLauncherFileEnv, launcherFile)
76 t.Setenv(spawnTestReleaseFileEnv, releaseFile)83 t.Setenv(spawnTestReleaseFileEnv, releaseFile)
84+ t.Setenv(spawnTestTraceFileEnv, traceFile)
85+ t.Setenv("VEV_PERF_TRACE", "parent-trace.jsonl")
77 86
78 // Register cleanup before starting either subprocess. The release marker87 // Register cleanup before starting either subprocess. The release marker
79 // unblocks a launcher even when setup or an assertion fails, and PID files88 // unblocks a launcher even when setup or an assertion fails, and PID files
@@ -122,6 +131,13 @@ func TestRealSpawnWaitsForLauncherExit(t *testing.T) {
122 if err := syscall.Kill(daemonPID, 0); err != nil {131 if err := syscall.Kill(daemonPID, 0); err != nil {
123 t.Fatalf("daemon exited with its launcher: %v", err)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 func waitForProcessRecord(t *testing.T, path string) int {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 type RuntimeMarkKind string14 type RuntimeMarkKind string
15 15
16 const (16 const (
17- RuntimeInputInjected RuntimeMarkKind = "input_injected"17+ RuntimeInputInjected RuntimeMarkKind = "input_injected"
18- RuntimeResizeRequested RuntimeMarkKind = "resize_requested"18+ RuntimeResizeRequested RuntimeMarkKind = "resize_requested"
19- RuntimeResizeCommitted RuntimeMarkKind = "resize_committed"19+ RuntimeResizeCommitted RuntimeMarkKind = "resize_committed"
20- RuntimeCaptureStart RuntimeMarkKind = "capture_start"20+ RuntimeCaptureStart RuntimeMarkKind = "capture_start"
21- RuntimeCaptureEnd RuntimeMarkKind = "capture_end"21+ RuntimeCaptureEnd RuntimeMarkKind = "capture_end"
22- RuntimeComposeStart RuntimeMarkKind = "compose_start"22+ RuntimeComposeStart RuntimeMarkKind = "compose_start"
23- RuntimeComposeEnd RuntimeMarkKind = "compose_end"23+ RuntimeComposeEnd RuntimeMarkKind = "compose_end"
24- RuntimeDiffStart RuntimeMarkKind = "diff_start"24+ RuntimeDiffStart RuntimeMarkKind = "diff_start"
25- RuntimeDiffEnd RuntimeMarkKind = "diff_end"25+ RuntimeDiffEnd RuntimeMarkKind = "diff_end"
26- RuntimeQueueEnqueued RuntimeMarkKind = "queue_enqueued"26+ RuntimeQueueEnqueued RuntimeMarkKind = "queue_enqueued"
27- RuntimeQueueDequeued RuntimeMarkKind = "queue_dequeued"27+ RuntimeQueueDequeued RuntimeMarkKind = "queue_dequeued"
28- RuntimeACKBlockedStart RuntimeMarkKind = "ack_blocked_start"28+ RuntimeACKBlockedStart RuntimeMarkKind = "ack_blocked_start"
29- RuntimeACKBlockedEnd RuntimeMarkKind = "ack_blocked_end"29+ RuntimeACKBlockedEnd RuntimeMarkKind = "ack_blocked_end"
30- RuntimeEmitStart RuntimeMarkKind = "emit_start"30+ RuntimeEmitStart RuntimeMarkKind = "emit_start"
31- RuntimeEmitEnd RuntimeMarkKind = "emit_end"31+ RuntimeEmitEnd RuntimeMarkKind = "emit_end"
32- RuntimeAdapterSendStart RuntimeMarkKind = "adapter_send_start"32+ RuntimeAdapterSendStart RuntimeMarkKind = "adapter_send_start"
33- RuntimeAdapterSendEnd RuntimeMarkKind = "adapter_send_end"33+ RuntimeAdapterSendEnd RuntimeMarkKind = "adapter_send_end"
34- RuntimeAdapterReceiveStart RuntimeMarkKind = "adapter_receive_start"34+ RuntimeAdapterReceiveStart RuntimeMarkKind = "adapter_receive_start"
35- RuntimeAdapterReceiveEnd RuntimeMarkKind = "adapter_receive_end"35+ RuntimeAdapterReceiveEnd RuntimeMarkKind = "adapter_receive_end"
36- RuntimeTerminalFlushed RuntimeMarkKind = "terminal_flushed"36+ RuntimeTerminalFlushed RuntimeMarkKind = "terminal_flushed"
37- RuntimeTransportDiagnostic RuntimeMarkKind = "transport_diagnostic"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 // RuntimeMark is a process-local observation record. Tick is intentionally43 // RuntimeMark is a process-local observation record. Tick is intentionally
@@ -221,7 +224,8 @@ func RuntimeMarkValid(mark RuntimeMark) bool {
221 RuntimeDiffStart, RuntimeDiffEnd, RuntimeQueueEnqueued, RuntimeQueueDequeued,224 RuntimeDiffStart, RuntimeDiffEnd, RuntimeQueueEnqueued, RuntimeQueueDequeued,
222 RuntimeACKBlockedStart, RuntimeACKBlockedEnd, RuntimeEmitStart, RuntimeEmitEnd,225 RuntimeACKBlockedStart, RuntimeACKBlockedEnd, RuntimeEmitStart, RuntimeEmitEnd,
223 RuntimeAdapterSendStart, RuntimeAdapterSendEnd, RuntimeAdapterReceiveStart,226 RuntimeAdapterSendStart, RuntimeAdapterSendEnd, RuntimeAdapterReceiveStart,
224- RuntimeAdapterReceiveEnd, RuntimeTerminalFlushed, RuntimeTransportDiagnostic:227+ RuntimeAdapterReceiveEnd, RuntimeTerminalFlushed, RuntimeScreenSnapshot,
228+ RuntimeScreenDelta, RuntimeScreenResetRequested, RuntimeTransportDiagnostic:
225 return true229 return true
226 default:230 default:
227 return false231 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 func TestRuntimeObserverRequiredSpanKinds(t *testing.T) {133 func TestRuntimeObserverRequiredSpanKinds(t *testing.T) {
112 pairs := []struct {134 pairs := []struct {
113 start RuntimeMarkKind135 start RuntimeMarkKind
M internal/ports/wire.go +0 -753
@@ -10,7 +10,6 @@ import (
10 "encoding/binary"10 "encoding/binary"
11 "errors"11 "errors"
12 "math"12 "math"
13- "unicode/utf8"
14 13
15 "github.com/bnema/vev/internal/domain"14 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/pkg/renderer"15 "github.com/bnema/vev/pkg/renderer"
@@ -1076,755 +1075,3 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
1076 }1075 }
1077 return Sessions{Sessions: sessions}, nil1076 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 "github.com/bnema/vev/pkg/renderer"11 "github.com/bnema/vev/pkg/renderer"
12 )12 )
13 13
14-type screenGoldenFixture struct {14+func screenGoldenMessages() []struct {
15 name string15 name string
16 msg ScreenUpdate16 msg ScreenUpdate
17 hex string17 hex string
18-}18+} {
19-
20-func screenGoldenMessages() []screenGoldenFixture {
21 defaultCell := func(r rune) renderer.Cell {19 defaultCell := func(r rune) renderer.Cell {
22 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}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 name: "initial 1x1 snapshot",28 name: "initial 1x1 snapshot",
27 msg: ScreenUpdate{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 func TestScreenUpdateStateNumbers(t *testing.T) {83 func TestScreenUpdateStateNumbers(t *testing.T) {
91- snapshot := screenGoldenMessage("initial 1x1 snapshot").msg84+ snapshot := screenGoldenMessages()[0].msg
92- delta := screenGoldenMessage("cursor-only delta").msg85+ delta := screenGoldenMessages()[3].msg
93 valid := []ScreenUpdate{86 valid := []ScreenUpdate{
94 snapshot,87 snapshot,
95 func() ScreenUpdate {88 func() ScreenUpdate {
@@ -212,7 +205,7 @@ func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {
212 }205 }
213 206
214 func TestScreenUpdateHostileFields(t *testing.T) {207 func TestScreenUpdateHostileFields(t *testing.T) {
215- golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)208+ golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex)
216 tests := []struct {209 tests := []struct {
217 name string210 name string
218 mut func([]byte)211 mut func([]byte)
@@ -225,7 +218,7 @@ func TestScreenUpdateHostileFields(t *testing.T) {
225 {"style ID mismatch", func(b []byte) { b[66], b[67] = 0, 1 }},218 {"style ID mismatch", func(b []byte) { b[66], b[67] = 0, 1 }},
226 {"run cell mismatch", func(b []byte) { b[68], b[69] = 0, 2 }},219 {"run cell mismatch", func(b []byte) { b[68], b[69] = 0, 2 }},
227 {"invalid Unicode", func(b []byte) { b[len(b)-1] = 0x81 }},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 for _, tt := range tests {223 for _, tt := range tests {
231 t.Run(tt.name, func(t *testing.T) {224 t.Run(tt.name, func(t *testing.T) {
@@ -255,13 +248,9 @@ func TestScreenUpdateHostileFields(t *testing.T) {
255 func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {248 func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {
256 cell := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}249 cell := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
257 base := ScreenUpdate{250 base := ScreenUpdate{
258- BaseStateNum: 1, NewStateNum: 2,
259 Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 2}, Cursor: ScreenCursor{Visible: true},251 Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 2}, Cursor: ScreenCursor{Visible: true},
260 Spans: []ScreenSpan{{Y: 0, X: 0, Cells: []renderer.Cell{cell}}, {Y: 1, X: 0, Cells: []renderer.Cell{cell}}},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 tests := []struct {254 tests := []struct {
266 name string255 name string
267 edit func(*ScreenUpdate)256 edit func(*ScreenUpdate)
@@ -288,19 +277,19 @@ func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {
288 if _, err := MarshalScreenUpdate(tooWide); !errors.Is(err, ErrInvalidScreenUpdate) {277 if _, err := MarshalScreenUpdate(tooWide); !errors.Is(err, ErrInvalidScreenUpdate) {
289 t.Fatalf("out-of-bounds span err = %v", err)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 t.Fatalf("invalid rune err = %v", err)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 t.Fatalf("incomplete snapshot err = %v", err)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 t.Fatalf("cursor style err = %v", err)287 t.Fatalf("cursor style err = %v", err)
299 }288 }
300 }289 }
301 290
302 func TestScreenUpdateOwnership(t *testing.T) {291 func TestScreenUpdateOwnership(t *testing.T) {
303- golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)292+ golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex)
304 decoded, err := UnmarshalScreenUpdate(golden)293 decoded, err := UnmarshalScreenUpdate(golden)
305 if err != nil {294 if err != nil {
306 t.Fatal(err)295 t.Fatal(err)
@@ -311,7 +300,7 @@ func TestScreenUpdateOwnership(t *testing.T) {
311 if err != nil {300 if err != nil {
312 t.Fatal(err)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 t.Fatal("decoded screen retained transport payload")304 t.Fatal("decoded screen retained transport payload")
316 }305 }
317 }306 }
@@ -320,8 +309,8 @@ func TestScreenUpdateLimits(t *testing.T) {
320 if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) {309 if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) {
321 t.Fatalf("oversize err = %v", err)310 t.Fatalf("oversize err = %v", err)
322 }311 }
323- tooMany := screenGoldenMessage("initial 1x1 snapshot").msg312+ tooMany := screenGoldenMessages()[0].msg
324- tooMany.Spans = make([]ScreenSpan, MaxScreenSpans+1)313+ tooMany.Spans = make([]ScreenSpan, screenSpanLimit+1)
325 if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) {314 if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) {
326 t.Fatalf("span limit err = %v", err)315 t.Fatalf("span limit err = %v", err)
327 }316 }
@@ -400,9 +389,6 @@ func FuzzScreenUpdate(f *testing.F) {
400 if err != nil {389 if err != nil {
401 t.Fatal(err)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 roundtrip, err := UnmarshalScreenUpdate(encoded)392 roundtrip, err := UnmarshalScreenUpdate(encoded)
407 if err != nil {393 if err != nil {
408 t.Fatal(err)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 assertAllPrefixesFail(t, got[:24], UnmarshalOutput)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 func TestAckGoldenAndRoundTrip(t *testing.T) {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 // still owns the activation barrier (and therefore sendMu).195 // still owns the activation barrier (and therefore sendMu).
196 func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) {196 func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) {
197 ac := result.published.ac197 ac := result.published.ac
198- if ac.output != nil {198+ ac.rebaseOutput()
199- ac.output.rebase()
200- }
201 ac.captureFrames = nil199 ac.captureFrames = nil
202 if sess, ok := localSession(result.published.sess); ok {200 if sess, ok := localSession(result.published.sess); ok {
203 d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false)201 d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false)
M internal/usecase/daemon/client.go +55 -7
@@ -36,9 +36,12 @@ type attachedClient struct {
36 tr ports.Transport36 tr ports.Transport
37 transportIncarnation uint6437 transportIncarnation uint64
38 output *outputStateStream38 output *outputStateStream
39- overlays *overlayRuntime39+ // screenOutput is allocated only for proxied attachments and shares output's
40- overlayOnce sync.Once40+ // state-number/ACK window. Both streams are serialized by sendMu.
41- clientID [16]byte41+ screenOutput *structuredOutputStream
42+ overlays *overlayRuntime
43+ overlayOnce sync.Once
44+ clientID [16]byte
42 // roleGeneration is the wire-facing capability generation. roleEffects is45 // roleGeneration is the wire-facing capability generation. roleEffects is
43 // the linearization gate for every role-bound observable operation. A role46 // the linearization gate for every role-bound observable operation. A role
44 // transition freezes and drains this gate before changing either generation47 // transition freezes and drains this gate before changing either generation
@@ -62,6 +65,9 @@ type attachedClient struct {
62 pipelineCache composeCacheInput65 pipelineCache composeCacheInput
63 pipelineScratch composeCacheInput66 pipelineScratch composeCacheInput
64 renderScratch renderCaptureScratch // only touched while sendMu is held67 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 // sessionMeta is the last authoritative metadata snapshot successfully sent71 // sessionMeta is the last authoritative metadata snapshot successfully sent
66 // on a proxied attachment. It is only read or written while sendMu is held.72 // on a proxied attachment. It is only read or written while sendMu is held.
67 sessionMeta ports.SessionMeta73 sessionMeta ports.SessionMeta
@@ -291,6 +297,46 @@ func (ac *attachedClient) ackOutputState(state uint64) {
291 ac.output.ack(state)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 var errTransportReplaced = errors.New("client transport was replaced")340 var errTransportReplaced = errors.New("client transport was replaced")
295 341
296 // sendExpectedTransport writes only when expected is still the attachment's342 // 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 if opts.resumeCapable {565 if opts.resumeCapable {
520 resumeToken = d.nextResumeTokenLocked()566 resumeToken = d.nextResumeTokenLocked()
521 }567 }
568+ output := newOutputStateStream(opts.maxOutputInFlight)
522 ac := &attachedClient{569 ac := &attachedClient{
523 tr: tr,570 tr: tr,
524- output: newOutputStateStream(opts.maxOutputInFlight),571+ output: output,
525 size: sz,572 size: sz,
526 clientID: opts.clientID,573 clientID: opts.clientID,
527 resumeCapable: opts.resumeCapable,574 resumeCapable: opts.resumeCapable,
528 resumeToken: resumeToken,575 resumeToken: resumeToken,
529 proxied: opts.proxied,576 proxied: opts.proxied,
530 }577 }
578+ if opts.proxied {
579+ ac.screenOutput = newStructuredOutputStream(output)
580+ }
531 ac.initOverlays()581 ac.initOverlays()
532 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)582 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
533 return ac583 return ac
@@ -642,9 +692,7 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
642 if token.ac.renderStages.handoffRebase != nil {692 if token.ac.renderStages.handoffRebase != nil {
643 token.ac.renderStages.handoffRebase()693 token.ac.renderStages.handoffRebase()
644 }694 }
645- if token.ac.output != nil {695+ token.ac.rebaseOutput()
646- token.ac.output.rebase()
647- }
648 // Captures belong to the old session even when pane-local IDs happen696 // Captures belong to the old session even when pane-local IDs happen
649 // to be reused by the destination.697 // to be reused by the destination.
650 token.ac.captureFrames = nil698 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 ac.sendMu.Unlock()265 ac.sendMu.Unlock()
266 return false266 return false
267 }267 }
268- ac.output.rebase()268+ ac.rebaseOutput()
269 ac.sendMu.Unlock()269 ac.sendMu.Unlock()
270 270
271 rc := token.sess.core().coordinator.Load()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 func (p *preparedOutput) commit() {86 func (p *preparedOutput) commit() {
87 p.draw.Commit()87 p.draw.Commit()
88- if p.reset {88+ p.stream.forceSnapshot = false
89- p.stream.forceSnapshot = false89+}
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 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {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 fillOutputStateRows(initial, []string{"abc"})41 fillOutputStateRows(initial, []string{"abc"})
42 first, err := stream.prepare(initial, nil, true)42 first, err := stream.prepare(initial, nil, true)
43 require.NoError(t, err)43 require.NoError(t, err)
44- var firstFrame ports.Frame44+ require.NoError(t, first.send(first.data, 0, func(ports.Frame) error { return nil }))
45- require.NoError(t, first.send(first.data, 0, func(frame ports.Frame) error {
46- firstFrame = frame
47- return nil
48- }))
49 require.Equal(t, uint64(1), stream.next)45 require.Equal(t, uint64(1), stream.next)
50 46
51 changed := initial.Clone()47 changed := initial.Clone()
@@ -57,11 +53,7 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
57 require.Empty(t, probe.Bytes(), "preparation must not advance the renderer shadow")53 require.Empty(t, probe.Bytes(), "preparation must not advance the renderer shadow")
58 54
59 sendErr := errors.New("send failed")55 sendErr := errors.New("send failed")
60- var failedFrame ports.Frame56+ require.ErrorIs(t, pending.send(pending.data, 0, func(ports.Frame) error { return sendErr }), sendErr)
61- require.ErrorIs(t, pending.send(pending.data, 0, func(frame ports.Frame) error {
62- failedFrame = frame
63- return sendErr
64- }), sendErr)
65 require.Equal(t, uint64(1), stream.next, "failed send must not advance the state chain")57 require.Equal(t, uint64(1), stream.next, "failed send must not advance the state chain")
66 pending.commitNoSend()58 pending.commitNoSend()
67 probe, err = stream.renderer.Prepare(initial, nil, false)59 probe, err = stream.renderer.Prepare(initial, nil, false)
@@ -79,18 +71,6 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
79 require.NoError(t, err)71 require.NoError(t, err)
80 require.Zero(t, out.BaseStateNum, "retry after ambiguous send failure must be dependency-free")72 require.Zero(t, out.BaseStateNum, "retry after ambiguous send failure must be dependency-free")
81 require.Equal(t, uint64(2), out.NewStateNum, "state numbers remain monotonic across rebases")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 require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")74 require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")
95 require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error {75 require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error {
96 t.Fatal("completed output sent twice")76 t.Fatal("completed output sent twice")
@@ -102,29 +82,6 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
102 require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow")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 func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) {85 func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
129 stream := newOutputStateStream()86 stream := newOutputStateStream()
130 frame := renderer.NewFrame(3, 1)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) {139+func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
183- prepared, err := stream.prepare(frame, damage, reset)140+ prepared, err := s.prepare(frame, damage, reset)
184 if err != nil {141 if err != nil {
185 return nil, err142 return nil, err
186 }143 }
@@ -190,21 +147,11 @@ func renderCommitted(stream *outputStateStream, frame renderer.Frame, damage []r
190 147
191 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {148 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
192 t.Helper()149 t.Helper()
193- prepared, err := stream.prepare(frame, damage, reset)150+ data, err := stream.render(frame, damage, reset)
194- if err != nil || len(prepared.data) == 0 {151+ if err != nil || len(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 {
205 return ports.Frame{}, false, err152 return ports.Frame{}, false, err
206 }153 }
207- return output, true, nil154+ return stream.frame(data, reset, echoAck), true, nil
208 }155 }
209 156
210 // A resize is a state reset, but it must not turn subsequent idle or damaged157 // 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 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"19 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
20 "github.com/bnema/vev/pkg/renderer"20 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
21 )22 )
22 23
23 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {24 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
@@ -214,9 +215,9 @@ func TestProxyANSIBenchmarkFixtures(t *testing.T) {
214 215
215 for _, fixture := range fixtures {216 for _, fixture := range fixtures {
216 t.Run(fixture.name, func(t *testing.T) {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 BaseStateNum: 0,221 BaseStateNum: 0,
221 NewStateNum: 1,222 NewStateNum: 1,
222 Data: fixture.data,223 Data: fixture.data,
@@ -225,22 +226,55 @@ func TestProxyANSIBenchmarkFixtures(t *testing.T) {
225 require.Equal(t, uint64(1), ack)226 require.Equal(t, uint64(1), ack)
226 require.False(t, reset)227 require.False(t, reset)
227 require.True(t, changed)228 require.True(t, changed)
228- proxy.mu.Lock()229+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, domain.Size{Cols: state.screen.Frame.Width, Rows: state.screen.Frame.Height})
229- require.Equal(t, domain.Size{Cols: 120, Rows: 40}, proxy.contentSize)
230- proxy.mu.Unlock()
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 t.Helper()246 t.Helper()
237- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "benchmark", Name: "ansi"}, domain.Size{Cols: 120, Rows: 40 + tabChromeRows})247+ return &proxyANSIApplyState{screen: vt.NewScreen(120, 40)}
238- require.NoError(t, err)248+}
239- proxy.mu.Lock()249+
240- contentSize := proxy.contentSize250+func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset, changed bool) {
241- proxy.mu.Unlock()251+ data := append([]byte(nil), out.Data...)
242- require.Equal(t, domain.Size{Cols: 120, Rows: 40}, contentSize)252+ s.mu.Lock()
243- return proxy253+ 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 type proxyANSIBenchmarkFixture struct {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 func TestPerformanceFixtureCounters(t *testing.T) {580 func TestPerformanceFixtureCounters(t *testing.T) {
276 fixture := newPerformanceFixture(t, performanceConfig{})581 fixture := newPerformanceFixture(t, performanceConfig{})
277 582
@@ -435,25 +740,24 @@ var daemonSnapshotTopologies = []daemonHistoryTopology{
435 {name: "1tab-4panes", tabs: 1, panes: 4},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 func BenchmarkProxyANSIApply(b *testing.B) {745 func BenchmarkProxyANSIApply(b *testing.B) {
439 for _, fixture := range proxyANSIBenchmarkFixtures() {746 for _, fixture := range proxyANSIBenchmarkFixtures() {
440 b.Run(fixture.name, func(b *testing.B) {747 b.Run(fixture.name, func(b *testing.B) {
441- proxy := newProxyANSIBenchmarkSession(b)748+ state := newProxyANSIApplyState(b)
442- generation := proxy.linkGeneration
443- state := uint64(0)
444 b.ReportAllocs()749 b.ReportAllocs()
445 b.SetBytes(int64(len(fixture.data)))750 b.SetBytes(int64(len(fixture.data)))
446 for b.Loop() {751 for b.Loop() {
447- next := state + 1752+ next := state.appliedState + 1
448- ack, reset, changed := proxy.applyOutputForGeneration(generation, ports.Output{753+ ack, reset, changed := state.apply(ports.Output{
449- BaseStateNum: state,754+ BaseStateNum: state.appliedState,
450 NewStateNum: next,755 NewStateNum: next,
451 Data: fixture.data,756 Data: fixture.data,
452 })757 })
453 if ack != next || reset || !changed {758 if ack != next || reset || !changed {
454 b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next)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 b.ReportMetric(float64(len(fixture.data)), "ansibytes/op")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 require.Equal(t, uint16(0), meta.Active)41 require.Equal(t, uint16(0), meta.Active)
42 require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs)42 require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs)
43 43
44- outputFrame := awaitFrame(t, sends, ports.MsgOutput)44+ screenFrame := awaitFrame(t, sends, ports.MsgScreenUpdate)
45- output, err := ports.UnmarshalOutput(outputFrame.Payload)45+ update, err := ports.UnmarshalScreenUpdate(screenFrame.Payload)
46 require.NoError(t, err)46 require.NoError(t, err)
47- require.Zero(t, output.BaseStateNum)47+ require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind)
48-48+ require.Zero(t, update.BaseStateNum)
49- client := vt.NewScreen(contentSize.Cols, contentSize.Rows)49+ require.Equal(t, contentSize, update.Size)
50- client.Write(output.Data)50+ require.Len(t, update.Spans, contentSize.Rows, "structured snapshots must carry every full row")
51- require.Equal(t, contentSize.Rows, client.Frame.Height)51+ for y, span := range update.Spans {
52- for _, row := range frameRows(client.Frame) {52+ require.Equal(t, uint16(y), span.Y)
53- require.NotContains(t, row, "remote-work", "proxied output must not contain remote session chrome")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 sess := firstSession(d)58 sess := firstSession(d)
@@ -102,6 +104,64 @@ func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
102 handlers.Wait()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 func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) {165 func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) {
106 paneFrame := renderer.NewFrame(8, 2)166 paneFrame := renderer.NewFrame(8, 2)
107 fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"})167 fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"})
@@ -131,6 +191,15 @@ func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) {
131 require.Equal(t, 1, ordinary.cursor.row)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 func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {203 func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {
135 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())204 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
136 ac.proxied = true205 ac.proxied = true
@@ -206,9 +275,10 @@ func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
206 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),275 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
207 }))276 }))
208 277
209- frame := awaitFrame(t, sends, ports.MsgOutput)278+ frame := awaitFrame(t, sends, ports.MsgScreenUpdate)
210- out, err := ports.UnmarshalOutput(frame.Payload)279+ out, err := ports.UnmarshalScreenUpdate(frame.Payload)
211 require.NoError(t, err)280 require.NoError(t, err)
281+ require.Equal(t, ports.ScreenUpdateSnapshot, out.Kind)
212 require.Zero(t, out.BaseStateNum)282 require.Zero(t, out.BaseStateNum)
213 ac.sendMu.Lock()283 ac.sendMu.Lock()
214 require.Equal(t, ac.output.maxOutstanding, ac.output.acked, "reset must retire the previously full output window")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 _ = awaitTestValue(t, clock.timers, "first command did not arm timeout")351 _ = awaitTestValue(t, clock.timers, "first command did not arm timeout")
352 352
353 generation := proxy.linkGeneration353 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 require.NoError(t, err)355 require.NoError(t, err)
356 require.Equal(t, proxyLinkResume, result)356 require.Equal(t, proxyLinkResume, result)
357 require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type)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 request := requireProxyCommandRequest(t, link)499 request := requireProxyCommandRequest(t, link)
500 _ = awaitTestValue(t, clock.timers, "command did not arm timeout")500 _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
501 oldGeneration := proxy.linkGeneration501 oldGeneration := proxy.linkGeneration
502- newGeneration, _ := proxy.installTransport(newProxyTestTransport())502+ newGeneration, _ := proxy.installTransport(newProxyTestTransport(), false)
503 require.Greater(t, newGeneration, oldGeneration)503 require.Greater(t, newGeneration, oldGeneration)
504 require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))504 require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))
505 _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))505 _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
@@ -602,6 +602,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
602 transport := newProxyTestTransport()602 transport := newProxyTestTransport()
603 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}603 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
604 transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}604 transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
605+ transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
605 factory := portsmocks.NewMockRemoteDialerFactory(t)606 factory := portsmocks.NewMockRemoteDialerFactory(t)
606 dialer := portsmocks.NewMockDialer(t)607 dialer := portsmocks.NewMockDialer(t)
607 var daemonLockAvailable, sourceLockAvailable bool608 var daemonLockAvailable, sourceLockAvailable bool
@@ -660,6 +661,7 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
660 transport := newProxyTestTransport()661 transport := newProxyTestTransport()
661 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}662 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
662 transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}663 transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
664+ transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
663 d.remoteDialerFactory = newProxyConstructionFactory(transport)665 d.remoteDialerFactory = newProxyConstructionFactory(transport)
664 d.remoteTransportMode = ports.RemoteTransportUDP666 d.remoteTransportMode = ports.RemoteTransportUDP
665 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{667 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -806,6 +808,7 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
806 remote := newProxyTestTransport()808 remote := newProxyTestTransport()
807 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}809 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
808 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}810 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
811+ remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
809 d.remoteDialerFactory = newProxyConstructionFactory(remote)812 d.remoteDialerFactory = newProxyConstructionFactory(remote)
810 d.remoteTransportMode = ports.RemoteTransportUDP813 d.remoteTransportMode = ports.RemoteTransportUDP
811 814
@@ -898,6 +901,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
898 remote := newProxyTestTransport()901 remote := newProxyTestTransport()
899 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}902 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
900 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}903 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
904+ remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
901 d.remoteDialerFactory = newProxyConstructionFactory(remote)905 d.remoteDialerFactory = newProxyConstructionFactory(remote)
902 d.remoteTransportMode = ports.RemoteTransportUDP906 d.remoteTransportMode = ports.RemoteTransportUDP
903 token := roleEffectForTest(t, local.attachmentToken(ac, client))907 token := roleEffectForTest(t, local.attachmentToken(ac, client))
@@ -936,6 +940,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
936 remote := newProxyTestTransport()940 remote := newProxyTestTransport()
937 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}941 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
938 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}942 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
943+ remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
939 d.remoteDialerFactory = newProxyConstructionFactory(remote)944 d.remoteDialerFactory = newProxyConstructionFactory(remote)
940 d.remoteTransportMode = ports.RemoteTransportUDP945 d.remoteTransportMode = ports.RemoteTransportUDP
941 946
@@ -992,6 +997,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
992 remote := newProxyTestTransport()997 remote := newProxyTestTransport()
993 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}998 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
994 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}999 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1000+ remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
995 d.remoteDialerFactory = newProxyConstructionFactory(remote)1001 d.remoteDialerFactory = newProxyConstructionFactory(remote)
996 d.remoteTransportMode = ports.RemoteTransportUDP1002 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 return d, proxy, transport146 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 func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {173 func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
150 clock := newProxyLifecycleClock()174 clock := newProxyLifecycleClock()
151 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())175 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
@@ -397,6 +421,7 @@ func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) {
397 _ = requireProxyHello(t, resumed)421 _ = requireProxyHello(t, resumed)
398 resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume|ports.CapabilityProxied)}422 resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume|ports.CapabilityProxied)}
399 resumed.recv <- proxyRecv{frame: proxyMeta(proxy.key.Name)}423 resumed.recv <- proxyRecv{frame: proxyMeta(proxy.key.Name)}
424+ resumed.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
400 require.Eventually(t, func() bool {425 require.Eventually(t, func() bool {
401 proxy.mu.Lock()426 proxy.mu.Lock()
402 defer proxy.mu.Unlock()427 defer proxy.mu.Unlock()
@@ -1160,6 +1185,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
1160 second := newProxyTestTransport()1185 second := newProxyTestTransport()
1161 second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityProxied)}1186 second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityProxied)}
1162 second.recv <- proxyRecv{frame: proxyMeta(key.Name)}1187 second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1188+ second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1163 factory := newProxyConstructionFactory(first, second)1189 factory := newProxyConstructionFactory(first, second)
1164 d := newProxyTestDaemon(t, factory, nil)1190 d := newProxyTestDaemon(t, factory, nil)
1165 1191
@@ -1186,6 +1212,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
1186 1212
1187 first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}1213 first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
1188 first.recv <- proxyRecv{frame: proxyMeta(key.Name)}1214 first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1215+ first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1189 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")1216 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1190 waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish")1217 waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish")
1191 require.NoError(t, leaderResult.err)1218 require.NoError(t, leaderResult.err)
@@ -1227,6 +1254,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
1227 1254
1228 first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}1255 first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
1229 first.recv <- proxyRecv{frame: proxyMeta(key.Name)}1256 first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1257+ first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1230 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")1258 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1231 require.NoError(t, leaderResult.err)1259 require.NoError(t, leaderResult.err)
1232 stopProxy(t, leaderResult.proxy)1260 stopProxy(t, leaderResult.proxy)
@@ -1408,7 +1436,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1408 d, p, clock := newProxyLifecycleFixture(t)1436 d, p, clock := newProxyLifecycleFixture(t)
1409 transport := newProxyTestTransport()1437 transport := newProxyTestTransport()
1410 t.Cleanup(func() { _ = transport.Close() })1438 t.Cleanup(func() { _ = transport.Close() })
1411- generation, _ := p.installTransport(transport)1439+ generation, _ := p.installTransport(transport, false)
1412 token, timer := armWarmTimer(t, d, p, clock)1440 token, timer := armWarmTimer(t, d, p, clock)
1413 1441
1414 require.True(t, d.markProxyReplaced(p, generation, transport))1442 require.True(t, d.markProxyReplaced(p, generation, transport))
@@ -1432,7 +1460,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1432 d, p, clock := newProxyLifecycleFixture(t)1460 d, p, clock := newProxyLifecycleFixture(t)
1433 transport := newProxyTestTransport()1461 transport := newProxyTestTransport()
1434 t.Cleanup(func() { _ = transport.Close() })1462 t.Cleanup(func() { _ = transport.Close() })
1435- generation, _ := p.installTransport(transport)1463+ generation, _ := p.installTransport(transport, false)
1436 1464
1437 require.True(t, d.markProxyReplaced(p, generation, transport))1465 require.True(t, d.markProxyReplaced(p, generation, transport))
1438 timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")1466 timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
@@ -1449,7 +1477,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1449 d, p, _ := newProxyLifecycleFixture(t)1477 d, p, _ := newProxyLifecycleFixture(t)
1450 transport := newProxyTestTransport()1478 transport := newProxyTestTransport()
1451 t.Cleanup(func() { _ = transport.Close() })1479 t.Cleanup(func() { _ = transport.Close() })
1452- generation, _ := p.installTransport(transport)1480+ generation, _ := p.installTransport(transport, false)
1453 1481
1454 require.False(t, d.markProxyReplaced(p, generation-1, transport))1482 require.False(t, d.markProxyReplaced(p, generation-1, transport))
1455 require.False(t, proxyExpired(p))1483 require.False(t, proxyExpired(p))
M internal/usecase/daemon/proxy_link.go +113 -24
@@ -7,6 +7,7 @@ import (
7 "io"7 "io"
8 "time"8 "time"
9 9
10+ "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"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 return err81 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 if previous != nil && previous != transport {96 if previous != nil && previous != transport {
85 _ = previous.Close()97 _ = previous.Close()
86 }98 }
@@ -91,15 +103,6 @@ func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent
91 _ = transport.Close()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 hello := ports.Hello{106 hello := ports.Hello{
104 Version: ports.ProtocolVersion,107 Version: ports.ProtocolVersion,
105 Intent: intent,108 Intent: intent,
@@ -114,13 +117,23 @@ func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent
114 return fmt.Errorf("proxy session: send hello: %w", err)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 if err != nil {121 if err != nil {
119 return err122 return err
120 }123 }
121 if err := ctx.Err(); err != nil {124 if err := ctx.Err(); err != nil {
122 return err125 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 p.mu.Lock()138 p.mu.Lock()
126 if p.linkGeneration != generation || p.transport != transport {139 if p.linkGeneration != generation || p.transport != transport {
@@ -143,25 +156,43 @@ func proxyOutputWindow(transport ports.Transport) uint8 {
143 return maxUnackedOutputStates156 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 frame, err := recvProxyHandshakeFrame(ctx, transport)160 frame, err := recvProxyHandshakeFrame(ctx, transport)
148 if err != nil {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 welcome, err := validateProxyWelcome(frame, sessionName)164 welcome, err := validateProxyWelcome(frame, sessionName)
152 if err != nil {165 if err != nil {
153- return ports.Welcome{}, ports.SessionMeta{}, err166+ return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, err
154 }167 }
155 168
156 frame, err = recvProxyHandshakeFrame(ctx, transport)169 frame, err = recvProxyHandshakeFrame(ctx, transport)
157 if err != nil {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 meta, err := validateProxyInitialMeta(frame, sessionName)173 meta, err := validateProxyInitialMeta(frame, sessionName)
161 if err != nil {174 if err != nil {
162- return ports.Welcome{}, ports.SessionMeta{}, err175+ 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, nil192+ 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 func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {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 // Link replacement participates in the same sender serialization as every256 // Link replacement participates in the same sender serialization as every
226 // frame. A request can therefore only be published wholly before the257 // frame. A request can therefore only be published wholly before the
227 // generation changes or wholly after it.258 // generation changes or wholly after it.
@@ -231,12 +262,18 @@ func (p *proxySession) installTransport(transport ports.Transport) (uint64, port
231 previous := p.transport262 previous := p.transport
232 p.linkGeneration++263 p.linkGeneration++
233 generation := p.linkGeneration264 generation := p.linkGeneration
234- // State numbers belong to one remote output stream. A replacement link265+ if resume {
235- // must prove its state with a base-zero paint before an incremental frame266+ // Resume keeps the applied remote state as the handshake floor, but the
236- // can advance this proxy.267+ // replacement must prove that state with a fresh base-zero snapshot.
237- p.resetOutputStateLocked()268+ p.screenReady = false
269+ p.resetRequested = false
270+ } else {
271+ p.resetOutputStateLocked()
272+ }
238 p.transport = transport273 p.transport = transport
239- p.linkState = ports.LinkStateConnected274+ // Authentication is not complete until the authoritative screen snapshot
275+ // has been applied and acknowledged.
276+ p.linkState = ports.LinkStateProbing
240 p.mu.Unlock()277 p.mu.Unlock()
241 p.failPendingCommandsLocked(errProxyCommandGeneration)278 p.failPendingCommandsLocked(errProxyCommandGeneration)
242 return generation, previous279 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 // handleLinkFrame returns proxyLinkResume to mean "continue this transport";620 // handleLinkFrame returns proxyLinkResume to mean "continue this transport";
544 // the other values are terminal actions.621 // the other values are terminal actions.
545 func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports.Frame) (proxyLinkResult, error) {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 if err != nil {626 if err != nil {
550 return proxyLinkStop, err627 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 return proxyLinkStop, err642 return proxyLinkStop, err
554 }643 }
555 return proxyLinkResume, nil644 return proxyLinkResume, nil
M internal/usecase/daemon/proxy_output.go +63 -66
@@ -4,78 +4,76 @@ import (
4 "fmt"4 "fmt"
5 5
6 "github.com/bnema/vev/internal/ports"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.9+// applyScreenUpdateForGeneration applies one semantic screen update while
11-// Link readers use applyOutputForGeneration to bind processing to the exact10+// holding only the proxy leaf lock. A link generation fence prevents a stale
12-// transport generation that received the frame.11+// receive goroutine from advancing a replacement link's screen.
13-func (p *proxySession) applyOutput(out ports.Output) (ack uint64, requestReset, changed bool) {12+func (p *proxySession) applyScreenUpdateForGeneration(generation uint64, update ports.ScreenUpdate) (ack uint64, requestReset, changed bool) {
14- p.mu.Lock()13+ if p == nil {
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 {
32 return 0, false, false14 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 return 0, false, false19 return 0, false, false
36 }20 }
37- if out.NewStateNum == 0 {21+
38- p.screen.Write(data)22+ // The wire codec validates all semantic bounds. Geometry is still checked
39- return 0, false, len(data) != 023+ // 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 {28+ if update.Kind == ports.ScreenUpdateSnapshot {
42- // A retransmitted full paint must not clobber a newer screen or regress29+ // A base-zero paint may only move the consumer forward. This floor is
43- // the applied state. Only a reset this proxy actually asked for may30+ // retained across resume and resize, so resetRequested never authorizes
44- // replay a state it has already applied.31+ // replaying a stale snapshot.
45- if !p.awaitingReset && out.NewStateNum <= p.appliedState {32+ if update.NewStateNum <= p.appliedState {
46- return 0, false, false33+ return 0, p.requestScreenResetLocked(), false
47 }34 }
48- p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)35+ before := p.screen.cursorOut
49- p.screen.Write(data)36+ if err := p.screen.Apply(update); err != nil {
50- p.appliedState = out.NewStateNum37+ return 0, p.requestScreenResetLocked(), false
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
60 }38 }
61- return 0, requestReset, false39+ 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)44+
64- p.appliedState = out.NewStateNum45+ if !p.screenReady || p.resetRequested || update.BaseStateNum != p.appliedState || update.NewStateNum != update.BaseStateNum+1 {
65- return out.NewStateNum, false, len(data) != 046+ 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 applyOutputForGeneration66+// handleProxyScreenUpdate completes screen handling after apply has released
69-// has released proxy.mu. In particular, transport I/O and local render67+// proxy.mu. Transport I/O and render invalidation never run under that lock.
70-// invalidation must never run under the VT lock.68+func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, update ports.ScreenUpdate) error {
71-func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {69+ marks := d.newRuntimeMarkBatch()
72- ack, requestReset, changed := p.applyOutputForGeneration(generation, out)70+ ack, requestReset, changed := p.applyScreenUpdateForGeneration(generation, update)
73 if ack != 0 {71 if ack != 0 {
74 if err := p.sendGeneration(generation, ports.Frame{72 if err := p.sendGeneration(generation, ports.Frame{
75 Type: ports.MsgAck,73 Type: ports.MsgAck,
76 Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}),74 Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}),
77 }); err != nil {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 if requestReset {79 if requestReset {
@@ -83,9 +81,13 @@ func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports
83 Type: ports.MsgOutputResetRequest,81 Type: ports.MsgOutputResetRequest,
84 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),82 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
85 }); err != nil {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 if !changed || !p.currentLinkGeneration(generation) {91 if !changed || !p.currentLinkGeneration(generation) {
90 return nil92 return nil
91 }93 }
@@ -97,18 +99,13 @@ func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports
97 return nil99 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 func (p *proxySession) resetOutputStateLocked() {102 func (p *proxySession) resetOutputStateLocked() {
110 p.appliedState = 0103 p.appliedState = 0
111- p.awaitingReset = false104+ p.screenReady = false
105+ p.resetRequested = false
106+ if p.screen != nil {
107+ p.screen.stateNum = 0
108+ }
112 }109 }
113 110
114 func (p *proxySession) currentLinkGeneration(generation uint64) bool {111 func (p *proxySession) currentLinkGeneration(generation uint64) bool {
M internal/usecase/daemon/proxy_output_test.go +460 -252
@@ -12,10 +12,13 @@ import (
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ "github.com/bnema/vev/pkg/renderer"
15 "github.com/stretchr/testify/mock"16 "github.com/stretchr/testify/mock"
16 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
17 )18 )
18 19
20+var proxyHandshakeState atomic.Uint64
21+
19 type proxyRecv struct {22 type proxyRecv struct {
20 frame ports.Frame23 frame ports.Frame
21 err error24 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 func (t *proxyTestTransport) Close() error {123 func (t *proxyTestTransport) Close() error {
99 t.closeOnce.Do(func() { close(t.closed) })124 t.closeOnce.Do(func() { close(t.closed) })
100 return nil125 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 func proxyMeta(name string) ports.Frame {157 func proxyMeta(name string) ports.Frame {
113 payload, err := ports.MarshalSessionMeta(ports.SessionMeta{158 payload, err := ports.MarshalSessionMeta(ports.SessionMeta{
114 SessionName: name,159 SessionName: name,
@@ -208,6 +253,7 @@ func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) {
208 tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)}253 tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)}
209 require.Empty(t, proxySessionSnapshot(d), "Welcome alone must not publish a proxy")254 require.Empty(t, proxySessionSnapshot(d), "Welcome alone must not publish a proxy")
210 tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}255 tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
256+ tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
211 257
212 created := awaitTestValue(t, result, "proxy handshake did not complete")258 created := awaitTestValue(t, result, "proxy handshake did not complete")
213 require.NoError(t, created.err)259 require.NoError(t, created.err)
@@ -252,9 +298,11 @@ func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) {
252 firstTransport := newProxyTestTransport()298 firstTransport := newProxyTestTransport()
253 firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)}299 firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)}
254 firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)}300 firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)}
301+ firstTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
255 secondTransport := newProxyTestTransport()302 secondTransport := newProxyTestTransport()
256 secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)}303 secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)}
257 secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)}304 secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)}
305+ secondTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
258 factory := portsmocks.NewMockRemoteDialerFactory(t)306 factory := portsmocks.NewMockRemoteDialerFactory(t)
259 for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} {307 for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} {
260 dialer := portsmocks.NewMockDialer(t)308 dialer := portsmocks.NewMockDialer(t)
@@ -339,6 +387,7 @@ func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) {
339 first := newProxyTestTransport()387 first := newProxyTestTransport()
340 first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)}388 first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)}
341 first.recv <- proxyRecv{frame: proxyMeta(key.Name)}389 first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
390+ first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
342 second := newProxyTestTransport()391 second := newProxyTestTransport()
343 factory := proxyFactoryFor(t, key, first, second)392 factory := proxyFactoryFor(t, key, first, second)
344 // The resume backoff is a ports.Clock deadline, so it is fired rather than393 // The resume backoff is a ports.Clock deadline, so it is fired rather than
@@ -361,6 +410,7 @@ func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) {
361 require.Equal(t, firstHello.ClientID, resumeHello.ClientID)410 require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
362 second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)}411 second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)}
363 second.recv <- proxyRecv{frame: proxyMeta(key.Name)}412 second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
413+ second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
364 second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}}414 second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}}
365 415
366 select {416 select {
@@ -396,9 +446,10 @@ func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
396 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}446 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
397 gate := make(chan struct{})447 gate := make(chan struct{})
398 entered := make(chan struct{}, 1)448 entered := make(chan struct{}, 1)
399- tr := newProxyTestTransportWithSendGate(gate, entered, 1)449+ tr := newProxyTestTransportWithSendGate(gate, entered, 2)
400 tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}450 tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
401 tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}451 tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
452+ tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
402 d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})453 d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
403 proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})454 proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
404 require.NoError(t, err)455 require.NoError(t, err)
@@ -424,6 +475,20 @@ func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
424 stopProxy(t, proxy)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 func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) {492 func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) {
428 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}493 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
429 tr := newProxyTestTransport()494 tr := newProxyTestTransport()
@@ -454,164 +519,319 @@ func newProxyOutputSession(t *testing.T) (*Daemon, *proxySession, *proxyTestTran
454 proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})519 proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
455 require.NoError(t, err)520 require.NoError(t, err)
456 transport := newProxyTestTransport()521 transport := newProxyTestTransport()
457- generation, _ := proxy.installTransport(transport)522+ generation, _ := proxy.installTransport(transport, false)
458 return newProxyTestDaemon(t, nil, stubClock{}), proxy, transport, generation523 return newProxyTestDaemon(t, nil, stubClock{}), proxy, transport, generation
459 }524 }
460 525
461-func proxyScreenText(p *proxySession) string {526+func TestProxyScreenResumeAndFreshLinksRequireMovingSnapshots(t *testing.T) {
462- p.mu.Lock()527+ d, proxy, first, generation := newProxyOutputSession(t)
463- defer p.mu.Unlock()528+ snapshot, err := ports.UnmarshalScreenUpdate(proxyHandshakeSnapshot().Payload)
464- return string([]rune{p.screen.Frame.At(0, 0).Rune, p.screen.Frame.At(1, 0).Rune})529+ require.NoError(t, err)
465-}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) {536+ second := newProxyTestTransport()
468- p.mu.Lock()537+ generation, _ = proxy.installTransport(second, true)
469- defer p.mu.Unlock()538+ proxy.mu.Lock()
470- return p.appliedState, p.awaitingReset539+ 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) {575+func TestProxyScreenStaleSnapshotsAndApplyFailuresRequestOneResetWithoutMutation(t *testing.T) {
474- tests := []struct {576+ newProxy := func(t *testing.T) (*Daemon, *proxySession, uint64) {
475- name string577+ t.Helper()
476- initial *ports.Output578+ d, proxy, _, generation := newProxyOutputSession(t)
477- output ports.Output579+ return d, proxy, generation
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- },
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)582+ t.Run("stale snapshot while not ready", func(t *testing.T) {
549- require.Equal(t, tt.wantAck, ack)583+ d, proxy, generation := newProxy(t)
550- require.Equal(t, tt.wantReset, reset)584+ snapshot := screenSnapshotForProxy(t, proxy, 7)
551- require.Equal(t, tt.wantChanged, changed)585+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
552- state, awaiting := proxyOutputState(proxy)586+ proxy.mu.Lock()
553- require.Equal(t, tt.wantState, state)587+ before := proxy.screen.frame.Clone()
554- require.Equal(t, tt.wantAwaiting, awaiting)588+ proxy.screenReady = false
555- require.Equal(t, tt.wantScreen, proxyScreenText(proxy))589+ proxy.resetRequested = false
556- })590+ proxy.mu.Unlock()
557- }591+
558-}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-zero611+ t.Run("snapshot apply failure", func(t *testing.T) {
561-// guard: a reset this proxy asked for must still be applied even when the612+ d, proxy, generation := newProxy(t)
562-// remote numbers it at or below the state already applied.613+ initial := screenSnapshotForProxy(t, proxy, 7)
563-func TestProxyOutputAwaitedResetAcceptsReplayedState(t *testing.T) {614+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial))
564- _, proxy, _, generation := newProxyOutputSession(t)615+ proxy.mu.Lock()
565- _, _, _ = proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")})616+ before := proxy.screen.frame.Clone()
566- _, requestReset, _ := proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 9, NewStateNum: 10, Data: []byte("x")})617+ proxy.mu.Unlock()
567- require.True(t, requestReset)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")})635+ t.Run("delta apply failure", func(t *testing.T) {
570- require.Equal(t, uint64(3), ack)636+ d, proxy, generation := newProxy(t)
571- require.False(t, reset)637+ initial := screenSnapshotForProxy(t, proxy, 7)
572- require.True(t, changed)638+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial))
573- state, awaiting := proxyOutputState(proxy)639+ proxy.mu.Lock()
574- require.Equal(t, uint64(3), state)640+ before := proxy.screen.frame.Clone()
575- require.False(t, awaiting)641+ size := proxy.contentSize
576- require.Equal(t, "B ", proxyScreenText(proxy))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 broken662+func screenSnapshotForProxy(t *testing.T, proxy *proxySession, state uint64) ports.ScreenUpdate {
580-// write from a broken remote: the frame was well formed, so the proxy link is663+ t.Helper()
581-// resumable and only this transport incarnation is retired.664+ proxy.mu.Lock()
582-func TestProxyLinkReplySendFailureResumesInsteadOfStopping(t *testing.T) {665+ size := proxy.contentSize
583- tests := []struct {666+ proxy.mu.Unlock()
584- name string667+ snapshot := blankSnapshot(size)
585- frame ports.Frame668+ 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- {708+ {name: "duplicate", base: 0, new: 1},
588- name: "pong reply",709+ {name: "gap", base: 3, new: 4},
589- frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})},710+ } {
590- },711+ t.Run(tc.name+" requests exactly one reset", func(t *testing.T) {
591- {712+ d, proxy, transport, generation := newProxyOutputSession(t)
592- name: "output ack",713+ snapshot := screenSnapshotForProxy(t, proxy, 1)
593- frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})},714+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
594- },715+ _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged")
595- {716+
596- name: "output reset request",717+ update := ports.ScreenUpdate{
597- frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 7, NewStateNum: 8, Data: []byte("A")})},718+ Kind: ports.ScreenUpdateDelta, BaseStateNum: tc.base, NewStateNum: tc.new,
598- },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 {736+
601- t.Run(tt.name, func(t *testing.T) {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 d, proxy, transport, generation := newProxyOutputSession(t)779 d, proxy, transport, generation := newProxyOutputSession(t)
603- t.Cleanup(func() { _ = transport.Close() })
604 transport.sendFails.Store(true)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)788+ t.Run("reset", func(t *testing.T) {
608- require.Equal(t, proxyLinkResume, result, "a failed reply must retire the transport, not the proxy link")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 widen833+// TestProxyLinkProtocolErrorStillStops fences malformed MsgOutput rejection
614-// into "every error is resumable".834+// separately from resumable structured screen reply failures.
615 func TestProxyLinkProtocolErrorStillStops(t *testing.T) {835 func TestProxyLinkProtocolErrorStillStops(t *testing.T) {
616 d, proxy, transport, generation := newProxyOutputSession(t)836 d, proxy, transport, generation := newProxyOutputSession(t)
617 t.Cleanup(func() { _ = transport.Close() })837 t.Cleanup(func() { _ = transport.Close() })
@@ -620,130 +840,118 @@ func TestProxyLinkProtocolErrorStillStops(t *testing.T) {
620 require.Equal(t, proxyLinkStop, d.runProxyTransport(context.Background(), proxy, generation, transport))840 require.Equal(t, proxyLinkStop, d.runProxyTransport(context.Background(), proxy, generation, transport))
621 }841 }
622 842
623-func TestProxyOutputMismatchSendsOneResetUntilBaseZero(t *testing.T) {843+func proxySideEffectFrame(data []byte) ports.Frame {
624- d, proxy, transport, generation := newProxyOutputSession(t)844+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
625- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))845+ BaseStateNum: 0,
626- require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial output was not acknowledged").Type)846+ NewStateNum: 0,
627-847+ Data: data,
628- mismatch := ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("discard")}848+ })}
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))
646 }849 }
647 850
648-func TestProxyOutputMalformedOrStaleCannotAdvanceState(t *testing.T) {851+func TestProxySideEffectForwardsOSC52ToCurrentThinClient(t *testing.T) {
649- t.Run("malformed", func(t *testing.T) {852+ d := newTestDaemon(t, nil, stubClock{})
650- d, proxy, _, generation := newProxyOutputSession(t)853+ remote := newProxyTestTransport()
651- result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}})854+ thin := newProxyTestTransport()
652- require.Equal(t, proxyLinkStop, result)855+ proxy, ac := newAttachedProxyFixture(t, d, thin, remote)
653- require.Error(t, err)856+ ac.echoAck.Store(17)
654- state, awaiting := proxyOutputState(proxy)
655- require.Zero(t, state)
656- require.False(t, awaiting)
657- })
658 857
659- t.Run("stale generation", func(t *testing.T) {858+ const osc52 = "\x1b]52;c;YQ==\a"
660- d, proxy, transport, oldGeneration := newProxyOutputSession(t)859+ result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte(osc52)))
661- newGeneration, _ := proxy.installTransport(newProxyTestTransport())860+ require.Equal(t, proxyLinkResume, result)
662- require.NotEqual(t, oldGeneration, newGeneration)861+ require.NoError(t, err)
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-}
675 862
676-func TestProxyOutputAckFailureStillDoesNotApplyStaleState(t *testing.T) {863+ forwarded := awaitTestValue(t, thin.sent, "proxy OSC52 side effect was not forwarded")
677- d, proxy, transport, generation := newProxyOutputSession(t)864+ require.Equal(t, ports.MsgOutput, forwarded.Type)
678- transport.sendGate = make(chan struct{})865+ out, err := ports.UnmarshalOutput(forwarded.Payload)
679- require.NoError(t, transport.Close())866+ require.NoError(t, err)
680- err := d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})867+ require.Zero(t, out.BaseStateNum)
681- require.Error(t, err)868+ require.Zero(t, out.NewStateNum)
682- state, _ := proxyOutputState(proxy)869+ require.Equal(t, uint64(17), out.EchoAck)
683- require.Equal(t, uint64(1), state, "ACK failure follows a successfully applied state")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) {888+func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) {
687- d, proxy, first, generation := newProxyOutputSession(t)889+ tests := []struct {
688- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))890+ name string
689- _ = awaitTestValue(t, first.sent, "initial output was not acknowledged") // ACK891+ frame ports.Frame
690-892+ }{
691- second := newProxyTestTransport()893+ {name: "state bearing", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("screen")})}},
692- generation, _ = proxy.installTransport(second)894+ {name: "malformed", frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}},
693- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))895+ }
694- frame := awaitTestValue(t, second.sent, "reconnected output did not request reset")896+ for _, tt := range tests {
695- require.Equal(t, ports.MsgOutputResetRequest, frame.Type)897+ t.Run(tt.name, func(t *testing.T) {
696- state, awaiting := proxyOutputState(proxy)898+ d := newTestDaemon(t, nil, stubClock{})
697- require.Zero(t, state)899+ remote := newProxyTestTransport()
698- require.True(t, awaiting)900+ thin := newProxyTestTransport()
699- require.Equal(t, "A ", proxyScreenText(proxy))901+ proxy, _ := newAttachedProxyFixture(t, d, thin, remote)
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
713 902
714- proxy.resetOutputState()903+ result, err := d.handleLinkFrame(proxy, 1, tt.frame)
715- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))904+ require.Equal(t, proxyLinkStop, result)
716- frame := awaitTestValue(t, transport.sent, "resize mismatch did not request reset")905+ require.Error(t, err)
717- require.Equal(t, ports.MsgOutputResetRequest, frame.Type)906+ select {
718- state, awaiting := proxyOutputState(proxy)907+ case frame := <-thin.sent:
719- require.Zero(t, state)908+ t.Fatalf("invalid proxy output forwarded: %v", frame.Type)
720- require.True(t, awaiting)909+ default:
721- require.Equal(t, "A ", proxyScreenText(proxy))910+ }
911+ })
912+ }
722 }913 }
723 914
724-func TestProxyOutputConcurrentCancelDoesNotHoldProxyLockDuringSend(t *testing.T) {915+func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) {
725- d, proxy, transport, generation := newProxyOutputSession(t)916+ d := newTestDaemon(t, nil, stubClock{})
726- gate := make(chan struct{})917+ remote := newProxyTestTransport()
727- entered := make(chan struct{}, 1)918+ thin := newProxyTestTransport()
728- transport.sendGate = gate919+ proxy, ac := newAttachedProxyFixture(t, d, thin, remote)
729- transport.sendEntered = entered920+ frame := proxySideEffectFrame([]byte("effect"))
730 921
731- result := make(chan error, 1)922+ result, err := d.handleLinkFrame(proxy, 0, frame)
732- go func() {923+ require.Equal(t, proxyLinkResume, result)
733- result <- d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})924+ require.NoError(t, err)
734- }()925+ select {
735- awaitTestCompletion(t, entered, "output sender did not enter transport")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{})931+ token := attachmentToken(proxy, ac, ac.transport())
738- go func() {932+ ticket, admitted := ac.beginRoleEffect(token)
739- _, _ = proxyOutputState(proxy)933+ require.True(t, admitted)
740- close(locked)934+ ticket.End()
741- }()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 select {939 select {
743- case <-locked:940+ case forwarded := <-thin.sent:
744- case <-time.After(time.Second):941+ t.Fatalf("stale attachment role forwarded side effect: %v", forwarded.Type)
745- t.Fatal("proxy.mu was held while transport Send was blocked")942+ default:
746 }943 }
747- require.NoError(t, transport.Close())944+}
748- require.Error(t, awaitTestValue(t, result, "blocked output send did not return"))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 const proxyRenderPaneID layout.PaneID = "proxy-content"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 // capturePrimary snapshots the remote VT while holding only proxy.mu. The20 // capturePrimary snapshots the remote VT while holding only proxy.mu. The
13 // snapshot is then composed by the ordinary local frame pipeline, which owns21 // snapshot is then composed by the ordinary local frame pipeline, which owns
14 // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore22 // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore
@@ -24,22 +32,33 @@ func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCapture
24 return nil, false32 return nil, false
25 }33 }
26 screen := p.screen34 screen := p.screen
27- frame := screen.Frame.Clone()35+ capture := &ac.proxyCapture
28 damage := screen.CaptureDamage()36 damage := screen.CaptureDamage()
29- cursorStyle, hasCursorStyle := screen.CursorStyle()37+ if capture.screen != screen {
30- cursor := capturedCursorInputs{38+ // A replacement screen has no relationship to the retained frame.
31- row: screen.CursorRow(),39+ capture.frame = renderer.Frame{}
32- col: screen.CursorCol(),40+ }
33- style: cursorStyle,41+ if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil {
34- hasStyle: hasCursorStyle,42+ screen.CaptureInto(&capture.frame)
35- visible: screen.CursorVisible(),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 renderable: frame.Width > 0 && frame.Height > 0,54 renderable: frame.Width > 0 && frame.Height > 0,
37 content: domain.Rect{Width: frame.Width, Height: frame.Height},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 p.mu.Unlock()59 p.mu.Unlock()
40 60
41 content := domain.Rect{Width: frame.Width, Height: frame.Height}61 content := domain.Rect{Width: frame.Width, Height: frame.Height}
42- paneDamage := append([]renderer.Damage(nil), damage.Damage...)
43 if uncertainDamage(paneDamage, frame.Width, frame.Height) {62 if uncertainDamage(paneDamage, frame.Width, frame.Height) {
44 paneDamage = []renderer.Damage{renderer.FullRedraw()}63 paneDamage = []renderer.Damage{renderer.FullRedraw()}
45 }64 }
@@ -98,6 +117,9 @@ func (d *Daemon) resizeProxyForLease(p *proxySession, ac *attachedClient, lease
98 if changed, _ := p.resize(size); !changed {117 if changed, _ := p.resize(size); !changed {
99 return118 return
100 }119 }
120+ ac.sendMu.Lock()
121+ ac.size = size
122+ ac.sendMu.Unlock()
101 if rc := attachmentRenderCoordinator(p); rc != nil {123 if rc := attachmentRenderCoordinator(p); rc != nil {
102 rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"})124 rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"})
103 return125 return
@@ -118,15 +140,22 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
118 return false, false140 return false, false
119 }141 }
120 content := contentSize(size, false)142 content := contentSize(size, false)
143+ if !validProxyScreenSize(content) {
144+ return false, false
145+ }
121 p.mu.Lock()146 p.mu.Lock()
122 if p.screen == nil || p.contentSize == content {147 if p.screen == nil || p.contentSize == content {
123 available := p.screen != nil148 available := p.screen != nil
124 p.mu.Unlock()149 p.mu.Unlock()
125 return false, available150 return false, available
126 }151 }
127- p.screen.Resize(content.Cols, content.Rows)152+ p.screen.ResizePlaceholder(content)
128 p.contentSize = content153 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 generation := p.linkGeneration159 generation := p.linkGeneration
131 p.mu.Unlock()160 p.mu.Unlock()
132 161
M internal/usecase/daemon/proxy_render_test.go +170 -3
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "fmt"
4 "io"5 "io"
5 "testing"6 "testing"
6 7
@@ -12,13 +13,50 @@ import (
12 "github.com/stretchr/testify/require"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 func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {53 func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
16 proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})54 proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
17 require.NoError(t, err)55 require.NoError(t, err)
18 proxy.mu.Lock()56 proxy.mu.Lock()
19 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}57 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
20 proxy.mu.Unlock()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 require.True(t, changed)60 require.True(t, changed)
23 61
24 state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{62 state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{
@@ -45,7 +83,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
45 proxy.mu.Lock()83 proxy.mu.Lock()
46 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}84 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
47 proxy.mu.Unlock()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 require.True(t, changed)87 require.True(t, changed)
50 88
51 d := newTestDaemon(t, nil, stubClock{})89 d := newTestDaemon(t, nil, stubClock{})
@@ -72,7 +110,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
72 proxy.mu.Lock()110 proxy.mu.Lock()
73 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}111 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
74 proxy.mu.Unlock()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 require.True(t, changed)114 require.True(t, changed)
77 d.mu.Lock()115 d.mu.Lock()
78 require.True(t, d.registerSessionLocked(proxy))116 require.True(t, d.registerSessionLocked(proxy))
@@ -100,6 +138,113 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
100 require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to local session did not paint").Type)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 func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {248 func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
104 proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8})249 proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8})
105 require.NoError(t, err)250 require.NoError(t, err)
@@ -107,6 +252,7 @@ func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
107 proxy.mu.Lock()252 proxy.mu.Lock()
108 proxy.transport = transport253 proxy.transport = transport
109 proxy.linkGeneration = 1254 proxy.linkGeneration = 1
255+ proxy.screenReady = true
110 proxy.mu.Unlock()256 proxy.mu.Unlock()
111 257
112 changed, sent := proxy.resize(domain.Size{Cols: 20, Rows: 10})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 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}287 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
142 proxy.transport = remoteTr288 proxy.transport = remoteTr
143 proxy.linkGeneration = 1289 proxy.linkGeneration = 1
290+ proxy.screenReady = true
144 proxy.mu.Unlock()291 proxy.mu.Unlock()
145 d.mu.Lock()292 d.mu.Lock()
146 require.True(t, d.registerSessionLocked(proxy))293 require.True(t, d.registerSessionLocked(proxy))
@@ -162,6 +309,26 @@ func newFailingTransport(t *testing.T) *portsmocks.MockTransport {
162 return tr309 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 // TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails covers the split332 // TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails covers the split
166 // between "the local content rectangle moved" and "the remote was told". The333 // between "the local content rectangle moved" and "the remote was told". The
167 // local VT has already been resized, so the client owes a repaint either way.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 import (3 import (
4 "fmt"4 "fmt"
5 "math"5 "math"
6+ "unicode/utf8"
6 7
7 "github.com/bnema/vev/internal/domain"8 "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
10 )11 )
11 12
12-const maxProxyScreenDamage = 102413+const (
14+ maxProxyScreenCells = 1 << 18
15+ maxProxyScreenDamage = 1024
16+ maxProxyScreenSpans = 4096
17+)
13 18
14 type proxyScreenDamageCapture struct {19 type proxyScreenDamageCapture struct {
15 Damage []renderer.Damage20 Damage []renderer.Damage
@@ -73,19 +78,93 @@ func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error {
73 }78 }
74 79
75 func (s *proxyScreenState) validateUpdate(update ports.ScreenUpdate) error {80 func (s *proxyScreenState) validateUpdate(update ports.ScreenUpdate) error {
76- if err := ports.ValidateScreenUpdate(update); err != nil {81+ if update.Kind != ports.ScreenUpdateSnapshot && update.Kind != ports.ScreenUpdateDelta {
77- return fmt.Errorf("proxy screen: %w", err)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 if update.Kind == ports.ScreenUpdateDelta {87 if update.Kind == ports.ScreenUpdateDelta {
80 if s.frame.Width != update.Size.Cols || s.frame.Height != update.Size.Rows {88 if s.frame.Width != update.Size.Cols || s.frame.Height != update.Size.Rows {
81 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)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 func (s *proxyScreenState) validateStateNumbers(update ports.ScreenUpdate) error {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 return fmt.Errorf("proxy screen: delta base state %d does not match %d", update.BaseStateNum, s.stateNum)168 return fmt.Errorf("proxy screen: delta base state %d does not match %d", update.BaseStateNum, s.stateNum)
90 }169 }
91 return nil170 return nil
@@ -183,11 +262,9 @@ func (s *proxyScreenState) AcknowledgeDamage(generation uint64) bool {
183 return true262 return true
184 }263 }
185 264
186-// CaptureInto updates dst from the live frame. dst must already contain the265+// CaptureInto updates dst from the live frame. Safe scroll/span damage is
187-// previous synchronized generation before incremental damage can be replayed;266+// replayed incrementally; snapshots, uncertain damage, and dimensions use a
188-// callers should initialize it with a full clone or equivalent first capture.267+// complete clone.
189-// Safe scroll/span damage is replayed incrementally; snapshots, uncertain
190-// damage, and dimensions use a complete clone.
191 func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) {268 func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) {
192 if s == nil || dst == nil || !validProxyFrame(*s) {269 if s == nil || dst == nil || !validProxyFrame(*s) {
193 return270 return
@@ -266,9 +343,36 @@ func validProxyScreenSize(size domain.Size) bool {
266 if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 {343 if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 {
267 return false344 return false
268 }345 }
269- return size.Rows <= ports.MaxScreenCells/size.Cols346+ return size.Rows <= maxProxyScreenCells/size.Cols
270 }347 }
271 348
272 func validProxyFrame(s proxyScreenState) bool {349 func validProxyFrame(s proxyScreenState) bool {
273 return s.frame.Validate() == nil && s.scratch.Validate() == nil350 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 t.Fatal("invalid update mutated metadata")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 invalid = ports.ScreenUpdate{66 invalid = ports.ScreenUpdate{
81 Kind: ports.ScreenUpdateDelta,67 Kind: ports.ScreenUpdateDelta,
82 BaseStateNum: 1,68 BaseStateNum: 1,
@@ -191,7 +177,7 @@ func TestProxyScreenStateRejectsWireInvalidSemanticUpdatesAtomically(t *testing.
191 beforeDamage = s.CaptureDamage()177 beforeDamage = s.CaptureDamage()
192 invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{178 invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{
193 Size: domain.Size{Cols: 2, Rows: 2},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 if err := s.Apply(invalidSpans); err == nil {182 if err := s.Apply(invalidSpans); err == nil {
197 t.Fatal("too many spans were accepted")183 t.Fatal("too many spans were accepted")
@@ -303,8 +289,8 @@ func TestProxyScreenStateCaptureFallsBackAfterSpanThenScroll(t *testing.T) {
303 t.Fatalf("span-then-scroll damage = %+v", capture.Damage)289 t.Fatalf("span-then-scroll damage = %+v", capture.Damage)
304 }290 }
305 s.CaptureInto(&dst)291 s.CaptureInto(&dst)
306- if !reflect.DeepEqual(dst, s.frame.Clone()) {292+ if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want {
307- t.Fatal("span-then-scroll capture diverged from live frame")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 t.Fatalf("repeated-scroll damage = %+v", capture.Damage)319 t.Fatalf("repeated-scroll damage = %+v", capture.Damage)
334 }320 }
335 s.CaptureInto(&dst)321 s.CaptureInto(&dst)
336- if !reflect.DeepEqual(dst, s.frame.Clone()) {322+ if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want {
337- t.Fatal("repeated-scroll capture diverged from live frame")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 func BenchmarkProxyScreenStateApply(b *testing.B) {360 func BenchmarkProxyScreenStateApply(b *testing.B) {
373 size := domain.Size{Cols: 120, Rows: 40}361 size := domain.Size{Cols: 120, Rows: 40}
374 cases := []struct {362 cases := []struct {
M internal/usecase/daemon/proxy_session.go +19 -4
@@ -11,7 +11,6 @@ import (
11 11
12 "github.com/bnema/vev/internal/domain"12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/pkg/vt"
15 )14 )
16 15
17 // proxySession is the local attachment identity for one structured remote16 // proxySession is the local attachment identity for one structured remote
@@ -28,8 +27,9 @@ type proxySession struct {
28 transport ports.Transport27 transport ports.Transport
29 resumeToken uint6428 resumeToken uint64
30 appliedState uint6429 appliedState uint64
31- awaitingReset bool30+ screenReady bool
32- screen *vt.Screen31+ resetRequested bool
32+ screen *proxyScreenState
33 meta ports.SessionMeta33 meta ports.SessionMeta
34 attentionAt []time.Time34 attentionAt []time.Time
35 linkState ports.LinkState35 linkState ports.LinkState
@@ -59,6 +59,18 @@ func (p *proxySession) core() *sessionCore {
59 59
60 func (p *proxySession) isProxy() bool { return true }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 // replaceSessionMetaLocked atomically replaces the relayed snapshot while74 // replaceSessionMetaLocked atomically replaces the relayed snapshot while
63 // preserving the first-observed timestamp of tabs that remain attentive.75 // preserving the first-observed timestamp of tabs that remain attentive.
64 // Callers must validate meta and hold p.mu.76 // Callers must validate meta and hold p.mu.
@@ -194,6 +206,9 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi
194 return nil, fmt.Errorf("proxy session key: %w", err)206 return nil, fmt.Errorf("proxy session key: %w", err)
195 }207 }
196 content := contentSize(size, false)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 var clientID [16]byte212 var clientID [16]byte
198 if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {213 if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {
199 return nil, fmt.Errorf("generate proxy client id: %w", err)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 key: key,222 key: key,
208 generation: 1,223 generation: 1,
209- screen: vt.NewScreen(content.Cols, content.Rows),224+ screen: newProxyScreenState(content),
210 linkState: ports.LinkStateConnected,225 linkState: ports.LinkStateConnected,
211 done: make(chan struct{}),226 done: make(chan struct{}),
212 clientID: clientID,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 func (b *runtimeMarkBatch) flush() {296 func (b *runtimeMarkBatch) flush() {
286 if b == nil || b.observer == nil {297 if b == nil || b.observer == nil {
287 return298 return
M internal/usecase/daemon/render_capture.go +1 -2
@@ -8,7 +8,6 @@ import (
8 themeui "github.com/bnema/vev/internal/usecase/theme"8 themeui "github.com/bnema/vev/internal/usecase/theme"
9 "github.com/bnema/vev/internal/usecase/ui"9 "github.com/bnema/vev/internal/usecase/ui"
10 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
11- "github.com/bnema/vev/pkg/vt"
12 )11 )
13 12
14 // capturedRenderState is the immutable hand-off from authoritative daemon13 // capturedRenderState is the immutable hand-off from authoritative daemon
@@ -38,7 +37,7 @@ type renderCaptureScratch struct {
38 type damageReceipt struct {37 type damageReceipt struct {
39 pane *pane38 pane *pane
40 proxy *proxySession39 proxy *proxySession
41- proxyScreen *vt.Screen40+ proxyScreen *proxyScreenState
42 generation uint6441 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 if !c.hasStyle {215 if !c.hasStyle {
216 style = 1216 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 func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {221 func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
@@ -430,7 +430,7 @@ func commitDamageReceipts(receipts []damageReceipt) {
430 }430 }
431 if receipt.proxy != nil {431 if receipt.proxy != nil {
432 receipt.proxy.mu.Lock()432 receipt.proxy.mu.Lock()
433- if receipt.proxy.screen == receipt.proxyScreen {433+ if receipt.proxy.screen != nil && receipt.proxy.screen == receipt.proxyScreen {
434 receipt.proxy.screen.AcknowledgeDamage(receipt.generation)434 receipt.proxy.screen.AcknowledgeDamage(receipt.generation)
435 }435 }
436 receipt.proxy.mu.Unlock()436 receipt.proxy.mu.Unlock()
@@ -473,9 +473,34 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
473 }473 }
474 }474 }
475 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)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 endDiff(0, err == nil)501 endDiff(0, err == nil)
478 if err != nil {502 if err != nil {
503+ ac.discardProxyCapture()
479 ac.sendMu.Unlock()504 ac.sendMu.Unlock()
480 d.log.Error("render draw failed", "err", err, "session", entry.core().name)505 d.log.Error("render draw failed", "err", err, "session", entry.core().name)
481 // Without a coordinator reportError repaints synchronously. Suppress only506 // Without a coordinator reportError repaints synchronously. Suppress only
@@ -498,9 +523,6 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
498 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")523 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
499 return true524 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 var sendTr ports.Transport526 var sendTr ports.Transport
505 var sendErr error527 var sendErr error
506 // Metadata precedes any output bytes and is published on its own when the528 // 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 if ac.proxied {533 if ac.proxied {
512 if sess, ok := localSession(entry); ok {534 if sess, ok := localSession(entry); ok {
513 sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.roleEffect)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 if len(data) > 0 {545 if len(data) > 0 {
@@ -524,17 +553,11 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
524 send = async.SendAsync553 send = async.SendAsync
525 }554 }
526 interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)555 interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
527- sendErr = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error {556+ if ac.proxied {
528- err := send(frame)557+ sendErr = preparedScreen.send(send)
529- if interruptible {558+ } else {
530- if err != nil {559+ sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
531- marks.roleEffect.reportTransportFailure(sendTransport)560+ }
532- }
533- marks.roleEffect.endTransportSend()
534- interruptible = false
535- }
536- return err
537- })
538 if interruptible {561 if interruptible {
539 if sendErr != nil {562 if sendErr != nil {
540 marks.roleEffect.reportTransportFailure(sendTransport)563 marks.roleEffect.reportTransportFailure(sendTransport)
@@ -543,17 +566,32 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
543 }566 }
544 }567 }
545 endEmit(uint64(len(data)), sendErr == nil)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 if sendErr == nil {578 if sendErr == nil {
549 if len(data) == 0 {579 if len(data) == 0 {
550- prepared.commitNoSend()580+ if ac.proxied {
581+ preparedScreen.commitNoSend()
582+ } else {
583+ preparedANSI.commitNoSend()
584+ }
551 }585 }
552 // Publish only after output preparation and transport emission both586 // Publish only after output preparation and transport emission both
553 // succeed. A cross-session transition may publish concurrently, but its587 // succeed. A cross-session transition may publish concurrently, but its
554 // mandatory first-paint rebase waits for sendMu and therefore follows this588 // mandatory first-paint rebase waits for sendMu and therefore follows this
555 // completed output transaction.589 // completed output transaction.
556- ac.lastCursor = cursor.next590+ if ac.proxied {
591+ ac.lastCursor = composed.cursor
592+ } else {
593+ ac.lastCursor = cursor.next
594+ }
557 ac.pipelineScratch = ac.pipelineCache595 ac.pipelineScratch = ac.pipelineCache
558 ac.pipelineCache = composed.cache596 ac.pipelineCache = composed.cache
559 597
@@ -565,6 +603,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
565 ac.renderStages.emit()603 ac.renderStages.emit()
566 }604 }
567 }605 }
606+ if sendErr != nil {
607+ ac.discardProxyCapture()
608+ }
568 ac.sendMu.Unlock()609 ac.sendMu.Unlock()
569 if sendErr != nil {610 if sendErr != nil {
570 // A transport failure may invalidate the role gate. Release this render's611 // 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 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
16 )16 )
17 17
18-type cacheFailTransport struct {18+type cacheFailTransport struct{}
19- err error
20-}
21 19
22-func (t cacheFailTransport) Send(ports.Frame) error {20+func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
23- if t.err != nil {
24- return t.err
25- }
26- return errors.New("send failed")
27-}
28 func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }21 func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
29 func (cacheFailTransport) Close() error { return nil }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 stream := newOutputStateStream()372 stream := newOutputStateStream()
373 first := composeFrame(makeState(tt.initial, []renderer.Damage{renderer.FullRedraw()}, true), composeCacheInput{})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 require.NoError(t, err)375 require.NoError(t, err)
376 client := vt.NewScreen(4, 5)376 client := vt.NewScreen(4, 5)
377 client.Write(firstBytes)377 client.Write(firstBytes)
378 378
379 second := composeFrame(makeState(tt.scrolled, tt.damage, false), first.cache)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 require.NoError(t, err)381 require.NoError(t, err)
382 require.Equal(t, tt.wantBytes, string(secondBytes))382 require.Equal(t, tt.wantBytes, string(secondBytes))
383 client.Write(secondBytes)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 // The parked attachment has no session owner and cannot paint. Retire the544 // The parked attachment has no session owner and cannot paint. Retire the
545 // abandoned transport's output chain before binding the replacement so the545 // abandoned transport's output chain before binding the replacement so the
546 // mandatory first paint cannot be blocked by ACKs that died with the link.546 // mandatory first paint cannot be blocked by ACKs that died with the link.
547- ac.output.rebase()547+ ac.rebaseOutput()
548 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))548 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
549 ac.replaceTransport(tr)549 ac.replaceTransport(tr)
550 ac.size = sz550 ac.size = sz
M internal/usecase/daemon/resume_test.go +4 -24
@@ -16,7 +16,6 @@ import (
16 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
17 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19- "github.com/bnema/vev/pkg/renderer"
20 "github.com/bnema/vev/pkg/vt"19 "github.com/bnema/vev/pkg/vt"
21 )20 )
22 21
@@ -1397,22 +1396,10 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
1397 1396
1398 ac.sendMu.Lock()1397 ac.sendMu.Lock()
1399 ac.output.next = 51398 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 reset := false1399 reset := false
1406 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")1400 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
1407- incrementalFrame := baseFrame.Clone()1401+
1408- incrementalFrame.Set(0, 0, renderer.Cell{Rune: 'i', Style: renderer.DefaultStyle()})1402+ f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0)
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- }))
1416 ac.sendMu.Unlock()1403 ac.sendMu.Unlock()
1417 out, err := ports.UnmarshalOutput(f.Payload)1404 out, err := ports.UnmarshalOutput(f.Payload)
1418 require.NoError(t, err)1405 require.NoError(t, err)
@@ -1422,16 +1409,9 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
1422 ac.sendMu.Lock()1409 ac.sendMu.Lock()
1423 reset = true1410 reset = true
1424 require.True(t, reset, "explicit reset should still force full repaint")1411 require.True(t, reset, "explicit reset should still force full repaint")
1425- fullFrame := incrementalFrame.Clone()1412+ full := ac.output.frame([]byte("explicit full repaint"), reset, 0)
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- }))
1433 ac.sendMu.Unlock()1413 ac.sendMu.Unlock()
1434- fullOut, err := ports.UnmarshalOutput(fullOutput.Payload)1414+ fullOut, err := ports.UnmarshalOutput(full.Payload)
1435 require.NoError(t, err)1415 require.NoError(t, err)
1436 require.Equal(t, uint64(0), fullOut.BaseStateNum)1416 require.Equal(t, uint64(0), fullOut.BaseStateNum)
1437 require.Equal(t, uint64(7), fullOut.NewStateNum)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 // beginRoleEffect is the sole role-bound effect admission point. The gate is198 // beginRoleEffect is the sole role-bound effect admission point. The gate is
199 // held only long enough to reserve the capability; the effect itself must run199 // held only long enough to reserve the capability; the effect itself must run
200 // without the gate mutex.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 func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {201 func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {
212 if ac == nil || token.ac != ac {202 if ac == nil || token.ac != ac {
213 return nil, false203 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 if !ok {71 if !ok {
72 return errSessionMetaUnavailable72 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 if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) {83 if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) {
75 return nil84 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 emit("initial")30 emit("initial")
31 firstMetaFrame := <-sends31 firstMetaFrame := <-sends
32- firstOutputFrame := <-sends32+ firstScreenFrame := <-sends
33 require.Equal(t, ports.MsgSessionMeta, firstMetaFrame.Type)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 firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload)38 firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload)
36 require.NoError(t, err)39 require.NoError(t, err)
37 require.Equal(t, "work", firstMeta.SessionName)40 require.Equal(t, "work", firstMeta.SessionName)
@@ -41,9 +44,12 @@ func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) {
41 sess.mu.Unlock()44 sess.mu.Unlock()
42 emit("changed")45 emit("changed")
43 changedMetaFrame := <-sends46 changedMetaFrame := <-sends
44- changedOutputFrame := <-sends47+ changedScreenFrame := <-sends
45 require.Equal(t, ports.MsgSessionMeta, changedMetaFrame.Type)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 changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload)53 changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload)
48 require.NoError(t, err)54 require.NoError(t, err)
49 require.Equal(t, "renamed", changedMeta.Tabs[0].Name)55 require.Equal(t, "renamed", changedMeta.Tabs[0].Name)
@@ -63,7 +69,7 @@ func TestProxiedMetadataSendsWithoutOutputBytes(t *testing.T) {
63 69
64 emit(true)70 emit(true)
65 require.Equal(t, ports.MsgSessionMeta, (<-sends).Type)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 sess.mu.Lock()74 sess.mu.Lock()
69 sess.tabs[0].name = "renamed"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 acA := sess.client299 acA := sess.client
300 sess.mu.Unlock()300 sess.mu.Unlock()
301 require.NotNil(t, acA)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 // Client B attaches to the same session, displacing A.309 // Client B attaches to the same session, displacing A.
304 trB, sendsB, releaseB := newConn(t, mustHello(ports.IntentAttach, "0", domain.Size{Cols: 80, Rows: 24}))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 if token := result.displaced; token.ac != nil && token.transport.transport != nil {188 if token := result.displaced; token.ac != nil && token.transport.transport != nil {
189 blockedRender := result.displacedInterrupted189 blockedRender := result.displacedInterrupted
190- if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) && token.ac.transportEffectActive() {190+ if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
191 blockedRender = !token.ac.sendMu.TryLock()191 blockedRender = !token.ac.sendMu.TryLock()
192 if !blockedRender {192 if !blockedRender {
193 token.ac.sendMu.Unlock()193 token.ac.sendMu.Unlock()
M internal/usecase/daemon/snatched_render.go +38 -24
@@ -4,7 +4,6 @@ import (
4 "errors"4 "errors"
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8 themeui "github.com/bnema/vev/internal/usecase/theme"7 themeui "github.com/bnema/vev/internal/usecase/theme"
9 "github.com/bnema/vev/internal/usecase/ui"8 "github.com/bnema/vev/internal/usecase/ui"
10 "github.com/bnema/vev/pkg/renderer"9 "github.com/bnema/vev/pkg/renderer"
@@ -115,38 +114,53 @@ func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapsho
115 114
116 applied := ac.getAppliedTheme()115 applied := ac.getAppliedTheme()
117 frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback)116 frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback)
118- ac.output.rebase()117+ ac.rebaseOutput()
119- prepared, err := ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true)118+ var data []byte
120- if err != nil {119+ var cursor cursorCandidate
121- return err120+ 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 if effect != nil && !effect.beginTransportSend(expected) {142 if effect != nil && !effect.beginTransportSend(expected) {
126 return errAttachmentTransition143 return errAttachmentTransition
127 }144 }
128- effectActive := effect != nil145+ if ac.proxied {
129- err = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error {146+ sendErr = preparedScreen.send(expected.transport.Send)
130- err := expected.transport.Send(frame)147+ } else {
131- if effectActive {148+ sendErr = preparedANSI.send(data, ac.echoAck.Load(), expected.transport.Send)
132- if err != nil {149+ }
133- effect.reportTransportFailure(expected)150+ if effect != nil {
134- }151+ if sendErr != nil {
135- effect.endTransportSend()
136- effectActive = false
137- }
138- return err
139- })
140- if effectActive {
141- if err != nil {
142 effect.reportTransportFailure(expected)152 effect.reportTransportFailure(expected)
143 }153 }
144 effect.endTransportSend()154 effect.endTransportSend()
145 }155 }
146- if err == nil {156+ if sendErr == nil {
147- ac.lastCursor = cursor.next157+ if ac.proxied {
158+ ac.lastCursor = cursorOut{hidden: true}
159+ } else {
160+ ac.lastCursor = cursor.next
161+ }
148 }162 }
149- return err163+ return sendErr
150 })164 })
151 if errors.Is(err, errSendTimedOut) {165 if errors.Is(err, errSendTimedOut) {
152 _ = ac.closeCapturedTransport(tr)166 _ = ac.closeCapturedTransport(tr)
M internal/usecase/daemon/snatched_render_test.go +21 -8
@@ -138,10 +138,28 @@ func TestSendSnatchedPanelRebasesOutput(t *testing.T) {
138 require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")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 func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) {160 func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) {
142 d := newTestDaemon(t, nil, stubClock{})161 d := newTestDaemon(t, nil, stubClock{})
143- sendErr := errors.New("send failed")162+ failed := cacheFailTransport{}
144- failed := cacheFailTransport{err: sendErr}
145 ac := &attachedClient{163 ac := &attachedClient{
146 tr: failed,164 tr: failed,
147 output: newOutputStateStream(),165 output: newOutputStateStream(),
@@ -153,14 +171,9 @@ func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) {
153 ac.roleGeneration.Store(1)171 ac.roleGeneration.Store(1)
154 beforeCursor := ac.lastCursor172 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 require.Equal(t, beforeCursor, ac.lastCursor)175 require.Equal(t, beforeCursor, ac.lastCursor)
158 require.Equal(t, uint64(7), ac.output.next)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 healthy := &closeTrackingTransport{}178 healthy := &closeTrackingTransport{}
166 ac.replaceTransport(healthy)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 require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})33 require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})
34 requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)34 requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)
35 requireToastFootprintsDamaged(t, updated.damage, updated.cache.toastFootprints)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 require.NoError(t, err)37 require.NoError(t, err)
38 fullRenderer := renderer.New(renderer.Capabilities{})38 fullRenderer := renderer.New(renderer.Capabilities{})
39 full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})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 func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {127 func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {
128 t.Helper()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 require.NoError(t, err)130 require.NoError(t, err)
131 terminal.Write(data)131 terminal.Write(data)
132 require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))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 // used for terminal emission. The source damage remains untouched because the11 // used for terminal emission. The source damage remains untouched because the
12 // scroll and shadow paths need its original structural information.12 // scroll and shadow paths need its original structural information.
13 func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool) {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 spans := make([]Span, 0)36 spans := make([]Span, 0)
15 for _, d := range damage {37 for _, d := range damage {
16 if skip != nil && sameDamage(d, *skip) {38 if skip != nil && sameDamage(d, *skip) {
@@ -30,9 +52,6 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool)
30 spans = append(spans, Span{Y: row, X: x, Width: width})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 slices.SortFunc(spans, func(a, b Span) int {56 slices.SortFunc(spans, func(a, b Span) int {
38 if c := cmp.Compare(a.Y, b.Y); c != 0 {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 name string11 name string
12 frame Frame12 frame Frame
13 damage []Damage13 damage []Damage
14- skip *Damage
15 want []Span14 want []Span
16 full bool15 full bool
17 }{16 }{
@@ -73,29 +72,12 @@ func TestBuildDamagePlan(t *testing.T) {
73 want: nil,72 want: nil,
74 full: true,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 for _, tt := range tests {77 for _, tt := range tests {
96 t.Run(tt.name, func(t *testing.T) {78 t.Run(tt.name, func(t *testing.T) {
97 original := append([]Damage(nil), tt.damage...)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 if full != tt.full || !reflect.DeepEqual(got, tt.want) {81 if full != tt.full || !reflect.DeepEqual(got, tt.want) {
100 t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)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 candidate.Plan.Snapshot = true40 candidate.Plan.Snapshot = true
41 return candidate, nil41 return candidate, nil
42 }42 }
43- if err := committed.Validate(); err != nil {
44- return DeltaCandidate{}, err
45- }
46 43
47 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {44 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
48 candidate.Plan = planSingleDamage(frame, damage[0])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 if needsFull(damage) {49 if needsFull(damage) {
53- dirty, full := buildDirtyLinePlan(frame, committed, nil)50+ dirty, full := buildDirtyLinePlan(frame, committed)
54 if len(dirty) > 0 || full {51 if len(dirty) > 0 || full {
55 candidate.Plan.Snapshot = true52 candidate.Plan.Snapshot = true
56 candidate.Plan.Spans = dirty53 candidate.Plan.Spans = dirty
@@ -70,7 +67,7 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
70 var spans []Span67 var spans []Span
71 var full bool68 var full bool
72 if len(damage) == 0 {69 if len(damage) == 0 {
73- spans, full = buildDirtyLinePlan(frame, committed, nil)70+ spans, full = buildDirtyLinePlan(frame, committed)
74 } else {71 } else {
75 spans, full = buildDamagePlan(frame, damage, skip)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 if candidate.Plan.Scroll.Height != 0 {79 if candidate.Plan.Scroll.Height != 0 {
83 candidate.Plan = DeltaPlan{Snapshot: true}80 candidate.Plan = DeltaPlan{Snapshot: true}
84 } else {81 } else {
85- dirty, full := buildDirtyLinePlan(frame, committed, spans[:0])82+ candidate.Plan.Snapshot = true
86- if full || len(dirty) == 0 {83+ candidate.Plan.Spans = spans
87- candidate.Plan = DeltaPlan{Snapshot: true}
88- } else {
89- candidate.Plan.Snapshot = true
90- candidate.Plan.Spans = dirty
91- }
92 }84 }
93 return candidate, nil85 return candidate, nil
94 }86 }
@@ -110,13 +102,13 @@ func planSingleDamage(frame Frame, d Damage) DeltaPlan {
110 }102 }
111 plan := DeltaPlan{Spans: spans}103 plan := DeltaPlan{Spans: spans}
112 if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) {104 if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) {
113- return DeltaPlan{Snapshot: true}105+ plan.Snapshot = true
114 }106 }
115 return plan107 return plan
116 }108 }
117 109
118-func buildDirtyLinePlan(frame, committed Frame, spans []Span) ([]Span, bool) {110+func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) {
119- spans = spans[:0]111+ var spans []Span
120 for y := range frame.Height {112 for y := range frame.Height {
121 frameRow := frame.Row(y)113 frameRow := frame.Row(y)
122 committedRow := committed.Row(y)114 committedRow := committed.Row(y)
M pkg/renderer/delta_plan_test.go +6 -34
@@ -116,14 +116,17 @@ func TestPlanDelta(t *testing.T) {
116 frame: testFrame("changed!"),116 frame: testFrame("changed!"),
117 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 8, Height: 1}},117 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 8, Height: 1}},
118 committed: NewFrame(8, 1),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 frame: testFrame("aaaa", "bbbb"),123 frame: testFrame("aaaa", "bbbb"),
124 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}},124 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}},
125 committed: NewFrame(4, 2),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 name: "snapshot cost includes style runs",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 func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) {213 func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) {
239 committed := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777")214 committed := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777")
240 frame := committed.Clone()215 frame := committed.Clone()
@@ -259,9 +234,6 @@ func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) {
259 t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))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 func fillFrameRow(frame Frame, y int, row string) {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 func TestWideRunCursorTrackingAndEL(t *testing.T) {264 func TestWideRunCursorTrackingAndEL(t *testing.T) {
265 t.Run("cursor advances two columns per wide rune", func(t *testing.T) {265 t.Run("cursor advances two columns per wide rune", func(t *testing.T) {
266 // Rect A ends with a wide pair (not at the edge); rect B starts one266 // 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 the267+ // column later. The gap between them must be crossed with the cursor
268- // damaged regions.268+ // position that reflects the terminal advancing two columns for the
269+ // wide rune — otherwise the relative move would be wrong.
269 r := New(Capabilities{})270 r := New(Capabilities{})
270 base := NewFrame(8, 1)271 base := NewFrame(8, 1)
271 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {272 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -285,8 +286,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
285 {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},286 {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},
286 }287 }
287 got := drawGolden(t, r, frame, damage)288 got := drawGolden(t, r, frame, damage)
288- // The literal space bridges the undamaged gap between the two regions.289+ // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF.
289- want := "\x1b[1;1H你AB CDE\x1b[0m"290+ want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m"
290 if got != want {291 if got != want {
291 t.Fatalf("output = %q, want %q", got, want)292 t.Fatalf("output = %q, want %q", got, want)
292 }293 }
@@ -294,8 +295,9 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
294 295
295 t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {296 t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
296 // A damage rect that ends on a wide head whose continuation lies297 // A damage rect that ends on a wide head whose continuation lies
297- // outside the rect is followed by a literal space that bridges the298+ // outside the rect makes the terminal advance one column further than
298- // damaged regions. The trailing erase sequence clears the row tail.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 r := New(Capabilities{})301 r := New(Capabilities{})
300 base := NewFrame(8, 1)302 base := NewFrame(8, 1)
301 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {303 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -312,9 +314,9 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
312 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},314 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
313 }315 }
314 got := drawGolden(t, r, frame, damage)316 got := drawGolden(t, r, frame, damage)
315- // The expected output preserves the literal-space bridge and trailing317+ // After A你 the real cursor is at col 3, not col 2: a relative CUF
316- // erase sequence for this split wide pair.318+ // would be off by one, so the second rect must use absolute CUP.
317- want := "\x1b[1;1HA你 B\x1b[K\x1b[0m"319+ want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
318 if got != want {320 if got != want {
319 t.Fatalf("output = %q, want %q", got, want)321 t.Fatalf("output = %q, want %q", got, want)
320 }322 }
@@ -423,7 +425,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
423 if err != nil {425 if err != nil {
424 t.Fatal(err)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 t.Fatalf("output = %q, want %q", got, want)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 var err error65 var err error
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) {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 if err = frame.Validate(); err == nil {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 } else {87 } else {
71 candidate, err = PlanDelta(frame, damage, r.committedFrame(), reset || len(r.shadow) == 0)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 return prepared, nil96 return prepared, nil
80 }97 }
81 98
82- buf, ok := bufferPool.Get().(*bytes.Buffer)99+ buf := bufferPool.Get().(*bytes.Buffer)
83- if !ok {
84- buf = new(bytes.Buffer)
85- }
86 buf.Reset()100 buf.Reset()
87 defer putBuffer(buf)101 defer putBuffer(buf)
88 if r.caps.SynchronizedOutput {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 want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",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 for _, tt := range tests {1294 for _, tt := range tests {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 13

↑↓ moveenter openesc close