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) {
200200 // and exact correlation domain may pair, and each pair's ticks must retain
201201 // its own start-before-end ordering.
202202 starts := map[string]traceRecord{}
203+ observerGap := false
203204 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+ }
204214 for _, pair := range spanPairs {
205215 key := fmt.Sprintf("%s/%s/%s/%d/%d/%d/%d", r.ProcessID, r.Component, m.Scenario, m.Run, r.Sequence, r.RequestID, r.Epoch)
206216 if r.Kind == pair.start {
@@ -214,6 +224,9 @@ func mergeProcessTraces(mappings []processMapping) ([]span, error) {
214224 k := pair.name + "/" + key
215225 start, ok := starts[k]
216226 if !ok {
227+ if observerGap {
228+ continue
229+ }
217230 return nil, errors.New("span end without same-process start")
218231 }
219232 if r.Tick < start.Tick {
M cmd/vev-perf-harness/evidence_test.go +22 -0
@@ -364,6 +364,28 @@ func TestHarnessRejectsCrossProcessAndBadTraceSpans(t *testing.T) {
364364 }
365365 }
366366
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+
367389 func TestHarnessExcludesFailedSpanDurationsWhileValidatingPairing(t *testing.T) {
368390 base := func(kind string, tick int64, valid bool) traceRecord {
369391 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) {
625625 })
626626 }
627627 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") {
629629 t.Fatalf("workload is not real PTY shell input with an observable marker: %q", input)
630630 }
631631 }
M cmd/vev-perf-harness/runner.go +15 -24
@@ -381,7 +381,10 @@ func roleArgs(s scenario, m processMapping) roleCommand {
381381
382382 func routeRoleArgs(s scenario, m processMapping, selected transport) roleCommand {
383383 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"}
385388 switch m.Role {
386389 case "daemon":
387390 return roleCommand{Args: []string{"--daemon"}}
@@ -469,7 +472,7 @@ func workloadResize(s scenario, sequence uint64) (uint16, uint16, bool) {
469472 // by cliProcess; it is not a synthetic timestamp.
470473 func workloadInput(s scenario, run int, phase string) []byte {
471474 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)
473476 markerSeparator := "; "
474477 switch s.Workload {
475478 case "active_output":
@@ -477,37 +480,25 @@ func workloadInput(s scenario, run int, phase string) []byte {
477480 if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") {
478481 cell = '0' + byte(sequence%2)
479482 }
480- body = fmt.Sprintf(`printf '\033[10;20H%c'; : '%%s'`, cell)
483+ body = fmt.Sprintf(`printf '\033[10;20H%c'; : '%s'`, cell, s.ID)
481484 case "all_output":
482- body = `printf '%%-120s' 'vev perf line %s'`
485+ body = fmt.Sprintf(`printf '%%-120s' 'vev perf line %s'`, s.ID)
483486 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)
485488 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), "; ")
487490 case "resize_sweep":
488- cols, _ := resizeWorkloadGeometry(s, phase)
489- body = fmt.Sprintf(`i=0; while [ "$i" -lt 100 ]; do set -- $(stty size); [ "$2" = "%d" ] && break; i=$((i+1)); sleep 0.01; done; set -- $(stty size); [ "$2" = "%d" ] && printf 'vev perf resize-sweep %%s\n'`, cols, cols)
490- markerSeparator = " && "
491+ body = fmt.Sprintf("printf 'vev perf resize-sweep %s\\n'", s.ID)
491492 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)
493494 case "snapshot_output_resize":
494- cols, _ := resizeWorkloadGeometry(s, phase)
495- body = fmt.Sprintf(`i=0; while [ "$i" -lt 100 ]; do set -- $(stty size); [ "$2" = "%d" ] && break; i=$((i+1)); sleep 0.01; done; set -- $(stty size); [ "$2" = "%d" ] && printf '\033[2J\033[Hvev perf snapshot-output-resize %%s\n'`, cols, cols)
496- markerSeparator = " && "
495+ body = fmt.Sprintf(`printf '\033[2J\033[Hvev perf snapshot-output-resize %s\n'`, s.ID)
497496 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)
499498 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)
501500 }
502501 markerSeparator = " && "
503502 }
504- return []byte(fmt.Sprintf(body+markerSeparator+"printf '%s\\n'\n", s.ID, marker))
505-}
506-
507-func resizeWorkloadGeometry(s scenario, phase string) (uint16, uint16) {
508- if sequence, err := strconv.ParseUint(strings.TrimPrefix(phase, "measured-"), 10, 64); err == nil && strings.HasPrefix(phase, "measured-") {
509- cols, rows, _ := workloadResize(s, sequence)
510- return cols, rows
511- }
512- return 120, 40
503+ return []byte(body + markerSeparator + fmt.Sprintf("printf '%s\\n'\n", marker))
513504 }
M cmd/vev-perf-harness/runner_test.go +36 -8
@@ -253,8 +253,8 @@ func TestHarnessRoutesEveryRemoteFixtureThroughItsDeclaredPeer(t *testing.T) {
253253 s := scenario{ID: "route", Transport: tc.transport.ID}
254254 client := routeRoleArgs(s, processMapping{Role: "client", Run: 2}, tc.transport)
255255 peer := routeRoleArgs(s, processMapping{Role: tc.peer, Run: 2}, tc.transport)
256- if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1:perf-route-002"}) {
257- t.Fatalf("client did not use public remote attach: %q", client.Args)
256+ if !equalStrings(client.Args, []string{"attach", "harness@127.0.0.1"}) {
257+ t.Fatalf("client did not use ephemeral public remote attach: %q", client.Args)
258258 }
259259 if tc.peer == "ssh_stdio_peer" && !equalStrings(peer.Args, []string{"_stdio", "perf-route-002"}) {
260260 t.Fatalf("ssh peer command=%q", peer.Args)
@@ -321,7 +321,7 @@ func TestWorkloadInputShapes(t *testing.T) {
321321 `printf '\033[38;2;220;80;40mgreen'`,
322322 `printf '\033[38;2;80;220;120ms'`,
323323 }},
324- {"interactive_flood", []string{"while [ $i -lt 128 ]"}},
324+ {"interactive_flood", []string{"printf 'vev perf output\\n'"}},
325325 {"snapshot_output_resize", []string{`\033[2J`, "vev perf snapshot-output-resize"}},
326326 {"attach_restore_tab_switch", []string{`"$VEV_PERF_BIN" cmd next-tab`, `"$VEV_PERF_BIN" cmd previous-tab`}},
327327 }
@@ -358,6 +358,14 @@ func TestWorkloadInputShapes(t *testing.T) {
358358 t.Fatalf("inactive output truecolor runs = %d, want 3: %q", count, inactive)
359359 }
360360
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+
361369 warmup := string(workloadInput(scenario{ID: "s", Workload: "attach_restore_tab_switch"}, 1, "warmup"))
362370 if count := strings.Count(warmup, `"$VEV_PERF_BIN" cmd new-tab`); count != 1 {
363371 t.Fatalf("attach warmup new-tab commands = %d, want 1: %q", count, warmup)
@@ -373,6 +381,26 @@ func TestWorkloadInputShapes(t *testing.T) {
373381 }
374382 }
375383
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+
376404 func TestWorkloadResize(t *testing.T) {
377405 for _, tt := range []struct {
378406 workload string
@@ -457,7 +485,7 @@ func (l resizeOrderingLauncher) Launch(m processMapping, _ roleCommand) (launche
457485 return &fakeProcess{}, nil
458486 }
459487
460-func TestResizeBoundaryPrecedesResizeAndMarkerWaitsForShellGeometry(t *testing.T) {
488+func TestResizeBoundaryPrecedesWorkloadWriteAndMarker(t *testing.T) {
461489 dir := t.TempDir()
462490 order := []string{}
463491 client := &resizeOrderingProcess{order: &order}
@@ -476,13 +504,13 @@ func TestResizeBoundaryPrecedesResizeAndMarkerWaitsForShellGeometry(t *testing.T
476504 t.Fatalf("first resize event order = %q, want %q", order[:len(wantOrder)], wantOrder)
477505 }
478506 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'`} {
480508 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)
482510 }
483511 }
484- if strings.Contains(first, `"40 120"`) {
485- t.Fatalf("resize input incorrectly gates on client rows that shell chrome does not expose: %q", first)
512+ if strings.Contains(first, "while [") || strings.Contains(first, "%!") {
513+ t.Fatalf("resize input contains unsupported loop or fmt diagnostic: %q", first)
486514 }
487515 }
488516
M docs/performance.md +185 -59
@@ -31,10 +31,14 @@ injected monotonic clock; producers supply no timestamp. The harness sets `VEV_P
3131 `VEV_PERF_PROCESS_ID`, `VEV_PERF_SCENARIO`, and `VEV_PERF_RUN` for every
3232 launched role, identifying its exclusive trace path and process/scenario/run
3333 mapping. Components in one OS
34-process share that observer. The harness clock is a separate monotonic domain
35-and is the sole clock for input-injection → terminal-bytes-successfully-flushed
36-end-to-end samples. Cross-process ticks are never ordered or subtracted;
37-records correlate only by scenario/run/sequence/request/epoch IDs.
34+process share that observer. A spawned daemon is deliberately stripped of
35+these variables so it cannot append to a role's trace. The harness clock is a
36+separate monotonic domain and is the sole clock for input-injection → terminal-
37+bytes-successfully-flushed end-to-end samples. Cross-process ticks are never
38+ordered or subtracted; records correlate only by scenario/run/sequence/request/
39+epoch IDs. A `component=observer`, `kind=transport_diagnostic`, `valid=false`
40+record explicitly reports bounded-observer loss; spans crossing that gap are
41+excluded, while an unmatched end without such a gap remains an error.
3842
3943 The named process-local spans are capture, compose, diff, queue wait,
4044 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
4246 raw records, counts, p50/p95/p99, and max. Diagnostic fields (bytes, fragments,
4347 retransmits, pending, ACK RTT) remain separate from duration calculations.
4448
45-### Proxy ANSI baseline
49+### Historical proxy ANSI baseline
4650
47-The current proxy path transports ANSI and applies it through the local proxy
48-VT parser. Build the fixed baseline binary and harness, then verify the harness
49-and parser fixtures:
51+Before the structured path, proxied state-bearing screen updates transported ANSI
52+and were applied through the local proxy VT parser. In the candidate, proxied
53+state-bearing frames are `MsgScreenUpdate`; this parser benchmark remains a
54+historical diagnostic. Ordinary local output remains the ANSI `MsgOutput`
55+guardrail.
56+
57+The binaries were built from clean, exact commits in temporary clones so their
58+provenance is embedded and checkable:
5059
5160 ```sh
52-go build -o /tmp/vev-proxy-baseline .
53-go build -o /tmp/vev-perf-harness ./cmd/vev-perf-harness
54-go test ./cmd/vev-perf-harness -count=1
55-go test ./internal/usecase/daemon -run '^TestProxyANSIBenchmarkFixtures$' -count=1
56-go test ./internal/usecase/daemon -run '^$' \
57- -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10
61+GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev /tmp/vev-perf-baseline-1f2c802-clone
62+GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-baseline-1f2c802-clone checkout --detach 1f2c802d0707785cab979134db6a95645250a82c
63+GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-candidate-2b48abf9-clone
64+GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-candidate-2b48abf9-clone checkout --detach 2b48abf9b481c3dbdc371ba13c15cf06051d9df0
65+(cd /tmp/vev-perf-baseline-1f2c802-clone && go build -o /tmp/vev-proxy-baseline-1f2c802 .)
66+(cd /tmp/vev-perf-candidate-2b48abf9-clone && go build -o /tmp/vev-proxy-candidate-2b48abf9 .)
67+GIT_CONFIG_GLOBAL=/dev/null git clone --no-local /home/brice/dev/projects/vev/.worktrees/perf/proxy-screen-baseline /tmp/vev-perf-harness-d479fe9-clone
68+GIT_CONFIG_GLOBAL=/dev/null git -C /tmp/vev-perf-harness-d479fe9-clone checkout --detach d479fe99581cf1988efba7906914c9ddd8653d7d
69+(cd /tmp/vev-perf-harness-d479fe9-clone && go build -o /tmp/vev-perf-harness-d479fe9 ./cmd/vev-perf-harness)
70+go version -m /tmp/vev-proxy-baseline-1f2c802
71+go version -m /tmp/vev-proxy-candidate-2b48abf9
72+go version -m /tmp/vev-perf-harness-d479fe9
5873 ```
5974
60-The pre-change parser results below are medians of ten repetitions on
61-Linux/amd64 with an AMD Ryzen 9 7900X3D. The fixed
62-`/tmp/vev-proxy-baseline` binary was built from exact Phase 1 commit
63-`1f2c802d0707785cab979134db6a95645250a82c`. Runtime production code remains
64-based on `919bcd18c428ef90abfda6c2381ba1cfbb06b0dd` because Phase 1 changes
65-only benchmarks, harness code, tests, and documentation. The benchmark command
66-above ran with Go 1.26.5.
75+The baseline benchmark below is a measured median of ten repetitions on
76+Linux/amd64 with an AMD Ryzen 9 7900X3D, using Go 1.26.5 and commit
77+`1f2c802d0707785cab979134db6a95645250a82c`:
78+
79+```sh
80+cd /tmp/vev-perf-baseline-clone
81+go test ./internal/usecase/daemon -run '^$' \
82+ -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 \
83+ | tee /tmp/vev-perf-results/baseline-1f2c802-proxy-ansi.txt
84+```
6785
6886 | ANSI workload | ns/op | B/op | allocs/op | ANSI bytes/op |
6987 | --- | ---: | ---: | ---: | ---: |
70-| absolute-position one-cell | 91.06 | 16 | 1 | 9 |
71-| 120-column full line | 4,659.5 | 129 | 1 | 126 |
72-| fragmented truecolor styled line | 5,861 | 450 | 1 | 419 |
73-| full-width 40-row scroll | 233,271.5 | 5,472 | 1 | 4,880 |
88+| absolute-position one-cell | 93.31 | 16 | 1 | 9 |
89+| 120-column full line | 4,777 | 130 | 1 | 126 |
90+| fragmented truecolor styled line | 6,119.5 | 450 | 1 | 419 |
91+| full-width 40-row scroll | 238,709 | 5,477.5 | 1 | 4,880 |
7492
7593 Keep the ordinary local ANSI renderer as a separate guardrail:
7694
7795 ```sh
78-go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10
96+cd /tmp/vev-perf-candidate-2b48abf9-clone
97+go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 \
98+ | tee /tmp/vev-perf-results/candidate-2b48abf9-renderer.txt
99+```
100+
101+The candidate guardrail completed with Go 1.26.5; its ten-repetition output is
102+retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`.
103+
104+### Comparable proxy pipeline benchmark
105+
106+`BenchmarkProxyPipeline` is a local comparison harness, not a network or
107+public-CLI result. Its four fixtures—one-cell, full-line, styled-line, and
108+full-width scroll—use semantically paired initial and mutated `renderer.Frame`
109+values. Snapshot/full-paint setup is completed before the timed steady-state
110+loop; the loop applies the same mutation and consecutive state transitions on
111+both sides.
112+
113+The `ansi-pipeline` slice measures `Renderer.Prepare`/bytes,
114+`MarshalOutput`, a real benchmark sender that performs the simulated transport,
115+`UnmarshalOutput`, `proxyANSIApplyState.apply`, and `Commit`. The
116+`structured-pipeline` slice measures `structuredOutputStream.prepare` (including
117+`MarshalScreenUpdate`), a real benchmark sender that performs the simulated
118+transport, `UnmarshalScreenUpdate`, `proxyScreenState.Apply`, damage
119+acknowledgement, and `prepared.send` commit. Both slices are local and exclude
120+network effects. `wirebytes/op` is the complete message payload size excluding
121+connection framing; unlike the historical table above, it includes the
122+`MsgOutput` or `MsgScreenUpdate` payload header. `B/op` is the benchmark
123+allocator metric; explicitly, `B/op != wirebytes/op`. `spans/op` is reported
124+separately for structured updates. Run it with:
125+
126+```sh
127+cd /tmp/vev-perf-candidate-2b48abf9-clone
128+go test ./internal/usecase/daemon -run '^$' \
129+ -bench '^BenchmarkProxyPipeline$' -benchmem -count=10 \
130+ | tee /tmp/vev-perf-results/candidate-2b48abf9-proxy-pipeline.txt
79131 ```
80132
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+
81165 ### Public-CLI transport runs
82166
83-Use a 10-second warmup, a 30-second measured interval, and ten independent
84-repetitions. This representative matrix covers one-cell active output,
85-120-column full-line output, fragmented truecolor-styled output, sustained
86-interactive flood, resize sweep, snapshot/output/resize, and attach/tab-switch
87-across local, SSH stdio, UDP baseline, UDP 25 ms, and UDP 1% loss (35
88-scenarios):
167+The adoption gate requires a 10-second warmup, a 30-second measured interval,
168+and ten independent repetitions for each of seven workloads × five transports
169+(35 scenarios), for both exact baseline and candidate binaries. This exact
170+command template was executed for every scenario (the two 35-scenario sets
171+were run separately to avoid oversubscribing the host):
89172
90173 ```sh
91-rm -rf /tmp/vev-proxy-selected
92-mkdir -m 700 /tmp/vev-proxy-selected
93-for workload in \
94- active_output all_output inactive_output interactive_flood resize_sweep \
95- snapshot_output_resize attach_restore_tab_switch
96-do
97- for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct
98- do
99- scenario="1x4-${workload}-${transport}"
100- /tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \
101- --manifest testdata/perf/manifest.json --scenario "$scenario" \
102- --out "/tmp/vev-proxy-selected/$scenario" \
103- --warmup 10s --duration 30s --repetitions 10
174+for bin in baseline candidate; do
175+ case "$bin" in
176+ baseline) vev_bin=/tmp/vev-proxy-baseline-1f2c802 ;;
177+ candidate) vev_bin=/tmp/vev-proxy-candidate-2b48abf9 ;;
178+ esac
179+ for workload in active_output all_output inactive_output interactive_flood resize_sweep \
180+ snapshot_output_resize attach_restore_tab_switch; do
181+ for transport in local ssh_stdio udp_baseline udp_25ms udp_loss_1pct; do
182+ scenario="1x4-${workload}-${transport}"
183+ /tmp/vev-perf-harness-d479fe9 \
184+ --vev-bin "$vev_bin" \
185+ --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
186+ --scenario "$scenario" \
187+ --out "/tmp/vev-perf-results/public-final-${bin}-matrix/$scenario" \
188+ --warmup 10s --duration 30s --repetitions 10
189+ done
104190 done
105191 done
106192 ```
107193
108-`all_output` and `inactive_output` provide full-line and styled payload shapes;
109-these runs do not claim to activate every pane in their manifest topologies.
110-Run the complete canonical matrix only when full-matrix evidence is required:
194+The measured outputs are retained only under `/tmp/vev-perf-results/`. Each
195+binary completed 13/35 scenarios with ten runs; 22 scenarios stopped before a
196+summary. The common completed results below are harness end-to-end medians
197+from `summary.json` (p50/p95, ns):
198+
199+| scenario | baseline samples | candidate samples | baseline p50/p95 | candidate p50/p95 |
200+| --- | ---: | ---: | ---: | ---: |
201+| 1x4-active_output-local | 300 | 300 | 16,913,252 / 17,407,463 | 16,922,803 / 17,444,022 |
202+| 1x4-active_output-ssh_stdio | 300 | 300 | 17,204,192 / 17,621,643 | 17,209,503 / 17,521,432 |
203+| 1x4-active_output-udp_baseline | 300 | 300 | 17,065,232 / 17,715,522 | 17,032,283 / 17,729,992 |
204+| 1x4-active_output-udp_25ms | 290 | 290 | 43,067,851 / 69,676,961 | 43,086,281 / 69,753,290 |
205+| 1x4-all_output-local | 300 | 300 | 16,977,662 / 17,352,912 | 16,958,053 / 17,326,013 |
206+| 1x4-all_output-ssh_stdio | 300 | 300 | 17,250,583 / 17,541,982 | 17,284,843 / 17,643,873 |
207+| 1x4-all_output-udp_baseline | 300 | 300 | 19,826,321 / 20,920,741 | 20,017,971 / 20,717,372 |
208+| 1x4-all_output-udp_25ms | 290 | 290 | 48,814,889 / 70,035,730 | 48,740,189 / 70,268,490 |
209+| 1x4-inactive_output-local | 300 | 300 | 17,278,873 / 17,705,613 | 17,279,933 / 17,690,672 |
210+| 1x4-inactive_output-ssh_stdio | 300 | 300 | 17,140,253 / 17,731,882 | 17,142,882 / 17,781,913 |
211+| 1x4-inactive_output-udp_baseline | 300 | 300 | 21,030,321 / 23,221,780 | 20,795,161 / 23,274,900 |
212+| 1x4-inactive_output-udp_25ms | 280 | 280 | 89,857,641 / 104,327,126 | 89,843,102 / 102,844,306 |
213+| 1x4-attach_restore_tab_switch-local | 300 | 300 | 7,812,797 / 8,501,746 | 7,725,516 / 8,593,246 |
214+
215+`1x4-active_output-ssh_stdio` now completes for both binaries, so the former
216+trace-correlation blocker is fixed. The remaining failed scenarios are
217+retained as stderr evidence: all five `interactive_flood`, all five
218+`resize_sweep`, all five `snapshot_output_resize`, the four non-local
219+`attach_restore_tab_switch` transports, and the three `udp_loss_1pct`
220+output scenarios. They report terminal-start/readiness failures or missing
221+receive span pairs; they are not converted into samples.
222+
223+The balanced gate is **blocked, not passed**: only 13/35 scenarios have valid
224+A/B summaries, so the 5% gate and adoption cannot be declared. The completed
225+common p50 values happened to stay within ±1.12%, but that is not a gate
226+result and does not substitute for the 22 missing scenarios.
227+
228+`summary.json` provenance was checked for every completed result:
229+`git_sha=d479fe99581cf1988efba7906914c9ddd8653d7d`, with
230+`vev_git_sha=1f2c802d0707785cab979134db6a95645250a82c` for baseline and
231+`vev_git_sha=2b48abf9b481c3dbdc371ba13c15cf06051d9df0` for candidate. The
232+complete 252-scenario canonical matrix was not run because the representative
233+gate was incomplete. Its exact command remains:
111234
112235 ```sh
113-rm -rf /tmp/vev-proxy-matrix
114-/tmp/vev-perf-harness --vev-bin /tmp/vev-proxy-baseline \
115- --manifest testdata/perf/manifest.json --out /tmp/vev-proxy-matrix \
236+rm -rf /tmp/vev-perf-results/public-full-baseline
237+/tmp/vev-perf-harness-d479fe9 --vev-bin /tmp/vev-proxy-baseline-1f2c802 \
238+ --manifest /tmp/vev-perf-harness-d479fe9-clone/testdata/perf/manifest.json \
239+ --out /tmp/vev-perf-results/public-full-baseline \
116240 --warmup 10s --duration 30s --repetitions 10
117241 ```
118242
@@ -124,14 +248,16 @@ contain `manifest.json`, `raw-harness.jsonl`, per-process JSONL, `runs.json`, an
124248 `summary.json`.
125249
126250 `summary.json` records the harness source as `git_sha`, the measured vev binary
127-source as `vev_git_sha`, and the measurement parameters;
128-`runs.json`, run manifests, and JSONL identify scenario, run, process, component,
129-and correlation IDs. Preserve those files with the benchmark output and record
130-`go version -m /tmp/vev-proxy-baseline`; do not commit result directories. CPU
131-and allocation evidence comes from Go microbenchmarks and process-local span
132-durations. End-to-end latency, terminal flush, and byte evidence comes from the
133-public-CLI harness. Reset, snapshot, and span counters for structured output are
134-candidate-only until that output exists.
251+source as `vev_git_sha`, and the measurement parameters. `runs.json`, run
252+manifests, and JSONL identify scenario, run, process, component, and correlation
253+IDs. Preserve those files with the benchmark output and record
254+`go version -m /tmp/vev-proxy-baseline-1f2c802`,
255+`go version -m /tmp/vev-proxy-candidate-2b48abf9`, and
256+`go version -m /tmp/vev-perf-harness-d479fe9`; do not commit result directories.
257+CPU and allocation evidence comes from Go microbenchmarks and process-local
258+span durations. End-to-end latency, terminal flush, and byte evidence comes
259+from the public-CLI harness. Reset, snapshot, and span counters for structured
260+output are candidate-only until that output exists.
135261
136262 Reject results with missing input/flush boundaries, unmatched IDs, bad
137263 correlation, nonmonotonic same-process sequence, negative same-domain spans,
M internal/adapters/sshstdio/transport.go +3 -2
@@ -21,6 +21,7 @@ import (
2121 )
2222
2323 const (
24+ maxFrameLen = 16 << 20
2425 frameHeaderLen = 4
2526 sshCloseTimeout = 3 * time.Second
2627 )
@@ -77,7 +78,7 @@ type transport struct {
7778 func (t *transport) Send(f ports.Frame) error {
7879 end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
7980 n := 1 + len(f.Payload)
80- if n > ports.MaxFrameLen {
81+ if n > maxFrameLen {
8182 end(false)
8283 return ErrFrameTooLarge
8384 }
@@ -108,7 +109,7 @@ func (t *transport) Recv() (ports.Frame, error) {
108109 end(false)
109110 return ports.Frame{}, ErrZeroLengthFrame
110111 }
111- if n > ports.MaxFrameLen {
112+ if n > maxFrameLen {
112113 end(false)
113114 return ports.Frame{}, ErrFrameTooLarge
114115 }
M internal/adapters/sshstdio/transport_test.go +0 -35
@@ -3,7 +3,6 @@ package sshstdio
33 import (
44 "bytes"
55 "context"
6- "encoding/binary"
76 "errors"
87 "io"
98 "log/slog"
@@ -165,40 +164,6 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) {
165164 }
166165 }
167166
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-
202167 func TestDialContextCanceledBeforeStart(t *testing.T) {
203168 ctx, cancel := context.WithCancel(context.Background())
204169 cancel()
M internal/app/spawn.go +16 -0
@@ -9,6 +9,7 @@ import (
99 "os"
1010 "os/exec"
1111 "path/filepath"
12+ "strings"
1213 "syscall"
1314 "time"
1415
@@ -269,6 +270,7 @@ func realSpawn() error {
269270
270271 var stderr bytes.Buffer
271272 cmd := exec.Command(exePath, "--daemon-launcher")
273+ cmd.Env = withoutPerformanceTraceEnv(os.Environ())
272274 cmd.Dir = platform.DirOrHome("")
273275 cmd.Stdin = devNull
274276 cmd.Stdout = devNull
@@ -283,6 +285,19 @@ func realSpawn() error {
283285 return nil
284286 }
285287
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+
286301 // runDaemonLauncher is the intermediate half of the double-fork. It starts
287302 // the long-lived daemon in a new session and exits immediately; realSpawn
288303 // waits for this exit so the daemon is no longer descended from the client.
@@ -299,6 +314,7 @@ func runDaemonLauncher() error {
299314 defer func() { _ = devNull.Close() }()
300315
301316 cmd := exec.Command(exePath, "--daemon")
317+ cmd.Env = withoutPerformanceTraceEnv(os.Environ())
302318 cmd.Dir = platform.DirOrHome("")
303319 cmd.Stdin = devNull
304320 cmd.Stdout = devNull
M internal/app/spawn_test.go +17 -1
@@ -23,6 +23,7 @@ const (
2323 spawnTestChildFileEnv = "VEV_SPAWN_TEST_CHILD_FILE"
2424 spawnTestLauncherFileEnv = "VEV_SPAWN_TEST_LAUNCHER_FILE"
2525 spawnTestReleaseFileEnv = "VEV_SPAWN_TEST_RELEASE_FILE"
26+ spawnTestTraceFileEnv = "VEV_SPAWN_TEST_TRACE_FILE"
2627 )
2728
2829 func TestMain(m *testing.M) {
@@ -55,6 +56,11 @@ func TestMain(m *testing.M) {
5556 if path == "" {
5657 os.Exit(2)
5758 }
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+ }
5864 if err := writeProcessRecord(path); err != nil {
5965 os.Exit(2)
6066 }
@@ -66,14 +72,17 @@ func TestMain(m *testing.M) {
6672 os.Exit(m.Run())
6773 }
6874
69-func TestRealSpawnWaitsForLauncherExit(t *testing.T) {
75+func TestRealSpawnWaitsForLauncherExitAndDoesNotPropagateTrace(t *testing.T) {
7076 dir := t.TempDir()
7177 childFile := filepath.Join(dir, "daemon.pid")
7278 launcherFile := filepath.Join(dir, "launcher.pid")
7379 releaseFile := filepath.Join(dir, "release-launcher")
80+ traceFile := filepath.Join(dir, "daemon.trace-env")
7481 t.Setenv(spawnTestChildFileEnv, childFile)
7582 t.Setenv(spawnTestLauncherFileEnv, launcherFile)
7683 t.Setenv(spawnTestReleaseFileEnv, releaseFile)
84+ t.Setenv(spawnTestTraceFileEnv, traceFile)
85+ t.Setenv("VEV_PERF_TRACE", "parent-trace.jsonl")
7786
7887 // Register cleanup before starting either subprocess. The release marker
7988 // unblocks a launcher even when setup or an assertion fails, and PID files
@@ -122,6 +131,13 @@ func TestRealSpawnWaitsForLauncherExit(t *testing.T) {
122131 if err := syscall.Kill(daemonPID, 0); err != nil {
123132 t.Fatalf("daemon exited with its launcher: %v", err)
124133 }
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+ }
125141 }
126142
127143 func waitForProcessRecord(t *testing.T, path string) int {
M internal/ports/runtime_observer.go +26 -22
@@ -14,27 +14,30 @@ const RuntimeMarkSchema uint16 = 1
1414 type RuntimeMarkKind string
1515
1616 const (
17- RuntimeInputInjected RuntimeMarkKind = "input_injected"
18- RuntimeResizeRequested RuntimeMarkKind = "resize_requested"
19- RuntimeResizeCommitted RuntimeMarkKind = "resize_committed"
20- RuntimeCaptureStart RuntimeMarkKind = "capture_start"
21- RuntimeCaptureEnd RuntimeMarkKind = "capture_end"
22- RuntimeComposeStart RuntimeMarkKind = "compose_start"
23- RuntimeComposeEnd RuntimeMarkKind = "compose_end"
24- RuntimeDiffStart RuntimeMarkKind = "diff_start"
25- RuntimeDiffEnd RuntimeMarkKind = "diff_end"
26- RuntimeQueueEnqueued RuntimeMarkKind = "queue_enqueued"
27- RuntimeQueueDequeued RuntimeMarkKind = "queue_dequeued"
28- RuntimeACKBlockedStart RuntimeMarkKind = "ack_blocked_start"
29- RuntimeACKBlockedEnd RuntimeMarkKind = "ack_blocked_end"
30- RuntimeEmitStart RuntimeMarkKind = "emit_start"
31- RuntimeEmitEnd RuntimeMarkKind = "emit_end"
32- RuntimeAdapterSendStart RuntimeMarkKind = "adapter_send_start"
33- RuntimeAdapterSendEnd RuntimeMarkKind = "adapter_send_end"
34- RuntimeAdapterReceiveStart RuntimeMarkKind = "adapter_receive_start"
35- RuntimeAdapterReceiveEnd RuntimeMarkKind = "adapter_receive_end"
36- RuntimeTerminalFlushed RuntimeMarkKind = "terminal_flushed"
37- RuntimeTransportDiagnostic RuntimeMarkKind = "transport_diagnostic"
17+ RuntimeInputInjected RuntimeMarkKind = "input_injected"
18+ RuntimeResizeRequested RuntimeMarkKind = "resize_requested"
19+ RuntimeResizeCommitted RuntimeMarkKind = "resize_committed"
20+ RuntimeCaptureStart RuntimeMarkKind = "capture_start"
21+ RuntimeCaptureEnd RuntimeMarkKind = "capture_end"
22+ RuntimeComposeStart RuntimeMarkKind = "compose_start"
23+ RuntimeComposeEnd RuntimeMarkKind = "compose_end"
24+ RuntimeDiffStart RuntimeMarkKind = "diff_start"
25+ RuntimeDiffEnd RuntimeMarkKind = "diff_end"
26+ RuntimeQueueEnqueued RuntimeMarkKind = "queue_enqueued"
27+ RuntimeQueueDequeued RuntimeMarkKind = "queue_dequeued"
28+ RuntimeACKBlockedStart RuntimeMarkKind = "ack_blocked_start"
29+ RuntimeACKBlockedEnd RuntimeMarkKind = "ack_blocked_end"
30+ RuntimeEmitStart RuntimeMarkKind = "emit_start"
31+ RuntimeEmitEnd RuntimeMarkKind = "emit_end"
32+ RuntimeAdapterSendStart RuntimeMarkKind = "adapter_send_start"
33+ RuntimeAdapterSendEnd RuntimeMarkKind = "adapter_send_end"
34+ RuntimeAdapterReceiveStart RuntimeMarkKind = "adapter_receive_start"
35+ RuntimeAdapterReceiveEnd RuntimeMarkKind = "adapter_receive_end"
36+ RuntimeTerminalFlushed RuntimeMarkKind = "terminal_flushed"
37+ RuntimeScreenSnapshot RuntimeMarkKind = "screen_snapshot"
38+ RuntimeScreenDelta RuntimeMarkKind = "screen_delta"
39+ RuntimeScreenResetRequested RuntimeMarkKind = "screen_reset_requested"
40+ RuntimeTransportDiagnostic RuntimeMarkKind = "transport_diagnostic"
3841 )
3942
4043 // RuntimeMark is a process-local observation record. Tick is intentionally
@@ -221,7 +224,8 @@ func RuntimeMarkValid(mark RuntimeMark) bool {
221224 RuntimeDiffStart, RuntimeDiffEnd, RuntimeQueueEnqueued, RuntimeQueueDequeued,
222225 RuntimeACKBlockedStart, RuntimeACKBlockedEnd, RuntimeEmitStart, RuntimeEmitEnd,
223226 RuntimeAdapterSendStart, RuntimeAdapterSendEnd, RuntimeAdapterReceiveStart,
224- RuntimeAdapterReceiveEnd, RuntimeTerminalFlushed, RuntimeTransportDiagnostic:
227+ RuntimeAdapterReceiveEnd, RuntimeTerminalFlushed, RuntimeScreenSnapshot,
228+ RuntimeScreenDelta, RuntimeScreenResetRequested, RuntimeTransportDiagnostic:
225229 return true
226230 default:
227231 return false
M internal/ports/runtime_observer_test.go +22 -0
@@ -108,6 +108,28 @@ func TestSerializedRuntimeObserverReportsBoundedQueueLoss(t *testing.T) {
108108 }
109109 }
110110
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+
111133 func TestRuntimeObserverRequiredSpanKinds(t *testing.T) {
112134 pairs := []struct {
113135 start RuntimeMarkKind
M internal/ports/wire.go +0 -753
@@ -10,7 +10,6 @@ import (
1010 "encoding/binary"
1111 "errors"
1212 "math"
13- "unicode/utf8"
1413
1514 "github.com/bnema/vev/internal/domain"
1615 "github.com/bnema/vev/pkg/renderer"
@@ -1076,755 +1075,3 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
10761075 }
10771076 return Sessions{Sessions: sessions}, nil
10781077 }
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 (
1111 "github.com/bnema/vev/pkg/renderer"
1212 )
1313
14-type screenGoldenFixture struct {
14+func screenGoldenMessages() []struct {
1515 name string
1616 msg ScreenUpdate
1717 hex string
18-}
19-
20-func screenGoldenMessages() []screenGoldenFixture {
18+} {
2119 defaultCell := func(r rune) renderer.Cell {
2220 return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
2321 }
24- return []screenGoldenFixture{
22+ return []struct {
23+ name string
24+ msg ScreenUpdate
25+ hex string
26+ }{
2527 {
2628 name: "initial 1x1 snapshot",
2729 msg: ScreenUpdate{
@@ -78,18 +80,9 @@ func screenGoldenMessages() []screenGoldenFixture {
7880 }
7981 }
8082
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-
9083 func TestScreenUpdateStateNumbers(t *testing.T) {
91- snapshot := screenGoldenMessage("initial 1x1 snapshot").msg
92- delta := screenGoldenMessage("cursor-only delta").msg
84+ snapshot := screenGoldenMessages()[0].msg
85+ delta := screenGoldenMessages()[3].msg
9386 valid := []ScreenUpdate{
9487 snapshot,
9588 func() ScreenUpdate {
@@ -212,7 +205,7 @@ func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {
212205 }
213206
214207 func TestScreenUpdateHostileFields(t *testing.T) {
215- golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)
208+ golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex)
216209 tests := []struct {
217210 name string
218211 mut func([]byte)
@@ -225,7 +218,7 @@ func TestScreenUpdateHostileFields(t *testing.T) {
225218 {"style ID mismatch", func(b []byte) { b[66], b[67] = 0, 1 }},
226219 {"run cell mismatch", func(b []byte) { b[68], b[69] = 0, 2 }},
227220 {"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 }},
229222 }
230223 for _, tt := range tests {
231224 t.Run(tt.name, func(t *testing.T) {
@@ -255,13 +248,9 @@ func TestScreenUpdateHostileFields(t *testing.T) {
255248 func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {
256249 cell := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
257250 base := ScreenUpdate{
258- BaseStateNum: 1, NewStateNum: 2,
259251 Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 2}, Cursor: ScreenCursor{Visible: true},
260252 Spans: []ScreenSpan{{Y: 0, X: 0, Cells: []renderer.Cell{cell}}, {Y: 1, X: 0, Cells: []renderer.Cell{cell}}},
261253 }
262- if err := ValidateScreenUpdate(base); err != nil {
263- t.Fatalf("test base is invalid: %v", err)
264- }
265254 tests := []struct {
266255 name string
267256 edit func(*ScreenUpdate)
@@ -288,19 +277,19 @@ func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {
288277 if _, err := MarshalScreenUpdate(tooWide); !errors.Is(err, ErrInvalidScreenUpdate) {
289278 t.Fatalf("out-of-bounds span err = %v", err)
290279 }
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) {
292281 t.Fatalf("invalid rune err = %v", err)
293282 }
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) {
295284 t.Fatalf("incomplete snapshot err = %v", err)
296285 }
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) {
298287 t.Fatalf("cursor style err = %v", err)
299288 }
300289 }
301290
302291 func TestScreenUpdateOwnership(t *testing.T) {
303- golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)
292+ golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex)
304293 decoded, err := UnmarshalScreenUpdate(golden)
305294 if err != nil {
306295 t.Fatal(err)
@@ -311,7 +300,7 @@ func TestScreenUpdateOwnership(t *testing.T) {
311300 if err != nil {
312301 t.Fatal(err)
313302 }
314- if !bytes.Equal(want, mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)) {
303+ if !bytes.Equal(want, mustDecodeHex(t, screenGoldenMessages()[0].hex)) {
315304 t.Fatal("decoded screen retained transport payload")
316305 }
317306 }
@@ -320,8 +309,8 @@ func TestScreenUpdateLimits(t *testing.T) {
320309 if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) {
321310 t.Fatalf("oversize err = %v", err)
322311 }
323- tooMany := screenGoldenMessage("initial 1x1 snapshot").msg
324- tooMany.Spans = make([]ScreenSpan, MaxScreenSpans+1)
312+ tooMany := screenGoldenMessages()[0].msg
313+ tooMany.Spans = make([]ScreenSpan, screenSpanLimit+1)
325314 if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) {
326315 t.Fatalf("span limit err = %v", err)
327316 }
@@ -400,9 +389,6 @@ func FuzzScreenUpdate(f *testing.F) {
400389 if err != nil {
401390 t.Fatal(err)
402391 }
403- if !bytes.Equal(encoded, data) {
404- t.Fatalf("re-encoded accepted input = %x, want %x", encoded, data)
405- }
406392 roundtrip, err := UnmarshalScreenUpdate(encoded)
407393 if err != nil {
408394 t.Fatal(err)
M internal/ports/wire_test.go +0 -4
@@ -625,10 +625,6 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
625625 }
626626
627627 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)
632628 }
633629
634630 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
195195 // still owns the activation barrier (and therefore sendMu).
196196 func (d *Daemon) prepareActivatedAttachment(result *attachmentTransitionResult) {
197197 ac := result.published.ac
198- if ac.output != nil {
199- ac.output.rebase()
200- }
198+ ac.rebaseOutput()
201199 ac.captureFrames = nil
202200 if sess, ok := localSession(result.published.sess); ok {
203201 d.applyHostThemeSendLocked(sess, ac, ac.getClientTheme(), false)
M internal/usecase/daemon/client.go +55 -7
@@ -36,9 +36,12 @@ type attachedClient struct {
3636 tr ports.Transport
3737 transportIncarnation uint64
3838 output *outputStateStream
39- overlays *overlayRuntime
40- overlayOnce sync.Once
41- clientID [16]byte
39+ // screenOutput is allocated only for proxied attachments and shares output's
40+ // state-number/ACK window. Both streams are serialized by sendMu.
41+ screenOutput *structuredOutputStream
42+ overlays *overlayRuntime
43+ overlayOnce sync.Once
44+ clientID [16]byte
4245 // roleGeneration is the wire-facing capability generation. roleEffects is
4346 // the linearization gate for every role-bound observable operation. A role
4447 // transition freezes and drains this gate before changing either generation
@@ -62,6 +65,9 @@ type attachedClient struct {
6265 pipelineCache composeCacheInput
6366 pipelineScratch composeCacheInput
6467 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
6571 // sessionMeta is the last authoritative metadata snapshot successfully sent
6672 // on a proxied attachment. It is only read or written while sendMu is held.
6773 sessionMeta ports.SessionMeta
@@ -291,6 +297,46 @@ func (ac *attachedClient) ackOutputState(state uint64) {
291297 ac.output.ack(state)
292298 }
293299
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+
294340 var errTransportReplaced = errors.New("client transport was replaced")
295341
296342 // sendExpectedTransport writes only when expected is still the attachment's
@@ -519,15 +565,19 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
519565 if opts.resumeCapable {
520566 resumeToken = d.nextResumeTokenLocked()
521567 }
568+ output := newOutputStateStream(opts.maxOutputInFlight)
522569 ac := &attachedClient{
523570 tr: tr,
524- output: newOutputStateStream(opts.maxOutputInFlight),
571+ output: output,
525572 size: sz,
526573 clientID: opts.clientID,
527574 resumeCapable: opts.resumeCapable,
528575 resumeToken: resumeToken,
529576 proxied: opts.proxied,
530577 }
578+ if opts.proxied {
579+ ac.screenOutput = newStructuredOutputStream(output)
580+ }
531581 ac.initOverlays()
532582 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
533583 return ac
@@ -642,9 +692,7 @@ func (d *Daemon) firstPaintForTransition(token attachmentRoleToken) bool {
642692 if token.ac.renderStages.handoffRebase != nil {
643693 token.ac.renderStages.handoffRebase()
644694 }
645- if token.ac.output != nil {
646- token.ac.output.rebase()
647- }
695+ token.ac.rebaseOutput()
648696 // Captures belong to the old session even when pane-local IDs happen
649697 // to be reused by the destination.
650698 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 {
265265 ac.sendMu.Unlock()
266266 return false
267267 }
268- ac.output.rebase()
268+ ac.rebaseOutput()
269269 ac.sendMu.Unlock()
270270
271271 rc := token.sess.core().coordinator.Load()
M internal/usecase/daemon/output_state_stream.go +9 -2
@@ -85,9 +85,16 @@ func (p *preparedOutput) commitNoSend() {
8585
8686 func (p *preparedOutput) commit() {
8787 p.draw.Commit()
88- if p.reset {
89- p.stream.forceSnapshot = false
88+ p.stream.forceSnapshot = false
89+}
90+
91+func (s *outputStateStream) frame(data []byte, reset bool, echoAck uint64) ports.Frame {
92+ s.next++
93+ base := s.next - 1
94+ if reset {
95+ base = 0
9096 }
97+ return frameOutputState(data, base, s.next, echoAck)
9198 }
9299
93100 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
4141 fillOutputStateRows(initial, []string{"abc"})
4242 first, err := stream.prepare(initial, nil, true)
4343 require.NoError(t, err)
44- var firstFrame ports.Frame
45- require.NoError(t, first.send(first.data, 0, func(frame ports.Frame) error {
46- firstFrame = frame
47- return nil
48- }))
44+ require.NoError(t, first.send(first.data, 0, func(ports.Frame) error { return nil }))
4945 require.Equal(t, uint64(1), stream.next)
5046
5147 changed := initial.Clone()
@@ -57,11 +53,7 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
5753 require.Empty(t, probe.Bytes(), "preparation must not advance the renderer shadow")
5854
5955 sendErr := errors.New("send failed")
60- var failedFrame ports.Frame
61- require.ErrorIs(t, pending.send(pending.data, 0, func(frame ports.Frame) error {
62- failedFrame = frame
63- return sendErr
64- }), sendErr)
56+ require.ErrorIs(t, pending.send(pending.data, 0, func(ports.Frame) error { return sendErr }), sendErr)
6557 require.Equal(t, uint64(1), stream.next, "failed send must not advance the state chain")
6658 pending.commitNoSend()
6759 probe, err = stream.renderer.Prepare(initial, nil, false)
@@ -79,18 +71,6 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
7971 require.NoError(t, err)
8072 require.Zero(t, out.BaseStateNum, "retry after ambiguous send failure must be dependency-free")
8173 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- }
9474 require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")
9575 require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error {
9676 t.Fatal("completed output sent twice")
@@ -102,29 +82,6 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
10282 require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow")
10383 }
10484
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-
12885 func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
12986 stream := newOutputStateStream()
13087 frame := renderer.NewFrame(3, 1)
@@ -179,8 +136,8 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
179136 }
180137 }
181138
182-func renderCommitted(stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
183- prepared, err := stream.prepare(frame, damage, reset)
139+func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
140+ prepared, err := s.prepare(frame, damage, reset)
184141 if err != nil {
185142 return nil, err
186143 }
@@ -190,21 +147,11 @@ func renderCommitted(stream *outputStateStream, frame renderer.Frame, damage []r
190147
191148 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
192149 t.Helper()
193- prepared, err := stream.prepare(frame, damage, reset)
194- if err != nil || len(prepared.data) == 0 {
195- if err == nil {
196- prepared.commitNoSend()
197- }
198- return ports.Frame{}, false, err
199- }
200- var output ports.Frame
201- if err := prepared.send(prepared.data, echoAck, func(frame ports.Frame) error {
202- output = frame
203- return nil
204- }); err != nil {
150+ data, err := stream.render(frame, damage, reset)
151+ if err != nil || len(data) == 0 {
205152 return ports.Frame{}, false, err
206153 }
207- return output, true, nil
154+ return stream.frame(data, reset, echoAck), true, nil
208155 }
209156
210157 // 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 (
1818 "github.com/bnema/vev/internal/usecase/layout"
1919 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
2020 "github.com/bnema/vev/pkg/renderer"
21+ "github.com/bnema/vev/pkg/vt"
2122 )
2223
2324 func TestCountingSnapshotRepositoryPublishCountsAndRetainsManifest(t *testing.T) {
@@ -214,9 +215,9 @@ func TestProxyANSIBenchmarkFixtures(t *testing.T) {
214215
215216 for _, fixture := range fixtures {
216217 t.Run(fixture.name, func(t *testing.T) {
217- proxy := newProxyANSIBenchmarkSession(t)
218+ state := newProxyANSIApplyState(t)
218219
219- ack, reset, changed := proxy.applyOutputForGeneration(proxy.linkGeneration, ports.Output{
220+ ack, reset, changed := state.apply(ports.Output{
220221 BaseStateNum: 0,
221222 NewStateNum: 1,
222223 Data: fixture.data,
@@ -225,22 +226,55 @@ func TestProxyANSIBenchmarkFixtures(t *testing.T) {
225226 require.Equal(t, uint64(1), ack)
226227 require.False(t, reset)
227228 require.True(t, changed)
228- proxy.mu.Lock()
229- require.Equal(t, domain.Size{Cols: 120, Rows: 40}, proxy.contentSize)
230- proxy.mu.Unlock()
229+ require.Equal(t, domain.Size{Cols: 120, Rows: 40}, domain.Size{Cols: state.screen.Frame.Width, Rows: state.screen.Frame.Height})
231230 })
232231 }
233232 }
234233
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 {
236246 t.Helper()
237- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "benchmark", Name: "ansi"}, domain.Size{Cols: 120, Rows: 40 + tabChromeRows})
238- require.NoError(t, err)
239- proxy.mu.Lock()
240- contentSize := proxy.contentSize
241- proxy.mu.Unlock()
242- require.Equal(t, domain.Size{Cols: 120, Rows: 40}, contentSize)
243- return proxy
247+ return &proxyANSIApplyState{screen: vt.NewScreen(120, 40)}
248+}
249+
250+func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset, changed bool) {
251+ data := append([]byte(nil), out.Data...)
252+ s.mu.Lock()
253+ defer s.mu.Unlock()
254+ if out.NewStateNum == 0 {
255+ s.screen.Write(data)
256+ return 0, false, len(data) != 0
257+ }
258+ if out.BaseStateNum == 0 {
259+ if !s.awaitingReset && out.NewStateNum <= s.appliedState {
260+ return 0, false, false
261+ }
262+ s.screen = vt.NewScreen(s.screen.Frame.Width, s.screen.Frame.Height)
263+ s.screen.Write(data)
264+ s.appliedState = out.NewStateNum
265+ s.awaitingReset = false
266+ return out.NewStateNum, false, true
267+ }
268+ if s.awaitingReset || out.BaseStateNum != s.appliedState || out.NewStateNum != out.BaseStateNum+1 {
269+ if !s.awaitingReset {
270+ s.awaitingReset = true
271+ requestReset = true
272+ }
273+ return 0, requestReset, false
274+ }
275+ s.screen.Write(data)
276+ s.appliedState = out.NewStateNum
277+ return out.NewStateNum, false, len(data) != 0
244278 }
245279
246280 type proxyANSIBenchmarkFixture struct {
@@ -272,6 +306,277 @@ func proxyANSIBenchmarkFixtures() []proxyANSIBenchmarkFixture {
272306 }
273307 }
274308
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+
275580 func TestPerformanceFixtureCounters(t *testing.T) {
276581 fixture := newPerformanceFixture(t, performanceConfig{})
277582
@@ -435,25 +740,24 @@ var daemonSnapshotTopologies = []daemonHistoryTopology{
435740 {name: "1tab-4panes", tabs: 1, panes: 4},
436741 }
437742
743+// BenchmarkProxyANSIApply is a component diagnostic for proxy ANSI parsing and
744+// apply only; it is not a pipeline comparison with structured output.
438745 func BenchmarkProxyANSIApply(b *testing.B) {
439746 for _, fixture := range proxyANSIBenchmarkFixtures() {
440747 b.Run(fixture.name, func(b *testing.B) {
441- proxy := newProxyANSIBenchmarkSession(b)
442- generation := proxy.linkGeneration
443- state := uint64(0)
748+ state := newProxyANSIApplyState(b)
444749 b.ReportAllocs()
445750 b.SetBytes(int64(len(fixture.data)))
446751 for b.Loop() {
447- next := state + 1
448- ack, reset, changed := proxy.applyOutputForGeneration(generation, ports.Output{
449- BaseStateNum: state,
752+ next := state.appliedState + 1
753+ ack, reset, changed := state.apply(ports.Output{
754+ BaseStateNum: state.appliedState,
450755 NewStateNum: next,
451756 Data: fixture.data,
452757 })
453758 if ack != next || reset || !changed {
454759 b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next)
455760 }
456- state = next
457761 }
458762 b.ReportMetric(float64(len(fixture.data)), "ansibytes/op")
459763 })
M internal/usecase/daemon/proxied_contract_test.go +81 -11
@@ -41,16 +41,18 @@ func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
4141 require.Equal(t, uint16(0), meta.Active)
4242 require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs)
4343
44- outputFrame := awaitFrame(t, sends, ports.MsgOutput)
45- output, err := ports.UnmarshalOutput(outputFrame.Payload)
44+ screenFrame := awaitFrame(t, sends, ports.MsgScreenUpdate)
45+ update, err := ports.UnmarshalScreenUpdate(screenFrame.Payload)
4646 require.NoError(t, err)
47- require.Zero(t, output.BaseStateNum)
48-
49- client := vt.NewScreen(contentSize.Cols, contentSize.Rows)
50- client.Write(output.Data)
51- require.Equal(t, contentSize.Rows, client.Frame.Height)
52- for _, row := range frameRows(client.Frame) {
53- require.NotContains(t, row, "remote-work", "proxied output must not contain remote session chrome")
47+ require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind)
48+ require.Zero(t, update.BaseStateNum)
49+ require.Equal(t, contentSize, update.Size)
50+ require.Len(t, update.Spans, contentSize.Rows, "structured snapshots must carry every full row")
51+ for y, span := range update.Spans {
52+ require.Equal(t, uint16(y), span.Y)
53+ require.Zero(t, span.X)
54+ require.Len(t, span.Cells, contentSize.Cols)
55+ require.NotContains(t, rowText(span.Cells), "remote-work", "proxied screen must not contain remote session chrome")
5456 }
5557
5658 sess := firstSession(d)
@@ -102,6 +104,64 @@ func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
102104 handlers.Wait()
103105 }
104106
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+
105165 func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) {
106166 paneFrame := renderer.NewFrame(8, 2)
107167 fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"})
@@ -131,6 +191,15 @@ func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) {
131191 require.Equal(t, 1, ordinary.cursor.row)
132192 }
133193
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+
134203 func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {
135204 d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
136205 ac.proxied = true
@@ -206,9 +275,10 @@ func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
206275 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
207276 }))
208277
209- frame := awaitFrame(t, sends, ports.MsgOutput)
210- out, err := ports.UnmarshalOutput(frame.Payload)
278+ frame := awaitFrame(t, sends, ports.MsgScreenUpdate)
279+ out, err := ports.UnmarshalScreenUpdate(frame.Payload)
211280 require.NoError(t, err)
281+ require.Equal(t, ports.ScreenUpdateSnapshot, out.Kind)
212282 require.Zero(t, out.BaseStateNum)
213283 ac.sendMu.Lock()
214284 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
351351 _ = awaitTestValue(t, clock.timers, "first command did not arm timeout")
352352
353353 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())
355355 require.NoError(t, err)
356356 require.Equal(t, proxyLinkResume, result)
357357 require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type)
@@ -499,7 +499,7 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T)
499499 request := requireProxyCommandRequest(t, link)
500500 _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
501501 oldGeneration := proxy.linkGeneration
502- newGeneration, _ := proxy.installTransport(newProxyTestTransport())
502+ newGeneration, _ := proxy.installTransport(newProxyTestTransport(), false)
503503 require.Greater(t, newGeneration, oldGeneration)
504504 require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))
505505 _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
@@ -602,6 +602,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
602602 transport := newProxyTestTransport()
603603 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
604604 transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
605+ transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
605606 factory := portsmocks.NewMockRemoteDialerFactory(t)
606607 dialer := portsmocks.NewMockDialer(t)
607608 var daemonLockAvailable, sourceLockAvailable bool
@@ -660,6 +661,7 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
660661 transport := newProxyTestTransport()
661662 transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
662663 transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
664+ transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
663665 d.remoteDialerFactory = newProxyConstructionFactory(transport)
664666 d.remoteTransportMode = ports.RemoteTransportUDP
665667 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -806,6 +808,7 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
806808 remote := newProxyTestTransport()
807809 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
808810 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
811+ remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
809812 d.remoteDialerFactory = newProxyConstructionFactory(remote)
810813 d.remoteTransportMode = ports.RemoteTransportUDP
811814
@@ -898,6 +901,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
898901 remote := newProxyTestTransport()
899902 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
900903 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
904+ remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
901905 d.remoteDialerFactory = newProxyConstructionFactory(remote)
902906 d.remoteTransportMode = ports.RemoteTransportUDP
903907 token := roleEffectForTest(t, local.attachmentToken(ac, client))
@@ -936,6 +940,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
936940 remote := newProxyTestTransport()
937941 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
938942 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
943+ remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
939944 d.remoteDialerFactory = newProxyConstructionFactory(remote)
940945 d.remoteTransportMode = ports.RemoteTransportUDP
941946
@@ -992,6 +997,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
992997 remote := newProxyTestTransport()
993998 remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
994999 remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1000+ remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
9951001 d.remoteDialerFactory = newProxyConstructionFactory(remote)
9961002 d.remoteTransportMode = ports.RemoteTransportUDP
9971003
M internal/usecase/daemon/proxy_lifecycle_test.go +31 -3
@@ -146,6 +146,30 @@ func newRegisteredLifecycleProxy(t *testing.T, clock ports.Clock) (*Daemon, *pro
146146 return d, proxy, transport
147147 }
148148
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+
149173 func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
150174 clock := newProxyLifecycleClock()
151175 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
@@ -397,6 +421,7 @@ func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) {
397421 _ = requireProxyHello(t, resumed)
398422 resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume|ports.CapabilityProxied)}
399423 resumed.recv <- proxyRecv{frame: proxyMeta(proxy.key.Name)}
424+ resumed.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
400425 require.Eventually(t, func() bool {
401426 proxy.mu.Lock()
402427 defer proxy.mu.Unlock()
@@ -1160,6 +1185,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
11601185 second := newProxyTestTransport()
11611186 second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityProxied)}
11621187 second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1188+ second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
11631189 factory := newProxyConstructionFactory(first, second)
11641190 d := newProxyTestDaemon(t, factory, nil)
11651191
@@ -1186,6 +1212,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
11861212
11871213 first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
11881214 first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1215+ first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
11891216 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
11901217 waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish")
11911218 require.NoError(t, leaderResult.err)
@@ -1227,6 +1254,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
12271254
12281255 first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
12291256 first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1257+ first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
12301258 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
12311259 require.NoError(t, leaderResult.err)
12321260 stopProxy(t, leaderResult.proxy)
@@ -1408,7 +1436,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
14081436 d, p, clock := newProxyLifecycleFixture(t)
14091437 transport := newProxyTestTransport()
14101438 t.Cleanup(func() { _ = transport.Close() })
1411- generation, _ := p.installTransport(transport)
1439+ generation, _ := p.installTransport(transport, false)
14121440 token, timer := armWarmTimer(t, d, p, clock)
14131441
14141442 require.True(t, d.markProxyReplaced(p, generation, transport))
@@ -1432,7 +1460,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
14321460 d, p, clock := newProxyLifecycleFixture(t)
14331461 transport := newProxyTestTransport()
14341462 t.Cleanup(func() { _ = transport.Close() })
1435- generation, _ := p.installTransport(transport)
1463+ generation, _ := p.installTransport(transport, false)
14361464
14371465 require.True(t, d.markProxyReplaced(p, generation, transport))
14381466 timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
@@ -1449,7 +1477,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
14491477 d, p, _ := newProxyLifecycleFixture(t)
14501478 transport := newProxyTestTransport()
14511479 t.Cleanup(func() { _ = transport.Close() })
1452- generation, _ := p.installTransport(transport)
1480+ generation, _ := p.installTransport(transport, false)
14531481
14541482 require.False(t, d.markProxyReplaced(p, generation-1, transport))
14551483 require.False(t, proxyExpired(p))
M internal/usecase/daemon/proxy_link.go +113 -24
@@ -7,6 +7,7 @@ import (
77 "io"
88 "time"
99
10+ "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/ports"
1112 )
1213
@@ -80,7 +81,18 @@ func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent
8081 return err
8182 }
8283
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)
8496 if previous != nil && previous != transport {
8597 _ = previous.Close()
8698 }
@@ -91,15 +103,6 @@ func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent
91103 _ = transport.Close()
92104 }
93105 }()
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- }
103106 hello := ports.Hello{
104107 Version: ports.ProtocolVersion,
105108 Intent: intent,
@@ -114,13 +117,23 @@ func (d *Daemon) dialProxyHandshake(ctx context.Context, p *proxySession, intent
114117 return fmt.Errorf("proxy session: send hello: %w", err)
115118 }
116119
117- welcome, meta, err := recvProxyHandshake(ctx, transport, p.key.Name)
120+ welcome, meta, snapshot, err := recvProxyHandshake(ctx, transport, p.key.Name, size, stateFloor)
118121 if err != nil {
119122 return err
120123 }
121124 if err := ctx.Err(); err != nil {
122125 return err
123126 }
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+ }
124137
125138 p.mu.Lock()
126139 if p.linkGeneration != generation || p.transport != transport {
@@ -143,25 +156,43 @@ func proxyOutputWindow(transport ports.Transport) uint8 {
143156 return maxUnackedOutputStates
144157 }
145158
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) {
147160 frame, err := recvProxyHandshakeFrame(ctx, transport)
148161 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)
150163 }
151164 welcome, err := validateProxyWelcome(frame, sessionName)
152165 if err != nil {
153- return ports.Welcome{}, ports.SessionMeta{}, err
166+ return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, err
154167 }
155168
156169 frame, err = recvProxyHandshakeFrame(ctx, transport)
157170 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)
159172 }
160173 meta, err := validateProxyInitialMeta(frame, sessionName)
161174 if err != nil {
162- return ports.Welcome{}, ports.SessionMeta{}, err
175+ return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, err
176+ }
177+
178+ frame, err = recvProxyHandshakeFrame(ctx, transport)
179+ if err != nil {
180+ return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive initial screen snapshot: %w", err)
181+ }
182+ if frame.Type != ports.MsgScreenUpdate {
183+ return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: third server frame is %d, want ScreenUpdate", frame.Type)
184+ }
185+ snapshot, err := ports.UnmarshalScreenUpdate(frame.Payload)
186+ if err != nil {
187+ return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: decode initial screen snapshot: %w", err)
188+ }
189+ if snapshot.Kind != ports.ScreenUpdateSnapshot || snapshot.BaseStateNum != 0 || snapshot.NewStateNum <= stateFloor {
190+ return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, errors.New("proxy session: initial screen frame is not a moving base-zero snapshot")
163191 }
164- return welcome, meta, nil
192+ if snapshot.Size != size {
193+ return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: initial screen geometry mismatch: got %dx%d, want %dx%d", snapshot.Size.Cols, snapshot.Size.Rows, size.Cols, size.Rows)
194+ }
195+ return welcome, meta, snapshot, nil
165196 }
166197
167198 func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {
@@ -221,7 +252,7 @@ func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (po
221252 }
222253 }
223254
224-func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
255+func (p *proxySession) installTransport(transport ports.Transport, resume bool) (uint64, ports.Transport) {
225256 // Link replacement participates in the same sender serialization as every
226257 // frame. A request can therefore only be published wholly before the
227258 // generation changes or wholly after it.
@@ -231,12 +262,18 @@ func (p *proxySession) installTransport(transport ports.Transport) (uint64, port
231262 previous := p.transport
232263 p.linkGeneration++
233264 generation := p.linkGeneration
234- // State numbers belong to one remote output stream. A replacement link
235- // must prove its state with a base-zero paint before an incremental frame
236- // can advance this proxy.
237- p.resetOutputStateLocked()
265+ if resume {
266+ // Resume keeps the applied remote state as the handshake floor, but the
267+ // replacement must prove that state with a fresh base-zero snapshot.
268+ p.screenReady = false
269+ p.resetRequested = false
270+ } else {
271+ p.resetOutputStateLocked()
272+ }
238273 p.transport = transport
239- p.linkState = ports.LinkStateConnected
274+ // Authentication is not complete until the authoritative screen snapshot
275+ // has been applied and acknowledged.
276+ p.linkState = ports.LinkStateProbing
240277 p.mu.Unlock()
241278 p.failPendingCommandsLocked(errProxyCommandGeneration)
242279 return generation, previous
@@ -540,6 +577,46 @@ func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generat
540577 }
541578 }
542579
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+
543620 // handleLinkFrame returns proxyLinkResume to mean "continue this transport";
544621 // the other values are terminal actions.
545622 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
549626 if err != nil {
550627 return proxyLinkStop, err
551628 }
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 {
553642 return proxyLinkStop, err
554643 }
555644 return proxyLinkResume, nil
M internal/usecase/daemon/proxy_output.go +63 -66
@@ -4,78 +4,76 @@ import (
44 "fmt"
55
66 "github.com/bnema/vev/internal/ports"
7- "github.com/bnema/vev/pkg/vt"
87 )
98
10-// applyOutput applies one decoded remote output frame for the current link.
11-// Link readers use applyOutputForGeneration to bind processing to the exact
12-// transport generation that received the frame.
13-func (p *proxySession) applyOutput(out ports.Output) (ack uint64, requestReset, changed bool) {
14- p.mu.Lock()
15- generation := p.linkGeneration
16- p.mu.Unlock()
17- return p.applyOutputForGeneration(generation, out)
18-}
19-
20-// applyOutputForGeneration applies one decoded remote output frame while
21-// holding only the proxy leaf lock. The supplied link generation prevents a
22-// receive goroutine from an old transport advancing the replacement link's VT
23-// state.
24-func (p *proxySession) applyOutputForGeneration(generation uint64, out ports.Output) (ack uint64, requestReset, changed bool) {
25- // Screen.Write may retain a partial escape sequence, so do not retain a
26- // transport-owned payload.
27- data := append([]byte(nil), out.Data...)
28-
29- p.mu.Lock()
30- defer p.mu.Unlock()
31- if p.linkGeneration != generation {
9+// applyScreenUpdateForGeneration applies one semantic screen update while
10+// holding only the proxy leaf lock. A link generation fence prevents a stale
11+// receive goroutine from advancing a replacement link's screen.
12+func (p *proxySession) applyScreenUpdateForGeneration(generation uint64, update ports.ScreenUpdate) (ack uint64, requestReset, changed bool) {
13+ if p == nil {
3214 return 0, false, false
3315 }
34- if p.screen == nil {
16+ p.mu.Lock()
17+ defer p.mu.Unlock()
18+ if p.linkGeneration != generation || p.screen == nil {
3519 return 0, false, false
3620 }
37- if out.NewStateNum == 0 {
38- p.screen.Write(data)
39- return 0, false, len(data) != 0
21+
22+ // The wire codec validates all semantic bounds. Geometry is still checked
23+ // here because it is attachment state, not merely a wire property: a
24+ // remote paint for the wrong content rectangle cannot be applied locally.
25+ if update.Size != p.contentSize {
26+ return 0, p.requestScreenResetLocked(), false
4027 }
41- if out.BaseStateNum == 0 {
42- // A retransmitted full paint must not clobber a newer screen or regress
43- // the applied state. Only a reset this proxy actually asked for may
44- // replay a state it has already applied.
45- if !p.awaitingReset && out.NewStateNum <= p.appliedState {
46- return 0, false, false
28+ if update.Kind == ports.ScreenUpdateSnapshot {
29+ // A base-zero paint may only move the consumer forward. This floor is
30+ // retained across resume and resize, so resetRequested never authorizes
31+ // replaying a stale snapshot.
32+ if update.NewStateNum <= p.appliedState {
33+ return 0, p.requestScreenResetLocked(), false
4734 }
48- p.screen = vt.NewScreen(p.screen.Frame.Width, p.screen.Frame.Height)
49- p.screen.Write(data)
50- p.appliedState = out.NewStateNum
51- p.awaitingReset = false
52- return out.NewStateNum, false, true
53- }
54- // Stateful frames are consecutive by construction. A duplicate or an
55- // otherwise non-monotonic state is invalid just like a missing dependency.
56- if p.awaitingReset || out.BaseStateNum != p.appliedState || out.NewStateNum != out.BaseStateNum+1 {
57- if !p.awaitingReset {
58- p.awaitingReset = true
59- requestReset = true
35+ before := p.screen.cursorOut
36+ if err := p.screen.Apply(update); err != nil {
37+ return 0, p.requestScreenResetLocked(), false
6038 }
61- return 0, requestReset, false
39+ p.appliedState = update.NewStateNum
40+ p.screenReady = true
41+ p.resetRequested = false
42+ return update.NewStateNum, false, update.Kind == ports.ScreenUpdateSnapshot || before != update.Cursor || len(update.Spans) != 0
6243 }
63- p.screen.Write(data)
64- p.appliedState = out.NewStateNum
65- return out.NewStateNum, false, len(data) != 0
44+
45+ if !p.screenReady || p.resetRequested || update.BaseStateNum != p.appliedState || update.NewStateNum != update.BaseStateNum+1 {
46+ return 0, p.requestScreenResetLocked(), false
47+ }
48+ before := p.screen.cursorOut
49+ if err := p.screen.Apply(update); err != nil {
50+ return 0, p.requestScreenResetLocked(), false
51+ }
52+ p.appliedState = update.NewStateNum
53+ return update.NewStateNum, false, update.Scroll != nil || len(update.Spans) != 0 || before != update.Cursor
54+}
55+
56+func (p *proxySession) requestScreenResetLocked() bool {
57+ if p.resetRequested {
58+ p.screenReady = false
59+ return false
60+ }
61+ p.screenReady = false
62+ p.resetRequested = true
63+ return true
6664 }
6765
68-// handleProxyOutput completes output handling after applyOutputForGeneration
69-// has released proxy.mu. In particular, transport I/O and local render
70-// invalidation must never run under the VT lock.
71-func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {
72- ack, requestReset, changed := p.applyOutputForGeneration(generation, out)
66+// handleProxyScreenUpdate completes screen handling after apply has released
67+// proxy.mu. Transport I/O and render invalidation never run under that lock.
68+func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, update ports.ScreenUpdate) error {
69+ marks := d.newRuntimeMarkBatch()
70+ ack, requestReset, changed := p.applyScreenUpdateForGeneration(generation, update)
7371 if ack != 0 {
7472 if err := p.sendGeneration(generation, ports.Frame{
7573 Type: ports.MsgAck,
7674 Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}),
7775 }); err != nil {
78- return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err)
76+ return fmt.Errorf("proxy screen ACK: %w: %w", errProxyLinkSend, err)
7977 }
8078 }
8179 if requestReset {
@@ -83,9 +81,13 @@ func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports
8381 Type: ports.MsgOutputResetRequest,
8482 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
8583 }); 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)
8785 }
86+ marks.diagnostic(ports.RuntimeScreenResetRequested, 0, 0)
8887 }
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()
8991 if !changed || !p.currentLinkGeneration(generation) {
9092 return nil
9193 }
@@ -97,18 +99,13 @@ func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports
9799 return nil
98100 }
99101
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-
109102 func (p *proxySession) resetOutputStateLocked() {
110103 p.appliedState = 0
111- p.awaitingReset = false
104+ p.screenReady = false
105+ p.resetRequested = false
106+ if p.screen != nil {
107+ p.screen.stateNum = 0
108+ }
112109 }
113110
114111 func (p *proxySession) currentLinkGeneration(generation uint64) bool {
M internal/usecase/daemon/proxy_output_test.go +460 -252
@@ -12,10 +12,13 @@ import (
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
1414 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15+ "github.com/bnema/vev/pkg/renderer"
1516 "github.com/stretchr/testify/mock"
1617 "github.com/stretchr/testify/require"
1718 )
1819
20+var proxyHandshakeState atomic.Uint64
21+
1922 type proxyRecv struct {
2023 frame ports.Frame
2124 err error
@@ -95,6 +98,28 @@ func (t *proxyTestTransport) Recv() (ports.Frame, error) {
9598 }
9699 }
97100
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+
98123 func (t *proxyTestTransport) Close() error {
99124 t.closeOnce.Do(func() { close(t.closed) })
100125 return nil
@@ -109,6 +134,26 @@ func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
109134 })}
110135 }
111136
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+
112157 func proxyMeta(name string) ports.Frame {
113158 payload, err := ports.MarshalSessionMeta(ports.SessionMeta{
114159 SessionName: name,
@@ -208,6 +253,7 @@ func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) {
208253 tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)}
209254 require.Empty(t, proxySessionSnapshot(d), "Welcome alone must not publish a proxy")
210255 tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
256+ tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
211257
212258 created := awaitTestValue(t, result, "proxy handshake did not complete")
213259 require.NoError(t, created.err)
@@ -252,9 +298,11 @@ func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) {
252298 firstTransport := newProxyTestTransport()
253299 firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)}
254300 firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)}
301+ firstTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
255302 secondTransport := newProxyTestTransport()
256303 secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)}
257304 secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)}
305+ secondTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
258306 factory := portsmocks.NewMockRemoteDialerFactory(t)
259307 for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} {
260308 dialer := portsmocks.NewMockDialer(t)
@@ -339,6 +387,7 @@ func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) {
339387 first := newProxyTestTransport()
340388 first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)}
341389 first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
390+ first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
342391 second := newProxyTestTransport()
343392 factory := proxyFactoryFor(t, key, first, second)
344393 // The resume backoff is a ports.Clock deadline, so it is fired rather than
@@ -361,6 +410,7 @@ func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) {
361410 require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
362411 second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)}
363412 second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
413+ second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
364414 second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}}
365415
366416 select {
@@ -396,9 +446,10 @@ func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
396446 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
397447 gate := make(chan struct{})
398448 entered := make(chan struct{}, 1)
399- tr := newProxyTestTransportWithSendGate(gate, entered, 1)
449+ tr := newProxyTestTransportWithSendGate(gate, entered, 2)
400450 tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
401451 tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
452+ tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
402453 d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
403454 proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
404455 require.NoError(t, err)
@@ -424,6 +475,20 @@ func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
424475 stopProxy(t, proxy)
425476 }
426477
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+
427492 func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) {
428493 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
429494 tr := newProxyTestTransport()
@@ -454,164 +519,319 @@ func newProxyOutputSession(t *testing.T) (*Daemon, *proxySession, *proxyTestTran
454519 proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
455520 require.NoError(t, err)
456521 transport := newProxyTestTransport()
457- generation, _ := proxy.installTransport(transport)
522+ generation, _ := proxy.installTransport(transport, false)
458523 return newProxyTestDaemon(t, nil, stubClock{}), proxy, transport, generation
459524 }
460525
461-func proxyScreenText(p *proxySession) string {
462- p.mu.Lock()
463- defer p.mu.Unlock()
464- return string([]rune{p.screen.Frame.At(0, 0).Rune, p.screen.Frame.At(1, 0).Rune})
465-}
526+func TestProxyScreenResumeAndFreshLinksRequireMovingSnapshots(t *testing.T) {
527+ d, proxy, first, generation := newProxyOutputSession(t)
528+ snapshot, err := ports.UnmarshalScreenUpdate(proxyHandshakeSnapshot().Payload)
529+ require.NoError(t, err)
530+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
531+ _ = awaitTestValue(t, first.sent, "initial screen was not acknowledged")
532+ proxy.mu.Lock()
533+ applied := proxy.appliedState
534+ proxy.mu.Unlock()
466535
467-func proxyOutputState(p *proxySession) (uint64, bool) {
468- p.mu.Lock()
469- defer p.mu.Unlock()
470- return p.appliedState, p.awaitingReset
536+ second := newProxyTestTransport()
537+ generation, _ = proxy.installTransport(second, true)
538+ proxy.mu.Lock()
539+ require.Equal(t, applied, proxy.appliedState, "resume must preserve the applied state floor")
540+ require.False(t, proxy.screenReady, "resume must wait for its replacement snapshot")
541+ proxy.mu.Unlock()
542+ gap := snapshot
543+ gap.Kind = ports.ScreenUpdateDelta
544+ gap.BaseStateNum, gap.NewStateNum = applied, applied+1
545+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap))
546+ require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, second.sent, "resume delta did not request a reset").Type)
547+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap))
548+ select {
549+ case frame := <-second.sent:
550+ t.Fatalf("duplicate resume gap sent %v", frame.Type)
551+ default:
552+ }
553+ moving := snapshot
554+ moving.NewStateNum = applied + 1
555+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, moving))
556+ require.Equal(t, ports.MsgAck, awaitTestValue(t, second.sent, "resume snapshot was not acknowledged").Type)
557+
558+ third := newProxyTestTransport()
559+ generation, _ = proxy.installTransport(third, false)
560+ proxy.mu.Lock()
561+ require.Zero(t, proxy.appliedState, "fresh link must clear the state floor")
562+ require.False(t, proxy.screenReady)
563+ proxy.mu.Unlock()
564+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap))
565+ require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, third.sent, "fresh delta did not request a reset").Type)
566+ fresh := snapshot
567+ fresh.NewStateNum = 1
568+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, fresh))
569+ require.Equal(t, ports.MsgAck, awaitTestValue(t, third.sent, "fresh state-one snapshot was not acknowledged").Type)
570+ proxy.mu.Lock()
571+ require.Equal(t, uint64(1), proxy.appliedState)
572+ proxy.mu.Unlock()
471573 }
472574
473-func TestProxyOutputStateMachine(t *testing.T) {
474- tests := []struct {
475- name string
476- initial *ports.Output
477- output ports.Output
478- wantAck uint64
479- wantReset bool
480- wantChanged bool
481- wantState uint64
482- wantAwaiting bool
483- wantScreen string
484- }{
485- {
486- name: "base zero initial reset",
487- output: ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
488- wantAck: 1,
489- wantChanged: true,
490- wantState: 1,
491- wantScreen: "A ",
492- },
493- {
494- name: "matching increment",
495- initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
496- output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
497- wantAck: 2,
498- wantChanged: true,
499- wantState: 2,
500- wantScreen: "AB",
501- },
502- {
503- name: "duplicate old state requests reset",
504- initial: &ports.Output{BaseStateNum: 0, NewStateNum: 2, Data: []byte("A")},
505- output: ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")},
506- wantReset: true,
507- wantState: 2,
508- wantAwaiting: true,
509- wantScreen: "A ",
510- },
511- {
512- name: "gap requests reset",
513- initial: &ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")},
514- output: ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("B")},
515- wantReset: true,
516- wantState: 1,
517- wantAwaiting: true,
518- wantScreen: "A ",
519- },
520- {
521- name: "stateless side effect does not advance state",
522- output: ports.Output{NewStateNum: 0, Data: []byte("!")},
523- wantChanged: true,
524- wantScreen: "! ",
525- },
526- {
527- name: "stale base zero is ignored",
528- initial: &ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")},
529- output: ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("B")},
530- wantState: 5,
531- wantScreen: "A ",
532- },
533- {
534- name: "duplicate base zero is ignored",
535- initial: &ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")},
536- output: ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("B")},
537- wantState: 5,
538- wantScreen: "A ",
539- },
575+func TestProxyScreenStaleSnapshotsAndApplyFailuresRequestOneResetWithoutMutation(t *testing.T) {
576+ newProxy := func(t *testing.T) (*Daemon, *proxySession, uint64) {
577+ t.Helper()
578+ d, proxy, _, generation := newProxyOutputSession(t)
579+ return d, proxy, generation
540580 }
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- }
547581
548- ack, reset, changed := proxy.applyOutput(tt.output)
549- require.Equal(t, tt.wantAck, ack)
550- require.Equal(t, tt.wantReset, reset)
551- require.Equal(t, tt.wantChanged, changed)
552- state, awaiting := proxyOutputState(proxy)
553- require.Equal(t, tt.wantState, state)
554- require.Equal(t, tt.wantAwaiting, awaiting)
555- require.Equal(t, tt.wantScreen, proxyScreenText(proxy))
556- })
557- }
558-}
582+ t.Run("stale snapshot while not ready", func(t *testing.T) {
583+ d, proxy, generation := newProxy(t)
584+ snapshot := screenSnapshotForProxy(t, proxy, 7)
585+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
586+ proxy.mu.Lock()
587+ before := proxy.screen.frame.Clone()
588+ proxy.screenReady = false
589+ proxy.resetRequested = false
590+ proxy.mu.Unlock()
591+
592+ stale := snapshot
593+ stale.NewStateNum = 7
594+ ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, stale)
595+ require.Zero(t, ack)
596+ require.True(t, reset, "a stale snapshot must request a reset while not ready")
597+ require.False(t, changed)
598+ proxy.mu.Lock()
599+ require.Equal(t, uint64(7), proxy.appliedState)
600+ require.False(t, proxy.screenReady)
601+ require.True(t, proxy.resetRequested)
602+ require.Equal(t, before, proxy.screen.frame)
603+ proxy.mu.Unlock()
604+
605+ ack, reset, changed = proxy.applyScreenUpdateForGeneration(generation, stale)
606+ require.Zero(t, ack)
607+ require.False(t, reset, "an already-requested reset must be deduplicated")
608+ require.False(t, changed)
609+ })
559610
560-// TestProxyOutputAwaitedResetAcceptsReplayedState fences the stale base-zero
561-// guard: a reset this proxy asked for must still be applied even when the
562-// remote numbers it at or below the state already applied.
563-func TestProxyOutputAwaitedResetAcceptsReplayedState(t *testing.T) {
564- _, proxy, _, generation := newProxyOutputSession(t)
565- _, _, _ = proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("A")})
566- _, requestReset, _ := proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 9, NewStateNum: 10, Data: []byte("x")})
567- require.True(t, requestReset)
611+ t.Run("snapshot apply failure", func(t *testing.T) {
612+ d, proxy, generation := newProxy(t)
613+ initial := screenSnapshotForProxy(t, proxy, 7)
614+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial))
615+ proxy.mu.Lock()
616+ before := proxy.screen.frame.Clone()
617+ proxy.mu.Unlock()
618+ invalid := initial
619+ invalid.NewStateNum = 8
620+ invalid.Spans = invalid.Spans[:len(invalid.Spans)-1]
621+ ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, invalid)
622+ require.Zero(t, ack)
623+ require.True(t, reset)
624+ require.False(t, changed)
625+ proxy.mu.Lock()
626+ require.Equal(t, uint64(7), proxy.appliedState)
627+ require.False(t, proxy.screenReady)
628+ require.True(t, proxy.resetRequested)
629+ require.Equal(t, before, proxy.screen.frame)
630+ proxy.mu.Unlock()
631+ _, reset, _ = proxy.applyScreenUpdateForGeneration(generation, invalid)
632+ require.False(t, reset, "snapshot Apply failure reset was not deduplicated")
633+ })
568634
569- ack, reset, changed := proxy.applyOutputForGeneration(generation, ports.Output{BaseStateNum: 0, NewStateNum: 3, Data: []byte("B")})
570- require.Equal(t, uint64(3), ack)
571- require.False(t, reset)
572- require.True(t, changed)
573- state, awaiting := proxyOutputState(proxy)
574- require.Equal(t, uint64(3), state)
575- require.False(t, awaiting)
576- require.Equal(t, "B ", proxyScreenText(proxy))
635+ t.Run("delta apply failure", func(t *testing.T) {
636+ d, proxy, generation := newProxy(t)
637+ initial := screenSnapshotForProxy(t, proxy, 7)
638+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial))
639+ proxy.mu.Lock()
640+ before := proxy.screen.frame.Clone()
641+ size := proxy.contentSize
642+ proxy.mu.Unlock()
643+ invalid := ports.ScreenUpdate{
644+ Kind: ports.ScreenUpdateDelta, BaseStateNum: 7, NewStateNum: 8, Size: size,
645+ Spans: []ports.ScreenSpan{{Y: uint16(size.Rows), Cells: []renderer.Cell{{Rune: 'x'}}}},
646+ }
647+ ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, invalid)
648+ require.Zero(t, ack)
649+ require.True(t, reset)
650+ require.False(t, changed)
651+ proxy.mu.Lock()
652+ require.Equal(t, uint64(7), proxy.appliedState)
653+ require.False(t, proxy.screenReady)
654+ require.True(t, proxy.resetRequested)
655+ require.Equal(t, before, proxy.screen.frame)
656+ proxy.mu.Unlock()
657+ _, reset, _ = proxy.applyScreenUpdateForGeneration(generation, invalid)
658+ require.False(t, reset, "delta Apply failure reset was not deduplicated")
659+ })
577660 }
578661
579-// TestProxyLinkReplySendFailureResumesInsteadOfStopping separates a broken
580-// write from a broken remote: the frame was well formed, so the proxy link is
581-// resumable and only this transport incarnation is retired.
582-func TestProxyLinkReplySendFailureResumesInsteadOfStopping(t *testing.T) {
583- tests := []struct {
584- name string
585- frame ports.Frame
662+func screenSnapshotForProxy(t *testing.T, proxy *proxySession, state uint64) ports.ScreenUpdate {
663+ t.Helper()
664+ proxy.mu.Lock()
665+ size := proxy.contentSize
666+ proxy.mu.Unlock()
667+ snapshot := blankSnapshot(size)
668+ snapshot.NewStateNum = state
669+ return snapshot
670+}
671+
672+func TestProxyScreenUpdate(t *testing.T) {
673+ t.Run("initial snapshot", func(t *testing.T) {
674+ d, proxy, transport, generation := newProxyOutputSession(t)
675+ snapshot := screenSnapshotForProxy(t, proxy, 1)
676+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
677+ require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial screen was not acknowledged").Type)
678+ proxy.mu.Lock()
679+ require.Equal(t, uint64(1), proxy.appliedState)
680+ require.True(t, proxy.screenReady)
681+ require.False(t, proxy.resetRequested)
682+ proxy.mu.Unlock()
683+ })
684+
685+ t.Run("matching delta", func(t *testing.T) {
686+ d, proxy, transport, generation := newProxyOutputSession(t)
687+ snapshot := screenSnapshotForProxy(t, proxy, 1)
688+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
689+ _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged")
690+
691+ delta := ports.ScreenUpdate{
692+ Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2,
693+ Size: snapshot.Size, Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("delta")}},
694+ }
695+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, delta))
696+ require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "screen delta was not acknowledged").Type)
697+ proxy.mu.Lock()
698+ require.Equal(t, uint64(2), proxy.appliedState)
699+ require.Equal(t, 'd', proxy.screen.frame.At(0, 0).Rune)
700+ proxy.mu.Unlock()
701+ })
702+
703+ for _, tc := range []struct {
704+ name string
705+ base uint64
706+ new uint64
586707 }{
587- {
588- name: "pong reply",
589- frame: ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})},
590- },
591- {
592- name: "output ack",
593- frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})},
594- },
595- {
596- name: "output reset request",
597- frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 7, NewStateNum: 8, Data: []byte("A")})},
598- },
708+ {name: "duplicate", base: 0, new: 1},
709+ {name: "gap", base: 3, new: 4},
710+ } {
711+ t.Run(tc.name+" requests exactly one reset", func(t *testing.T) {
712+ d, proxy, transport, generation := newProxyOutputSession(t)
713+ snapshot := screenSnapshotForProxy(t, proxy, 1)
714+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
715+ _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged")
716+
717+ update := ports.ScreenUpdate{
718+ Kind: ports.ScreenUpdateDelta, BaseStateNum: tc.base, NewStateNum: tc.new,
719+ Size: snapshot.Size,
720+ }
721+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, update))
722+ require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "screen mismatch did not request reset").Type)
723+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, update))
724+ select {
725+ case frame := <-transport.sent:
726+ t.Fatalf("duplicate %s sent %v", tc.name, frame.Type)
727+ default:
728+ }
729+ proxy.mu.Lock()
730+ require.Equal(t, uint64(1), proxy.appliedState)
731+ require.False(t, proxy.screenReady)
732+ require.True(t, proxy.resetRequested)
733+ proxy.mu.Unlock()
734+ })
599735 }
600- for _, tt := range tests {
601- t.Run(tt.name, func(t *testing.T) {
736+
737+ t.Run("awaited base-zero snapshot advances the floor", func(t *testing.T) {
738+ d, proxy, transport, generation := newProxyOutputSession(t)
739+ initial := screenSnapshotForProxy(t, proxy, 5)
740+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial))
741+ _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged")
742+
743+ gap := ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, BaseStateNum: 9, NewStateNum: 10, Size: initial.Size}
744+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap))
745+ require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "screen gap did not request reset").Type)
746+
747+ moving := initial
748+ moving.NewStateNum = 6
749+ require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, moving))
750+ require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "awaited snapshot was not acknowledged").Type)
751+ proxy.mu.Lock()
752+ require.Equal(t, uint64(6), proxy.appliedState)
753+ require.True(t, proxy.screenReady)
754+ require.False(t, proxy.resetRequested)
755+ proxy.mu.Unlock()
756+ })
757+
758+ t.Run("stale generation is ignored", func(t *testing.T) {
759+ _, proxy, transport, oldGeneration := newProxyOutputSession(t)
760+ replacement := newProxyTestTransport()
761+ newGeneration, _ := proxy.installTransport(replacement, false)
762+ require.NotEqual(t, oldGeneration, newGeneration)
763+ ack, reset, changed := proxy.applyScreenUpdateForGeneration(oldGeneration, screenSnapshotForProxy(t, proxy, 1))
764+ require.Zero(t, ack)
765+ require.False(t, reset)
766+ require.False(t, changed)
767+ proxy.mu.Lock()
768+ require.Zero(t, proxy.appliedState)
769+ proxy.mu.Unlock()
770+ select {
771+ case frame := <-transport.sent:
772+ t.Fatalf("stale screen update sent %v", frame.Type)
773+ default:
774+ }
775+ })
776+
777+ t.Run("ACK and reset send failures are resumable", func(t *testing.T) {
778+ t.Run("ACK", func(t *testing.T) {
602779 d, proxy, transport, generation := newProxyOutputSession(t)
603- t.Cleanup(func() { _ = transport.Close() })
604780 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+ })
606787
607- result := d.runProxyTransport(context.Background(), proxy, generation, transport)
608- require.Equal(t, proxyLinkResume, result, "a failed reply must retire the transport, not the proxy link")
788+ t.Run("reset", func(t *testing.T) {
789+ d, proxy, transport, generation := newProxyOutputSession(t)
790+ transport.sendFails.Store(true)
791+ proxy.mu.Lock()
792+ size := proxy.contentSize
793+ proxy.mu.Unlock()
794+ gap := ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2, Size: size}
795+ payload, err := ports.MarshalScreenUpdate(gap)
796+ require.NoError(t, err)
797+ transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload}}
798+ require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, generation, transport))
609799 })
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+ })
611831 }
612832
613-// TestProxyLinkProtocolErrorStillStops fences the sentinel so it cannot widen
614-// into "every error is resumable".
833+// TestProxyLinkProtocolErrorStillStops fences malformed MsgOutput rejection
834+// separately from resumable structured screen reply failures.
615835 func TestProxyLinkProtocolErrorStillStops(t *testing.T) {
616836 d, proxy, transport, generation := newProxyOutputSession(t)
617837 t.Cleanup(func() { _ = transport.Close() })
@@ -620,130 +840,118 @@ func TestProxyLinkProtocolErrorStillStops(t *testing.T) {
620840 require.Equal(t, proxyLinkStop, d.runProxyTransport(context.Background(), proxy, generation, transport))
621841 }
622842
623-func TestProxyOutputMismatchSendsOneResetUntilBaseZero(t *testing.T) {
624- d, proxy, transport, generation := newProxyOutputSession(t)
625- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
626- require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial output was not acknowledged").Type)
627-
628- mismatch := ports.Output{BaseStateNum: 3, NewStateNum: 4, Data: []byte("discard")}
629- require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
630- reset := awaitTestValue(t, transport.sent, "output mismatch did not request reset")
631- require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
632- require.NoError(t, d.handleProxyOutput(proxy, generation, mismatch))
633- select {
634- case frame := <-transport.sent:
635- t.Fatalf("second mismatch sent %v, want no frame", frame.Type)
636- default:
637- }
638-
639- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 5, Data: []byte("R")}))
640- ack := awaitTestValue(t, transport.sent, "reset output was not acknowledged")
641- require.Equal(t, ports.MsgAck, ack.Type)
642- state, awaiting := proxyOutputState(proxy)
643- require.Equal(t, uint64(5), state)
644- require.False(t, awaiting)
645- require.Equal(t, "R ", proxyScreenText(proxy))
843+func proxySideEffectFrame(data []byte) ports.Frame {
844+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
845+ BaseStateNum: 0,
846+ NewStateNum: 0,
847+ Data: data,
848+ })}
646849 }
647850
648-func TestProxyOutputMalformedOrStaleCannotAdvanceState(t *testing.T) {
649- t.Run("malformed", func(t *testing.T) {
650- d, proxy, _, generation := newProxyOutputSession(t)
651- result, err := d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}})
652- require.Equal(t, proxyLinkStop, result)
653- require.Error(t, err)
654- state, awaiting := proxyOutputState(proxy)
655- require.Zero(t, state)
656- require.False(t, awaiting)
657- })
851+func TestProxySideEffectForwardsOSC52ToCurrentThinClient(t *testing.T) {
852+ d := newTestDaemon(t, nil, stubClock{})
853+ remote := newProxyTestTransport()
854+ thin := newProxyTestTransport()
855+ proxy, ac := newAttachedProxyFixture(t, d, thin, remote)
856+ ac.echoAck.Store(17)
658857
659- t.Run("stale generation", func(t *testing.T) {
660- d, proxy, transport, oldGeneration := newProxyOutputSession(t)
661- newGeneration, _ := proxy.installTransport(newProxyTestTransport())
662- require.NotEqual(t, oldGeneration, newGeneration)
663- require.NoError(t, d.handleProxyOutput(proxy, oldGeneration, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
664- state, awaiting := proxyOutputState(proxy)
665- require.Zero(t, state)
666- require.False(t, awaiting)
667- require.Equal(t, " ", proxyScreenText(proxy))
668- select {
669- case frame := <-transport.sent:
670- t.Fatalf("stale output sent %v", frame.Type)
671- default:
672- }
673- })
674-}
858+ const osc52 = "\x1b]52;c;YQ==\a"
859+ result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte(osc52)))
860+ require.Equal(t, proxyLinkResume, result)
861+ require.NoError(t, err)
675862
676-func TestProxyOutputAckFailureStillDoesNotApplyStaleState(t *testing.T) {
677- d, proxy, transport, generation := newProxyOutputSession(t)
678- transport.sendGate = make(chan struct{})
679- require.NoError(t, transport.Close())
680- err := d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
681- require.Error(t, err)
682- state, _ := proxyOutputState(proxy)
683- require.Equal(t, uint64(1), state, "ACK failure follows a successfully applied state")
863+ forwarded := awaitTestValue(t, thin.sent, "proxy OSC52 side effect was not forwarded")
864+ require.Equal(t, ports.MsgOutput, forwarded.Type)
865+ out, err := ports.UnmarshalOutput(forwarded.Payload)
866+ require.NoError(t, err)
867+ require.Zero(t, out.BaseStateNum)
868+ require.Zero(t, out.NewStateNum)
869+ require.Equal(t, uint64(17), out.EchoAck)
870+ require.Equal(t, []byte(osc52), out.Data)
871+ select {
872+ case frame := <-thin.sent:
873+ t.Fatalf("proxy OSC52 side effect forwarded more than once: %v", frame.Type)
874+ default:
875+ }
876+ proxy.mu.Lock()
877+ require.Zero(t, proxy.appliedState)
878+ require.False(t, proxy.resetRequested)
879+ require.True(t, proxy.screenReady, "side effect must not alter proxy screen readiness")
880+ proxy.mu.Unlock()
881+ select {
882+ case frame := <-remote.sent:
883+ t.Fatalf("side effect unexpectedly replied on proxy link: %v", frame.Type)
884+ default:
885+ }
684886 }
685887
686-func TestProxyOutputReconnectRequiresBaseZero(t *testing.T) {
687- d, proxy, first, generation := newProxyOutputSession(t)
688- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
689- _ = awaitTestValue(t, first.sent, "initial output was not acknowledged") // ACK
690-
691- second := newProxyTestTransport()
692- generation, _ = proxy.installTransport(second)
693- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
694- frame := awaitTestValue(t, second.sent, "reconnected output did not request reset")
695- require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
696- state, awaiting := proxyOutputState(proxy)
697- require.Zero(t, state)
698- require.True(t, awaiting)
699- require.Equal(t, "A ", proxyScreenText(proxy))
700-
701- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 7, Data: []byte("R")}))
702- frame = awaitTestValue(t, second.sent, "reset reconnected output was not acknowledged")
703- require.Equal(t, ports.MsgAck, frame.Type)
704- state, awaiting = proxyOutputState(proxy)
705- require.Equal(t, uint64(7), state)
706- require.False(t, awaiting)
707-}
708-
709-func TestProxyOutputResizeRequiresBaseZero(t *testing.T) {
710- d, proxy, transport, generation := newProxyOutputSession(t)
711- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")}))
712- _ = awaitTestValue(t, transport.sent, "initial output was not acknowledged") // ACK
888+func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) {
889+ tests := []struct {
890+ name string
891+ frame ports.Frame
892+ }{
893+ {name: "state bearing", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("screen")})}},
894+ {name: "malformed", frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}},
895+ }
896+ for _, tt := range tests {
897+ t.Run(tt.name, func(t *testing.T) {
898+ d := newTestDaemon(t, nil, stubClock{})
899+ remote := newProxyTestTransport()
900+ thin := newProxyTestTransport()
901+ proxy, _ := newAttachedProxyFixture(t, d, thin, remote)
713902
714- proxy.resetOutputState()
715- require.NoError(t, d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("B")}))
716- frame := awaitTestValue(t, transport.sent, "resize mismatch did not request reset")
717- require.Equal(t, ports.MsgOutputResetRequest, frame.Type)
718- state, awaiting := proxyOutputState(proxy)
719- require.Zero(t, state)
720- require.True(t, awaiting)
721- require.Equal(t, "A ", proxyScreenText(proxy))
903+ result, err := d.handleLinkFrame(proxy, 1, tt.frame)
904+ require.Equal(t, proxyLinkStop, result)
905+ require.Error(t, err)
906+ select {
907+ case frame := <-thin.sent:
908+ t.Fatalf("invalid proxy output forwarded: %v", frame.Type)
909+ default:
910+ }
911+ })
912+ }
722913 }
723914
724-func TestProxyOutputConcurrentCancelDoesNotHoldProxyLockDuringSend(t *testing.T) {
725- d, proxy, transport, generation := newProxyOutputSession(t)
726- gate := make(chan struct{})
727- entered := make(chan struct{}, 1)
728- transport.sendGate = gate
729- transport.sendEntered = entered
915+func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) {
916+ d := newTestDaemon(t, nil, stubClock{})
917+ remote := newProxyTestTransport()
918+ thin := newProxyTestTransport()
919+ proxy, ac := newAttachedProxyFixture(t, d, thin, remote)
920+ frame := proxySideEffectFrame([]byte("effect"))
730921
731- result := make(chan error, 1)
732- go func() {
733- result <- d.handleProxyOutput(proxy, generation, ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("A")})
734- }()
735- awaitTestCompletion(t, entered, "output sender did not enter transport")
922+ result, err := d.handleLinkFrame(proxy, 0, frame)
923+ require.Equal(t, proxyLinkResume, result)
924+ require.NoError(t, err)
925+ select {
926+ case forwarded := <-thin.sent:
927+ t.Fatalf("stale proxy generation forwarded side effect: %v", forwarded.Type)
928+ default:
929+ }
736930
737- locked := make(chan struct{})
738- go func() {
739- _, _ = proxyOutputState(proxy)
740- close(locked)
741- }()
931+ token := attachmentToken(proxy, ac, ac.transport())
932+ ticket, admitted := ac.beginRoleEffect(token)
933+ require.True(t, admitted)
934+ ticket.End()
935+ ac.roleGeneration.Add(1)
936+ result, err = d.handleLinkFrame(proxy, 1, frame)
937+ require.Equal(t, proxyLinkResume, result)
938+ require.NoError(t, err)
742939 select {
743- case <-locked:
744- case <-time.After(time.Second):
745- t.Fatal("proxy.mu was held while transport Send was blocked")
940+ case forwarded := <-thin.sent:
941+ t.Fatalf("stale attachment role forwarded side effect: %v", forwarded.Type)
942+ default:
746943 }
747- require.NoError(t, transport.Close())
748- require.Error(t, awaitTestValue(t, result, "blocked output send did not return"))
944+}
945+
946+func TestProxySideEffectSendFailureResumesLink(t *testing.T) {
947+ d := newTestDaemon(t, nil, stubClock{})
948+ remote := newProxyTestTransport()
949+ thin := newProxyTestTransport()
950+ thin.sendFails.Store(true)
951+ proxy, _ := newAttachedProxyFixture(t, d, thin, remote)
952+ err := d.handleProxySideEffect(proxy, 1, ports.Output{Data: []byte("effect")})
953+ require.ErrorContains(t, err, "link reply send failed")
954+ remote.recv <- proxyRecv{frame: proxySideEffectFrame([]byte("effect"))}
955+
956+ require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, 1, remote))
749957 }
M internal/usecase/daemon/proxy_render.go +40 -11
@@ -9,6 +9,14 @@ import (
99
1010 const proxyRenderPaneID layout.PaneID = "proxy-content"
1111
12+type proxyCapture struct {
13+ screen *proxyScreenState
14+ frame renderer.Frame
15+ damage []renderer.Damage
16+ generation uint64
17+ cursor capturedCursorInputs
18+}
19+
1220 // capturePrimary snapshots the remote VT while holding only proxy.mu. The
1321 // snapshot is then composed by the ordinary local frame pipeline, which owns
1422 // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore
@@ -24,22 +32,33 @@ func (p *proxySession) capturePrimary(ac *attachedClient, request primaryCapture
2432 return nil, false
2533 }
2634 screen := p.screen
27- frame := screen.Frame.Clone()
35+ capture := &ac.proxyCapture
2836 damage := screen.CaptureDamage()
29- cursorStyle, hasCursorStyle := screen.CursorStyle()
30- cursor := capturedCursorInputs{
31- row: screen.CursorRow(),
32- col: screen.CursorCol(),
33- style: cursorStyle,
34- hasStyle: hasCursorStyle,
35- visible: screen.CursorVisible(),
37+ if capture.screen != screen {
38+ // A replacement screen has no relationship to the retained frame.
39+ capture.frame = renderer.Frame{}
40+ }
41+ if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil {
42+ screen.CaptureInto(&capture.frame)
43+ }
44+ capture.screen = screen
45+ capture.generation = damage.Generation
46+ capture.damage = append(capture.damage[:0], damage.Damage...)
47+ frame := capture.frame
48+ capture.cursor = capturedCursorInputs{
49+ row: int(screen.cursorOut.Row),
50+ col: int(screen.cursorOut.Col),
51+ style: int(screen.cursorOut.Style),
52+ hasStyle: screen.cursorOut.StyleSet,
53+ visible: screen.cursorOut.Visible,
3654 renderable: frame.Width > 0 && frame.Height > 0,
3755 content: domain.Rect{Width: frame.Width, Height: frame.Height},
3856 }
57+ cursor := capture.cursor
58+ paneDamage := append([]renderer.Damage(nil), capture.damage...)
3959 p.mu.Unlock()
4060
4161 content := domain.Rect{Width: frame.Width, Height: frame.Height}
42- paneDamage := append([]renderer.Damage(nil), damage.Damage...)
4362 if uncertainDamage(paneDamage, frame.Width, frame.Height) {
4463 paneDamage = []renderer.Damage{renderer.FullRedraw()}
4564 }
@@ -98,6 +117,9 @@ func (d *Daemon) resizeProxyForLease(p *proxySession, ac *attachedClient, lease
98117 if changed, _ := p.resize(size); !changed {
99118 return
100119 }
120+ ac.sendMu.Lock()
121+ ac.size = size
122+ ac.sendMu.Unlock()
101123 if rc := attachmentRenderCoordinator(p); rc != nil {
102124 rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"})
103125 return
@@ -118,15 +140,22 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
118140 return false, false
119141 }
120142 content := contentSize(size, false)
143+ if !validProxyScreenSize(content) {
144+ return false, false
145+ }
121146 p.mu.Lock()
122147 if p.screen == nil || p.contentSize == content {
123148 available := p.screen != nil
124149 p.mu.Unlock()
125150 return false, available
126151 }
127- p.screen.Resize(content.Cols, content.Rows)
152+ p.screen.ResizePlaceholder(content)
128153 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
130159 generation := p.linkGeneration
131160 p.mu.Unlock()
132161
M internal/usecase/daemon/proxy_render_test.go +170 -3
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "fmt"
45 "io"
56 "testing"
67
@@ -12,13 +13,50 @@ import (
1213 "github.com/stretchr/testify/require"
1314 )
1415
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+
1553 func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
1654 proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
1755 require.NoError(t, err)
1856 proxy.mu.Lock()
1957 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
2058 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")
2260 require.True(t, changed)
2361
2462 state, ok := proxy.capturePrimary(&attachedClient{}, primaryCaptureRequest{
@@ -45,7 +83,7 @@ func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
4583 proxy.mu.Lock()
4684 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
4785 proxy.mu.Unlock()
48- _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
86+ changed := applyTestScreenText(proxy, 0, 0, "remote")
4987 require.True(t, changed)
5088
5189 d := newTestDaemon(t, nil, stubClock{})
@@ -72,7 +110,7 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
72110 proxy.mu.Lock()
73111 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
74112 proxy.mu.Unlock()
75- _, _, changed := proxy.applyOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("remote")})
113+ changed := applyTestScreenText(proxy, 0, 0, "remote")
76114 require.True(t, changed)
77115 d.mu.Lock()
78116 require.True(t, d.registerSessionLocked(proxy))
@@ -100,6 +138,113 @@ func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
100138 require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to local session did not paint").Type)
101139 }
102140
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+
103248 func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
104249 proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8})
105250 require.NoError(t, err)
@@ -107,6 +252,7 @@ func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
107252 proxy.mu.Lock()
108253 proxy.transport = transport
109254 proxy.linkGeneration = 1
255+ proxy.screenReady = true
110256 proxy.mu.Unlock()
111257
112258 changed, sent := proxy.resize(domain.Size{Cols: 20, Rows: 10})
@@ -141,6 +287,7 @@ func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport,
141287 proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
142288 proxy.transport = remoteTr
143289 proxy.linkGeneration = 1
290+ proxy.screenReady = true
144291 proxy.mu.Unlock()
145292 d.mu.Lock()
146293 require.True(t, d.registerSessionLocked(proxy))
@@ -162,6 +309,26 @@ func newFailingTransport(t *testing.T) *portsmocks.MockTransport {
162309 return tr
163310 }
164311
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+
165332 // TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails covers the split
166333 // between "the local content rectangle moved" and "the remote was told". The
167334 // 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
33 import (
44 "fmt"
55 "math"
6+ "unicode/utf8"
67
78 "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
910 "github.com/bnema/vev/pkg/renderer"
1011 )
1112
12-const maxProxyScreenDamage = 1024
13+const (
14+ maxProxyScreenCells = 1 << 18
15+ maxProxyScreenDamage = 1024
16+ maxProxyScreenSpans = 4096
17+)
1318
1419 type proxyScreenDamageCapture struct {
1520 Damage []renderer.Damage
@@ -73,19 +78,93 @@ func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error {
7378 }
7479
7580 func (s *proxyScreenState) validateUpdate(update ports.ScreenUpdate) error {
76- if err := ports.ValidateScreenUpdate(update); err != nil {
77- return fmt.Errorf("proxy screen: %w", err)
81+ if update.Kind != ports.ScreenUpdateSnapshot && update.Kind != ports.ScreenUpdateDelta {
82+ return fmt.Errorf("proxy screen: unknown update kind %d", update.Kind)
83+ }
84+ if !validProxyScreenSize(update.Size) {
85+ return fmt.Errorf("proxy screen: invalid size %dx%d", update.Size.Cols, update.Size.Rows)
7886 }
7987 if update.Kind == ports.ScreenUpdateDelta {
8088 if s.frame.Width != update.Size.Cols || s.frame.Height != update.Size.Rows {
8189 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)
8290 }
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+ }
83115 }
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
85152 }
86153
87154 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 {
89168 return fmt.Errorf("proxy screen: delta base state %d does not match %d", update.BaseStateNum, s.stateNum)
90169 }
91170 return nil
@@ -183,11 +262,9 @@ func (s *proxyScreenState) AcknowledgeDamage(generation uint64) bool {
183262 return true
184263 }
185264
186-// CaptureInto updates dst from the live frame. dst must already contain the
187-// previous synchronized generation before incremental damage can be replayed;
188-// callers should initialize it with a full clone or equivalent first capture.
189-// Safe scroll/span damage is replayed incrementally; snapshots, uncertain
190-// damage, and dimensions use a complete clone.
265+// CaptureInto updates dst from the live frame. Safe scroll/span damage is
266+// replayed incrementally; snapshots, uncertain damage, and dimensions use a
267+// complete clone.
191268 func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) {
192269 if s == nil || dst == nil || !validProxyFrame(*s) {
193270 return
@@ -266,9 +343,36 @@ func validProxyScreenSize(size domain.Size) bool {
266343 if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 {
267344 return false
268345 }
269- return size.Rows <= ports.MaxScreenCells/size.Cols
346+ return size.Rows <= maxProxyScreenCells/size.Cols
270347 }
271348
272349 func validProxyFrame(s proxyScreenState) bool {
273350 return s.frame.Validate() == nil && s.scratch.Validate() == nil
274351 }
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) {
6363 t.Fatal("invalid update mutated metadata")
6464 }
6565
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-
8066 invalid = ports.ScreenUpdate{
8167 Kind: ports.ScreenUpdateDelta,
8268 BaseStateNum: 1,
@@ -191,7 +177,7 @@ func TestProxyScreenStateRejectsWireInvalidSemanticUpdatesAtomically(t *testing.
191177 beforeDamage = s.CaptureDamage()
192178 invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{
193179 Size: domain.Size{Cols: 2, Rows: 2},
194- Spans: make([]ports.ScreenSpan, ports.MaxScreenSpans+1),
180+ Spans: make([]ports.ScreenSpan, maxProxyScreenSpans+1),
195181 })
196182 if err := s.Apply(invalidSpans); err == nil {
197183 t.Fatal("too many spans were accepted")
@@ -303,8 +289,8 @@ func TestProxyScreenStateCaptureFallsBackAfterSpanThenScroll(t *testing.T) {
303289 t.Fatalf("span-then-scroll damage = %+v", capture.Damage)
304290 }
305291 s.CaptureInto(&dst)
306- if !reflect.DeepEqual(dst, s.frame.Clone()) {
307- t.Fatal("span-then-scroll capture diverged from live frame")
292+ if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want {
293+ t.Fatalf("span-then-scroll capture = %q, want %q", got, want)
308294 }
309295 }
310296
@@ -333,8 +319,8 @@ func TestProxyScreenStateCaptureFallsBackAfterRepeatedScroll(t *testing.T) {
333319 t.Fatalf("repeated-scroll damage = %+v", capture.Damage)
334320 }
335321 s.CaptureInto(&dst)
336- if !reflect.DeepEqual(dst, s.frame.Clone()) {
337- t.Fatal("repeated-scroll capture diverged from live frame")
322+ if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want {
323+ t.Fatalf("repeated-scroll capture = %q, want %q", got, want)
338324 }
339325 }
340326
@@ -369,6 +355,8 @@ func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) {
369355 }
370356 }
371357
358+// BenchmarkProxyScreenStateApply is a component diagnostic for applying an
359+// already-decoded update; it is not a pipeline comparison with ANSI apply.
372360 func BenchmarkProxyScreenStateApply(b *testing.B) {
373361 size := domain.Size{Cols: 120, Rows: 40}
374362 cases := []struct {
M internal/usecase/daemon/proxy_session.go +19 -4
@@ -11,7 +11,6 @@ import (
1111
1212 "github.com/bnema/vev/internal/domain"
1313 "github.com/bnema/vev/internal/ports"
14- "github.com/bnema/vev/pkg/vt"
1514 )
1615
1716 // proxySession is the local attachment identity for one structured remote
@@ -28,8 +27,9 @@ type proxySession struct {
2827 transport ports.Transport
2928 resumeToken uint64
3029 appliedState uint64
31- awaitingReset bool
32- screen *vt.Screen
30+ screenReady bool
31+ resetRequested bool
32+ screen *proxyScreenState
3333 meta ports.SessionMeta
3434 attentionAt []time.Time
3535 linkState ports.LinkState
@@ -59,6 +59,18 @@ func (p *proxySession) core() *sessionCore {
5959
6060 func (p *proxySession) isProxy() bool { return true }
6161
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+
6274 // replaceSessionMetaLocked atomically replaces the relayed snapshot while
6375 // preserving the first-observed timestamp of tabs that remain attentive.
6476 // Callers must validate meta and hold p.mu.
@@ -194,6 +206,9 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi
194206 return nil, fmt.Errorf("proxy session key: %w", err)
195207 }
196208 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+ }
197212 var clientID [16]byte
198213 if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {
199214 return nil, fmt.Errorf("generate proxy client id: %w", err)
@@ -206,7 +221,7 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi
206221 },
207222 key: key,
208223 generation: 1,
209- screen: vt.NewScreen(content.Cols, content.Rows),
224+ screen: newProxyScreenState(content),
210225 linkState: ports.LinkStateConnected,
211226 done: make(chan struct{}),
212227 clientID: clientID,
M internal/usecase/daemon/render.go +11 -0
@@ -282,6 +282,17 @@ func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64)
282282 }
283283 }
284284
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+
285296 func (b *runtimeMarkBatch) flush() {
286297 if b == nil || b.observer == nil {
287298 return
M internal/usecase/daemon/render_capture.go +1 -2
@@ -8,7 +8,6 @@ import (
88 themeui "github.com/bnema/vev/internal/usecase/theme"
99 "github.com/bnema/vev/internal/usecase/ui"
1010 "github.com/bnema/vev/pkg/renderer"
11- "github.com/bnema/vev/pkg/vt"
1211 )
1312
1413 // capturedRenderState is the immutable hand-off from authoritative daemon
@@ -38,7 +37,7 @@ type renderCaptureScratch struct {
3837 type damageReceipt struct {
3938 pane *pane
4039 proxy *proxySession
41- proxyScreen *vt.Screen
40+ proxyScreen *proxyScreenState
4241 generation uint64
4342 }
4443
M internal/usecase/daemon/render_pipeline.go +60 -19
@@ -215,7 +215,7 @@ func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
215215 if !c.hasStyle {
216216 style = 1
217217 }
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}
219219 }
220220
221221 func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapshot, paletteCfg domain.PaletteConfig) {
@@ -430,7 +430,7 @@ func commitDamageReceipts(receipts []damageReceipt) {
430430 }
431431 if receipt.proxy != nil {
432432 receipt.proxy.mu.Lock()
433- if receipt.proxy.screen == receipt.proxyScreen {
433+ if receipt.proxy.screen != nil && receipt.proxy.screen == receipt.proxyScreen {
434434 receipt.proxy.screen.AcknowledgeDamage(receipt.generation)
435435 }
436436 receipt.proxy.mu.Unlock()
@@ -473,9 +473,34 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
473473 }
474474 }
475475 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+ }
477501 endDiff(0, err == nil)
478502 if err != nil {
503+ ac.discardProxyCapture()
479504 ac.sendMu.Unlock()
480505 d.log.Error("render draw failed", "err", err, "session", entry.core().name)
481506 // Without a coordinator reportError repaints synchronously. Suppress only
@@ -498,9 +523,6 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
498523 d.invalidateRender(entry, ac, true, "render_pipeline.go:prepare-failed")
499524 return true
500525 }
501- cursor := ac.prepareCursorTail(composed.cursor, len(prepared.data) > 0)
502- data := append([]byte(nil), prepared.data...)
503- data = append(data, cursor.data...)
504526 var sendTr ports.Transport
505527 var sendErr error
506528 // 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
511533 if ac.proxied {
512534 if sess, ok := localSession(entry); ok {
513535 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+ }
514543 }
515544 }
516545 if len(data) > 0 {
@@ -524,17 +553,11 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
524553 send = async.SendAsync
525554 }
526555 interruptible := marks.roleEffect != nil && marks.roleEffect.beginTransportSend(sendTransport)
527- sendErr = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error {
528- err := send(frame)
529- if interruptible {
530- if err != nil {
531- marks.roleEffect.reportTransportFailure(sendTransport)
532- }
533- marks.roleEffect.endTransportSend()
534- interruptible = false
535- }
536- return err
537- })
556+ if ac.proxied {
557+ sendErr = preparedScreen.send(send)
558+ } else {
559+ sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
560+ }
538561 if interruptible {
539562 if sendErr != nil {
540563 marks.roleEffect.reportTransportFailure(sendTransport)
@@ -543,17 +566,32 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
543566 }
544567 }
545568 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+ }
546576 }
547577 }
548578 if sendErr == nil {
549579 if len(data) == 0 {
550- prepared.commitNoSend()
580+ if ac.proxied {
581+ preparedScreen.commitNoSend()
582+ } else {
583+ preparedANSI.commitNoSend()
584+ }
551585 }
552586 // Publish only after output preparation and transport emission both
553587 // succeed. A cross-session transition may publish concurrently, but its
554588 // mandatory first-paint rebase waits for sendMu and therefore follows this
555589 // completed output transaction.
556- ac.lastCursor = cursor.next
590+ if ac.proxied {
591+ ac.lastCursor = composed.cursor
592+ } else {
593+ ac.lastCursor = cursor.next
594+ }
557595 ac.pipelineScratch = ac.pipelineCache
558596 ac.pipelineCache = composed.cache
559597
@@ -565,6 +603,9 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
565603 ac.renderStages.emit()
566604 }
567605 }
606+ if sendErr != nil {
607+ ac.discardProxyCapture()
608+ }
568609 ac.sendMu.Unlock()
569610 if sendErr != nil {
570611 // 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 (
1515 "github.com/stretchr/testify/require"
1616 )
1717
18-type cacheFailTransport struct {
19- err error
20-}
18+type cacheFailTransport struct{}
2119
22-func (t cacheFailTransport) Send(ports.Frame) error {
23- if t.err != nil {
24- return t.err
25- }
26- return errors.New("send failed")
27-}
20+func (cacheFailTransport) Send(ports.Frame) error { return errors.New("send failed") }
2821 func (cacheFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
2922 func (cacheFailTransport) Close() error { return nil }
3023
M internal/usecase/daemon/render_pipeline_test.go +2 -2
@@ -371,13 +371,13 @@ func TestComposeEmitExactReplaySafeAndUnsafeScroll(t *testing.T) {
371371 }
372372 stream := newOutputStateStream()
373373 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)
375375 require.NoError(t, err)
376376 client := vt.NewScreen(4, 5)
377377 client.Write(firstBytes)
378378
379379 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)
381381 require.NoError(t, err)
382382 require.Equal(t, tt.wantBytes, string(secondBytes))
383383 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
544544 // The parked attachment has no session owner and cannot paint. Retire the
545545 // abandoned transport's output chain before binding the replacement so the
546546 // mandatory first paint cannot be blocked by ACKs that died with the link.
547- ac.output.rebase()
547+ ac.rebaseOutput()
548548 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
549549 ac.replaceTransport(tr)
550550 ac.size = sz
M internal/usecase/daemon/resume_test.go +4 -24
@@ -16,7 +16,6 @@ import (
1616 "github.com/bnema/vev/internal/ports"
1717 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1818 "github.com/bnema/vev/internal/usecase/layout"
19- "github.com/bnema/vev/pkg/renderer"
2019 "github.com/bnema/vev/pkg/vt"
2120 )
2221
@@ -1397,22 +1396,10 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
13971396
13981397 ac.sendMu.Lock()
13991398 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-
14051399 reset := false
14061400 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
1407- incrementalFrame := baseFrame.Clone()
1408- incrementalFrame.Set(0, 0, renderer.Cell{Rune: 'i', Style: renderer.DefaultStyle()})
1409- prepared, err := ac.output.prepare(incrementalFrame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, reset)
1410- require.NoError(t, err)
1411- var f ports.Frame
1412- require.NoError(t, prepared.send(prepared.data, 0, func(frame ports.Frame) error {
1413- f = frame
1414- return nil
1415- }))
1401+
1402+ f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0)
14161403 ac.sendMu.Unlock()
14171404 out, err := ports.UnmarshalOutput(f.Payload)
14181405 require.NoError(t, err)
@@ -1422,16 +1409,9 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
14221409 ac.sendMu.Lock()
14231410 reset = true
14241411 require.True(t, reset, "explicit reset should still force full repaint")
1425- fullFrame := incrementalFrame.Clone()
1426- full, err := ac.output.prepare(fullFrame, nil, reset)
1427- require.NoError(t, err)
1428- var fullOutput ports.Frame
1429- require.NoError(t, full.send(full.data, 0, func(frame ports.Frame) error {
1430- fullOutput = frame
1431- return nil
1432- }))
1412+ full := ac.output.frame([]byte("explicit full repaint"), reset, 0)
14331413 ac.sendMu.Unlock()
1434- fullOut, err := ports.UnmarshalOutput(fullOutput.Payload)
1414+ fullOut, err := ports.UnmarshalOutput(full.Payload)
14351415 require.NoError(t, err)
14361416 require.Equal(t, uint64(0), fullOut.BaseStateNum)
14371417 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() {
198198 // beginRoleEffect is the sole role-bound effect admission point. The gate is
199199 // held only long enough to reserve the capability; the effect itself must run
200200 // 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-
211201 func (ac *attachedClient) beginRoleEffect(token attachmentRoleToken) (*roleEffectTicket, bool) {
212202 if ac == nil || token.ac != ac {
213203 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
7171 if !ok {
7272 return errSessionMetaUnavailable
7373 }
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+ }
7483 if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) {
7584 return nil
7685 }
M internal/usecase/daemon/session_meta_test.go +11 -5
@@ -29,9 +29,12 @@ func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) {
2929
3030 emit("initial")
3131 firstMetaFrame := <-sends
32- firstOutputFrame := <-sends
32+ firstScreenFrame := <-sends
3333 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)
3538 firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload)
3639 require.NoError(t, err)
3740 require.Equal(t, "work", firstMeta.SessionName)
@@ -41,9 +44,12 @@ func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) {
4144 sess.mu.Unlock()
4245 emit("changed")
4346 changedMetaFrame := <-sends
44- changedOutputFrame := <-sends
47+ changedScreenFrame := <-sends
4548 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)
4753 changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload)
4854 require.NoError(t, err)
4955 require.Equal(t, "renamed", changedMeta.Tabs[0].Name)
@@ -63,7 +69,7 @@ func TestProxiedMetadataSendsWithoutOutputBytes(t *testing.T) {
6369
6470 emit(true)
6571 require.Equal(t, ports.MsgSessionMeta, (<-sends).Type)
66- require.Equal(t, ports.MsgOutput, (<-sends).Type)
72+ require.Equal(t, ports.MsgScreenUpdate, (<-sends).Type)
6773
6874 sess.mu.Lock()
6975 sess.tabs[0].name = "renamed"
M internal/usecase/daemon/session_test.go +6 -0
@@ -299,6 +299,12 @@ func TestAttachReplaceKeepsOldClientSnatched(t *testing.T) {
299299 acA := sess.client
300300 sess.mu.Unlock()
301301 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()
302308
303309 // Client B attaches to the same session, displacing A.
304310 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
187187 }
188188 if token := result.displaced; token.ac != nil && token.transport.transport != nil {
189189 blockedRender := result.displacedInterrupted
190- if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) && token.ac.transportEffectActive() {
190+ if !blockedRender && !token.ac.initialSnatchedPanelClaimed(token.generation) {
191191 blockedRender = !token.ac.sendMu.TryLock()
192192 if !blockedRender {
193193 token.ac.sendMu.Unlock()
M internal/usecase/daemon/snatched_render.go +38 -24
@@ -4,7 +4,6 @@ import (
44 "errors"
55
66 "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
87 themeui "github.com/bnema/vev/internal/usecase/theme"
98 "github.com/bnema/vev/internal/usecase/ui"
109 "github.com/bnema/vev/pkg/renderer"
@@ -115,38 +114,53 @@ func (d *Daemon) sendSnatchedPanel(ac *attachedClient, expected transportSnapsho
115114
116115 applied := ac.getAppliedTheme()
117116 frame := snatchedPanelFrame(ac.size, applied.Resolved.Styles, feedback)
118- ac.output.rebase()
119- prepared, err := ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true)
120- if err != nil {
121- return err
117+ ac.rebaseOutput()
118+ var data []byte
119+ var cursor cursorCandidate
120+ var preparedANSI *preparedOutput
121+ var preparedScreen *preparedStructuredOutput
122+ var sendErr error
123+ if ac.proxied {
124+ screenOutput := ac.ensureScreenOutput()
125+ if screenOutput == nil {
126+ return errors.New("proxied attachment has no structured output stream")
127+ }
128+ preparedScreen, sendErr = screenOutput.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, cursorOut{hidden: true}, true, ac.echoAck.Load())
129+ if sendErr == nil {
130+ data = preparedScreen.data
131+ }
132+ } else {
133+ preparedANSI, sendErr = ac.output.prepare(frame, []renderer.Damage{renderer.FullRedraw()}, true)
134+ if sendErr == nil {
135+ cursor = ac.prepareCursorTail(cursorOut{hidden: true}, true)
136+ data = append(append([]byte(nil), preparedANSI.data...), cursor.data...)
137+ }
138+ }
139+ if sendErr != nil {
140+ return sendErr
122141 }
123- cursor := ac.prepareCursorTail(cursorOut{hidden: true}, true)
124- data := append(append([]byte(nil), prepared.data...), cursor.data...)
125142 if effect != nil && !effect.beginTransportSend(expected) {
126143 return errAttachmentTransition
127144 }
128- effectActive := effect != nil
129- err = prepared.send(data, ac.echoAck.Load(), func(frame ports.Frame) error {
130- err := expected.transport.Send(frame)
131- if effectActive {
132- if err != nil {
133- effect.reportTransportFailure(expected)
134- }
135- effect.endTransportSend()
136- effectActive = false
137- }
138- return err
139- })
140- if effectActive {
141- if err != nil {
145+ if ac.proxied {
146+ sendErr = preparedScreen.send(expected.transport.Send)
147+ } else {
148+ sendErr = preparedANSI.send(data, ac.echoAck.Load(), expected.transport.Send)
149+ }
150+ if effect != nil {
151+ if sendErr != nil {
142152 effect.reportTransportFailure(expected)
143153 }
144154 effect.endTransportSend()
145155 }
146- if err == nil {
147- ac.lastCursor = cursor.next
156+ if sendErr == nil {
157+ if ac.proxied {
158+ ac.lastCursor = cursorOut{hidden: true}
159+ } else {
160+ ac.lastCursor = cursor.next
161+ }
148162 }
149- return err
163+ return sendErr
150164 })
151165 if errors.Is(err, errSendTimedOut) {
152166 _ = ac.closeCapturedTransport(tr)
M internal/usecase/daemon/snatched_render_test.go +21 -8
@@ -138,10 +138,28 @@ func TestSendSnatchedPanelRebasesOutput(t *testing.T) {
138138 require.True(t, strings.HasSuffix(string(out.Data), "\x1b[?25l"), "panel must force the cursor hidden")
139139 }
140140
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+
141160 func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) {
142161 d := newTestDaemon(t, nil, stubClock{})
143- sendErr := errors.New("send failed")
144- failed := cacheFailTransport{err: sendErr}
162+ failed := cacheFailTransport{}
145163 ac := &attachedClient{
146164 tr: failed,
147165 output: newOutputStateStream(),
@@ -153,14 +171,9 @@ func TestSendSnatchedPanelPublishesCursorOnlyAfterSendSuccess(t *testing.T) {
153171 ac.roleGeneration.Store(1)
154172 beforeCursor := ac.lastCursor
155173
156- require.ErrorIs(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""), sendErr)
174+ require.Error(t, d.sendSnatchedPanel(ac, ac.transportSnapshot(), 1, ""))
157175 require.Equal(t, beforeCursor, ac.lastCursor)
158176 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")
164177
165178 healthy := &closeTrackingTransport{}
166179 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
3333 require.Contains(t, updated.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 1, Height: 1})
3434 requireToastFootprintsDamaged(t, updated.damage, shown.cache.toastFootprints)
3535 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)
3737 require.NoError(t, err)
3838 fullRenderer := renderer.New(renderer.Capabilities{})
3939 full, err := fullRenderer.Draw(updated.frame, []renderer.Damage{renderer.FullRedraw()})
@@ -126,7 +126,7 @@ func toastDamageState(notices []domain.Notification, damage []renderer.Damage, r
126126
127127 func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {
128128 t.Helper()
129- data, err := renderCommitted(stream, composed.frame, composed.damage, composed.reset)
129+ data, err := stream.render(composed.frame, composed.damage, composed.reset)
130130 require.NoError(t, err)
131131 terminal.Write(data)
132132 require.Equal(t, frameRows(composed.frame), frameRows(terminal.Frame))
M pkg/renderer/damage_plan.go +22 -3
@@ -11,6 +11,28 @@ const maxPlannedDamageSpans = 4096
1111 // used for terminal emission. The source damage remains untouched because the
1212 // scroll and shadow paths need its original structural information.
1313 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+
1436 spans := make([]Span, 0)
1537 for _, d := range damage {
1638 if skip != nil && sameDamage(d, *skip) {
@@ -30,9 +52,6 @@ func buildDamagePlan(frame Frame, damage []Damage, skip *Damage) ([]Span, bool)
3052 spans = append(spans, Span{Y: row, X: x, Width: width})
3153 }
3254 }
33- if len(spans) == 0 {
34- return nil, false
35- }
3655
3756 slices.SortFunc(spans, func(a, b Span) int {
3857 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) {
1111 name string
1212 frame Frame
1313 damage []Damage
14- skip *Damage
1514 want []Span
1615 full bool
1716 }{
@@ -73,29 +72,12 @@ func TestBuildDamagePlan(t *testing.T) {
7372 want: nil,
7473 full: true,
7574 },
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- },
9375 }
9476
9577 for _, tt := range tests {
9678 t.Run(tt.name, func(t *testing.T) {
9779 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)
9981 if full != tt.full || !reflect.DeepEqual(got, tt.want) {
10082 t.Fatalf("buildDamagePlan() = %#v, full = %v; want %#v, %v", got, full, tt.want, tt.full)
10183 }
M pkg/renderer/delta_plan.go +7 -15
@@ -40,9 +40,6 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
4040 candidate.Plan.Snapshot = true
4141 return candidate, nil
4242 }
43- if err := committed.Validate(); err != nil {
44- return DeltaCandidate{}, err
45- }
4643
4744 if len(damage) == 1 && (damage[0].Kind == DamageText || damage[0].Kind == DamageClear) {
4845 candidate.Plan = planSingleDamage(frame, damage[0])
@@ -50,7 +47,7 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
5047 }
5148
5249 if needsFull(damage) {
53- dirty, full := buildDirtyLinePlan(frame, committed, nil)
50+ dirty, full := buildDirtyLinePlan(frame, committed)
5451 if len(dirty) > 0 || full {
5552 candidate.Plan.Snapshot = true
5653 candidate.Plan.Spans = dirty
@@ -70,7 +67,7 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
7067 var spans []Span
7168 var full bool
7269 if len(damage) == 0 {
73- spans, full = buildDirtyLinePlan(frame, committed, nil)
70+ spans, full = buildDirtyLinePlan(frame, committed)
7471 } else {
7572 spans, full = buildDamagePlan(frame, damage, skip)
7673 }
@@ -82,13 +79,8 @@ func PlanDelta(frame Frame, damage []Damage, committed Frame, reset bool) (Delta
8279 if candidate.Plan.Scroll.Height != 0 {
8380 candidate.Plan = DeltaPlan{Snapshot: true}
8481 } else {
85- dirty, full := buildDirtyLinePlan(frame, committed, spans[:0])
86- if full || len(dirty) == 0 {
87- candidate.Plan = DeltaPlan{Snapshot: true}
88- } else {
89- candidate.Plan.Snapshot = true
90- candidate.Plan.Spans = dirty
91- }
82+ candidate.Plan.Snapshot = true
83+ candidate.Plan.Spans = spans
9284 }
9385 return candidate, nil
9486 }
@@ -110,13 +102,13 @@ func planSingleDamage(frame Frame, d Damage) DeltaPlan {
110102 }
111103 plan := DeltaPlan{Spans: spans}
112104 if (frame.Height == 1 || len(spans) > 1) && deltaCostsSnapshot(frame, spans, false) {
113- return DeltaPlan{Snapshot: true}
105+ plan.Snapshot = true
114106 }
115107 return plan
116108 }
117109
118-func buildDirtyLinePlan(frame, committed Frame, spans []Span) ([]Span, bool) {
119- spans = spans[:0]
110+func buildDirtyLinePlan(frame, committed Frame) ([]Span, bool) {
111+ var spans []Span
120112 for y := range frame.Height {
121113 frameRow := frame.Row(y)
122114 committedRow := committed.Row(y)
M pkg/renderer/delta_plan_test.go +6 -34
@@ -116,14 +116,17 @@ func TestPlanDelta(t *testing.T) {
116116 frame: testFrame("changed!"),
117117 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 8, Height: 1}},
118118 committed: NewFrame(8, 1),
119- want: DeltaPlan{Snapshot: true},
119+ want: DeltaPlan{Snapshot: true, Spans: []Span{{Y: 0, X: 0, Width: 8}}},
120120 },
121121 {
122- name: "single broad damage snapshots completely",
122+ name: "single broad damage retains spans for snapshot encoding",
123123 frame: testFrame("aaaa", "bbbb"),
124124 damage: []Damage{{Kind: DamageText, X: 0, Y: 0, Width: 4, Height: 2}},
125125 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+ },
127130 },
128131 {
129132 name: "snapshot cost includes style runs",
@@ -207,34 +210,6 @@ func TestPlanDeltaRejectsInvalidFrame(t *testing.T) {
207210 }
208211 }
209212
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-
238213 func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) {
239214 committed := testFrame("0000", "1111", "2222", "3333", "4444", "5555", "6666", "7777")
240215 frame := committed.Clone()
@@ -259,9 +234,6 @@ func TestDeltaCandidateCommitAppliesScrollToLogicalRows(t *testing.T) {
259234 t.Fatalf("committed row %d = %#v, want %#v", y, committed.Row(y), frame.Row(y))
260235 }
261236 }
262- if err := committed.CheckInvariants(); err != nil {
263- t.Fatalf("committed frame invariants: %v", err)
264- }
265237 }
266238
267239 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) {
264264 func TestWideRunCursorTrackingAndEL(t *testing.T) {
265265 t.Run("cursor advances two columns per wide rune", func(t *testing.T) {
266266 // Rect A ends with a wide pair (not at the edge); rect B starts one
267- // column later. The literal space in the expected output bridges the
268- // damaged regions.
267+ // column later. The gap between them must be crossed with the cursor
268+ // position that reflects the terminal advancing two columns for the
269+ // wide rune — otherwise the relative move would be wrong.
269270 r := New(Capabilities{})
270271 base := NewFrame(8, 1)
271272 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -285,8 +286,8 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
285286 {Kind: DamageText, X: 5, Y: 0, Width: 3, Height: 1, Count: 1},
286287 }
287288 got := drawGolden(t, r, frame, damage)
288- // The literal space bridges the undamaged gap between the two regions.
289- want := "\x1b[1;1H你AB CDE\x1b[0m"
289+ // After 你AB the cursor is at col 4; moving to col 5 is a one-column CUF.
290+ want := "\x1b[1;1H你AB\x1b[CCDE\x1b[0m"
290291 if got != want {
291292 t.Fatalf("output = %q, want %q", got, want)
292293 }
@@ -294,8 +295,9 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
294295
295296 t.Run("rect splitting a wide pair invalidates tracking", func(t *testing.T) {
296297 // A damage rect that ends on a wide head whose continuation lies
297- // outside the rect is followed by a literal space that bridges the
298- // damaged regions. The trailing erase sequence clears the row tail.
298+ // outside the rect makes the terminal advance one column further than
299+ // the rect width accounts for. Tracking must be dropped: the next
300+ // rect must be positioned with absolute CUP, not a relative move.
299301 r := New(Capabilities{})
300302 base := NewFrame(8, 1)
301303 if _, err := r.Draw(base, []Damage{FullRedraw()}); err != nil {
@@ -312,9 +314,9 @@ func TestWideRunCursorTrackingAndEL(t *testing.T) {
312314 {Kind: DamageText, X: 4, Y: 0, Width: 1, Height: 1, Count: 1},
313315 }
314316 got := drawGolden(t, r, frame, damage)
315- // The expected output preserves the literal-space bridge and trailing
316- // erase sequence for this split wide pair.
317- want := "\x1b[1;1HA你 B\x1b[K\x1b[0m"
317+ // After A你 the real cursor is at col 3, not col 2: a relative CUF
318+ // would be off by one, so the second rect must use absolute CUP.
319+ want := "\x1b[1;1HA你\x1b[1;5HB\x1b[0m"
318320 if got != want {
319321 t.Fatalf("output = %q, want %q", got, want)
320322 }
@@ -423,7 +425,7 @@ func TestCanonicalDamagePreservesWideHeadTracking(t *testing.T) {
423425 if err != nil {
424426 t.Fatal(err)
425427 }
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 {
427429 t.Fatalf("output = %q, want %q", got, want)
428430 }
429431 }
M pkg/renderer/renderer.go +19 -5
@@ -65,7 +65,24 @@ func (r *Renderer) Prepare(frame Frame, damage []Damage, reset bool) (PreparedDr
6565 var err error
6666 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) {
6767 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}
6986 }
7087 } else {
7188 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
7996 return prepared, nil
8097 }
8198
82- buf, ok := bufferPool.Get().(*bytes.Buffer)
83- if !ok {
84- buf = new(bytes.Buffer)
85- }
99+ buf := bufferPool.Get().(*bytes.Buffer)
86100 buf.Reset()
87101 defer putBuffer(buf)
88102 if r.caps.SynchronizedOutput {
M pkg/renderer/renderer_test.go +0 -11
@@ -1289,17 +1289,6 @@ func TestRendererPrepare(t *testing.T) {
12891289 },
12901290 want: "\x1b[0m\x1b[1;4r\x1b[4;1H\n\x1b[r\x1b[4;1Hnew!!\x1b[0m",
12911291 },
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- },
13031292 }
13041293
13051294 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