detached ee0f20d

Merge pull request #174 from bnema/feat/per-attachment-output-streams

feat(session): stream output per attachment
commit
ee0f20d552b984d39e2cb04d88e35d745b1572c2
author
bnema · · 2026-08-04 07:11 UTC
committer
GitHub · · 2026-08-04 07:11 UTC
merge of
6a384ad + 8421039 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
82 files · +2439 -6815
D .coderabbit.yaml +0 -11
@@ -1,11 +0,0 @@
1-# Temporary PR #173 review scope; remove after the clean reviewed-head merge.
2-reviews:
3- path_filters:
4- - "!internal/usecase/daemon/move_atomicity_test.go"
5- - "!internal/usecase/daemon/move_final_source_attachment_race_test.go"
6- - "!internal/usecase/daemon/pane_sync_owner_test.go"
7- - "!internal/usecase/daemon/role_effect_gate_test.go"
8- - "!internal/usecase/daemon/snatched_client_test.go"
9- - "!internal/usecase/daemon/snatched_reclaim_test.go"
10- - "!internal/usecase/daemon/snatched_render_test.go"
11- - "!internal/usecase/daemon/stalled_attachment_test.go"
M docs/performance.md +3 -65
@@ -48,10 +48,9 @@ retransmits, pending, ACK RTT) remain separate from duration calculations.
48 48
49 ### Historical proxy ANSI baseline49 ### Historical proxy ANSI baseline
50 50
51-Before the structured path, proxied state-bearing screen updates transported ANSI51+Before this refactor, proxied state-bearing screen updates transported ANSI
52-and were applied through the local proxy VT parser. In the candidate, proxied52+and were applied through the local proxy VT parser. This parser benchmark remains
53-state-bearing frames are `MsgScreenUpdate`; this parser benchmark remains a53+a historical diagnostic. Ordinary local output remains the ANSI `MsgOutput`
54-historical diagnostic. Ordinary local output remains the ANSI `MsgOutput`
55 guardrail.54 guardrail.
56 55
57 The binaries were built from clean, exact commits in temporary clones so their56 The binaries were built from clean, exact commits in temporary clones so their
@@ -101,67 +100,6 @@ go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10
101 The candidate guardrail completed with Go 1.26.5; its ten-repetition output is100 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`.101 retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`.
103 102
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
131-```
132-
133-Measured candidate medians from that command (Go 1.26.5, Linux/amd64):
134-
135-| fixture | ANSI ns/op | structured ns/op | ANSI B/op | structured B/op | ANSI wirebytes/op | structured wirebytes/op |
136-| --- | ---: | ---: | ---: | ---: | ---: | ---: |
137-| one-cell | 530.55 | 1,016.5 | 192 | 888 | 37 | 71 |
138-| full-line | 8,637.5 | 22,187.5 | 674 | 19,928 | 154 | 190 |
139-| styled-line | 10,565.5 | 25,682 | 1,652 | 24,952 | 388 | 432 |
140-| full-width-scroll | 55,713.5 | 73,858 | 760 | 19,944 | 177 | 196 |
141-
142-The component diagnostics completed ten repetitions with separate
143-commands; their raw output is retained under `/tmp/vev-perf-results/`:
144-
145-```sh
146-cd /tmp/vev-perf-candidate-2b48abf9-clone
147-go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 \
148- | tee /tmp/vev-perf-results/candidate-2b48abf9-proxy-ansi.txt
149-go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkStructuredOutputPrepare$' -benchmem -count=10 \
150- | tee /tmp/vev-perf-results/candidate-2b48abf9-structured-prepare.txt
151-go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkProxyScreenStateApply$' -benchmem -count=10 \
152- | tee /tmp/vev-perf-results/candidate-2b48abf9-screen-apply.txt
153-```
154-
155-Their medians are respectively 107.45/5,107/6,561.5/251,126.5 ns/op for
156-ANSI one-cell/full-line/styled-line/scroll rows as reported in the raw file;
157-structured prepare medians are 673/16,928/23,220.5/61,424.5 ns/op and decoded
158-apply medians are 53.29/1,225/1,234/3,659.5 ns/op. These remain component
159-diagnostics, not head-to-head pipeline comparisons.
160-
161-The 35-scenario public-CLI harness A/B matrix remains the adoption gate. This
162-local pipeline benchmark does not replace that gate and this document records
163-no unmeasured result or conclusion.
164-
165 ### Public-CLI transport runs103 ### Public-CLI transport runs
166 104
167 The adoption gate requires a 10-second warmup, a 30-second measured interval,105 The adoption gate requires a 10-second warmup, a 30-second measured interval,
M internal/adapters/dgram/network_sim_test.go +18 -12
@@ -9,6 +9,7 @@ import (
9 "testing"9 "testing"
10 "time"10 "time"
11 11
12+ "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
13 pdgram "github.com/bnema/vev/pkg/dgram"14 pdgram "github.com/bnema/vev/pkg/dgram"
14 )15 )
@@ -451,7 +452,7 @@ func TestTransportFloodClassification(t *testing.T) {
451 }452 }
452 }453 }
453 b.mu.Unlock()454 b.mu.Unlock()
454- primer := ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("primer")})455+ primer := mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("primer")})
455 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: primer}); err != nil {456 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: primer}); err != nil {
456 t.Fatal(err)457 t.Fatal(err)
457 }458 }
@@ -528,10 +529,12 @@ func TestTransportFloodClassification(t *testing.T) {
528 }529 }
529 })530 })
530 for state := 1; state < outputCount; state++ {531 for state := 1; state < outputCount; state++ {
531- payload := ports.MarshalOutput(ports.Output{532+ payload := mustMarshalOutput(ports.Output{
532- BaseStateNum: uint64(state),533+ Epoch: 1,
533- NewStateNum: uint64(state + 1),534+ Base: uint64(state),
534- Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes),535+ New: uint64(state + 1),
536+ Size: domain.Size{Cols: 1, Rows: 1},
537+ Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes),
535 })538 })
536 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {539 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
537 t.Fatal(err)540 t.Fatal(err)
@@ -631,7 +634,7 @@ func TestTransportFloodClassification(t *testing.T) {
631 if err != nil {634 if err != nil {
632 t.Fatal(err)635 t.Fatal(err)
633 }636 }
634- latestState = max(latestState, output.NewStateNum)637+ latestState = max(latestState, output.New)
635 default:638 default:
636 goto drainedOutput639 goto drainedOutput
637 }640 }
@@ -851,10 +854,13 @@ func floodState(sender, receiver *Transport, link *simulatedLink) floodTransport
851 }854 }
852 855
853 func floodOutputPayload(baseState, mtu int) []byte {856 func floodOutputPayload(baseState, mtu int) []byte {
854- return ports.MarshalOutput(ports.Output{857+ return mustMarshalOutput(ports.Output{
855- BaseStateNum: uint64(baseState),858+ Epoch: 1,
856- NewStateNum: uint64(baseState + 1),859+ Base: uint64(baseState),
857- Data: make([]byte, floodRecordMTUs*mtu),860+ New: uint64(baseState + 1),
861+ Size: domain.Size{Cols: 1, Rows: 1},
862+ Full: baseState == 0,
863+ Data: make([]byte, floodRecordMTUs*mtu),
858 })864 })
859 }865 }
860 866
@@ -866,8 +872,8 @@ func TestFloodOutputPayloadIsIncrementalStateBearingOutput(t *testing.T) {
866 if err != nil {872 if err != nil {
867 t.Fatalf("state %d: %v", state, err)873 t.Fatalf("state %d: %v", state, err)
868 }874 }
869- if output.BaseStateNum != uint64(state) || output.NewStateNum != uint64(state+1) {875+ if output.Base != uint64(state) || output.New != uint64(state+1) {
870- t.Fatalf("output state = %d -> %d, want %d -> %d", output.BaseStateNum, output.NewStateNum, state, state+1)876+ t.Fatalf("output state = %d -> %d, want %d -> %d", output.Base, output.New, state, state+1)
871 }877 }
872 minimumBytes := floodRecordMTUs * mtu878 minimumBytes := floodRecordMTUs * mtu
873 if len(output.Data) < minimumBytes || len(payload) < minimumBytes {879 if len(output.Data) < minimumBytes || len(payload) < minimumBytes {
M internal/adapters/dgram/proxy_test.go +5 -4
@@ -10,6 +10,7 @@ import (
10 "testing"10 "testing"
11 "time"11 "time"
12 12
13+ "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
14 )15 )
15 16
@@ -87,7 +88,7 @@ func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testi
87 daemon := newFakeTransport()88 daemon := newFakeTransport()
88 errCh := make(chan error, 1)89 errCh := make(chan error, 1)
89 go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()90 go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
90- hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", MaxOutputInFlight: requested}91+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: requested}
91 client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}}92 client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}}
92 93
93 select {94 select {
@@ -104,7 +105,7 @@ func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testi
104 }105 }
105 106
106 input := ports.Frame{Type: ports.MsgInput, Payload: []byte("still flowing")}107 input := ports.Frame{Type: ports.MsgInput, Payload: []byte("still flowing")}
107- ack := ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 7})}108+ ack := ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: 1, State: 7})}
108 client.recv <- recvResult{frame: input}109 client.recv <- recvResult{frame: input}
109 client.recv <- recvResult{frame: ack}110 client.recv <- recvResult{frame: ack}
110 for _, want := range []ports.Frame{input, ack} {111 for _, want := range []ports.Frame{input, ack} {
@@ -488,7 +489,7 @@ func TestProxyCopierStopsRetryingWhenContextCanceled(t *testing.T) {
488 489
489 func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T) {490 func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T) {
490 t.Run("malformed payload remains malformed", func(t *testing.T) {491 t.Run("malformed payload remains malformed", func(t *testing.T) {
491- payload := append(ports.MarshalHello(ports.Hello{Version: ports.ProtocolVersion, MaxOutputInFlight: 8}), 0xff)492+ payload := append(ports.MarshalHello(ports.Hello{Version: ports.ProtocolVersion, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 8}), 0xff)
492 frame := ports.Frame{Type: ports.MsgHello, Payload: payload}493 frame := ports.Frame{Type: ports.MsgHello, Payload: payload}
493 got := clampDatagramHelloOutputWindow(frame)494 got := clampDatagramHelloOutputWindow(frame)
494 if !reflect.DeepEqual(got, frame) {495 if !reflect.DeepEqual(got, frame) {
@@ -502,7 +503,7 @@ func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T)
502 t.Run("mismatched version remains mismatched", func(t *testing.T) {503 t.Run("mismatched version remains mismatched", func(t *testing.T) {
503 wantVersion := ports.ProtocolVersion + 1504 wantVersion := ports.ProtocolVersion + 1
504 frame := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{505 frame := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
505- Version: wantVersion, MaxOutputInFlight: 8,506+ Version: wantVersion, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 8,
506 })}507 })}
507 got, err := ports.UnmarshalHello(clampDatagramHelloOutputWindow(frame).Payload)508 got, err := ports.UnmarshalHello(clampDatagramHelloOutputWindow(frame).Payload)
508 if err != nil {509 if err != nil {
M internal/adapters/dgram/transport_send_test.go +7 -6
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10+ "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 pdgram "github.com/bnema/vev/pkg/dgram"12 pdgram "github.com/bnema/vev/pkg/dgram"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
@@ -113,7 +114,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
113 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil {114 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil {
114 t.Fatal(err)115 t.Fatal(err)
115 }116 }
116- large := ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: make([]byte, 24*a.mtu)})117+ large := mustMarshalOutput(ports.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 24*a.mtu)})
117 for range 2 {118 for range 2 {
118 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil {119 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil {
119 t.Fatal(err)120 t.Fatal(err)
@@ -121,7 +122,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
121 }122 }
122 waitForManualTimers(t, clk, 4)123 waitForManualTimers(t, clk, 4)
123 previousPackets := len(aPC.in)124 previousPackets := len(aPC.in)
124- for tick := 0; tick < 128; tick++ {125+ for tick := range 128 {
125 clk.advance(initialPacingRTT)126 clk.advance(initialPacingRTT)
126 time.Sleep(time.Millisecond)127 time.Sleep(time.Millisecond)
127 packets := len(aPC.in)128 packets := len(aPC.in)
@@ -154,7 +155,7 @@ func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) {
154 done := make(chan error, 1)155 done := make(chan error, 1)
155 go func() { done <- a.Send(large) }()156 go func() { done <- a.Send(large) }()
156 start := clk.Now()157 start := clk.Now()
157- for tick := 0; tick < 128; tick++ {158+ for range 128 {
158 a.mu.Lock()159 a.mu.Lock()
159 p := a.pending[1]160 p := a.pending[1]
160 if p != nil && p.initialInFlight && !p.last.IsZero() {161 if p != nil && p.initialInFlight && !p.last.IsZero() {
@@ -220,11 +221,11 @@ func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *tes
220 }221 }
221 a.mu.Unlock()222 a.mu.Unlock()
222 go a.resendPending()223 go a.resendPending()
223- sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: make([]byte, 100*1024)})}224+ sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 100*1024)})}
224 done := make(chan error, 1)225 done := make(chan error, 1)
225 go func() { done <- a.SendSynchronous(sideEffect) }()226 go func() { done <- a.SendSynchronous(sideEffect) }()
226 227
227- for tick := 0; tick < 512; tick++ {228+ for range 512 {
228 clk.advance(initialPacingRTT)229 clk.advance(initialPacingRTT)
229 time.Sleep(time.Millisecond)230 time.Sleep(time.Millisecond)
230 select {231 select {
@@ -328,7 +329,7 @@ func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) {
328 frame ports.Frame329 frame ports.Frame
329 }{330 }{
330 {name: "control", frame: ports.Frame{Type: ports.MsgPing}},331 {name: "control", frame: ports.Frame{Type: ports.MsgPing}},
331- {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: []byte("osc")})}},332+ {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("osc")})}},
332 }333 }
333 for _, tt := range frames {334 for _, tt := range frames {
334 t.Run(tt.name, func(t *testing.T) {335 t.Run(tt.name, func(t *testing.T) {
A internal/adapters/dgram/wire_test_helpers_test.go +19 -0
@@ -0,0 +1,19 @@
1+package dgram
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalOutput(m ports.Output) []byte {
6+ payload, err := ports.MarshalOutput(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
12+
13+func mustMarshalAck(m ports.Ack) []byte {
14+ payload, err := ports.MarshalAck(m)
15+ if err != nil {
16+ panic(err)
17+ }
18+ return payload
19+}
M internal/adapters/ipc/transport_test.go +1 -1
@@ -140,7 +140,7 @@ func TestTransportManySmallFramesBackToBack(t *testing.T) {
140 }140 }
141 // Vary a couple to prove ordering and content are both preserved.141 // Vary a couple to prove ordering and content are both preserved.
142 frames[7] = ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("hop")})}142 frames[7] = ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("hop")})}
143- frames[150] = ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 5, Rows: 6}})}143+ frames[150] = ports.Frame{Type: ports.MsgResize, Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 5, Rows: 6}})}
144 144
145 var wg sync.WaitGroup145 var wg sync.WaitGroup
146 wg.Go(func() {146 wg.Go(func() {
A internal/adapters/ipc/wire_test_helpers_test.go +11 -0
@@ -0,0 +1,11 @@
1+package ipc
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalResize(m ports.Resize) []byte {
6+ payload, err := ports.MarshalResize(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
M internal/adapters/sshstdio/transport_test.go +2 -1
@@ -12,6 +12,7 @@ import (
12 "testing"12 "testing"
13 "time"13 "time"
14 14
15+ "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
16 )17 )
17 18
@@ -120,7 +121,7 @@ func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
120 defer func() { _ = client.Close() }()121 defer func() { _ = client.Close() }()
121 defer func() { _ = server.Close() }()122 defer func() { _ = server.Close() }()
122 123
123- hello := ports.Hello{Version: ports.ProtocolVersion + 1, Intent: ports.IntentAttach, Name: "work"}124+ hello := ports.Hello{Version: ports.ProtocolVersion + 1, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}}
124 go func() {125 go func() {
125 f, err := server.Recv()126 f, err := server.Recv()
126 if err != nil {127 if err != nil {
M internal/ports/frame.go +2 -2
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 214+const ProtocolVersion uint16 = 23
5 5
6 // MaxFrameLen is the largest permitted frame length, including the type byte6 // MaxFrameLen is the largest permitted frame length, including the type byte
7 // and excluding the four-byte length prefix.7 // and excluding the four-byte length prefix.
@@ -36,7 +36,7 @@ const (
36 MsgSessions MsgType = 2136 MsgSessions MsgType = 21
37 MsgCommandResult MsgType = 2237 MsgCommandResult MsgType = 22
38 MsgSessionMeta MsgType = 2338 MsgSessionMeta MsgType = 23
39- MsgScreenUpdate MsgType = 2439+ MsgAttachTarget MsgType = 25
40 )40 )
41 41
42 // Frame is the unit of exchange over a Transport: a typed, length-delimited42 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/ports/frame_test.go +11 -37
@@ -3,17 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 21 {6+ if ProtocolVersion != 23 {
7- t.Fatalf("ProtocolVersion = %d, want 21", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion)
8- }
9-}
10-
11-func TestScreenUpdateMessageType(t *testing.T) {
12- if MsgScreenUpdate != 24 {
13- t.Fatalf("MsgScreenUpdate = %d, want 24", MsgScreenUpdate)
14- }
15- if MaxFrameLen != 16<<20 {
16- t.Fatalf("MaxFrameLen = %d, want %d", MaxFrameLen, 16<<20)
17 }8 }
18 }9 }
19 10
@@ -30,9 +21,6 @@ func TestControlMsgTypes(t *testing.T) {
30 if MsgSessionMeta != 23 {21 if MsgSessionMeta != 23 {
31 t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta)22 t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta)
32 }23 }
33- if CapabilityProxied != 1<<3 {
34- t.Fatalf("CapabilityProxied = %d, want 8", CapabilityProxied)
35- }
36 }24 }
37 25
38 func TestMsgTypeUnique(t *testing.T) {26 func TestMsgTypeUnique(t *testing.T) {
@@ -40,28 +28,15 @@ func TestMsgTypeUnique(t *testing.T) {
40 name string28 name string
41 typ MsgType29 typ MsgType
42 }{30 }{
43- {"MsgHello", MsgHello},31+ {"MsgHello", MsgHello}, {"MsgInput", MsgInput}, {"MsgResize", MsgResize},
44- {"MsgInput", MsgInput},32+ {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},
45- {"MsgResize", MsgResize},33+ {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
46- {"MsgDetach", MsgDetach},34+ {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
47- {"MsgPing", MsgPing},35+ {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest},
48- {"MsgList", MsgList},36+ {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
49- {"MsgKill", MsgKill},37+ {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
50- {"MsgTheme", MsgTheme},38+ {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta},
51- {"MsgAck", MsgAck},39+ {"MsgAttachTarget", MsgAttachTarget},
52- {"MsgImagePush", MsgImagePush},
53- {"MsgClientNotice", MsgClientNotice},
54- {"MsgCommand", MsgCommand},
55- {"MsgOutputResetRequest", MsgOutputResetRequest},
56- {"MsgWelcome", MsgWelcome},
57- {"MsgError", MsgError},
58- {"MsgOutput", MsgOutput},
59- {"MsgDetached", MsgDetached},
60- {"MsgPong", MsgPong},
61- {"MsgSessions", MsgSessions},
62- {"MsgCommandResult", MsgCommandResult},
63- {"MsgSessionMeta", MsgSessionMeta},
64- {"MsgScreenUpdate", MsgScreenUpdate},
65 }40 }
66 41
67 seen := make(map[MsgType]string, len(tests))42 seen := make(map[MsgType]string, len(tests))
@@ -71,7 +46,6 @@ func TestMsgTypeUnique(t *testing.T) {
71 }46 }
72 seen[tt.typ] = tt.name47 seen[tt.typ] = tt.name
73 }48 }
74-
75 if len(seen) != len(tests) {49 if len(seen) != len(tests) {
76 t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen))50 t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen))
77 }51 }
M internal/ports/wire.go +361 -43
@@ -9,6 +9,7 @@ package ports
9 import (9 import (
10 "encoding/binary"10 "encoding/binary"
11 "errors"11 "errors"
12+ "fmt"
12 "math"13 "math"
13 14
14 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
@@ -26,6 +27,27 @@ var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
26 27
27 var errInvalidBoolean = errors.New("ports: invalid boolean flag")28 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
28 29
30+const (
31+ outputHeaderLen = 5*8 + 2*2 + 1
32+ outputPayloadOverhead = outputHeaderLen + 4
33+)
34+
35+// ErrInvalidSize reports a terminal size that cannot be represented by the
36+// protocol or would exceed the bounded screen area.
37+var ErrInvalidSize = errors.New("ports: invalid terminal size")
38+
39+// ErrInvalidHello reports a malformed Hello semantic value.
40+var ErrInvalidHello = errors.New("ports: invalid hello")
41+
42+// ErrInvalidOutput reports a malformed Output semantic value.
43+var ErrInvalidOutput = errors.New("ports: invalid output")
44+
45+// ErrInvalidAck reports a malformed Ack semantic value.
46+var ErrInvalidAck = errors.New("ports: invalid ack")
47+
48+// ErrInvalidAttachTarget reports a malformed attach target.
49+var ErrInvalidAttachTarget = errors.New("ports: invalid attach target")
50+
29 // ErrInvalidSessionMeta reports metadata that cannot be represented by the51 // ErrInvalidSessionMeta reports metadata that cannot be represented by the
30 // protocol's ordered uint16-indexed tab layout.52 // protocol's ordered uint16-indexed tab layout.
31 var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata")53 var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata")
@@ -47,7 +69,6 @@ const (
47 CapabilityResume uint32 = 1 << 069 CapabilityResume uint32 = 1 << 0
48 CapabilityUDP uint32 = 1 << 170 CapabilityUDP uint32 = 1 << 1
49 CapabilityPredict uint32 = 1 << 271 CapabilityPredict uint32 = 1 << 2
50- CapabilityProxied uint32 = 1 << 3
51 )72 )
52 73
53 // ErrorMsg codes.74 // ErrorMsg codes.
@@ -77,7 +98,6 @@ const (
77 type Hello struct {98 type Hello struct {
78 Version uint1699 Version uint16
79 Intent uint8100 Intent uint8
80- Proxied bool
81 ClientID [16]byte101 ClientID [16]byte
82 ResumeToken uint64102 ResumeToken uint64
83 Name string103 Name string
@@ -160,9 +180,10 @@ type Ping struct{}
160 // Pong answers a Ping.180 // Pong answers a Ping.
161 type Pong struct{}181 type Pong struct{}
162 182
163-// Ack acknowledges receipt/application of an output state number.183+// Ack acknowledges receipt/application of an output state in one output epoch.
164 type Ack struct {184 type Ack struct {
165- AckedStateNum uint64185+ Epoch uint64
186+ State uint64
166 }187 }
167 188
168 // Welcome is the daemon's reply to a successful Hello.189 // Welcome is the daemon's reply to a successful Hello.
@@ -180,14 +201,26 @@ type ErrorMsg struct {
180 Text string201 Text string
181 }202 }
182 203
183-// Output carries raw PTY output, destined for the client's terminal.204+// Output carries one ordered output publication for an attachment.
184 type Output struct {205 type Output struct {
185- BaseStateNum uint64206+ Epoch uint64
186- NewStateNum uint64207+ Base uint64
187- EchoAck uint64208+ New uint64
209+ Echo uint64
210+ ViewRevision uint64
211+ Size domain.Size
212+ Full bool
188 Data []byte213 Data []byte
189 }214 }
190 215
216+// AttachTarget identifies the endpoint and session selected for a new
217+// attachment. It deliberately carries no viewport, resume, or proxy state.
218+type AttachTarget struct {
219+ Endpoint string
220+ Session string
221+ Intent uint8
222+}
223+
191 // Detached tells a client it has been disconnected from its session and why.224 // Detached tells a client it has been disconnected from its session and why.
192 type Detached struct {225 type Detached struct {
193 Reason uint8226 Reason uint8
@@ -227,18 +260,17 @@ type Sessions struct {
227 Sessions []SessionInfo260 Sessions []SessionInfo
228 }261 }
229 262
230-// OutputResetRequest asks a proxied daemon to rebase its output stream.263+// OutputResetRequest asks a remote output stream to rebase.
231 type OutputResetRequest struct{}264 type OutputResetRequest struct{}
232 265
233-// SessionTabMeta describes one tab in a proxied session's authoritative266+// SessionTabMeta describes one tab in a remote session's metadata snapshot.
234-// metadata snapshot.
235 type SessionTabMeta struct {267 type SessionTabMeta struct {
236 Index uint16268 Index uint16
237 Name string269 Name string
238 Attention bool270 Attention bool
239 }271 }
240 272
241-// SessionMeta is the authoritative tab metadata sent to a proxied client.273+// SessionMeta is authoritative tab metadata for a remote session.
242 type SessionMeta struct {274 type SessionMeta struct {
243 SessionName string275 SessionName string
244 Active uint16276 Active uint16
@@ -296,6 +328,11 @@ func (w *payloadWriter) putLongString(s string) {
296 w.b = append(w.b, s...)328 w.b = append(w.b, s...)
297 }329 }
298 330
331+func (w *payloadWriter) putLongBytes(b []byte) {
332+ w.putUint32(uint32(len(b)))
333+ w.b = append(w.b, b...)
334+}
335+
299 // payloadReader consumes a message payload field by field in wire order,336 // payloadReader consumes a message payload field by field in wire order,
300 // erroring instead of panicking on any short read.337 // erroring instead of panicking on any short read.
301 type payloadReader struct {338 type payloadReader struct {
@@ -384,6 +421,43 @@ func (r *payloadReader) getLongString() (string, error) {
384 return s, nil421 return s, nil
385 }422 }
386 423
424+func (r *payloadReader) getLongBytes() ([]byte, error) {
425+ n, err := r.getUint32()
426+ if err != nil {
427+ return nil, err
428+ }
429+ if uint64(n) > uint64(len(r.b)) {
430+ return nil, errShortPayload
431+ }
432+ b := append([]byte(nil), r.b[:int(n)]...)
433+ r.b = r.b[int(n):]
434+ return b, nil
435+}
436+
437+func (r *payloadReader) skipString() error {
438+ n, err := r.getUint16()
439+ if err != nil {
440+ return err
441+ }
442+ if int(n) > len(r.b) {
443+ return errShortPayload
444+ }
445+ r.b = r.b[n:]
446+ return nil
447+}
448+
449+func (r *payloadReader) skipLongString() error {
450+ n, err := r.getUint32()
451+ if err != nil {
452+ return err
453+ }
454+ if uint64(n) > uint64(len(r.b)) {
455+ return errShortPayload
456+ }
457+ r.b = r.b[n:]
458+ return nil
459+}
460+
387 // rest consumes and returns all remaining bytes, copied so the result is461 // rest consumes and returns all remaining bytes, copied so the result is
388 // independent of the reader's backing array.462 // independent of the reader's backing array.
389 func (r *payloadReader) rest() []byte {463 func (r *payloadReader) rest() []byte {
@@ -408,12 +482,72 @@ func PeekHelloVersion(b []byte) (uint16, bool) {
408 return binary.BigEndian.Uint16(b), true482 return binary.BigEndian.Uint16(b), true
409 }483 }
410 484
485+// ValidateHello validates Hello semantics without retaining or allocating any
486+// payload data.
487+func ValidateHello(h Hello) error {
488+ if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
489+ return ErrInvalidHello
490+ }
491+ if err := ValidateSize(h.Size); err != nil {
492+ return fmt.Errorf("%w: size", ErrInvalidHello)
493+ }
494+ if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 {
495+ return ErrInvalidHello
496+ }
497+ for _, entry := range h.Env {
498+ if uint64(len(entry)) > math.MaxUint32 {
499+ return ErrInvalidHello
500+ }
501+ }
502+ return nil
503+}
504+
505+// ValidateOutput validates the final output state before data allocation.
506+func ValidateOutput(m Output) error {
507+ if m.Epoch == 0 {
508+ return ErrInvalidOutput
509+ }
510+ if m.New == 0 {
511+ if m.Base != 0 || m.Full {
512+ return ErrInvalidOutput
513+ }
514+ } else if (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) {
515+ return ErrInvalidOutput
516+ }
517+ if err := ValidateSize(m.Size); err != nil {
518+ return fmt.Errorf("%w: size", ErrInvalidOutput)
519+ }
520+ if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxFrameLen-1-outputPayloadOverhead {
521+ return ErrInvalidOutput
522+ }
523+ return nil
524+}
525+
526+// ValidateAck validates an epoch-scoped output acknowledgement.
527+func ValidateAck(m Ack) error {
528+ if m.Epoch == 0 {
529+ return ErrInvalidAck
530+ }
531+ return nil
532+}
533+
534+// ValidateAttachTarget validates only the endpoint, session, and intent
535+// fields carried by the server attach-target message.
536+func ValidateAttachTarget(m AttachTarget) error {
537+ if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 {
538+ return ErrInvalidAttachTarget
539+ }
540+ if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume {
541+ return ErrInvalidAttachTarget
542+ }
543+ return nil
544+}
545+
411 // MarshalHello encodes h into a Hello message payload.546 // MarshalHello encodes h into a Hello message payload.
412 func MarshalHello(h Hello) []byte {547 func MarshalHello(h Hello) []byte {
413 w := payloadWriter{}548 w := payloadWriter{}
414 w.putUint16(h.Version)549 w.putUint16(h.Version)
415 w.putUint8(h.Intent)550 w.putUint8(h.Intent)
416- w.putBool(h.Proxied)
417 w.putBytes(h.ClientID[:])551 w.putBytes(h.ClientID[:])
418 w.putUint64(h.ResumeToken)552 w.putUint64(h.ResumeToken)
419 w.putString(h.Name)553 w.putString(h.Name)
@@ -430,8 +564,69 @@ func MarshalHello(h Hello) []byte {
430 return w.b564 return w.b
431 }565 }
432 566
567+func preflightHello(b []byte) error {
568+ r := payloadReader{b: b}
569+ version, err := r.getUint16()
570+ if err != nil {
571+ return err
572+ }
573+ if version == 21 {
574+ return ErrInvalidHello
575+ }
576+ if _, err := r.getUint8(); err != nil {
577+ return err
578+ }
579+ if len(r.b) < 16 {
580+ return errShortPayload
581+ }
582+ r.b = r.b[16:]
583+ if _, err := r.getUint64(); err != nil {
584+ return err
585+ }
586+ if err := r.skipString(); err != nil {
587+ return err
588+ }
589+ if _, err := r.getUint16(); err != nil {
590+ return err
591+ }
592+ if _, err := r.getUint16(); err != nil {
593+ return err
594+ }
595+ if err := r.skipString(); err != nil {
596+ return err
597+ }
598+ if err := r.skipString(); err != nil {
599+ return err
600+ }
601+ if _, err := r.getBool(); err != nil {
602+ return err
603+ }
604+ if _, err := r.getUint8(); err != nil {
605+ return err
606+ }
607+ envCount, err := r.getUint32()
608+ if err != nil {
609+ return err
610+ }
611+ if uint64(envCount) > uint64(len(r.b)/4) {
612+ return errShortPayload
613+ }
614+ for range int(envCount) {
615+ if err := r.skipLongString(); err != nil {
616+ return err
617+ }
618+ }
619+ return r.done()
620+}
621+
433 // UnmarshalHello decodes a Hello message payload.622 // UnmarshalHello decodes a Hello message payload.
434 func UnmarshalHello(b []byte) (Hello, error) {623 func UnmarshalHello(b []byte) (Hello, error) {
624+ if len(b) > MaxFrameLen-1 {
625+ return Hello{}, ErrInvalidHello
626+ }
627+ if err := preflightHello(b); err != nil {
628+ return Hello{}, err
629+ }
435 r := payloadReader{b: b}630 r := payloadReader{b: b}
436 var h Hello631 var h Hello
437 var err error632 var err error
@@ -442,9 +637,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
442 if h.Intent, err = r.getUint8(); err != nil {637 if h.Intent, err = r.getUint8(); err != nil {
443 return Hello{}, err638 return Hello{}, err
444 }639 }
445- if h.Proxied, err = r.getBool(); err != nil {
446- return Hello{}, err
447- }
448 clientID, err := r.getBytes(len(h.ClientID))640 clientID, err := r.getBytes(len(h.ClientID))
449 if err != nil {641 if err != nil {
450 return Hello{}, err642 return Hello{}, err
@@ -471,11 +663,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
471 if h.Cwd, err = r.getString(); err != nil {663 if h.Cwd, err = r.getString(); err != nil {
472 return Hello{}, err664 return Hello{}, err
473 }665 }
474- trueColor, err := r.getUint8()666+ if h.TrueColor, err = r.getBool(); err != nil {
475- if err != nil {
476 return Hello{}, err667 return Hello{}, err
477 }668 }
478- h.TrueColor = trueColor != 0
479 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {669 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {
480 return Hello{}, err670 return Hello{}, err
481 }671 }
@@ -501,6 +691,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
501 if err := r.done(); err != nil {691 if err := r.done(); err != nil {
502 return Hello{}, err692 return Hello{}, err
503 }693 }
694+ if err := ValidateHello(h); err != nil {
695+ return Hello{}, err
696+ }
504 return h, nil697 return h, nil
505 }698 }
506 699
@@ -571,11 +764,14 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
571 }764 }
572 765
573 // MarshalResize encodes m into a Resize message payload.766 // MarshalResize encodes m into a Resize message payload.
574-func MarshalResize(m Resize) []byte {767+func MarshalResize(m Resize) ([]byte, error) {
768+ if err := ValidateSize(m.Size); err != nil {
769+ return nil, err
770+ }
575 w := payloadWriter{}771 w := payloadWriter{}
576 w.putUint16(uint16(m.Size.Cols))772 w.putUint16(uint16(m.Size.Cols))
577 w.putUint16(uint16(m.Size.Rows))773 w.putUint16(uint16(m.Size.Rows))
578- return w.b774+ return w.b, nil
579 }775 }
580 776
581 // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.777 // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.
@@ -691,7 +887,11 @@ func UnmarshalResize(b []byte) (Resize, error) {
691 if err := r.done(); err != nil {887 if err := r.done(); err != nil {
692 return Resize{}, err888 return Resize{}, err
693 }889 }
694- return Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}, nil890+ m := Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}
891+ if err := ValidateSize(m.Size); err != nil {
892+ return Resize{}, err
893+ }
894+ return m, nil
695 }895 }
696 896
697 // MarshalDetach encodes a Detach message payload (always empty).897 // MarshalDetach encodes a Detach message payload (always empty).
@@ -736,24 +936,35 @@ func UnmarshalPong(b []byte) (Pong, error) {
736 return Pong{}, nil936 return Pong{}, nil
737 }937 }
738 938
739-// MarshalAck encodes m into an Ack message payload.939+// MarshalAck encodes m into an epoch/state Ack payload.
740-func MarshalAck(m Ack) []byte {940+func MarshalAck(m Ack) ([]byte, error) {
941+ if err := ValidateAck(m); err != nil {
942+ return nil, err
943+ }
741 w := payloadWriter{}944 w := payloadWriter{}
742- w.putUint64(m.AckedStateNum)945+ w.putUint64(m.Epoch)
743- return w.b946+ w.putUint64(m.State)
947+ return w.b, nil
744 }948 }
745 949
746-// UnmarshalAck decodes an Ack message payload.950+// UnmarshalAck decodes an epoch/state Ack payload.
747 func UnmarshalAck(b []byte) (Ack, error) {951 func UnmarshalAck(b []byte) (Ack, error) {
748 r := payloadReader{b: b}952 r := payloadReader{b: b}
749- acked, err := r.getUint64()953+ var m Ack
750- if err != nil {954+ var err error
955+ if m.Epoch, err = r.getUint64(); err != nil {
956+ return Ack{}, err
957+ }
958+ if m.State, err = r.getUint64(); err != nil {
751 return Ack{}, err959 return Ack{}, err
752 }960 }
753 if err := r.done(); err != nil {961 if err := r.done(); err != nil {
754 return Ack{}, err962 return Ack{}, err
755 }963 }
756- return Ack{AckedStateNum: acked}, nil964+ if err := ValidateAck(m); err != nil {
965+ return Ack{}, err
966+ }
967+ return m, nil
757 }968 }
758 969
759 // MarshalWelcome encodes m into a Welcome message payload.970 // MarshalWelcome encodes m into a Welcome message payload.
@@ -826,33 +1037,140 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
826 return m, nil1037 return m, nil
827 }1038 }
828 1039
829-// MarshalOutput encodes m into an Output message payload.1040+// MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/
830-func MarshalOutput(m Output) []byte {1041+// size/full/data message layout.
1042+func MarshalOutput(m Output) ([]byte, error) {
1043+ if err := ValidateOutput(m); err != nil {
1044+ return nil, err
1045+ }
831 w := payloadWriter{}1046 w := payloadWriter{}
832- w.putUint64(m.BaseStateNum)1047+ w.putUint64(m.Epoch)
833- w.putUint64(m.NewStateNum)1048+ w.putUint64(m.Base)
834- w.putUint64(m.EchoAck)1049+ w.putUint64(m.New)
835- w.putBytes(m.Data)1050+ w.putUint64(m.Echo)
836- return w.b1051+ w.putUint64(m.ViewRevision)
1052+ w.putUint16(uint16(m.Size.Cols))
1053+ w.putUint16(uint16(m.Size.Rows))
1054+ w.putBool(m.Full)
1055+ w.putLongBytes(m.Data)
1056+ return w.b, nil
837 }1057 }
838 1058
839-// UnmarshalOutput decodes an Output message payload. After fixed state1059+// UnmarshalOutput strictly decodes one final Output payload. Header semantics
840-// numbers, the rest of the payload is data; there is no length prefix.1060+// are validated before the length-prefixed data is copied.
841 func UnmarshalOutput(b []byte) (Output, error) {1061 func UnmarshalOutput(b []byte) (Output, error) {
1062+ if len(b) > MaxFrameLen-1 {
1063+ return Output{}, ErrInvalidOutput
1064+ }
1065+ if len(b) < outputPayloadOverhead {
1066+ return Output{}, errShortPayload
1067+ }
842 r := payloadReader{b: b}1068 r := payloadReader{b: b}
843- base, err := r.getUint64()1069+ var m Output
1070+ var err error
1071+ if m.Epoch, err = r.getUint64(); err != nil {
1072+ return Output{}, err
1073+ }
1074+ if m.Base, err = r.getUint64(); err != nil {
1075+ return Output{}, err
1076+ }
1077+ if m.New, err = r.getUint64(); err != nil {
1078+ return Output{}, err
1079+ }
1080+ if m.Echo, err = r.getUint64(); err != nil {
1081+ return Output{}, err
1082+ }
1083+ if m.ViewRevision, err = r.getUint64(); err != nil {
1084+ return Output{}, err
1085+ }
1086+ cols, err := r.getUint16()
844 if err != nil {1087 if err != nil {
845 return Output{}, err1088 return Output{}, err
846 }1089 }
847- newState, err := r.getUint64()1090+ rows, err := r.getUint16()
848 if err != nil {1091 if err != nil {
849 return Output{}, err1092 return Output{}, err
850 }1093 }
851- echoAck, err := r.getUint64()1094+ m.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
1095+ if m.Full, err = r.getBool(); err != nil {
1096+ return Output{}, err
1097+ }
1098+ if err := ValidateOutput(m); err != nil {
1099+ return Output{}, err
1100+ }
1101+ if len(r.b) < 4 {
1102+ return Output{}, errShortPayload
1103+ }
1104+ dataLen := binary.BigEndian.Uint32(r.b)
1105+ if uint64(dataLen) > uint64(len(r.b)-4) {
1106+ return Output{}, errShortPayload
1107+ }
1108+ if uint64(dataLen) < uint64(len(r.b)-4) {
1109+ return Output{}, errTrailingBytes
1110+ }
1111+ data, err := r.getLongBytes()
852 if err != nil {1112 if err != nil {
853 return Output{}, err1113 return Output{}, err
854 }1114 }
855- return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil1115+ if err := r.done(); err != nil {
1116+ return Output{}, err
1117+ }
1118+ m.Data = data
1119+ return m, nil
1120+}
1121+
1122+// MarshalAttachTarget encodes a strict server attach-target payload.
1123+func MarshalAttachTarget(m AttachTarget) []byte {
1124+ if err := ValidateAttachTarget(m); err != nil {
1125+ return nil
1126+ }
1127+ w := payloadWriter{}
1128+ w.putString(m.Endpoint)
1129+ w.putString(m.Session)
1130+ w.putUint8(m.Intent)
1131+ return w.b
1132+}
1133+
1134+// UnmarshalAttachTarget decodes a strict server attach-target payload.
1135+func UnmarshalAttachTarget(b []byte) (AttachTarget, error) {
1136+ // Preflight lengths and intent before getString can allocate either value.
1137+ probe := payloadReader{b: b}
1138+ endpointLen, err := probe.getUint16()
1139+ if err != nil || endpointLen == 0 || int(endpointLen) > len(probe.b) {
1140+ return AttachTarget{}, ErrInvalidAttachTarget
1141+ }
1142+ probe.b = probe.b[endpointLen:]
1143+ sessionLen, err := probe.getUint16()
1144+ if err != nil || sessionLen == 0 || int(sessionLen) > len(probe.b) {
1145+ return AttachTarget{}, ErrInvalidAttachTarget
1146+ }
1147+ probe.b = probe.b[sessionLen:]
1148+ intent, err := probe.getUint8()
1149+ if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) {
1150+ return AttachTarget{}, ErrInvalidAttachTarget
1151+ }
1152+ if err := probe.done(); err != nil {
1153+ return AttachTarget{}, ErrInvalidAttachTarget
1154+ }
1155+
1156+ r := payloadReader{b: b}
1157+ var m AttachTarget
1158+ if m.Endpoint, err = r.getString(); err != nil {
1159+ return AttachTarget{}, err
1160+ }
1161+ if m.Session, err = r.getString(); err != nil {
1162+ return AttachTarget{}, err
1163+ }
1164+ if m.Intent, err = r.getUint8(); err != nil {
1165+ return AttachTarget{}, err
1166+ }
1167+ if err := r.done(); err != nil {
1168+ return AttachTarget{}, err
1169+ }
1170+ if err := ValidateAttachTarget(m); err != nil {
1171+ return AttachTarget{}, err
1172+ }
1173+ return m, nil
856 }1174 }
857 1175
858 // MarshalDetached encodes m into a Detached message payload.1176 // MarshalDetached encodes m into a Detached message payload.
A internal/ports/wire_final_test.go +197 -0
@@ -0,0 +1,197 @@
1+package ports
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "errors"
7+ "reflect"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+)
12+
13+func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
14+ if ProtocolVersion != 23 {
15+ t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion)
16+ }
17+ payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
18+ if len(payload) < 2 {
19+ t.Fatal("MarshalHello produced a truncated valid payload")
20+ }
21+ binary.BigEndian.PutUint16(payload, 21)
22+ if _, err := UnmarshalHello(payload); !errors.Is(err, ErrInvalidHello) {
23+ t.Fatalf("UnmarshalHello(v21) error = %v, want ErrInvalidHello", err)
24+ }
25+}
26+
27+func TestFinalHelloGoldenStrict(t *testing.T) {
28+ msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
29+ want := append([]byte{0, 23, 2}, make([]byte, 16+8)...)
30+ want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
31+ got := MarshalHello(msg)
32+ if !bytes.Equal(got, want) {
33+ t.Fatalf("Hello bytes = %x, want %x", got, want)
34+ }
35+ back, err := UnmarshalHello(got)
36+ if err != nil {
37+ t.Fatalf("UnmarshalHello() error = %v", err)
38+ }
39+ if !reflect.DeepEqual(back, msg) {
40+ t.Fatalf("Hello = %+v, want %+v", back, msg)
41+ }
42+ assertAllPrefixesFail(t, got, UnmarshalHello)
43+ assertTrailingGarbageFails(t, got, UnmarshalHello)
44+}
45+
46+func TestFinalHelloSemanticValidation(t *testing.T) {
47+ valid := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}
48+ for _, tt := range []struct {
49+ name string
50+ mutate func(*Hello)
51+ }{
52+ {name: "intent", mutate: func(h *Hello) { h.Intent = 99 }},
53+ {name: "zero columns", mutate: func(h *Hello) { h.Size.Cols = 0 }},
54+ {name: "zero rows", mutate: func(h *Hello) { h.Size.Rows = 0 }},
55+ {name: "area", mutate: func(h *Hello) { h.Size = domain.Size{Cols: 513, Rows: 512} }},
56+ } {
57+ t.Run(tt.name, func(t *testing.T) {
58+ msg := valid
59+ tt.mutate(&msg)
60+ if err := ValidateHello(msg); err == nil {
61+ t.Fatal("ValidateHello accepted malformed semantics")
62+ }
63+ })
64+ }
65+}
66+
67+func TestFinalResizeGoldenStrict(t *testing.T) {
68+ msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}}
69+ want := []byte{0, 80, 0, 24}
70+ got, err := MarshalResize(msg)
71+ if err != nil {
72+ t.Fatalf("MarshalResize() error = %v", err)
73+ }
74+ if !bytes.Equal(got, want) {
75+ t.Fatalf("Resize bytes = %x, want %x", got, want)
76+ }
77+ back, err := UnmarshalResize(got)
78+ if err != nil || back != msg {
79+ t.Fatalf("Resize = %+v, error %v, want %+v", back, err, msg)
80+ }
81+ assertAllPrefixesFail(t, got, UnmarshalResize)
82+ assertTrailingGarbageFails(t, got, UnmarshalResize)
83+ for _, size := range []domain.Size{{}, {Cols: 513, Rows: 512}} {
84+ if got, err := MarshalResize(Resize{Size: size}); err == nil || got != nil {
85+ t.Fatalf("MarshalResize(%+v) = (%x, %v), want nil payload and error", size, got, err)
86+ }
87+ }
88+}
89+
90+func TestFinalOutputGoldenStrict(t *testing.T) {
91+ msg := Output{
92+ Epoch: 1, Base: 0, New: 7, Echo: 8, ViewRevision: 9,
93+ Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("ok"),
94+ }
95+ want := []byte{
96+ 0, 0, 0, 0, 0, 0, 0, 1,
97+ 0, 0, 0, 0, 0, 0, 0, 0,
98+ 0, 0, 0, 0, 0, 0, 0, 7,
99+ 0, 0, 0, 0, 0, 0, 0, 8,
100+ 0, 0, 0, 0, 0, 0, 0, 9,
101+ 0, 80, 0, 24, 1,
102+ 0, 0, 0, 2, 'o', 'k',
103+ }
104+ got, err := MarshalOutput(msg)
105+ if err != nil {
106+ t.Fatalf("MarshalOutput() error = %v", err)
107+ }
108+ if !bytes.Equal(got, want) {
109+ t.Fatalf("Output bytes = %x, want %x", got, want)
110+ }
111+ back, err := UnmarshalOutput(got)
112+ if err != nil || back.Epoch != msg.Epoch || back.Base != msg.Base || back.New != msg.New || back.Echo != msg.Echo || back.ViewRevision != msg.ViewRevision || back.Size != msg.Size || back.Full != msg.Full || !bytes.Equal(back.Data, msg.Data) {
113+ t.Fatalf("Output = %+v, error %v, want %+v", back, err, msg)
114+ }
115+ assertAllPrefixesFail(t, got, UnmarshalOutput)
116+ assertTrailingGarbageFails(t, got, UnmarshalOutput)
117+}
118+
119+func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
120+ valid := Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true}
121+ for _, bad := range []Output{
122+ {},
123+ {Epoch: 1, Base: 0, New: 1, Size: valid.Size},
124+ {Epoch: 1, Base: 0, New: 1, Full: true},
125+ } {
126+ if got, err := MarshalOutput(bad); err == nil || got != nil {
127+ t.Fatalf("MarshalOutput(%+v) = (%x, %v), want nil payload and error", bad, got, err)
128+ }
129+ }
130+ payload, err := MarshalOutput(valid)
131+ if err != nil {
132+ t.Fatal(err)
133+ }
134+ for _, tt := range []struct {
135+ name string
136+ mutate func([]byte)
137+ }{
138+ {name: "zero epoch", mutate: func(b []byte) { b[7] = 0 }},
139+ {name: "new does not advance", mutate: func(b []byte) { binary.BigEndian.PutUint64(b[16:24], 0) }},
140+ {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }},
141+ {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }},
142+ {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }},
143+ {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[45:49], ^uint32(0)) }},
144+ } {
145+ t.Run(tt.name, func(t *testing.T) {
146+ bad := append([]byte(nil), payload...)
147+ tt.mutate(bad)
148+ if _, err := UnmarshalOutput(bad); err == nil {
149+ t.Fatal("UnmarshalOutput accepted malformed semantics")
150+ }
151+ })
152+ }
153+}
154+
155+func TestFinalAckGoldenStrict(t *testing.T) {
156+ msg := Ack{Epoch: 2, State: 7}
157+ want := []byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 7}
158+ got, err := MarshalAck(msg)
159+ if err != nil {
160+ t.Fatalf("MarshalAck() error = %v", err)
161+ }
162+ if !bytes.Equal(got, want) {
163+ t.Fatalf("Ack bytes = %x, want %x", got, want)
164+ }
165+ back, err := UnmarshalAck(got)
166+ if err != nil || back != (Ack{Epoch: 2, State: 7}) {
167+ t.Fatalf("Ack = %+v, error %v", back, err)
168+ }
169+ assertAllPrefixesFail(t, got, UnmarshalAck)
170+ assertTrailingGarbageFails(t, got, UnmarshalAck)
171+ if _, err := UnmarshalAck(make([]byte, 16)); err == nil {
172+ t.Fatal("UnmarshalAck accepted zero epoch")
173+ }
174+ if got, err := MarshalAck(Ack{State: 7}); err == nil || got != nil {
175+ t.Fatalf("MarshalAck accepted zero epoch: payload=%x err=%v", got, err)
176+ }
177+}
178+
179+func TestFinalAttachTargetGoldenStrict(t *testing.T) {
180+ msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach}
181+ want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2}
182+ got := MarshalAttachTarget(msg)
183+ if !bytes.Equal(got, want) {
184+ t.Fatalf("AttachTarget bytes = %x, want %x", got, want)
185+ }
186+ back, err := UnmarshalAttachTarget(got)
187+ if err != nil || back != msg {
188+ t.Fatalf("AttachTarget = %+v, error %v, want %+v", back, err, msg)
189+ }
190+ assertAllPrefixesFail(t, got, UnmarshalAttachTarget)
191+ assertTrailingGarbageFails(t, got, UnmarshalAttachTarget)
192+ for _, bad := range []AttachTarget{{Session: "work", Intent: IntentAttach}, {Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} {
193+ if got := MarshalAttachTarget(bad); got != nil {
194+ t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got)
195+ }
196+ }
197+}
D internal/ports/wire_screen.go +0 -756
@@ -1,756 +0,0 @@
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- screenFlagCursorVisible = 1 << 0
70- screenFlagCursorStyleSet = 1 << 1
71- screenKnownCursorFlags = screenFlagCursorVisible | screenFlagCursorStyleSet
72-
73- styleBoldBit = 1 << 0
74- styleItalicBit = 1 << 1
75- styleInverseBit = 1 << 2
76- styleDimBit = 1 << 3
77- styleUnderlineBit = 1 << 4
78- styleBlinkBit = 1 << 5
79- styleStrikethroughBit = 1 << 6
80- styleForegroundRGBBit = 1 << 7
81- styleBackgroundRGBBit = 1 << 8
82- styleUnderlineColorBit = 1 << 9
83- styleUnderlineColorRGBBit = 1 << 10
84- styleKnownBits = (1 << 11) - 1
85-)
86-
87-// The frame length includes MsgScreenUpdate's type byte. The codec takes
88-// only the payload, so one byte is reserved here.
89-var screenPayloadLimit = MaxFrameLen - 1
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- switch m.Kind {
357- case ScreenUpdateSnapshot:
358- if m.BaseStateNum != 0 || m.NewStateNum == 0 {
359- return ErrInvalidScreenUpdate
360- }
361- case ScreenUpdateDelta:
362- if m.BaseStateNum == 0 || m.BaseStateNum == math.MaxUint64 || m.NewStateNum != m.BaseStateNum+1 {
363- return ErrInvalidScreenUpdate
364- }
365- default:
366- return ErrInvalidScreenUpdate
367- }
368- if (m.Kind != ScreenUpdateSnapshot && m.Kind != ScreenUpdateDelta) ||
369- m.Size.Cols <= 0 || m.Size.Rows <= 0 || m.Size.Cols > math.MaxUint16 || m.Size.Rows > math.MaxUint16 ||
370- !screenAreaWithinLimit(m.Size) || m.Cursor.Row >= uint16(m.Size.Rows) || m.Cursor.Col >= uint16(m.Size.Cols) {
371- return ErrInvalidScreenUpdate
372- }
373- if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) {
374- return ErrInvalidScreenUpdate
375- }
376- if m.Kind == ScreenUpdateSnapshot && m.Scroll != nil {
377- return ErrInvalidScreenUpdate
378- }
379- if m.Scroll != nil && !validScreenScroll(*m.Scroll, uint16(m.Size.Rows)) {
380- return ErrInvalidScreenUpdate
381- }
382- if len(m.Spans) > screenSpanLimit || len(m.Spans) > math.MaxUint16 || (m.Kind == ScreenUpdateSnapshot && len(m.Spans) == 0) {
383- return ErrInvalidScreenUpdate
384- }
385- return validateScreenSpans(m)
386-}
387-
388-func screenAreaWithinLimit(size domain.Size) bool {
389- return size.Cols > 0 && size.Rows > 0 && size.Rows <= maxScreenCells/size.Cols
390-}
391-
392-func validScreenScroll(scroll ScreenScroll, rows uint16) bool {
393- return scroll.Height != 0 && scroll.Count != 0 && scroll.Count < scroll.Height &&
394- scroll.Top < rows && uint64(scroll.Top)+uint64(scroll.Height) <= uint64(rows)
395-}
396-
397-func validateScreenSpans(m ScreenUpdate) error {
398- cols, rows := uint32(m.Size.Cols), uint32(m.Size.Rows)
399- var previousY uint16
400- var previousEnd uint32
401- for i, span := range m.Spans {
402- 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) {
403- return ErrInvalidScreenUpdate
404- }
405- if i > 0 && (span.Y < previousY || (span.Y == previousY && uint32(span.X) < previousEnd)) {
406- return ErrInvalidScreenUpdate
407- }
408- for _, cell := range span.Cells {
409- if (cell.Continuation && cell.Rune != 0) || (!cell.Continuation && !utf8.ValidRune(cell.Rune)) {
410- return ErrInvalidScreenUpdate
411- }
412- if err := validateScreenStyle(cell.Style); err != nil {
413- return err
414- }
415- }
416- previousY = span.Y
417- previousEnd = uint32(span.X) + uint32(len(span.Cells))
418- }
419- if m.Kind == ScreenUpdateSnapshot {
420- if len(m.Spans) != m.Size.Rows {
421- return ErrInvalidScreenUpdate
422- }
423- for y, span := range m.Spans {
424- if int(span.Y) != y || span.X != 0 || len(span.Cells) != m.Size.Cols {
425- return ErrInvalidScreenUpdate
426- }
427- }
428- }
429- return nil
430-}
431-
432-func validateScreenStyle(style renderer.Style) error {
433- if style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 || style.UnderlineStyle > renderer.UnderlineDashed {
434- return ErrInvalidScreenUpdate
435- }
436- if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > math.MaxUint8 || style.ForegroundRGB != (renderer.RGB{})) {
437- return ErrInvalidScreenUpdate
438- }
439- if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > math.MaxUint8 || style.BackgroundRGB != (renderer.RGB{})) {
440- return ErrInvalidScreenUpdate
441- }
442- if style.HasUnderlineColorRGB && style.HasUnderlineColor {
443- return ErrInvalidScreenUpdate
444- } else if style.HasUnderlineColorRGB {
445- // The indexed value is inactive when RGB is selected.
446- } else if style.HasUnderlineColor {
447- if style.UnderlineColor < 0 || style.UnderlineColor > math.MaxUint8 || style.UnderlineColorRGB != (renderer.RGB{}) {
448- return ErrInvalidScreenUpdate
449- }
450- } else if style.UnderlineColorRGB != (renderer.RGB{}) {
451- return ErrInvalidScreenUpdate
452- }
453- return nil
454-}
455-
456-func canonicalScreenStyle(style renderer.Style) renderer.Style {
457- if style.HasForegroundRGB {
458- style.Foreground = -1
459- } else {
460- style.ForegroundRGB = renderer.RGB{}
461- }
462- if style.HasBackgroundRGB {
463- style.Background = -1
464- } else {
465- style.BackgroundRGB = renderer.RGB{}
466- }
467- if style.HasUnderlineColorRGB {
468- style.UnderlineColor = -1
469- } else if style.HasUnderlineColor {
470- style.UnderlineColorRGB = renderer.RGB{}
471- } else {
472- style.UnderlineColor = -1
473- style.UnderlineColorRGB = renderer.RGB{}
474- }
475- return style
476-}
477-
478-func screenStyleBits(style renderer.Style) uint16 {
479- var bits uint16
480- if style.Bold {
481- bits |= styleBoldBit
482- }
483- if style.Italic {
484- bits |= styleItalicBit
485- }
486- if style.Inverse {
487- bits |= styleInverseBit
488- }
489- if style.Attrs&renderer.AttrDim != 0 {
490- bits |= styleDimBit
491- }
492- if style.Attrs&renderer.AttrUnderline != 0 {
493- bits |= styleUnderlineBit
494- }
495- if style.Attrs&renderer.AttrBlink != 0 {
496- bits |= styleBlinkBit
497- }
498- if style.Attrs&renderer.AttrStrikethrough != 0 {
499- bits |= styleStrikethroughBit
500- }
501- if style.HasForegroundRGB {
502- bits |= styleForegroundRGBBit
503- }
504- if style.HasBackgroundRGB {
505- bits |= styleBackgroundRGBBit
506- }
507- if style.HasUnderlineColor {
508- bits |= styleUnderlineColorBit
509- }
510- if style.HasUnderlineColorRGB {
511- bits |= styleUnderlineColorRGBBit
512- }
513- return bits
514-}
515-
516-func appendScreenStyle(w *screenWriter, style renderer.Style) {
517- style = canonicalScreenStyle(style)
518- w.u16(screenStyleBits(style))
519- w.u16(screenColorIndex(style.Foreground))
520- w.u16(screenColorIndex(style.Background))
521- w.u16(screenColorIndex(style.UnderlineColor))
522- w.u8(style.ForegroundRGB.R)
523- w.u8(style.ForegroundRGB.G)
524- w.u8(style.ForegroundRGB.B)
525- w.u8(style.BackgroundRGB.R)
526- w.u8(style.BackgroundRGB.G)
527- w.u8(style.BackgroundRGB.B)
528- w.u8(style.UnderlineColorRGB.R)
529- w.u8(style.UnderlineColorRGB.G)
530- w.u8(style.UnderlineColorRGB.B)
531- w.u8(uint8(style.UnderlineStyle))
532-}
533-
534-func screenColorIndex(index int) uint16 {
535- if index < 0 {
536- return math.MaxUint16
537- }
538- return uint16(index)
539-}
540-
541-func screenColorValue(index uint16) int {
542- if index == math.MaxUint16 {
543- return -1
544- }
545- return int(index)
546-}
547-
548-func readScreenStyle(r *screenReader) (renderer.Style, bool) {
549- bits, ok := r.u16ok()
550- if !ok || bits&^uint16(styleKnownBits) != 0 {
551- return renderer.Style{}, false
552- }
553- foregroundIndex, ok := r.u16ok()
554- if !ok {
555- return renderer.Style{}, false
556- }
557- backgroundIndex, ok := r.u16ok()
558- if !ok {
559- return renderer.Style{}, false
560- }
561- underlineIndex, ok := r.u16ok()
562- if !ok {
563- return renderer.Style{}, false
564- }
565- fgRGB, ok := r.rgb()
566- if !ok {
567- return renderer.Style{}, false
568- }
569- bgRGB, ok := r.rgb()
570- if !ok {
571- return renderer.Style{}, false
572- }
573- underlineRGB, ok := r.rgb()
574- if !ok {
575- return renderer.Style{}, false
576- }
577- underlineStyle, ok := r.u8ok()
578- if !ok || underlineStyle > uint8(renderer.UnderlineDashed) {
579- return renderer.Style{}, false
580- }
581- style := renderer.Style{
582- Bold: bits&styleBoldBit != 0,
583- Italic: bits&styleItalicBit != 0,
584- Inverse: bits&styleInverseBit != 0,
585- UnderlineStyle: renderer.UnderlineStyle(underlineStyle),
586- Foreground: screenColorValue(foregroundIndex),
587- Background: screenColorValue(backgroundIndex),
588- HasForegroundRGB: bits&styleForegroundRGBBit != 0,
589- ForegroundRGB: fgRGB,
590- HasBackgroundRGB: bits&styleBackgroundRGBBit != 0,
591- BackgroundRGB: bgRGB,
592- HasUnderlineColor: bits&styleUnderlineColorBit != 0,
593- UnderlineColor: screenColorValue(underlineIndex),
594- HasUnderlineColorRGB: bits&styleUnderlineColorRGBBit != 0,
595- UnderlineColorRGB: underlineRGB,
596- }
597- if style.HasUnderlineColor && style.HasUnderlineColorRGB {
598- return renderer.Style{}, false
599- }
600- style.Attrs = 0
601- if bits&styleDimBit != 0 {
602- style.Attrs |= renderer.AttrDim
603- }
604- if bits&styleUnderlineBit != 0 {
605- style.Attrs |= renderer.AttrUnderline
606- }
607- if bits&styleBlinkBit != 0 {
608- style.Attrs |= renderer.AttrBlink
609- }
610- if bits&styleStrikethroughBit != 0 {
611- style.Attrs |= renderer.AttrStrikethrough
612- }
613- if style.HasForegroundRGB {
614- if foregroundIndex != math.MaxUint16 {
615- return renderer.Style{}, false
616- }
617- style.Foreground = -1
618- } else if (foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8) || fgRGB != (renderer.RGB{}) {
619- return renderer.Style{}, false
620- }
621- if style.HasBackgroundRGB {
622- if backgroundIndex != math.MaxUint16 {
623- return renderer.Style{}, false
624- }
625- style.Background = -1
626- } else if (backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8) || bgRGB != (renderer.RGB{}) {
627- return renderer.Style{}, false
628- }
629- if style.HasUnderlineColorRGB {
630- if underlineIndex != math.MaxUint16 {
631- return renderer.Style{}, false
632- }
633- style.UnderlineColor = -1
634- } else if style.HasUnderlineColor {
635- if underlineIndex == math.MaxUint16 || underlineIndex > math.MaxUint8 || underlineRGB != (renderer.RGB{}) {
636- return renderer.Style{}, false
637- }
638- } else if underlineIndex != math.MaxUint16 || underlineRGB != (renderer.RGB{}) {
639- return renderer.Style{}, false
640- }
641- return style, true
642-}
643-
644-func screenRunCount(cells []renderer.Cell) int {
645- if len(cells) == 0 {
646- return 0
647- }
648- n := 1
649- previous := canonicalScreenStyle(cells[0].Style)
650- for _, cell := range cells[1:] {
651- style := canonicalScreenStyle(cell.Style)
652- if style != previous {
653- n++
654- previous = style
655- }
656- }
657- return n
658-}
659-
660-func screenRune(token uint64) (rune, bool) {
661- if token == 0 || token > uint64(utf8.MaxRune)+1 {
662- return 0, false
663- }
664- r := rune(token - 1)
665- return r, utf8.ValidRune(r)
666-}
667-
668-type screenWriter struct {
669- b []byte
670- tooLarge bool
671-}
672-
673-func (w *screenWriter) append(p ...byte) {
674- if w.tooLarge || len(w.b) > screenPayloadLimit-len(p) {
675- w.tooLarge = true
676- return
677- }
678- w.b = append(w.b, p...)
679-}
680-func (w *screenWriter) u8(v uint8) { w.append(v) }
681-func (w *screenWriter) u16(v uint16) { w.append(byte(v>>8), byte(v)) }
682-func (w *screenWriter) u64(v uint64) {
683- var b [8]byte
684- binary.BigEndian.PutUint64(b[:], v)
685- w.append(b[:]...)
686-}
687-func (w *screenWriter) uvarint(v uint64) {
688- var b [10]byte
689- n := binary.PutUvarint(b[:], v)
690- w.append(b[:n]...)
691-}
692-
693-type screenReader struct{ b []byte }
694-
695-func (r *screenReader) take(n int) ([]byte, bool) {
696- if n < 0 || len(r.b) < n {
697- return nil, false
698- }
699- p := r.b[:n]
700- r.b = r.b[n:]
701- return p, true
702-}
703-func (r *screenReader) u8() (uint8, error) {
704- v, ok := r.u8ok()
705- if !ok {
706- return 0, ErrInvalidScreenUpdate
707- }
708- return v, nil
709-}
710-func (r *screenReader) u8ok() (uint8, bool) {
711- p, ok := r.take(1)
712- if !ok {
713- return 0, false
714- }
715- return p[0], true
716-}
717-func (r *screenReader) u16() (uint16, error) {
718- p, ok := r.take(2)
719- if !ok {
720- return 0, ErrInvalidScreenUpdate
721- }
722- return binary.BigEndian.Uint16(p), nil
723-}
724-func (r *screenReader) u16ok() (uint16, bool) {
725- p, ok := r.take(2)
726- if !ok {
727- return 0, false
728- }
729- return binary.BigEndian.Uint16(p), true
730-}
731-func (r *screenReader) u64() (uint64, error) {
732- p, ok := r.take(8)
733- if !ok {
734- return 0, ErrInvalidScreenUpdate
735- }
736- return binary.BigEndian.Uint64(p), nil
737-}
738-func (r *screenReader) rgb() (renderer.RGB, bool) {
739- p, ok := r.take(3)
740- if !ok {
741- return renderer.RGB{}, false
742- }
743- return renderer.RGB{R: p[0], G: p[1], B: p[2]}, true
744-}
745-func (r *screenReader) uvarint() (uint64, bool) {
746- v, n := binary.Uvarint(r.b)
747- if n <= 0 {
748- return 0, false
749- }
750- var canonical [binary.MaxVarintLen64]byte
751- if binary.PutUvarint(canonical[:], v) != n {
752- return 0, false
753- }
754- r.b = r.b[n:]
755- return v, true
756-}
D internal/ports/wire_screen_test.go +0 -398
@@ -1,398 +0,0 @@
1-package ports
2-
3-import (
4- "bytes"
5- "encoding/binary"
6- "encoding/hex"
7- "errors"
8- "testing"
9-
10- "github.com/bnema/vev/internal/domain"
11- "github.com/bnema/vev/pkg/renderer"
12-)
13-
14-func screenGoldenMessages() []struct {
15- name string
16- msg ScreenUpdate
17- hex string
18-} {
19- defaultCell := func(r rune) renderer.Cell {
20- return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
21- }
22- return []struct {
23- name string
24- msg ScreenUpdate
25- hex string
26- }{
27- {
28- name: "initial 1x1 snapshot",
29- msg: ScreenUpdate{
30- NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1},
31- Cursor: ScreenCursor{Visible: true},
32- Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}},
33- },
34- hex: "000000000000000000000000000000010000000000000000010001000100000000000100000100010000ffffffffffff0000000000000000000000000000000100010000000121",
35- },
36- {
37- name: "explicit zero cursor style",
38- msg: ScreenUpdate{
39- NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1},
40- Cursor: ScreenCursor{Visible: true, StyleSet: true},
41- Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}},
42- },
43- hex: "000000000000000000000000000000010000000000000000010001000100000000000300000100010000ffffffffffff0000000000000000000000000000000100010000000121",
44- },
45- {
46- name: "later base-zero snapshot",
47- msg: ScreenUpdate{
48- NewStateNum: 9, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1},
49- Cursor: ScreenCursor{Visible: true},
50- Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}},
51- },
52- hex: "000000000000000000000000000000090000000000000000010001000100000000000100000100010000ffffffffffff0000000000000000000000000000000100010000000121",
53- },
54- {
55- name: "cursor-only delta",
56- msg: ScreenUpdate{
57- BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Kind: ScreenUpdateDelta,
58- Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{Visible: true, StyleSet: true},
59- },
60- hex: "00000000000000010000000000000002000000000000000702000100010000000000030000000000",
61- },
62- {
63- name: "upward scroll",
64- msg: ScreenUpdate{
65- BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 2, Rows: 2},
66- Cursor: ScreenCursor{Visible: true}, Scroll: &ScreenScroll{Top: 0, Height: 2, Count: 1},
67- Spans: []ScreenSpan{{Y: 1, Cells: []renderer.Cell{defaultCell('a'), defaultCell('b')}}},
68- },
69- hex: "000000000000000100000000000000020000000000000000020002000200000000000101000100010000000200010000ffffffffffff000000000000000000000001000000020001000000026263",
70- },
71- {
72- name: "unicode and wide continuation",
73- msg: ScreenUpdate{
74- BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 1},
75- Cursor: ScreenCursor{Visible: true},
76- Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell('界'), {Continuation: true, Style: renderer.DefaultStyle()}, defaultCell('x')}}},
77- },
78- hex: "000000000000000100000000000000020000000000000000020003000100000000000100000100010000ffffffffffff00000000000000000000000000000003000100000003cdea010079",
79- },
80- }
81-}
82-
83-func TestScreenUpdateStateNumbers(t *testing.T) {
84- snapshot := screenGoldenMessages()[0].msg
85- delta := screenGoldenMessages()[3].msg
86- valid := []ScreenUpdate{
87- snapshot,
88- func() ScreenUpdate {
89- m := snapshot
90- m.NewStateNum = 9
91- return m
92- }(),
93- delta,
94- }
95- for _, m := range valid {
96- wire, err := MarshalScreenUpdate(m)
97- if err != nil {
98- t.Fatalf("valid %d state numbers: %v", m.Kind, err)
99- }
100- if _, err := UnmarshalScreenUpdate(wire); err != nil {
101- t.Fatalf("valid %d state numbers decode: %v", m.Kind, err)
102- }
103- }
104-
105- invalid := []struct {
106- name string
107- kind ScreenUpdateKind
108- base uint64
109- new uint64
110- }{
111- {"snapshot nonzero base", ScreenUpdateSnapshot, 1, 1},
112- {"snapshot zero new", ScreenUpdateSnapshot, 0, 0},
113- {"delta zero base", ScreenUpdateDelta, 0, 1},
114- {"delta zero new", ScreenUpdateDelta, 1, 0},
115- {"delta equal", ScreenUpdateDelta, 1, 1},
116- {"delta gap", ScreenUpdateDelta, 1, 3},
117- {"delta overflow", ScreenUpdateDelta, ^uint64(0), 0},
118- }
119- for _, tt := range invalid {
120- t.Run(tt.name, func(t *testing.T) {
121- m := snapshot
122- if tt.kind == ScreenUpdateDelta {
123- m = delta
124- }
125- m.BaseStateNum, m.NewStateNum = tt.base, tt.new
126- if _, err := MarshalScreenUpdate(m); !errors.Is(err, ErrInvalidScreenUpdate) {
127- t.Fatalf("marshal err = %v", err)
128- }
129-
130- validWire, err := MarshalScreenUpdate(map[ScreenUpdateKind]ScreenUpdate{
131- ScreenUpdateSnapshot: snapshot,
132- ScreenUpdateDelta: delta,
133- }[tt.kind])
134- if err != nil {
135- t.Fatal(err)
136- }
137- binary.BigEndian.PutUint64(validWire[0:8], tt.base)
138- binary.BigEndian.PutUint64(validWire[8:16], tt.new)
139- if _, err := UnmarshalScreenUpdate(validWire); !errors.Is(err, ErrInvalidScreenUpdate) {
140- t.Fatalf("unmarshal err = %v", err)
141- }
142- })
143- }
144-}
145-
146-func TestScreenUpdateGoldens(t *testing.T) {
147- for _, tt := range screenGoldenMessages() {
148- t.Run(tt.name, func(t *testing.T) {
149- want := mustDecodeHex(t, tt.hex)
150- got, err := MarshalScreenUpdate(tt.msg)
151- if err != nil {
152- t.Fatal(err)
153- }
154- if !bytes.Equal(got, want) {
155- t.Fatalf("wire = %x, want %x", got, want)
156- }
157- decoded, err := UnmarshalScreenUpdate(want)
158- if err != nil {
159- t.Fatal(err)
160- }
161- assertScreenEqual(t, tt.msg, decoded)
162- })
163- }
164-}
165-
166-func assertScreenEqual(t *testing.T, want, got ScreenUpdate) {
167- t.Helper()
168- if want.BaseStateNum != got.BaseStateNum || want.NewStateNum != got.NewStateNum || want.EchoAck != got.EchoAck || want.Kind != got.Kind || want.Size != got.Size || want.Cursor != got.Cursor || len(want.Spans) != len(got.Spans) {
169- t.Fatalf("screen metadata = %+v, want %+v", got, want)
170- }
171- if (want.Scroll == nil) != (got.Scroll == nil) || want.Scroll != nil && *want.Scroll != *got.Scroll {
172- t.Fatalf("scroll = %+v, want %+v", got.Scroll, want.Scroll)
173- }
174- for i := range want.Spans {
175- if want.Spans[i].Y != got.Spans[i].Y || want.Spans[i].X != got.Spans[i].X || len(want.Spans[i].Cells) != len(got.Spans[i].Cells) {
176- t.Fatalf("span %d = %+v, want %+v", i, got.Spans[i], want.Spans[i])
177- }
178- for j := range want.Spans[i].Cells {
179- if !want.Spans[i].Cells[j].Equal(got.Spans[i].Cells[j]) {
180- t.Fatalf("cell %d/%d = %+v, want %+v", i, j, got.Spans[i].Cells[j], want.Spans[i].Cells[j])
181- }
182- }
183- }
184-}
185-
186-func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {
187- for _, tt := range screenGoldenMessages() {
188- data := mustDecodeHex(t, tt.hex)
189- for n := 0; n < len(data); n++ {
190- if _, err := UnmarshalScreenUpdate(data[:n]); !errors.Is(err, ErrInvalidScreenUpdate) {
191- t.Fatalf("%s prefix %d: err = %v", tt.name, n, err)
192- }
193- }
194- trailing := append(append([]byte(nil), data...), 0)
195- if _, err := UnmarshalScreenUpdate(trailing); !errors.Is(err, ErrInvalidScreenUpdate) {
196- t.Fatalf("%s trailing: err = %v", tt.name, err)
197- }
198- }
199-}
200-
201-func TestScreenUpdateHostileFields(t *testing.T) {
202- golden := mustDecodeHex(t, screenGoldenMessages()[0].hex)
203- tests := []struct {
204- name string
205- mut func([]byte)
206- }{
207- {"zero columns", func(b []byte) { b[25], b[26] = 0, 0 }},
208- {"unknown kind", func(b []byte) { b[24] = 3 }},
209- {"unknown cursor flags", func(b []byte) { b[34] = 4 }},
210- {"unknown style bits", func(b []byte) { b[40], b[41] = 0x08, 0x00 }},
211- {"noncanonical foreground RGB", func(b []byte) { b[40], b[41] = 0, 0x80; b[42], b[43] = 0, 0 }},
212- {"style ID mismatch", func(b []byte) { b[66], b[67] = 0, 1 }},
213- {"run cell mismatch", func(b []byte) { b[68], b[69] = 0, 2 }},
214- {"invalid Unicode", func(b []byte) { b[len(b)-1] = 0x81 }},
215- {"overflow varint", func(b []byte) { b[len(b)-1] = 0x80 }},
216- }
217- for _, tt := range tests {
218- t.Run(tt.name, func(t *testing.T) {
219- data := append([]byte(nil), golden...)
220- tt.mut(data)
221- if _, err := UnmarshalScreenUpdate(data); !errors.Is(err, ErrInvalidScreenUpdate) {
222- t.Fatalf("err = %v", err)
223- }
224- })
225- }
226- for _, tt := range []struct {
227- name string
228- token []byte
229- }{
230- {"noncanonical varint", []byte{0xa1, 0x00}},
231- {"overflow varint", append(bytes.Repeat([]byte{0xff}, 10), 0x02)},
232- } {
233- t.Run(tt.name, func(t *testing.T) {
234- data := append(append([]byte(nil), golden[:len(golden)-1]...), tt.token...)
235- if _, err := UnmarshalScreenUpdate(data); !errors.Is(err, ErrInvalidScreenUpdate) {
236- t.Fatalf("err = %v", err)
237- }
238- })
239- }
240-}
241-
242-func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {
243- cell := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
244- base := ScreenUpdate{
245- Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 2}, Cursor: ScreenCursor{Visible: true},
246- Spans: []ScreenSpan{{Y: 0, X: 0, Cells: []renderer.Cell{cell}}, {Y: 1, X: 0, Cells: []renderer.Cell{cell}}},
247- }
248- tests := []struct {
249- name string
250- edit func(*ScreenUpdate)
251- }{
252- {"unordered", func(m *ScreenUpdate) { m.Spans[0].Y = 1; m.Spans[1].Y = 0 }},
253- {"overlap", func(m *ScreenUpdate) { m.Spans[1].Y = 0; m.Spans[1].X = 0 }},
254- {"out of bounds", func(m *ScreenUpdate) { m.Spans[1].X = 3 }},
255- {"zero scroll", func(m *ScreenUpdate) { m.Scroll = &ScreenScroll{Top: 0, Height: 0, Count: 1} }},
256- {"scroll count reaches height", func(m *ScreenUpdate) { m.Scroll = &ScreenScroll{Top: 0, Height: 1, Count: 1} }},
257- }
258- for _, tt := range tests {
259- t.Run(tt.name, func(t *testing.T) {
260- m := base
261- m.Spans = append([]ScreenSpan(nil), base.Spans...)
262- tt.edit(&m)
263- if _, err := MarshalScreenUpdate(m); !errors.Is(err, ErrInvalidScreenUpdate) {
264- t.Fatalf("err = %v", err)
265- }
266- })
267- }
268- tooWide := base
269- tooWide.Size = domain.Size{Cols: 1, Rows: 1}
270- tooWide.Spans = []ScreenSpan{{Cells: []renderer.Cell{cell, cell}}}
271- if _, err := MarshalScreenUpdate(tooWide); !errors.Is(err, ErrInvalidScreenUpdate) {
272- t.Fatalf("out-of-bounds span err = %v", err)
273- }
274- 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) {
275- t.Fatalf("invalid rune err = %v", err)
276- }
277- 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) {
278- t.Fatalf("incomplete snapshot err = %v", err)
279- }
280- 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) {
281- t.Fatalf("cursor style err = %v", err)
282- }
283-}
284-
285-func TestScreenUpdateOwnership(t *testing.T) {
286- golden := mustDecodeHex(t, screenGoldenMessages()[0].hex)
287- decoded, err := UnmarshalScreenUpdate(golden)
288- if err != nil {
289- t.Fatal(err)
290- }
291- golden[0] ^= 0xff
292- golden[len(golden)-1] ^= 0xff
293- want, err := MarshalScreenUpdate(decoded)
294- if err != nil {
295- t.Fatal(err)
296- }
297- if !bytes.Equal(want, mustDecodeHex(t, screenGoldenMessages()[0].hex)) {
298- t.Fatal("decoded screen retained transport payload")
299- }
300-}
301-
302-func TestScreenUpdateLimits(t *testing.T) {
303- if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) {
304- t.Fatalf("oversize err = %v", err)
305- }
306- previousLimit := screenPayloadLimit
307- t.Cleanup(func() { screenPayloadLimit = previousLimit })
308- screenPayloadLimit = screenHeaderLen
309- if _, err := MarshalScreenUpdate(screenGoldenMessages()[0].msg); !errors.Is(err, ErrScreenUpdateTooLarge) {
310- t.Fatalf("marshal oversize err = %v", err)
311- }
312- tooMany := screenGoldenMessages()[0].msg
313- tooMany.Spans = make([]ScreenSpan, screenSpanLimit+1)
314- if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) {
315- t.Fatalf("span limit err = %v", err)
316- }
317-}
318-
319-func TestScreenUpdateScreenAreaLimit(t *testing.T) {
320- boundary := ScreenUpdate{
321- BaseStateNum: 1,
322- NewStateNum: 2,
323- Kind: ScreenUpdateDelta,
324- Size: domain.Size{Cols: 512, Rows: 512},
325- Cursor: ScreenCursor{Visible: true},
326- }
327- wire, err := MarshalScreenUpdate(boundary)
328- if err != nil {
329- t.Fatalf("boundary marshal: %v", err)
330- }
331- if _, err := UnmarshalScreenUpdate(wire); err != nil {
332- t.Fatalf("boundary unmarshal: %v", err)
333- }
334-
335- over := boundary
336- over.Size = domain.Size{Cols: 513, Rows: 512}
337- if _, err := MarshalScreenUpdate(over); !errors.Is(err, ErrInvalidScreenUpdate) {
338- t.Fatalf("over-cap marshal err = %v", err)
339- }
340-
341- // Include a span descriptor and enough cell tokens that an unchecked
342- // decoder would reach its per-span cell allocation.
343- hostile := make([]byte, screenHeaderLen+8+4+513)
344- hostile[24] = byte(ScreenUpdateDelta)
345- binary.BigEndian.PutUint16(hostile[25:27], 513)
346- binary.BigEndian.PutUint16(hostile[27:29], 512)
347- binary.BigEndian.PutUint16(hostile[38:40], 1)
348- binary.BigEndian.PutUint16(hostile[40:42], 0) // y
349- binary.BigEndian.PutUint16(hostile[42:44], 0) // x
350- binary.BigEndian.PutUint16(hostile[44:46], 513)
351- binary.BigEndian.PutUint16(hostile[46:48], 1)
352- binary.BigEndian.PutUint16(hostile[48:50], 0) // style ID
353- binary.BigEndian.PutUint16(hostile[50:52], 513)
354- if _, err := UnmarshalScreenUpdate(hostile); !errors.Is(err, ErrInvalidScreenUpdate) {
355- t.Fatalf("over-cap hostile header err = %v", err)
356- }
357- if allocs := testing.AllocsPerRun(100, func() {
358- if _, err := UnmarshalScreenUpdate(hostile); !errors.Is(err, ErrInvalidScreenUpdate) {
359- panic(err)
360- }
361- }); allocs != 0 {
362- t.Fatalf("over-cap hostile header allocations = %v, want 0", allocs)
363- }
364-}
365-
366-func mustDecodeHex(t testing.TB, s string) []byte {
367- t.Helper()
368- b, err := hex.DecodeString(s)
369- if err != nil {
370- t.Fatal(err)
371- }
372- return b
373-}
374-
375-func FuzzScreenUpdate(f *testing.F) {
376- for _, tt := range screenGoldenMessages() {
377- b := mustDecodeHex(f, tt.hex)
378- f.Add(b)
379- }
380- f.Fuzz(func(t *testing.T, data []byte) {
381- decoded, err := UnmarshalScreenUpdate(data)
382- if err != nil {
383- return
384- }
385- encoded, err := MarshalScreenUpdate(decoded)
386- if err != nil {
387- t.Fatal(err)
388- }
389- if !bytes.Equal(encoded, data) {
390- t.Fatalf("accepted data is not canonical: got %x, want %x", encoded, data)
391- }
392- roundtrip, err := UnmarshalScreenUpdate(encoded)
393- if err != nil {
394- t.Fatal(err)
395- }
396- assertScreenEqual(t, decoded, roundtrip)
397- })
398-}
A internal/ports/wire_size.go +23 -0
@@ -0,0 +1,23 @@
1+package ports
2+
3+import (
4+ "math"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// MaxWireScreenCells bounds every terminal size carried by the final wire
10+// protocol. The uint16 dimension limit is checked separately.
11+const MaxWireScreenCells = 1 << 18
12+
13+// ValidateSize validates a terminal size before a codec allocates or mutates
14+// state.
15+func ValidateSize(size domain.Size) error {
16+ if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 {
17+ return ErrInvalidSize
18+ }
19+ if size.Rows > MaxWireScreenCells/size.Cols {
20+ return ErrInvalidSize
21+ }
22+ return nil
23+}
M internal/ports/wire_test.go +46 -80
@@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
73 TrueColor: true,73 TrueColor: true,
74 MaxOutputInFlight: 8,74 MaxOutputInFlight: 8,
75 },75 },
76- want: []byte{0x00, 0x01, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00},76+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00},
77 },77 },
78 {78 {
79 name: "empty strings",79 name: "empty strings",
@@ -81,12 +81,12 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
81 Version: 1,81 Version: 1,
82 Intent: IntentEphemeral,82 Intent: IntentEphemeral,
83 Name: "",83 Name: "",
84- Size: domain.Size{Cols: 0, Rows: 0},84+ Size: domain.Size{Cols: 1, Rows: 1},
85 TermEnv: "",85 TermEnv: "",
86 Cwd: "",86 Cwd: "",
87 TrueColor: false,87 TrueColor: false,
88 },88 },
89- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},89+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
90 },90 },
91 }91 }
92 92
@@ -117,17 +117,19 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
117 117
118 func TestHelloEnvironmentCodec(t *testing.T) {118 func TestHelloEnvironmentCodec(t *testing.T) {
119 t.Run("literal wire layout", func(t *testing.T) {119 t.Run("literal wire layout", func(t *testing.T) {
120- got := MarshalHello(Hello{120+ msg := Hello{
121 Version: ProtocolVersion,121 Version: ProtocolVersion,
122+ Size: domain.Size{Cols: 1, Rows: 1},
122 Env: []string{"A=B", "XY=123"},123 Env: []string{"A=B", "XY=123"},
123- })124+ }
125+ got := MarshalHello(msg)
124 want := []byte{126 want := []byte{
125- 0x00, 0x15, 0x00, 0x00, // version, intent, proxied127+ 0x00, 0x17, 0x00, // version, intent
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
129 0x00, 0x00, // name131 0x00, 0x00, // name
130- 0x00, 0x00, 0x00, 0x00, // size132+ 0x00, 0x01, 0x00, 0x01, // size
131 0x00, 0x00, // TERM133 0x00, 0x00, // TERM
132 0x00, 0x00, // cwd134 0x00, 0x00, // cwd
133 0x00, 0x00, // true color, max output in flight135 0x00, 0x00, // true color, max output in flight
@@ -136,27 +138,30 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
137 }139 }
138 require.Equal(t, want, got)140 require.Equal(t, want, got)
139- assertAllPrefixesFail(t, got, UnmarshalHello)141+ decoded, err := UnmarshalHello(want)
140- assertTrailingGarbageFails(t, got, UnmarshalHello)142+ require.NoError(t, err)
143+ require.Equal(t, msg, decoded)
144+ assertAllPrefixesFail(t, want, UnmarshalHello)
145+ assertTrailingGarbageFails(t, want, UnmarshalHello)
141 })146 })
142 147
143 t.Run("lossless order and values", func(t *testing.T) {148 t.Run("lossless order and values", func(t *testing.T) {
144 entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))149 entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
145 want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}150 want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"}
146- payload := MarshalHello(Hello{Version: ProtocolVersion, Env: want})151+ payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: want})
147 got, err := UnmarshalHello(payload)152 got, err := UnmarshalHello(payload)
148 require.NoError(t, err)153 require.NoError(t, err)
149 require.Equal(t, want, got.Env)154 require.Equal(t, want, got.Env)
150 })155 })
151 156
152 t.Run("empty", func(t *testing.T) {157 t.Run("empty", func(t *testing.T) {
153- payload := MarshalHello(Hello{Version: ProtocolVersion, Env: []string{}})158+ payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: []string{}})
154 got, err := UnmarshalHello(payload)159 got, err := UnmarshalHello(payload)
155 require.NoError(t, err)160 require.NoError(t, err)
156 require.Empty(t, got.Env)161 require.Empty(t, got.Env)
157 })162 })
158 163
159- base := MarshalHello(Hello{Version: ProtocolVersion})164+ base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
160 withCount := func(count byte) []byte {165 withCount := func(count byte) []byte {
161 payload := append([]byte(nil), base...)166 payload := append([]byte(nil), base...)
162 payload[len(payload)-1] = count167 payload[len(payload)-1] = count
@@ -247,7 +252,7 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
247 func TestThemeGoldenAndRoundTrip(t *testing.T) {252 func TestThemeGoldenAndRoundTrip(t *testing.T) {
248 palette := [16]renderer.RGB{253 palette := [16]renderer.RGB{
249 {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15},254 {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15},
250- {R: 0x16, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b},255+ {R: 0x17, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b},
251 {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21},256 {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21},
252 {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27},257 {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27},
253 {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d},258 {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d},
@@ -296,7 +301,7 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) {
296 },301 },
297 want: []byte{302 want: []byte{
298 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01,303 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01,
299- 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18,304+ 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x17, 0x17, 0x18,
300 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21,305 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21,
301 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,306 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
302 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33,307 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33,
@@ -336,14 +341,17 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336 }341 }
337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)342 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338 require.Equal(t, want, MarshalTheme(cleared))343 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(21), ProtocolVersion)344+ require.Equal(t, uint16(23), ProtocolVersion)
340 }345 }
341 346
342 func TestResizeGoldenAndRoundTrip(t *testing.T) {347 func TestResizeGoldenAndRoundTrip(t *testing.T) {
343 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}348 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
344 want := []byte{0x00, 0x64, 0x00, 0x28}349 want := []byte{0x00, 0x64, 0x00, 0x28}
345 350
346- got := MarshalResize(msg)351+ got, err := MarshalResize(msg)
352+ if err != nil {
353+ t.Fatalf("MarshalResize() error = %v", err)
354+ }
347 if !bytes.Equal(got, want) {355 if !bytes.Equal(got, want) {
348 t.Fatalf("MarshalResize() = %#v, want %#v", got, want)356 t.Fatalf("MarshalResize() = %#v, want %#v", got, want)
349 }357 }
@@ -461,7 +469,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461 name: "minimal",469 name: "minimal",
462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},470 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463 want: []byte{471 want: []byte{
464- 0x00, 0x15,472+ 0x00, 0x17,
465 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID473 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
466 0x00, // attached474 0x00, // attached
467 0x00, // self475 0x00, // self
@@ -484,7 +492,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
484 JSON: true,492 JSON: true,
485 },493 },
486 want: []byte{494 want: []byte{
487- 0x00, 0x15,495+ 0x00, 0x17,
488 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID496 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
489 0x00, // attached497 0x00, // attached
490 0x01, // self498 0x01, // self
@@ -609,10 +617,13 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
609 }617 }
610 618
611 func TestOutputGoldenAndRoundTrip(t *testing.T) {619 func TestOutputGoldenAndRoundTrip(t *testing.T) {
612- msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")}620+ msg := Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n")}
613- want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}621+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x06, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
614 622
615- got := MarshalOutput(msg)623+ got, err := MarshalOutput(msg)
624+ if err != nil {
625+ t.Fatalf("MarshalOutput() error = %v", err)
626+ }
616 if !bytes.Equal(got, want) {627 if !bytes.Equal(got, want) {
617 t.Fatalf("MarshalOutput() = %#v, want %#v", got, want)628 t.Fatalf("MarshalOutput() = %#v, want %#v", got, want)
618 }629 }
@@ -624,25 +635,18 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
624 t.Fatalf("round trip = %#v, want %#v", back, msg)635 t.Fatalf("round trip = %#v, want %#v", back, msg)
625 }636 }
626 637
627- assertAllPrefixesFail(t, got[:24], UnmarshalOutput)638+ assertAllPrefixesFail(t, got, UnmarshalOutput)
628-639+ assertTrailingGarbageFails(t, got, UnmarshalOutput)
629- extra := []byte{0x00, 0xff, 0x7f}
630- withTrailing := append(append([]byte(nil), got...), extra...)
631- back, err = UnmarshalOutput(withTrailing)
632- if err != nil {
633- t.Fatalf("UnmarshalOutput() with trailing payload error = %v", err)
634- }
635- wantData := append(append([]byte(nil), msg.Data...), extra...)
636- if !bytes.Equal(back.Data, wantData) {
637- t.Fatalf("trailing payload data = %#v, want %#v", back.Data, wantData)
638- }
639 }640 }
640 641
641 func TestAckGoldenAndRoundTrip(t *testing.T) {642 func TestAckGoldenAndRoundTrip(t *testing.T) {
642- msg := Ack{AckedStateNum: 0x0102030405060708}643+ msg := Ack{Epoch: 1, State: 0x0102030405060708}
643- want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}644+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
644 645
645- got := MarshalAck(msg)646+ got, err := MarshalAck(msg)
647+ if err != nil {
648+ t.Fatalf("MarshalAck() error = %v", err)
649+ }
646 if !bytes.Equal(got, want) {650 if !bytes.Equal(got, want) {
647 t.Fatalf("MarshalAck() = %#v, want %#v", got, want)651 t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
648 }652 }
@@ -884,11 +888,11 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
884 func TestHelloOutputWindowByteExactValues(t *testing.T) {888 func TestHelloOutputWindowByteExactValues(t *testing.T) {
885 for _, window := range []uint8{0, 1, 8} {889 for _, window := range []uint8{0, 1, 8} {
886 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {890 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
887- hello := Hello{Version: 14, MaxOutputInFlight: window}891+ hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
888- // The empty Hello has 39 zero bytes before the negotiated output window,892+ // The empty Hello has a fixed 1x1 size before the negotiated output window.
889- // followed by a uint32 zero environment-entry count.893+ want := append(make([]byte, 38), window, 0, 0, 0, 0)
890- want := append(make([]byte, 39), window, 0, 0, 0, 0)
891 want[1] = 14894 want[1] = 14
895+ want[30], want[32] = 1, 1
892 got := MarshalHello(hello)896 got := MarshalHello(hello)
893 requireBytesEqual(t, want, got)897 requireBytesEqual(t, want, got)
894 back, err := UnmarshalHello(got)898 back, err := UnmarshalHello(got)
@@ -906,44 +910,6 @@ func requireBytesEqual(t *testing.T, want, got []byte) {
906 t.Fatalf("bytes = %#v, want %#v", got, want)910 t.Fatalf("bytes = %#v, want %#v", got, want)
907 }911 }
908 }912 }
909-func TestProxiedHelloGoldenAndStrict(t *testing.T) {
910- msg := Hello{
911- Version: ProtocolVersion,
912- Intent: IntentAttach,
913- Proxied: true,
914- ClientID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16},
915- ResumeToken: 0x0102030405060708,
916- Name: "remote",
917- Size: domain.Size{Cols: 80, Rows: 22},
918- TermEnv: "xterm",
919- Cwd: "/tmp",
920- TrueColor: true,
921- MaxOutputInFlight: 8,
922- }
923- want := []byte{
924- 0x00, 0x15, 0x02, 0x01,
925- 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
926- 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
927- 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e',
928- 0x00, 0x50, 0x00, 0x16,
929- 0x00, 0x05, 'x', 't', 'e', 'r', 'm',
930- 0x00, 0x04, '/', 't', 'm', 'p',
931- 0x01, 0x08, 0x00, 0x00, 0x00, 0x00,
932- }
933- got := MarshalHello(msg)
934- require.Equal(t, want, got)
935- back, err := UnmarshalHello(got)
936- require.NoError(t, err)
937- require.Equal(t, msg, back)
938- assertAllPrefixesFail(t, got, UnmarshalHello)
939- assertTrailingGarbageFails(t, got, UnmarshalHello)
940-
941- malformed := append([]byte(nil), got...)
942- malformed[3] = 2
943- _, err = UnmarshalHello(malformed)
944- require.Error(t, err)
945-}
946-
947 func TestCommandCorrelationGoldenAndStrict(t *testing.T) {913 func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
948 tests := []struct {914 tests := []struct {
949 name string915 name string
@@ -954,7 +920,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
954 name: "attached",920 name: "attached",
955 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},921 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
956 want: []byte{922 want: []byte{
957- 0x00, 0x15, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,923+ 0x00, 0x17, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
958 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',924 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
959 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',925 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
960 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,926 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -964,7 +930,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
964 name: "control",930 name: "control",
965 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},931 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
966 want: []byte{932 want: []byte{
967- 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,933+ 0x00, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
968 0x00, 0x02, 'l', 's', 0x00, 0x00,934 0x00, 0x02, 'l', 's', 0x00, 0x00,
969 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,935 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
970 },936 },
M internal/testutil/replaytest/replay.go +11 -2
@@ -7,12 +7,21 @@ import (
7 "reflect"7 "reflect"
8 "testing"8 "testing"
9 9
10+ "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 )12 )
12 13
14+func mustMarshalOutput(m ports.Output) []byte {
15+ payload, err := ports.MarshalOutput(m)
16+ if err != nil {
17+ panic(err)
18+ }
19+ return payload
20+}
21+
13 var transcript = []ports.Frame{22 var transcript = []ports.Frame{
14- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},23+ {Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
15- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},24+ {Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Base: 1, New: 2, Echo: 7, Size: domain.Size{Cols: 80, Rows: 24}, Data: []byte("\x1b[2;1HTWO")})},
16 }25 }
17 26
18 // Transcript returns a deep copy of the canonical replay transcript so an27 // Transcript returns a deep copy of the canonical replay transcript so an
M internal/usecase/client/ack_queue_test.go +4 -2
@@ -21,9 +21,10 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) {
21 for range cap(normal) {21 for range cap(normal) {
22 normal <- ports.Frame{Type: ports.MsgInput}22 normal <- ports.Frame{Type: ports.MsgInput}
23 }23 }
24+ const epoch = 7
24 acks := newCumulativeAckQueue()25 acks := newCumulativeAckQueue()
25 for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ {26 for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ {
26- acks.offer(state)27+ acks.offer(epoch, state)
27 }28 }
28 29
29 ctx, cancel := context.WithCancel(context.Background())30 ctx, cancel := context.WithCancel(context.Background())
@@ -36,7 +37,8 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) {
36 require.Equal(t, ports.MsgAck, first.Type)37 require.Equal(t, ports.MsgAck, first.Type)
37 ack, err := ports.UnmarshalAck(first.Payload)38 ack, err := ports.UnmarshalAck(first.Payload)
38 require.NoError(t, err)39 require.NoError(t, err)
39- require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.AckedStateNum)40+ require.Equal(t, uint64(epoch), ack.Epoch)
41+ require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.State)
40 }42 }
41 43
42 const maxUnackedOutputStatesForTest = 844 const maxUnackedOutputStatesForTest = 8
M internal/usecase/client/client.go +73 -35
@@ -251,13 +251,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
251 }251 }
252 rememberOnce.Do(func() {252 rememberOnce.Do(func() {
253 learnerStarted = true253 learnerStarted = true
254- rememberWG.Add(1)254+ rememberWG.Go(func() {
255- go func() {
256- defer rememberWG.Done()
257 if err := r.remoteHostLearner.RememberRemoteHost(); err != nil {255 if err := r.remoteHostLearner.RememberRemoteHost(); err != nil {
258 r.logger.Warn("remembering remote host failed", "err", err)256 r.logger.Warn("remembering remote host failed", "err", err)
259 }257 }
260- }()258+ })
261 })259 })
262 }260 }
263 defer func() {261 defer func() {
@@ -832,12 +830,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
832 clip = clipboard830 clip = clipboard
833 }831 }
834 awaitingReconnectReset := false832 awaitingReconnectReset := false
833+ outputState := outputApplyState{}
834+ outputResetRequested := false
835 if reconnect.showing {835 if reconnect.showing {
836 if reconnect.remote {836 if reconnect.remote {
837 // Before the asynchronous sender starts, preserve transport ordering by837 // Before the asynchronous sender starts, preserve transport ordering by
838 // requesting the reset synchronously after the initial Theme publication.838 // requesting the reset synchronously after the initial Theme publication.
839 closed, resetErr := boundedPreWelcome(ctx, clk, transport, func() error {839 closed, resetErr := boundedPreWelcome(ctx, clk, transport, func() error {
840- return transport.Send(ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})})840+ payload, err := ports.MarshalResize(ports.Resize{Size: size})
841+ if err != nil {
842+ return err
843+ }
844+ return transport.Send(ports.Frame{Type: ports.MsgResize, Payload: payload})
841 })845 })
842 if resetErr != nil {846 if resetErr != nil {
843 resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr))847 resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr))
@@ -881,8 +885,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
881 if serr != nil {885 if serr != nil {
882 return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr)886 return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr)
883 }887 }
888+ payload, err := ports.MarshalResize(ports.Resize{Size: size})
889+ if err != nil {
890+ return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err)
891+ }
884 select {892 select {
885- case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})}:893+ case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: payload}:
886 awaitingReconnectReset = true894 awaitingReconnectReset = true
887 return nil895 return nil
888 case <-loopCtx.Done():896 case <-loopCtx.Done():
@@ -990,19 +998,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
990 if derr != nil {998 if derr != nil {
991 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))999 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
992 }1000 }
993- // Dismissing or changing a local overlay requests a same-size resize,1001+ nextState, accepted := outputState.next(o)
994- // whose stateful base-zero Output is the daemon's authoritative reset.1002+ if !accepted {
995- // ACK intervening increments so the output window keeps moving, but do1003+ // A discarded frame was never written and must never be ACKed.
996- // not apply diffs based on cells hidden by the client-local overlay.1004+ // Ask the daemon for one authoritative full reset, coalescing
997- resetFrame := isReconnectResetFrame(o)1005+ // repeated gaps while that reset is in flight.
998- if awaitingReconnectReset && !resetFrame && o.NewStateNum != 0 {1006+ if !outputResetRequested {
999- ackQueue.offer(o.NewStateNum)1007+ select {
1008+ case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}:
1009+ outputResetRequested = true
1010+ case <-loopCtx.Done():
1011+ return loopCanceledResult()
1012+ }
1013+ }
1000 continue1014 continue
1001 }1015 }
1002 if _, werr := term.Out().Write(o.Data); werr != nil {1016 if _, werr := term.Out().Write(o.Data); werr != nil {
1003 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))1017 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
1004 }1018 }
1005- if awaitingReconnectReset && resetFrame {1019+ if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 {
1006 awaitingReconnectReset = false1020 awaitingReconnectReset = false
1007 }1021 }
1008 if reconnect.showing && reconnect.remote {1022 if reconnect.showing && reconnect.remote {
@@ -1016,13 +1030,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1016 } else if ferr := term.Flush(); ferr != nil {1030 } else if ferr := term.Flush(); ferr != nil {
1017 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))1031 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
1018 }1032 }
1033+ outputState = nextState
1034+ if o.New != 0 {
1035+ ackQueue.offer(o.Epoch, o.New)
1036+ outputResetRequested = false
1037+ }
1019 // The terminal boundary is after a successful flush and before ACK.1038 // The terminal boundary is after a successful flush and before ACK.
1020 if observer != nil {1039 if observer != nil {
1021 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))1040 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
1022 }1041 }
1023- if o.NewStateNum != 0 {
1024- ackQueue.offer(o.NewStateNum)
1025- }
1026 if !ms.firstOutput {1042 if !ms.firstOutput {
1027 ms.firstOutput = true1043 ms.firstOutput = true
1028 log.Debug("received first output")1044 log.Debug("received first output")
@@ -1049,13 +1065,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
1049 }1065 }
1050 }1066 }
1051 1067
1052-// isReconnectResetFrame identifies the authoritative stateful reset emitted
1053-// by the daemon's existing resize path. Stateless side effects can also have a
1054-// zero base, so they must not dismiss or reconcile a client-local overlay.
1055-func isReconnectResetFrame(o ports.Output) bool {
1056- return o.BaseStateNum == 0 && o.NewStateNum != 0
1057-}
1058-
1059 // recvResult carries one framed message (or a read error) from the receive1068 // recvResult carries one framed message (or a read error) from the receive
1060 // pump to the main loop.1069 // pump to the main loop.
1061 type recvResult struct {1070 type recvResult struct {
@@ -1084,27 +1093,39 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu
1084 // resize pumps through one goroutine keeps the transport's single-writer1093 // resize pumps through one goroutine keeps the transport's single-writer
1085 // contract intact. A send failure cancels the loop and is surfaced once.1094 // contract intact. A send failure cancels the loop and is surfaced once.
1086 type cumulativeAckQueue struct {1095 type cumulativeAckQueue struct {
1087- latest atomic.Uint641096+ mu sync.Mutex
1088- wake chan struct{}1097+ latestEpoch uint64
1098+ latestState uint64
1099+ wake chan struct{}
1089 }1100 }
1090 1101
1091 func newCumulativeAckQueue() *cumulativeAckQueue {1102 func newCumulativeAckQueue() *cumulativeAckQueue {
1092 return &cumulativeAckQueue{wake: make(chan struct{}, 1)}1103 return &cumulativeAckQueue{wake: make(chan struct{}, 1)}
1093 }1104 }
1094 1105
1095-func (q *cumulativeAckQueue) offer(state uint64) {1106+func (q *cumulativeAckQueue) offer(epoch, state uint64) {
1096- for {1107+ if epoch == 0 || state == 0 {
1097- current := q.latest.Load()1108+ return
1098- if state <= current || q.latest.CompareAndSwap(current, state) {1109+ }
1099- break1110+ q.mu.Lock()
1100- }1111+ if epoch > q.latestEpoch || (epoch == q.latestEpoch && state > q.latestState) {
1112+ q.latestEpoch, q.latestState = epoch, state
1101 }1113 }
1114+ q.mu.Unlock()
1102 select {1115 select {
1103 case q.wake <- struct{}{}:1116 case q.wake <- struct{}{}:
1104 default:1117 default:
1105 }1118 }
1106 }1119 }
1107 1120
1121+func (q *cumulativeAckQueue) take() (uint64, uint64) {
1122+ q.mu.Lock()
1123+ defer q.mu.Unlock()
1124+ epoch, state := q.latestEpoch, q.latestState
1125+ q.latestEpoch, q.latestState = 0, 0
1126+ return epoch, state
1127+}
1128+
1108 func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {1129 func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
1109 defer log.Debug("sender pump exited")1130 defer log.Debug("sender pump exited")
1110 send := func(f ports.Frame) bool {1131 send := func(f ports.Frame) bool {
@@ -1119,8 +1140,20 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1119 return true1140 return true
1120 }1141 }
1121 sendAck := func() bool {1142 sendAck := func() bool {
1122- state := acks.latest.Swap(0)1143+ epoch, state := acks.take()
1123- return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: state})})1144+ if state == 0 {
1145+ return true
1146+ }
1147+ payload, err := ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})
1148+ if err != nil {
1149+ select {
1150+ case errCh <- fmt.Errorf("encoding output ACK: %w", err):
1151+ default:
1152+ }
1153+ cancel()
1154+ return false
1155+ }
1156+ return send(ports.Frame{Type: ports.MsgAck, Payload: payload})
1124 }1157 }
1125 for {1158 for {
1126 select {1159 select {
@@ -1718,7 +1751,12 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.
1718 if !ok {1751 if !ok {
1719 return1752 return
1720 }1753 }
1721- frame := ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: sz})}1754+ payload, err := ports.MarshalResize(ports.Resize{Size: sz})
1755+ if err != nil {
1756+ log.Error("encoding terminal resize", "error", err)
1757+ continue
1758+ }
1759+ frame := ports.Frame{Type: ports.MsgResize, Payload: payload}
1722 select {1760 select {
1723 case out <- frame:1761 case out <- frame:
1724 case <-ctx.Done():1762 case <-ctx.Done():
M internal/usecase/client/client_test.go +1 -1
@@ -408,7 +408,7 @@ func TestAttachHappyPath(t *testing.T) {
408 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()408 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
409 unblock := scriptRecv(tr,409 unblock := scriptRecv(tr,
410 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))},410 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))},
411- recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("hello world")}))},411+ recvItem{f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("hello world")}))},
412 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},412 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
413 )413 )
414 defer unblock()414 defer unblock()
M internal/usecase/client/clipboard_intercept_test.go +1 -1
@@ -198,7 +198,7 @@ func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testi
198 }198 }
199 }199 }
200 beforeOutput := out.String()200 beforeOutput := out.String()
201- transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")}))201+ transport.recv <- frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("incremental")}))
202 transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))202 transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
203 203
204 select {204 select {
A internal/usecase/client/output_state.go +50 -0
@@ -0,0 +1,50 @@
1+package client
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+// outputApplyState is the client-side dependency chain for one attachment.
6+// State-bearing frames are accepted only when their epoch, base, and view
7+// revision continue the last frame; a new epoch must carry a full reset.
8+type outputApplyState struct {
9+ epoch uint64
10+ state uint64
11+ viewRevision uint64
12+ initialized bool
13+}
14+
15+func (s outputApplyState) next(output ports.Output) (outputApplyState, bool) {
16+ if output.Epoch == 0 {
17+ return outputApplyState{}, false
18+ }
19+ if !s.initialized {
20+ if output.New == 0 {
21+ return s, true
22+ }
23+ if output.Base != 0 || !output.Full {
24+ return outputApplyState{}, false
25+ }
26+ return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
27+ }
28+ if output.Epoch < s.epoch {
29+ return outputApplyState{}, false
30+ }
31+ if output.Epoch == s.epoch {
32+ if output.ViewRevision != s.viewRevision {
33+ return outputApplyState{}, false
34+ }
35+ if output.New == 0 {
36+ if output.Base != 0 || output.Full {
37+ return outputApplyState{}, false
38+ }
39+ return s, true
40+ }
41+ if output.Full || output.Base != s.state || output.New != output.Base+1 {
42+ return outputApplyState{}, false
43+ }
44+ return outputApplyState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true
45+ }
46+ if !output.Full || output.Base != 0 {
47+ return outputApplyState{}, false
48+ }
49+ return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true
50+}
A internal/usecase/client/output_state_test.go +37 -0
@@ -0,0 +1,37 @@
1+package client
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+func TestOutputApplyStateTransitions(t *testing.T) {
11+ full := ports.Output{Epoch: 1, Base: 0, New: 1, Full: true, ViewRevision: 2, Size: domain.Size{Cols: 80, Rows: 24}}
12+ tests := []struct {
13+ name string
14+ state outputApplyState
15+ output ports.Output
16+ accepted bool
17+ want outputApplyState
18+ }{
19+ {name: "first full", output: full, accepted: true, want: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}},
20+ {name: "first gap", output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 2}, accepted: false},
21+ {name: "increment", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 2}, accepted: true, want: outputApplyState{epoch: 1, state: 2, viewRevision: 2, initialized: true}},
22+ {name: "base gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 0, New: 2, Full: true, ViewRevision: 2}, accepted: false},
23+ {name: "revision gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 3}, accepted: false},
24+ {name: "new epoch reset", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 2, Base: 0, New: 1, Full: true, ViewRevision: 3}, accepted: true, want: outputApplyState{epoch: 2, state: 1, viewRevision: 3, initialized: true}},
25+ }
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ got, accepted := tt.state.next(tt.output)
29+ if accepted != tt.accepted {
30+ t.Fatalf("accepted = %v, want %v", accepted, tt.accepted)
31+ }
32+ if accepted && got != tt.want {
33+ t.Fatalf("state = %+v, want %+v", got, tt.want)
34+ }
35+ })
36+ }
37+}
M internal/usecase/client/reconnect_toast_test.go +88 -56
@@ -375,10 +375,13 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
375 require.Contains(t, firstToast, "┌")375 require.Contains(t, firstToast, "┌")
376 require.Contains(t, firstToast, "probing UDP path")376 require.Contains(t, firstToast, "probing UDP path")
377 377
378- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{378+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
379- BaseStateNum: 1,379+ Epoch: 1,
380- NewStateNum: 2,380+ Base: 0,
381- Data: []byte("daemon incremental"),381+ New: 2,
382+ Size: domain.Size{Cols: 1, Rows: 1},
383+ Full: true,
384+ Data: []byte("daemon incremental"),
382 })}}385 })}}
383 redrawn := requireReconnectToastOutput(t, out.completed)386 redrawn := requireReconnectToastOutput(t, out.completed)
384 require.Contains(t, redrawn, "daemon incremental")387 require.Contains(t, redrawn, "daemon incremental")
@@ -391,44 +394,53 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
391 394
392 beforeAwaitingReset := out.String()395 beforeAwaitingReset := out.String()
393 beforeStatelessFlushes := flushes.Load()396 beforeStatelessFlushes := flushes.Load()
394- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{397+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
395- BaseStateNum: 0,398+ Epoch: 1,
396- NewStateNum: 0,399+ Base: 0,
397- Data: []byte("stateless side effect"),400+ New: 0,
401+ Size: domain.Size{Cols: 1, Rows: 1},
402+ Data: []byte("stateless side effect"),
398 })}}403 })}}
399 require.Eventually(t, func() bool {404 require.Eventually(t, func() bool {
400 return strings.Contains(out.String(), "stateless side effect") && flushes.Load() > beforeStatelessFlushes405 return strings.Contains(out.String(), "stateless side effect") && flushes.Load() > beforeStatelessFlushes
401 }, time.Second, time.Millisecond)406 }, time.Second, time.Millisecond)
402 407
403 afterStateless := out.String()408 afterStateless := out.String()
404- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{409+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
405- BaseStateNum: 2,410+ Epoch: 1,
406- NewStateNum: 3,411+ Base: 2,
407- Data: []byte("intervening incremental"),412+ New: 3,
413+ Size: domain.Size{Cols: 1, Rows: 1},
414+ Data: []byte("intervening incremental"),
408 })}}415 })}}
409- requireAckedState(t, tr.sends, 3)416+ requireAckedState(t, tr.sends, 1, 3)
410- require.Equal(t, afterStateless, out.String(), "stateless output must not satisfy pending reset reconciliation")417+ require.Contains(t, out.String(), "intervening incremental")
411 require.Contains(t, afterStateless[len(beforeAwaitingReset):], "stateless side effect")418 require.Contains(t, afterStateless[len(beforeAwaitingReset):], "stateless side effect")
412 419
413- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{420+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
414- BaseStateNum: 0,421+ Epoch: 2,
415- NewStateNum: 4,422+ Base: 0,
416- Data: []byte("authoritative reset"),423+ New: 4,
424+ Size: domain.Size{Cols: 1, Rows: 1},
425+ Full: true,
426+ Data: []byte("authoritative reset"),
417 })}}427 })}}
418- requireAckedState(t, tr.sends, 4)428+ requireAckedState(t, tr.sends, 2, 4)
419 require.Eventually(t, func() bool { return strings.Contains(out.String(), "authoritative reset") }, time.Second, time.Millisecond)429 require.Eventually(t, func() bool { return strings.Contains(out.String(), "authoritative reset") }, time.Second, time.Millisecond)
420 require.NotContains(t, out.String()[len(beforeAwaitingReset):], strings.Repeat(" ", reconnectToastBoundsFor(term.size, "probing UDP path").Width))430 require.NotContains(t, out.String()[len(beforeAwaitingReset):], strings.Repeat(" ", reconnectToastBoundsFor(term.size, "probing UDP path").Width))
421 431
422 beforeIncrementFlushes := flushes.Load()432 beforeIncrementFlushes := flushes.Load()
423- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{433+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
424- BaseStateNum: 4,434+ Epoch: 2,
425- NewStateNum: 5,435+ Base: 4,
426- Data: []byte("increment after reset"),436+ New: 5,
437+ Size: domain.Size{Cols: 1, Rows: 1},
438+ Data: []byte("increment after reset"),
427 })}}439 })}}
428 require.Eventually(t, func() bool {440 require.Eventually(t, func() bool {
429 return strings.Contains(out.String(), "increment after reset") && flushes.Load() > beforeIncrementFlushes441 return strings.Contains(out.String(), "increment after reset") && flushes.Load() > beforeIncrementFlushes
430 }, time.Second, time.Millisecond)442 }, time.Second, time.Millisecond)
431- requireAckedState(t, tr.sends, 5)443+ requireAckedState(t, tr.sends, 2, 5)
432 444
433 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}445 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
434 result := requireAttachResult(t, resultCh)446 result := requireAttachResult(t, resultCh)
@@ -462,37 +474,46 @@ func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T)
462 474
463 beforeReset := out.String()475 beforeReset := out.String()
464 for _, output := range []ports.Output{476 for _, output := range []ports.Output{
465- {BaseStateNum: 1, NewStateNum: 2, Data: []byte("first skipped increment")},477+ {Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("first skipped increment")},
466- {BaseStateNum: 2, NewStateNum: 3, Data: []byte("second skipped increment")},478+ {Epoch: 1, Base: 2, New: 3, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("second skipped increment")},
467 } {479 } {
468- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(output)}}480+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)}}
469- requireAckedState(t, tr.sends, output.NewStateNum)
470 require.Equal(t, beforeReset, out.String())481 require.Equal(t, beforeReset, out.String())
471 }482 }
483+ requireSentFrame(t, tr.sends, "output reset request", func(frame ports.Frame) bool {
484+ return frame.Type == ports.MsgOutputResetRequest
485+ })
486+ _, sentAck := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgAck })
487+ require.False(t, sentAck, "discarded output must not be ACKed")
472 488
473 beforeResetFlushes := flushes.Load()489 beforeResetFlushes := flushes.Load()
474- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{490+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
475- BaseStateNum: 0,491+ Epoch: 2,
476- NewStateNum: 4,492+ Base: 0,
477- Data: []byte("stage transition reset"),493+ New: 4,
494+ Size: domain.Size{Cols: 1, Rows: 1},
495+ Full: true,
496+ Data: []byte("stage transition reset"),
478 })}}497 })}}
479 redrawn := requireReconnectToastOutput(t, out.completed)498 redrawn := requireReconnectToastOutput(t, out.completed)
480 require.Contains(t, redrawn, "stage transition reset")499 require.Contains(t, redrawn, "stage transition reset")
481 require.Contains(t, redrawn, offlineMessage)500 require.Contains(t, redrawn, offlineMessage)
482 require.Eventually(t, func() bool { return flushes.Load() > beforeResetFlushes }, time.Second, time.Millisecond)501 require.Eventually(t, func() bool { return flushes.Load() > beforeResetFlushes }, time.Second, time.Millisecond)
483- requireAckedState(t, tr.sends, 4)502+ requireAckedState(t, tr.sends, 2, 4)
484 503
485 beforeIncrementFlushes := flushes.Load()504 beforeIncrementFlushes := flushes.Load()
486- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{505+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
487- BaseStateNum: 4,506+ Epoch: 2,
488- NewStateNum: 5,507+ Base: 4,
489- Data: []byte("stage increment after reset"),508+ New: 5,
509+ Size: domain.Size{Cols: 1, Rows: 1},
510+ Data: []byte("stage increment after reset"),
490 })}}511 })}}
491 incremental := requireReconnectToastOutput(t, out.completed)512 incremental := requireReconnectToastOutput(t, out.completed)
492 require.Contains(t, incremental, "stage increment after reset")513 require.Contains(t, incremental, "stage increment after reset")
493 require.Contains(t, incremental, offlineMessage)514 require.Contains(t, incremental, offlineMessage)
494 require.Eventually(t, func() bool { return flushes.Load() > beforeIncrementFlushes }, time.Second, time.Millisecond)515 require.Eventually(t, func() bool { return flushes.Load() > beforeIncrementFlushes }, time.Second, time.Millisecond)
495- requireAckedState(t, tr.sends, 5)516+ requireAckedState(t, tr.sends, 2, 5)
496 517
497 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}518 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
498 result := requireAttachResult(t, resultCh)519 result := requireAttachResult(t, resultCh)
@@ -537,17 +558,19 @@ func requireResize(t *testing.T, sends *reconnectToastSentFrames) ports.Resize {
537 return resize558 return resize
538 }559 }
539 560
540-func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, state uint64) {561+func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, epoch, state uint64) {
541 t.Helper()562 t.Helper()
542- frame := requireSentFrame(t, sends, fmt.Sprintf("ACK for state %d", state), func(frame ports.Frame) bool {563+ frame := requireSentFrame(t, sends, fmt.Sprintf("ACK for epoch %d state %d", epoch, state), func(frame ports.Frame) bool {
543 if frame.Type != ports.MsgAck {564 if frame.Type != ports.MsgAck {
544 return false565 return false
545 }566 }
546 ack, err := ports.UnmarshalAck(frame.Payload)567 ack, err := ports.UnmarshalAck(frame.Payload)
547- return err == nil && ack.AckedStateNum >= state568+ return err == nil && ack.Epoch == epoch && ack.State >= state
548 })569 })
549- _, err := ports.UnmarshalAck(frame.Payload)570+ ack, err := ports.UnmarshalAck(frame.Payload)
550 require.NoError(t, err)571 require.NoError(t, err)
572+ require.Equal(t, epoch, ack.Epoch)
573+ require.GreaterOrEqual(t, ack.State, state)
551 }574 }
552 575
553 func requireAttachResult(t *testing.T, results <-chan attachResult) attachResult {576 func requireAttachResult(t *testing.T, results <-chan attachResult) attachResult {
@@ -652,10 +675,13 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi
652 require.Eventually(t, func() bool { return flushes.Load() > beforeStatus }, time.Second, time.Millisecond)675 require.Eventually(t, func() bool { return flushes.Load() > beforeStatus }, time.Second, time.Millisecond)
653 beforeOutput := flushes.Load()676 beforeOutput := flushes.Load()
654 677
655- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{678+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
656- BaseStateNum: 1,679+ Epoch: 1,
657- NewStateNum: 2,680+ Base: 0,
658- Data: []byte("daemon output behind local reconnect status"),681+ New: 2,
682+ Size: domain.Size{Cols: 1, Rows: 1},
683+ Full: true,
684+ Data: []byte("daemon output behind local reconnect status"),
659 })}}685 })}}
660 686
661 select {687 select {
@@ -664,7 +690,7 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi
664 case <-time.After(time.Second):690 case <-time.After(time.Second):
665 t.Fatal("timed out waiting for terminal-flushed observation")691 t.Fatal("timed out waiting for terminal-flushed observation")
666 }692 }
667- requireAckedState(t, tr.sends, 2)693+ requireAckedState(t, tr.sends, 1, 2)
668 require.Greater(t, flushes.Load(), beforeOutput, "daemon output must be flushed before ACK")694 require.Greater(t, flushes.Load(), beforeOutput, "daemon output must be flushed before ACK")
669 695
670 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}696 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
@@ -701,13 +727,16 @@ func TestLocalReconnectStatusTransitionsDoNotRequestAuthoritativeReset(t *testin
701 }727 }
702 728
703 const output = "local status output remains visible"729 const output = "local status output remains visible"
704- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{730+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
705- BaseStateNum: 1,731+ Epoch: 1,
706- NewStateNum: 2,732+ Base: 0,
707- Data: []byte(output),733+ New: 2,
734+ Size: domain.Size{Cols: 1, Rows: 1},
735+ Full: true,
736+ Data: []byte(output),
708 })}}737 })}}
709 require.Eventually(t, func() bool { return strings.Contains(out.String(), output) }, time.Second, time.Millisecond)738 require.Eventually(t, func() bool { return strings.Contains(out.String(), output) }, time.Second, time.Millisecond)
710- requireAckedState(t, tr.sends, 2)739+ requireAckedState(t, tr.sends, 1, 2)
711 _, sentResize := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgResize })740 _, sentResize := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgResize })
712 require.False(t, sentResize, "local reconnect status must not request a daemon reset")741 require.False(t, sentResize, "local reconnect status must not request a daemon reset")
713 742
@@ -766,10 +795,13 @@ func TestReconnectOverlayRedrawFailureDoesNotObserveOrAckOutput(t *testing.T) {
766 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}795 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
767 requireReconnectToastOutput(t, out.completed)796 requireReconnectToastOutput(t, out.completed)
768 arm()797 arm()
769- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{798+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
770- BaseStateNum: 1,799+ Epoch: 1,
771- NewStateNum: 2,800+ Base: 0,
772- Data: []byte("daemon output before failed redraw"),801+ New: 2,
802+ Size: domain.Size{Cols: 1, Rows: 1},
803+ Full: true,
804+ Data: []byte("daemon output before failed redraw"),
773 })}}805 })}}
774 806
775 result := requireAttachResult(t, resultCh)807 result := requireAttachResult(t, resultCh)
M internal/usecase/client/transport_observability_test.go +2 -2
@@ -50,7 +50,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
50 transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()50 transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
51 unblock := scriptRecv(transport,51 unblock := scriptRecv(transport,
52 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},52 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
53- recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("flush-before-observe"), NewStateNum: 3}))},53+ recvItem{f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("flush-before-observe"), New: 3}))},
54 )54 )
55 defer unblock()55 defer unblock()
56 transport.EXPECT().Close().Return(nil).Once()56 transport.EXPECT().Close().Return(nil).Once()
@@ -90,7 +90,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
90 transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()90 transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
91 unblock := scriptRecv(transport,91 unblock := scriptRecv(transport,
92 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},92 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
93- recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))},93+ recvItem{f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("unchanged-by-observer"), New: 3}))},
94 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},94 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
95 )95 )
96 defer unblock()96 defer unblock()
A internal/usecase/client/wire_external_test_helpers_test.go +11 -0
@@ -0,0 +1,11 @@
1+package client_test
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalOutput(m ports.Output) []byte {
6+ payload, err := ports.MarshalOutput(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
A internal/usecase/client/wire_test_helpers_test.go +11 -0
@@ -0,0 +1,11 @@
1+package client
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalOutput(m ports.Output) []byte {
6+ payload, err := ports.MarshalOutput(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
M internal/usecase/daemon/attachment_effect_gate_test.go +1 -1
@@ -614,7 +614,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
614 setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) {614 setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) {
615 ac.output.next = 3615 ac.output.next = 3
616 },616 },
617- frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})},617+ frame: ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: 1, State: 1})},
618 assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {618 assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
619 ac.sendMu.Lock()619 ac.sendMu.Lock()
620 defer ac.sendMu.Unlock()620 defer ac.sendMu.Unlock()
M internal/usecase/daemon/attachment_registry.go +19 -3
@@ -17,6 +17,7 @@ type attachmentView struct {
17 paneID domain.PaneStableID17 paneID domain.PaneStableID
18 windowTop int18 windowTop int
19 windowRows int19 windowRows int
20+ windowSet bool
20 bookmark vt.RowID21 bookmark vt.RowID
21 liveBottom bool22 liveBottom bool
22 revision uint6423 revision uint64
@@ -45,6 +46,19 @@ func (ac *attachedClient) publishView(view attachmentView) {
45 return46 return
46 }47 }
47 ac.viewMu.Lock()48 ac.viewMu.Lock()
49+ // Rebase while viewMu is held, before the new revision becomes visible.
50+ // Output fences take the same lock, so no side effect can pair the new
51+ // ViewRevision with the previous output epoch. Test/headless fixtures may
52+ // leave output.attachment unset; use the stream-local lock for those.
53+ if ac.output != nil && ac.view.revision != view.revision {
54+ if ac.output.attachment == ac {
55+ ac.output.rebaseLocked()
56+ } else {
57+ ac.output.stateMu.Lock()
58+ ac.output.rebaseLocked()
59+ ac.output.stateMu.Unlock()
60+ }
61+ }
48 ac.view = view62 ac.view = view
49 ac.viewMu.Unlock()63 ac.viewMu.Unlock()
50 }64 }
@@ -241,6 +255,7 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
241 view.paneID = ""255 view.paneID = ""
242 view.windowTop = 0256 view.windowTop = 0
243 view.windowRows = 0257 view.windowRows = 0
258+ view.windowSet = false
244 view.bookmark = 0259 view.bookmark = 0
245 view.liveBottom = true260 view.liveBottom = true
246 return view261 return view
@@ -293,7 +308,7 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool {
293 }308 }
294 before := ac.viewSnapshot()309 before := ac.viewSnapshot()
295 after := s.repairAttachmentViewLocked(ac, before)310 after := s.repairAttachmentViewLocked(ac, before)
296- if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {311+ if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom {
297 return false312 return false
298 }313 }
299 after.revision++314 after.revision++
@@ -324,6 +339,7 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index
324 view.paneID = ""339 view.paneID = ""
325 view.bookmark = 0340 view.bookmark = 0
326 view.liveBottom = true341 view.liveBottom = true
342+ view.revision++
327 ac.publishView(view)343 ac.publishView(view)
328 }344 }
329 }345 }
@@ -369,7 +385,7 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
369 }385 }
370 view := ac.viewSnapshot()386 view := ac.viewSnapshot()
371 repaired := s.repairAttachmentViewLocked(ac, view)387 repaired := s.repairAttachmentViewLocked(ac, view)
372- if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {388+ if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom {
373 repaired.revision++389 repaired.revision++
374 ac.publishView(repaired)390 ac.publishView(repaired)
375 }391 }
@@ -476,7 +492,7 @@ func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachme
476 next = s.repairAttachmentViewLocked(ac, next)492 next = s.repairAttachmentViewLocked(ac, next)
477 next.revision++493 next.revision++
478 ac.publishView(next)494 ac.publishView(next)
479- changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom495+ changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.windowSet != before.windowSet || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom
480 return nil496 return nil
481 })497 })
482 return changed498 return changed
M internal/usecase/daemon/attachment_registry_test.go +63 -0
@@ -5,6 +5,8 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
8 )10 )
9 11
10 func registryTestSession() *session {12 func registryTestSession() *session {
@@ -73,6 +75,67 @@ func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) {
73 }75 }
74 }76 }
75 77
78+func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing.T) {
79+ sess := registryTestSession()
80+ ac := registryTestAttachment(1)
81+ ac.size = domain.Size{Cols: 80, Rows: 23}
82+ ac.output = newOutputStateStream()
83+ ac.output.attachment = ac
84+ require.True(t, sess.registerAttachment(ac))
85+ require.True(t, sess.selectAttachmentTab(ac, "tab-b"))
86+
87+ before := ac.viewSnapshot()
88+ beforeEpoch := ac.output.currentEpoch()
89+ require.NoError(t, sess.runMutation(func() error {
90+ sess.mu.Lock()
91+ sess.tabs = sess.tabs[:1]
92+ sess.mu.Unlock()
93+ return nil
94+ }))
95+
96+ require.Same(t, sess.tabs[0], sess.tabForAttachment(ac))
97+ after := ac.viewSnapshot()
98+ require.Greater(t, after.revision, before.revision)
99+ require.Greater(t, ac.output.currentEpoch(), beforeEpoch)
100+ frame, err := ac.output.sideEffect([]byte("x"), 0)
101+ require.NoError(t, err)
102+ output, err := ports.UnmarshalOutput(frame.Payload)
103+ require.NoError(t, err)
104+ require.Equal(t, after.revision, output.ViewRevision)
105+ require.Equal(t, ac.output.currentEpoch(), output.Epoch)
106+}
107+
108+func TestPrepareRemovedTabViewRebasesOutputBeforeSideEffect(t *testing.T) {
109+ sess := registryTestSession()
110+ ac := registryTestAttachment(1)
111+ ac.size = domain.Size{Cols: 80, Rows: 23}
112+ ac.output = newOutputStateStream()
113+ ac.output.attachment = ac
114+ require.True(t, sess.registerAttachment(ac))
115+ require.True(t, sess.selectAttachmentTab(ac, "tab-b"))
116+
117+ before := ac.viewSnapshot()
118+ beforeEpoch := ac.output.currentEpoch()
119+ sess.mu.Lock()
120+ sess.prepareAttachmentViewsForRemovedTabLocked(sess.tabs[1], 1)
121+ sess.tabs = sess.tabs[:1]
122+ sess.mu.Unlock()
123+
124+ after := ac.viewSnapshot()
125+ require.Equal(t, domain.TabStableID("tab-a"), after.tabID)
126+ require.Equal(t, domain.PaneStableID(""), after.paneID)
127+ require.Equal(t, before.revision+1, after.revision)
128+ require.Greater(t, ac.output.currentEpoch(), beforeEpoch)
129+
130+ frame, err := ac.output.sideEffect([]byte("replacement"), 0)
131+ require.NoError(t, err)
132+ output, err := ports.UnmarshalOutput(frame.Payload)
133+ require.NoError(t, err)
134+ require.Equal(t, after.revision, output.ViewRevision)
135+ require.NotEqual(t, beforeEpoch, output.Epoch)
136+ require.Equal(t, ac.output.currentEpoch(), output.Epoch)
137+}
138+
76 func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) {139 func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) {
77 sess := registryTestSession()140 sess := registryTestSession()
78 ac := registryTestAttachment(1)141 ac := registryTestAttachment(1)
A internal/usecase/daemon/attachment_render_test.go +113 -0
@@ -0,0 +1,113 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "sync"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+type blockedAttachmentTransport struct {
16+ entered chan struct{}
17+ release chan struct{}
18+ sends chan ports.Frame
19+ blocked atomic.Bool
20+ once sync.Once
21+}
22+
23+func (t *blockedAttachmentTransport) Send(frame ports.Frame) error {
24+ if frame.Type == ports.MsgOutput && t.blocked.Load() {
25+ t.once.Do(func() { close(t.entered) })
26+ <-t.release
27+ }
28+ t.sends <- frame
29+ return nil
30+}
31+func (*blockedAttachmentTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
32+func (*blockedAttachmentTransport) Close() error { return nil }
33+
34+func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) {
35+ pty, releasePTY := newBlockingPTY(t)
36+ defer releasePTY()
37+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
38+ firstTransport, _ := newCapturingTransport(t)
39+ sess, first, err := d.route(ports.Hello{
40+ Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work",
41+ Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1},
42+ }, firstTransport)
43+ require.NoError(t, err)
44+ secondTransport, _ := newCapturingTransport(t)
45+ _, second, err := d.route(ports.Hello{
46+ Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work",
47+ Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2},
48+ }, secondTransport)
49+ require.NoError(t, err)
50+
51+ tb := sess.tabs[0]
52+ tb.mu.Lock()
53+ contentBefore := tb.size
54+ tb.mu.Unlock()
55+ firstSize := first.size
56+ secondRevision := second.viewSnapshot().revision
57+ secondEpoch := second.output.currentEpoch()
58+
59+ rc := sess.renderCoordinator()
60+ lease := rc.attachmentLease(second)
61+ require.True(t, rc.markAttachmentReady(lease))
62+ token := sess.attachmentToken(second, secondTransport)
63+ token.lease = lease
64+ ticket, admitted := second.beginAttachmentEffect(token)
65+ require.True(t, admitted)
66+ token.effect = ticket
67+ require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 120, Rows: 50}))
68+ ticket.End()
69+
70+ tb.mu.Lock()
71+ require.Equal(t, contentBefore, tb.size)
72+ tb.mu.Unlock()
73+ require.Equal(t, firstSize, first.size)
74+ require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size)
75+ require.Equal(t, secondRevision+1, second.viewSnapshot().revision)
76+ require.Greater(t, second.output.currentEpoch(), secondEpoch)
77+}
78+
79+func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) {
80+ pty, releasePTY := newBlockingPTY(t)
81+ defer releasePTY()
82+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
83+ firstTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
84+ sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, firstTransport)
85+ require.NoError(t, err)
86+ secondTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)}
87+ _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}}, secondTransport)
88+ require.NoError(t, err)
89+ rc := sess.renderCoordinator()
90+ for _, ac := range sess.snapshotAttachments() {
91+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
92+ }
93+ firstTransport.blocked.Store(true)
94+
95+ sess.tabs[0].focusedPane().screen.Write([]byte("shared"))
96+ rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"})
97+ rc.fireCurrent(false)
98+ select {
99+ case <-firstTransport.entered:
100+ case <-time.After(time.Second):
101+ t.Fatal("first attachment did not reach blocked send")
102+ }
103+ select {
104+ case frame := <-secondTransport.sends:
105+ require.Equal(t, ports.MsgOutput, frame.Type)
106+ out, err := ports.UnmarshalOutput(frame.Payload)
107+ require.NoError(t, err)
108+ require.True(t, out.Full, "fan-out peers must send a fresh first frame even after another attachment acknowledges shared damage")
109+ case <-time.After(time.Second):
110+ t.Fatal("second attachment waited for first attachment transport")
111+ }
112+ close(firstTransport.release)
113+}
M internal/usecase/daemon/client.go +113 -93
@@ -36,12 +36,9 @@ type attachedClient struct {
36 tr ports.Transport36 tr ports.Transport
37 transportIncarnation uint6437 transportIncarnation uint64
38 output *outputStateStream38 output *outputStateStream
39- // screenOutput is allocated only for proxied attachments and shares output's39+ overlays *overlayRuntime
40- // state-number/ACK window. Both streams are serialized by sendMu.40+ overlayOnce sync.Once
41- screenOutput *structuredOutputStream41+ clientID [16]byte
42- overlays *overlayRuntime
43- overlayOnce sync.Once
44- clientID [16]byte
45 // connectionGeneration is the wire-facing capability generation. attachmentEffects is42 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
46 // the linearization gate for every attachment-bound observable operation. An attachment43 // the linearization gate for every attachment-bound observable operation. An attachment
47 // transition freezes and drains this gate before changing either generation44 // transition freezes and drains this gate before changing either generation
@@ -55,10 +52,7 @@ type attachedClient struct {
55 // credential instead of consuming it.52 // credential instead of consuming it.
56 resumeClaimToken uint6453 resumeClaimToken uint64
57 parked bool54 parked bool
58- // proxied is negotiated once by Hello and remains immutable for this55+ echoAck atomic.Uint64
59- // attachment, including across transport resume.
60- proxied bool
61- echoAck atomic.Uint64
62 // prepareFailureFallback prevents a direct fallback paint from recursively56 // prepareFailureFallback prevents a direct fallback paint from recursively
63 // reporting the same failed prepare through its notice repaint. It is only57 // reporting the same failed prepare through its notice repaint. It is only
64 // needed while no render coordinator is installed.58 // needed while no render coordinator is installed.
@@ -69,13 +63,6 @@ type attachedClient struct {
69 pipelineCache composeCacheInput63 pipelineCache composeCacheInput
70 pipelineScratch composeCacheInput64 pipelineScratch composeCacheInput
71 renderScratch renderCaptureScratch // only touched while sendMu is held65 renderScratch renderCaptureScratch // only touched while sendMu is held
72- // proxyCapture retains the last captured proxy frame for damage-aware
73- // updates. It is attachment-owned and only touched while sendMu is held.
74- proxyCapture proxyCapture
75- // sessionMeta is the last authoritative metadata snapshot successfully sent
76- // on a proxied attachment. It is only read or written while sendMu is held.
77- sessionMeta ports.SessionMeta
78- sessionMetaSent bool
79 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so66 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
80 // snapshots cannot leak when an attachment switches tabs or sessions.67 // snapshots cannot leak when an attachment switches tabs or sessions.
81 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held68 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
@@ -289,50 +276,23 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
289 return tr != nil && ac.transportIs(tr)276 return tr != nil && ac.transportIs(tr)
290 }277 }
291 278
292-func (ac *attachedClient) ackOutputState(state uint64) {279+func (ac *attachedClient) ackOutputState(epoch, state uint64) {
293 ac.sendMu.Lock()280 ac.sendMu.Lock()
294 defer ac.sendMu.Unlock()281 defer ac.sendMu.Unlock()
295- ac.output.ack(state)282+ if ac.output != nil {
296-}283+ ac.output.ack(epoch, state)
297-
298-// ensureScreenOutput is called while sendMu owns the attachment. Production
299-// constructors allocate this at attachment creation; the lazy path keeps
300-// hand-built test attachments faithful when they set proxied after creation.
301-func (ac *attachedClient) ensureScreenOutput() *structuredOutputStream {
302- if ac == nil || !ac.proxied || ac.output == nil {
303- return nil
304- }
305- if ac.screenOutput == nil {
306- ac.screenOutput = newStructuredOutputStream(ac.output)
307- }
308- return ac.screenOutput
309-}
310-
311-// discardProxyCapture abandons a candidate that was not emitted. The next
312-// capture clones the authoritative screen once, avoiding replaying pending
313-// scroll damage onto an already-captured failed candidate.
314-func (ac *attachedClient) discardProxyCapture() {
315- if ac != nil && ac.proxied {
316- ac.proxyCapture = proxyCapture{}
317 }284 }
318 }285 }
319 286
320-// rebaseOutput resets both wire representations while retaining the shared287+// rebaseOutput resets only this attachment's output representations. Callers
321-// state-number chain. Callers hold sendMu (or the activation barrier).288+// hold sendMu (or the activation barrier).
322 func (ac *attachedClient) rebaseOutput() {289 func (ac *attachedClient) rebaseOutput() {
323 if ac == nil {290 if ac == nil {
324 return291 return
325 }292 }
326- if ac.proxied {
327- ac.ensureScreenOutput()
328- }
329 if ac.output != nil {293 if ac.output != nil {
330 ac.output.rebase()294 ac.output.rebase()
331 }295 }
332- if ac.screenOutput != nil {
333- ac.screenOutput.rebase()
334- }
335- ac.proxyCapture = proxyCapture{}
336 }296 }
337 297
338 var errTransportReplaced = errors.New("client transport was replaced")298 var errTransportReplaced = errors.New("client transport was replaced")
@@ -384,6 +344,31 @@ func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportS
384 return err344 return err
385 }345 }
386 346
347+// sendExpectedSideEffectForAttachment captures the output epoch, view revision,
348+// and window while sendMu is held. A resize or handoff therefore cannot put a
349+// side-effect frame from the old attachment state onto the new transport state.
350+func (ac *attachedClient) sendExpectedSideEffectForAttachment(expected transportSnapshot, data []byte, echoAck uint64, ticket *attachmentEffectTicket) error {
351+ ac.sendMu.Lock()
352+ defer ac.sendMu.Unlock()
353+ if ticket == nil {
354+ return errAttachmentTransition
355+ }
356+ if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
357+ return errAttachmentTransition
358+ }
359+ ac.output.lockView()
360+ defer ac.output.unlockView()
361+ frame, err := ac.output.sideEffectLocked(data, echoAck)
362+ if err == nil {
363+ err = expected.transport.Send(frame)
364+ }
365+ if err != nil {
366+ ticket.reportTransportFailure(expected)
367+ }
368+ ticket.endTransportSend()
369+ return err
370+}
371+
387 // boundedSend sends f to ac with a deadline watchdog: if the send (including372 // boundedSend sends f to ac with a deadline watchdog: if the send (including
388 // waiting on sendMu behind a wedged paint) does not complete within373 // waiting on sendMu behind a wedged paint) does not complete within
389 // detachNotifyTimeout, the transport is force-closed, failing the in-flight374 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -413,24 +398,37 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
413 }398 }
414 399
415 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {400 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
416- frame := ac.output.sideEffect(b, ac.echoAck.Load())
417 expected := ac.transportSnapshot()401 expected := ac.transportSnapshot()
418 if expected.transport == nil {402 if expected.transport == nil {
419 return nil, errors.New("client transport is nil")403 return nil, errors.New("client transport is nil")
420 }404 }
421- ac.sendMu.Lock()
422- if !ac.transportSnapshotCurrent(expected) {
423- ac.sendMu.Unlock()
424- return expected.transport, errTransportReplaced
425- }
426 if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok {405 if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok {
427- err := owned.SendSynchronous(frame)406+ ac.sendMu.Lock()
428- ac.sendMu.Unlock()407+ defer ac.sendMu.Unlock()
408+ if !ac.transportSnapshotCurrent(expected) {
409+ return expected.transport, errTransportReplaced
410+ }
411+ ac.output.lockView()
412+ defer ac.output.unlockView()
413+ frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
414+ if err == nil {
415+ err = owned.SendSynchronous(frame)
416+ }
429 return expected.transport, err417 return expected.transport, err
430 }418 }
431- ac.sendMu.Unlock()
432 return d.boundedSendWith(expected.transport, func() error {419 return d.boundedSendWith(expected.transport, func() error {
433- return ac.sendExpectedTransport(expected, frame)420+ ac.sendMu.Lock()
421+ defer ac.sendMu.Unlock()
422+ if !ac.transportSnapshotCurrent(expected) {
423+ return errTransportReplaced
424+ }
425+ ac.output.lockView()
426+ defer ac.output.unlockView()
427+ frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
428+ if err != nil {
429+ return err
430+ }
431+ return expected.transport.Send(frame)
434 })432 })
435 }433 }
436 434
@@ -523,7 +521,6 @@ type attachClientOptions struct {
523 clientID [16]byte521 clientID [16]byte
524 resumeCapable bool522 resumeCapable bool
525 maxOutputInFlight uint8523 maxOutputInFlight uint8
526- proxied bool
527 }524 }
528 525
529 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {526 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
@@ -567,14 +564,12 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size,
567 tr: tr,564 tr: tr,
568 output: output,565 output: output,
569 size: sz,566 size: sz,
567+ view: attachmentView{windowRows: sz.Rows, windowSet: true},
570 clientID: opts.clientID,568 clientID: opts.clientID,
571 resumeCapable: opts.resumeCapable,569 resumeCapable: opts.resumeCapable,
572 resumeToken: resumeToken,570 resumeToken: resumeToken,
573- proxied: opts.proxied,
574- }
575- if opts.proxied {
576- ac.screenOutput = newStructuredOutputStream(output)
577 }571 }
572+ output.attachment = ac
578 ac.initOverlays()573 ac.initOverlays()
579 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)574 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
580 return ac575 return ac
@@ -648,6 +643,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
648 d.paint(entry, attachment, fanoutReset, lease)643 d.paint(entry, attachment, fanoutReset, lease)
649 } else if len(attachments) > 1 {644 } else if len(attachments) > 1 {
650 go d.paint(entry, attachment, fanoutReset, lease)645 go d.paint(entry, attachment, fanoutReset, lease)
646+
651 } else {647 } else {
652 d.paint(entry, attachment, fanoutReset, lease)648 d.paint(entry, attachment, fanoutReset, lease)
653 }649 }
@@ -709,8 +705,8 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
709 token.ac.captureFrames = nil705 token.ac.captureFrames = nil
710 token.ac.sendMu.Unlock()706 token.ac.sendMu.Unlock()
711 }707 }
712- if proxy, ok := token.sess.(*proxySession); ok {708+ if _, ok := token.sess.(*proxySession); ok {
713- d.firstProxyPaintWithLease(proxy, token.ac, token.lease)709+ // Remote ordinary output arrives independently after the proxy Welcome.
714 return true710 return true
715 }711 }
716 sess, ok := localSession(token.sess)712 sess, ok := localSession(token.sess)
@@ -721,15 +717,14 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
721 return true717 return true
722 }718 }
723 719
724-// firstPaint guarantees the freshly attached client sees the full screen: if720+// firstPaint guarantees the freshly attached client sees the full screen. The
725-// the tab size differs from the client's it resizes first, then immediately721+// session content geometry is fixed by the first valid Hello; an attachment's
726-// emits a full redraw. It is retained for direct test/headless setup; active722+// window is rendered without resizing shared tabs or PTYs.
727-// attachment transitions use firstPaintForTransition with their exact lease.
728 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {723 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
729 d.firstPaintWithLease(sess, ac, clientSize, nil)724 d.firstPaintWithLease(sess, ac, clientSize, nil)
730 }725 }
731 726
732-func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) {727+func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain.Size, lease *attachmentLease) {
733 // Global notices raised while nothing was attached surface on this client.728 // Global notices raised while nothing was attached surface on this client.
734 // Drained before the early return below so a session without an active tab729 // Drained before the early return below so a session without an active tab
735 // cannot swallow the queue.730 // cannot swallow the queue.
@@ -738,29 +733,14 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
738 if tb == nil {733 if tb == nil {
739 return734 return
740 }735 }
741- tb.mu.Lock()
742- wsz := tb.size
743- tb.mu.Unlock()
744-
745- outerResizeAccepted := false
746- if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) {
747- if lease == nil {
748- outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize)
749- } else {
750- outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true)
751- }
752- }
753 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)736 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
754- // Activation can synchronously resize a retained floating pane. An accepted737+ // Activation still performs attachment-local warmup, but it must not turn a
755- // synchronous outer request already includes that pane and emits the reset,738+ // new window into a shared session resize.
756- // but activation still performs its warmup work.739+ d.ensureFloatingWarm(sess, tb)
757- activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease)740+ if lease == nil {
758- if !outerResizeAccepted && !activationResized {741+ d.invalidateRenderNow(sess, ac, true, "client.go")
759- if lease == nil {742+ } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
760- d.invalidateRenderNow(sess, ac, true, "client.go")743+ rc.fireCurrentForLease(lease)
761- } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
762- rc.fireCurrentForLease(lease)
763- }
764 }744 }
765 }745 }
766 746
@@ -814,6 +794,46 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
814 d.requestTransactionalResize(sess, ac, sz, false)794 d.requestTransactionalResize(sess, ac, sz, false)
815 }795 }
816 796
797+// resizeAttachmentForLease changes only the sender's window/view. Session tab
798+// geometry and PTY sizes are deliberately outside this mutation boundary.
799+// Callers hold the attachment effect admission; output state is rebased before
800+// the targeted redraw so every subsequent frame starts a new epoch.
801+func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {
802+ if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() {
803+ return false
804+ }
805+ ac := token.ac
806+ ac.sendMu.Lock()
807+ if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) {
808+ ac.sendMu.Unlock()
809+ return false
810+ }
811+ if ac.size == size {
812+ ac.sendMu.Unlock()
813+ return false
814+ }
815+ ac.size = size
816+ view := ac.viewSnapshot()
817+ view.windowRows = size.Rows
818+ view.windowSet = true
819+ view.windowTop = 0
820+ view.revision++
821+ ac.publishView(view)
822+ ac.pipelineCache = composeCacheInput{}
823+ ac.pipelineScratch = composeCacheInput{}
824+ ac.sendMu.Unlock()
825+
826+ entry := token.sess
827+ // Rendering is attachment-scoped. Run it independently of the connection
828+ // reader so a blocked transport cannot hold session dispatch or peers.
829+ if proxy, ok := entry.(*proxySession); ok {
830+ _, _ = proxy.resize(size)
831+ return true
832+ }
833+ go d.paint(entry, ac, true, token.lease)
834+ return true
835+}
836+
817 // handleClientNotice maps the closed client-event enum to daemon-owned notice837 // handleClientNotice maps the closed client-event enum to daemon-owned notice
818 // content. routingMu makes ownership validation and toast mutation one atomic838 // content. routingMu makes ownership validation and toast mutation one atomic
819 // attachment-routing operation: attachment publication also takes routingMu839 // attachment-routing operation: attachment publication also takes routingMu
M internal/usecase/daemon/client_frame_routing.go +19 -26
@@ -102,11 +102,7 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
102 }102 }
103 case ports.MsgResize:103 case ports.MsgResize:
104 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {104 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {
105- if sess, ok := localSession(token.sess); ok {105+ d.resizeAttachmentForLease(token, rz.Size)
106- d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false)
107- } else if proxy, ok := token.sess.(*proxySession); ok {
108- d.resizeProxyForLease(proxy, token.ac, token.lease, rz.Size)
109- }
110 }106 }
111 case ports.MsgTheme:107 case ports.MsgTheme:
112 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {108 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
@@ -129,24 +125,22 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
129 }125 }
130 case ports.MsgAck:126 case ports.MsgAck:
131 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {127 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
132- d.ackOutput(token, ack.AckedStateNum)128+ d.ackOutput(token, ack.Epoch, ack.State)
133 }129 }
134 case ports.MsgOutputResetRequest:130 case ports.MsgOutputResetRequest:
135 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {131 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
136 d.resetOutput(token)132 d.resetOutput(token)
137 }133 }
138 case ports.MsgCommand:134 case ports.MsgCommand:
139- if token.ac.proxied {135+ request, derr := ports.UnmarshalCommandRequest(f.Payload)
140- request, derr := ports.UnmarshalCommandRequest(f.Payload)136+ if derr != nil {
141- if derr != nil {137+ return false
142- return false138+ }
143- }139+ result := d.executeAttachedCommand(token, request)
144- result := d.executeAttachedCommand(token, request)140+ if err := token.sendControl(frameCommandResult(result)); err != nil {
145- if err := token.sendControl(frameCommandResult(result)); err != nil {141+ token.endAttachmentEffect()
146- token.endAttachmentEffect()142+ d.detachOnAttachmentSendError(token, token.transport.transport)
147- d.detachOnAttachmentSendError(token, token.transport.transport)143+ return true
148- return true
149- }
150 }144 }
151 case ports.MsgPing:145 case ports.MsgPing:
152 if err := token.sendControl(framePong()); err != nil {146 if err := token.sendControl(framePong()); err != nil {
@@ -246,30 +240,29 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool {
246 // transport revalidation under that lock rejects a link replaced while waiting.240 // transport revalidation under that lock rejects a link replaced while waiting.
247 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {241 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
248 ac := token.ac242 ac := token.ac
249- if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() {243+ if ac == nil || token.effect == nil || token.effect.ended.Load() {
250 return false244 return false
251 }245 }
252 ac.sendMu.Lock()246 ac.sendMu.Lock()
253- if token.effect.ended.Load() || !ac.proxied || !token.attachmentCurrent() || ac.output == nil {247+ if token.effect.ended.Load() || !token.attachmentCurrent() || ac.output == nil {
254 ac.sendMu.Unlock()248 ac.sendMu.Unlock()
255 return false249 return false
256 }250 }
257 ac.rebaseOutput()251 ac.rebaseOutput()
252+ ac.pipelineCache = composeCacheInput{}
253+ ac.pipelineScratch = composeCacheInput{}
258 ac.sendMu.Unlock()254 ac.sendMu.Unlock()
259-255+ go d.paint(token.sess, ac, true, token.lease)
260- rc := token.sess.core().coordinator.Load()256+ return true
261- return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{
262- class: invalidateUrgent, reset: true, producer: "output-reset-request",
263- })
264 }257 }
265 258
266-func (d *Daemon) ackOutput(token attachmentConnectionToken, state uint64) bool {259+func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) bool {
267 ac := token.ac260 ac := token.ac
268 if token.effect == nil || token.effect.ended.Load() {261 if token.effect == nil || token.effect.ended.Load() {
269 return false262 return false
270 }263 }
271 ac.sendMu.Lock()264 ac.sendMu.Lock()
272- ac.output.ack(state)265+ ac.output.ack(epoch, state)
273 ac.sendMu.Unlock()266 ac.sendMu.Unlock()
274 if rc := token.sess.core().coordinator.Load(); rc != nil {267 if rc := token.sess.core().coordinator.Load(); rc != nil {
275 rc.notifyAckForLease(token.lease)268 rc.notifyAckForLease(token.lease)
M internal/usecase/daemon/clipboard.go +9 -6
@@ -271,12 +271,15 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
271 if !beginClipboardOwnerSend(item.owner, ticket, expected) {271 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
272 return errAttachmentTransition272 return errAttachmentTransition
273 }273 }
274- frame := ac.output.sideEffect(item.seq, ac.echoAck.Load())274+ ac.output.lockView()
275- var err error275+ defer ac.output.unlockView()
276- if owned {276+ frame, err := ac.output.sideEffectLocked(item.seq, ac.echoAck.Load())
277- err = ownedTransport.SendSynchronous(frame)277+ if err == nil {
278- } else {278+ if owned {
279- err = expected.transport.Send(frame)279+ err = ownedTransport.SendSynchronous(frame)
280+ } else {
281+ err = expected.transport.Send(frame)
282+ }
280 }283 }
281 if err != nil {284 if err != nil {
282 ticket.reportTransportFailure(expected)285 ticket.reportTransportFailure(expected)
M internal/usecase/daemon/daemon.go +8 -19
@@ -64,7 +64,8 @@ func normalizeOutputWindow(window uint8) uint8 {
64 64
65 const defaultResumeParkGrace = 15 * time.Minute65 const defaultResumeParkGrace = 15 * time.Minute
66 66
67-// defaultSize is used when a client's Hello carries no valid dimensions.67+// defaultSize is retained for headless layout helpers that have no client
68+// window; Hello routing rejects invalid dimensions instead of using it.
68 var defaultSize = domain.Size{Cols: 80, Rows: 24}69 var defaultSize = domain.Size{Cols: 80, Rows: 24}
69 70
70 type Daemon struct {71 type Daemon struct {
@@ -73,7 +74,7 @@ type Daemon struct {
73 stopped map[string]stoppedSession74 stopped map[string]stoppedSession
74 // creating reserves names while durable creation I/O runs without mu.75 // creating reserves names while durable creation I/O runs without mu.
75 creating map[string]struct{}76 creating map[string]struct{}
76- // proxyConstructions serializes remote IntentAttach handshakes by structured77+ // proxyConstructions serializes remote IntentAttach handshakes by remote
77 // key. Entries are guarded by mu and never retain it across dial or link I/O.78 // key. Entries are guarded by mu and never retain it across dial or link I/O.
78 proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction79 proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction
79 nextID uint6480 nextID uint64
@@ -1142,16 +1143,6 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
1142 d.clientGone(sess, ac, tr, false)1143 d.clientGone(sess, ac, tr, false)
1143 return1144 return
1144 }1145 }
1145- if ac.proxied {
1146- meta, ok := sess.sessionMetaSnapshotFor(ac)
1147- metaFrame, metaErr := frameSessionMeta(meta)
1148- if !ok || metaErr != nil || ac.sendExpectedTransportForAttachment(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil {
1149- postWelcomeTicket.End()
1150- d.clientGone(sess, ac, tr, false)
1151- return
1152- }
1153- ac.markSessionMetaSent(meta)
1154- }
1155 if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {1146 if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
1156 postWelcomeTicket.End()1147 postWelcomeTicket.End()
1157 // The attachment was detached while Welcome was in flight; never let1148 // The attachment was detached while Welcome was in flight; never let
@@ -1193,7 +1184,6 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size,
1193 clientID: h.ClientID,1184 clientID: h.ClientID,
1194 resumeCapable: true,1185 resumeCapable: true,
1195 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),1186 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1196- proxied: h.Proxied,
1197 }1187 }
1198 ac := d.prepareAttachedClientLocked(tr, sz, opts)1188 ac := d.prepareAttachedClientLocked(tr, sz, opts)
1199 d.mu.Unlock()1189 d.mu.Unlock()
@@ -1267,7 +1257,7 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1267 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {1257 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
1268 sz := h.Size1258 sz := h.Size
1269 if !sz.Valid() {1259 if !sz.Valid() {
1270- sz = defaultSize1260+ return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1271 }1261 }
1272 term := terminalEnv{TrueColor: h.TrueColor}1262 term := terminalEnv{TrueColor: h.TrueColor}
1273 1263
@@ -1317,7 +1307,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1317 return nil, nil, err1307 return nil, nil, err
1318 }1308 }
1319 }1309 }
1320-
1321 d.mu.Lock()1310 d.mu.Lock()
1322 // Shutdown/create interlock: once shutdown has begun (last session removed,1311 // Shutdown/create interlock: once shutdown has begun (last session removed,
1323 // or shutdownAll started) no new session may be created and no attach may1312 // or shutdownAll started) no new session may be created and no attach may
@@ -1339,7 +1328,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1339 }1328 }
1340 cwd := d.dirOrHome(stopped.cwd)1329 cwd := d.dirOrHome(stopped.cwd)
1341 var err error1330 var err error
1342- sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames)1331+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
1343 if err != nil {1332 if err != nil {
1344 d.mu.Unlock()1333 d.mu.Unlock()
1345 return nil, nil, err1334 return nil, nil, err
@@ -1350,7 +1339,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1350 1339
1351 case ports.IntentEphemeral:1340 case ports.IntentEphemeral:
1352 name := d.allocEphemeralNameLocked()1341 name := d.allocEphemeralNameLocked()
1353- sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env, h.Proxied)1342+ sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env)
1354 if err != nil {1343 if err != nil {
1355 d.mu.Unlock()1344 d.mu.Unlock()
1356 return nil, nil, err1345 return nil, nil, err
@@ -1371,7 +1360,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1371 d.mu.Unlock()1360 d.mu.Unlock()
1372 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}1361 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
1373 }1362 }
1374- sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env, h.Proxied)1363+ sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env)
1375 if err != nil {1364 if err != nil {
1376 d.mu.Unlock()1365 d.mu.Unlock()
1377 return nil, nil, err1366 return nil, nil, err
@@ -1389,7 +1378,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1389 }1378 }
1390 cwd := d.dirOrHome(stopped.cwd)1379 cwd := d.dirOrHome(stopped.cwd)
1391 var err error1380 var err error
1392- sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames)1381+ sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames)
1393 if err != nil {1382 if err != nil {
1394 d.mu.Unlock()1383 d.mu.Unlock()
1395 return nil, nil, err1384 return nil, nil, err
M internal/usecase/daemon/daemon_test.go +41 -1
@@ -574,6 +574,46 @@ func tabCount(sess *session) int {
574 574
575 // --- handshake --------------------------------------------------------------575 // --- handshake --------------------------------------------------------------
576 576
577+func TestRouteRejectsInvalidTerminalSizeBeforeResumeMutation(t *testing.T) {
578+ tests := []struct {
579+ name string
580+ size domain.Size
581+ }{
582+ {name: "zero columns", size: domain.Size{Cols: 0, Rows: 24}},
583+ {name: "zero rows", size: domain.Size{Cols: 80, Rows: 0}},
584+ }
585+ for _, tt := range tests {
586+ t.Run(tt.name, func(t *testing.T) {
587+ pty, release := newBlockingPTY(t)
588+ defer release()
589+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
590+
591+ transport := &closeTrackingTransport{}
592+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport)
593+ require.NoError(t, err)
594+ token := ac.resumeToken
595+ d.clientGone(sess, ac, transport, false)
596+
597+ d.mu.Lock()
598+ parked := d.parked[token]
599+ d.mu.Unlock()
600+ require.NotNil(t, parked)
601+
602+ invalid := helloResumeCapable(ports.IntentResume, "work", token)
603+ invalid.Size = tt.size
604+ _, _, err = d.route(invalid, &closeTrackingTransport{})
605+ var protocolErr *protoErr
606+ require.ErrorAs(t, err, &protocolErr)
607+ require.Equal(t, ports.ErrInternal, protocolErr.code)
608+
609+ d.mu.Lock()
610+ require.Same(t, parked, d.parked[token], "invalid resume must not consume the parked attachment")
611+ d.mu.Unlock()
612+ require.Same(t, transport, ac.transport(), "invalid resume must not bind a replacement transport")
613+ })
614+ }
615+}
616+
577 func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {617 func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
578 done := make(chan struct{})618 done := make(chan struct{})
579 go func() {619 go func() {
@@ -651,7 +691,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
651 output := awaitFrame(t, tr.sends, ports.MsgOutput)691 output := awaitFrame(t, tr.sends, ports.MsgOutput)
652 first, err := ports.UnmarshalOutput(output.Payload)692 first, err := ports.UnmarshalOutput(output.Payload)
653 require.NoError(t, err)693 require.NoError(t, err)
654- require.Zero(t, first.BaseStateNum, "the first post-Welcome frame is full")694+ require.Zero(t, first.Base, "the first post-Welcome frame is full")
655 tr.finish()695 tr.finish()
656 awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion")696 awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion")
657 requireNoCoordinatorOutputFrame(t, tr.sends)697 requireNoCoordinatorOutputFrame(t, tr.sends)
M internal/usecase/daemon/durable_recovery_test.go +1 -1
@@ -812,7 +812,7 @@ func TestAttachWaitsForRestore(t *testing.T) {
812 812
813 result := make(chan error, 1)813 result := make(chan error, 1)
814 go func() {814 go func() {
815- _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)815+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name, Size: domain.Size{Cols: 80, Rows: 24}}, nil)
816 result <- err816 result <- err
817 }()817 }()
818 818
M internal/usecase/daemon/floating_test.go +1 -1
@@ -543,7 +543,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
543 repaint := awaitFrame(t, sends, ports.MsgOutput)543 repaint := awaitFrame(t, sends, ports.MsgOutput)
544 output, err := ports.UnmarshalOutput(repaint.Payload)544 output, err := ports.UnmarshalOutput(repaint.Payload)
545 require.NoError(t, err)545 require.NoError(t, err)
546- require.Zero(t, output.BaseStateNum, "visible EOF must force a dependency-free full repaint")546+ require.Zero(t, output.Base, "visible EOF must force a dependency-free full repaint")
547 require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup")547 require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup")
548 require.NotNil(t, floating)548 require.NotNil(t, floating)
549 })549 })
M internal/usecase/daemon/input.go +2 -17
@@ -16,7 +16,7 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
16 // Do not acknowledge client-side echo prediction here: input has only been16 // Do not acknowledge client-side echo prediction here: input has only been
17 // accepted/routed, not necessarily echoed by the PTY and incorporated into a17 // accepted/routed, not necessarily echoed by the PTY and incorporated into a
18 // rendered screen state. Until prediction is implemented against rendered18 // rendered screen state. Until prediction is implemented against rendered
19- // output state, EchoAck must remain conservative.19+ // output state, Echo must remain conservative.
20 d.handleInput(sess, ac, data)20 d.handleInput(sess, ac, data)
21 }21 }
22 22
@@ -486,16 +486,6 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
486 case keys.ActionOpenPalette:486 case keys.ActionOpenPalette:
487 h.d.enterPalette(sess, h.ac)487 h.d.enterPalette(sess, h.ac)
488 case keys.ActionJumpAttention:488 case keys.ActionJumpAttention:
489- if !proxiedJumpSearchesOtherSessions(h.ac.proxied) {
490- _ = sess.runMutation(func() error {
491- if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTabForDispatch(h.ac, idx) {
492- h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil)
493- h.d.invalidateRender(sess, h.ac, true, "input.go")
494- }
495- return nil
496- })
497- return
498- }
499 if effect == nil {489 if effect == nil {
500 if err := h.d.jumpAttention(sess, h.ac); err != nil {490 if err := h.d.jumpAttention(sess, h.ac); err != nil {
501 h.d.reportError(sess, err)491 h.d.reportError(sess, err)
@@ -550,12 +540,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
550 }540 }
551 541
552 func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {542 func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {
553- var err error543+ err := h.d.focusDir(sess, h.ac, dir, effect)
554- if h.ac.proxied {
555- err = h.d.focusDirProxied(sess, h.ac, dir)
556- } else {
557- err = h.d.focusDir(sess, h.ac, dir, effect)
558- }
559 if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) {544 if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) {
560 h.d.reportError(sess, err)545 h.d.reportError(sess, err)
561 }546 }
M internal/usecase/daemon/input_test.go +1 -1
@@ -214,7 +214,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
214 second := awaitFrame(t, sends, ports.MsgOutput)214 second := awaitFrame(t, sends, ports.MsgOutput)
215 secondOutput, err := ports.UnmarshalOutput(second.Payload)215 secondOutput, err := ports.UnmarshalOutput(second.Payload)
216 require.NoError(t, err)216 require.NoError(t, err)
217- require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first")217+ require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first")
218 terminal.Write(secondOutput.Data)218 terminal.Write(secondOutput.Data)
219 require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")219 require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")
220 }220 }
M internal/usecase/daemon/output_state_stream.go +211 -25
@@ -1,8 +1,11 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "errors"
5+ "sync"
4 "sync/atomic"6 "sync/atomic"
5 7
8+ "github.com/bnema/vev/internal/domain"
6 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
7 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
8 )11 )
@@ -11,19 +14,50 @@ import (
11 // attached client. Each incremental output is rendered from the preceding14 // attached client. Each incremental output is rendered from the preceding
12 // emitted frame and therefore remains pipelineable without waiting for an15 // emitted frame and therefore remains pipelineable without waiting for an
13 // ACK. Cumulative ACKs advance the bounded paint window.16 // ACK. Cumulative ACKs advance the bounded paint window.
14-// A reset frame is the only dependency-free output and advertises state 0 as17+// A reset frame starts a new epoch, clears the dependency chain, and advertises
15-// its base.18+// state 0 as its base.
16 //19 //
17 // Callers serialize access with attachedClient.sendMu. A prepared transaction20 // Callers serialize access with attachedClient.sendMu. A prepared transaction
18 // must be sent or committed as a no-op before the next transaction is prepared.21 // must be sent or committed as a no-op before the next transaction is prepared.
19 type outputStateStream struct {22 type outputStateStream struct {
20 renderer *renderer.Renderer23 renderer *renderer.Renderer
24+ epoch uint64
21 next uint6425 next uint64
22 acked uint6426 acked uint64
27+ generation uint64
23 maxOutstanding uint6428 maxOutstanding uint64
24 outstandingAtomic atomic.Uint6429 outstandingAtomic atomic.Uint64
25 maxOutstandingAtomic atomic.Uint6430 maxOutstandingAtomic atomic.Uint64
26 forceSnapshot bool31 forceSnapshot bool
32+ initialized bool
33+ attachment *attachedClient
34+ stateMu sync.Mutex
35+}
36+
37+// lockView serializes an attachment's view publication with every output
38+// state transition. It deliberately does not take sendMu: callers that hold
39+// architecture locks can publish a view without reversing the paint path's
40+// sendMu -> session lock order.
41+func (s *outputStateStream) lockView() {
42+ if s == nil {
43+ return
44+ }
45+ if s.attachment == nil {
46+ s.stateMu.Lock()
47+ return
48+ }
49+ s.attachment.viewMu.Lock()
50+}
51+
52+func (s *outputStateStream) unlockView() {
53+ if s == nil {
54+ return
55+ }
56+ if s.attachment == nil {
57+ s.stateMu.Unlock()
58+ return
59+ }
60+ s.attachment.viewMu.Unlock()
27 }61 }
28 62
29 func newOutputStateStream(windowSize ...uint8) *outputStateStream {63 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
@@ -33,6 +67,7 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
33 }67 }
34 stream := &outputStateStream{68 stream := &outputStateStream{
35 renderer: renderer.New(renderer.Capabilities{}),69 renderer: renderer.New(renderer.Capabilities{}),
70+ epoch: 1,
36 maxOutstanding: uint64(window),71 maxOutstanding: uint64(window),
37 }72 }
38 stream.maxOutstandingAtomic.Store(uint64(window))73 stream.maxOutstandingAtomic.Store(uint64(window))
@@ -41,39 +76,79 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
41 }76 }
42 77
43 type preparedOutput struct {78 type preparedOutput struct {
44- stream *outputStateStream79+ stream *outputStateStream
45- draw renderer.PreparedDraw80+ draw renderer.PreparedDraw
46- next uint6481+ base uint64
47- data []byte82+ next uint64
48- reset bool83+ epoch uint64
49- attempted bool84+ generation uint64
85+ connectionGeneration uint64
86+ viewRevision uint64
87+ size domain.Size
88+ data []byte
89+ reset bool
90+ attempted bool
91+ sent bool
50 }92 }
51 93
52 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {94 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
53- reset = reset || s.forceSnapshot95+ s.lockView()
96+ defer s.unlockView()
97+ if s.epoch == 0 {
98+ s.epoch = 1
99+ }
100+ if reset && s.initialized {
101+ s.rebaseLocked()
102+ }
103+ reset = reset || s.forceSnapshot || !s.initialized
104+ if s.next == ^uint64(0) {
105+ return nil, errors.New("output state number exhausted")
106+ }
54 draw, err := s.renderer.Prepare(frame, damage, reset)107 draw, err := s.renderer.Prepare(frame, damage, reset)
55 if err != nil {108 if err != nil {
56 return nil, err109 return nil, err
57 }110 }
111+ s.initialized = true
112+ generation, epoch, viewRevision := s.fenceLocked()
58 return &preparedOutput{113 return &preparedOutput{
59- stream: s,114+ stream: s,
60- draw: draw,115+ draw: draw,
61- next: s.next + 1,116+ base: s.next,
62- data: draw.Bytes(),117+ next: s.next + 1,
63- reset: reset,118+ epoch: epoch,
119+ generation: s.generation,
120+ connectionGeneration: generation,
121+ viewRevision: viewRevision,
122+ size: domain.Size{Cols: frame.Width, Rows: frame.Height},
123+ data: draw.Bytes(),
124+ reset: reset,
64 }, nil125 }, nil
65 }126 }
66 127
67 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {128 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {
68- if p.attempted {129+ if p == nil || p.stream == nil || p.attempted {
69 return nil130 return nil
70 }131 }
132+ p.stream.lockView()
133+ defer p.stream.unlockView()
71 p.attempted = true134 p.attempted = true
72- base := p.stream.next135+ if !p.currentLocked() {
136+ return nil
137+ }
138+ if len(data) == 0 {
139+ p.commit()
140+ p.sent = true
141+ return nil
142+ }
143+ base := p.base
73 if p.reset {144 if p.reset {
74 base = 0145 base = 0
75 }146 }
76- out := frameOutputState(data, base, p.next, echoAck)147+ out, err := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset)
148+ if err != nil {
149+ p.stream.forceSnapshot = true
150+ return err
151+ }
77 if err := send(out); err != nil {152 if err := send(out); err != nil {
78 p.stream.forceSnapshot = true153 p.stream.forceSnapshot = true
79 return err154 return err
@@ -81,15 +156,22 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
81 p.commit()156 p.commit()
82 p.stream.next = p.next157 p.stream.next = p.next
83 p.stream.publishOutstanding()158 p.stream.publishOutstanding()
159+ p.sent = true
84 return nil160 return nil
85 }161 }
86 162
87 func (p *preparedOutput) commitNoSend() {163 func (p *preparedOutput) commitNoSend() {
88- if p == nil || p.attempted || len(p.data) != 0 {164+ if p == nil || p.stream == nil || p.attempted {
89 return165 return
90 }166 }
167+ p.stream.lockView()
168+ defer p.stream.unlockView()
91 p.attempted = true169 p.attempted = true
170+ if !p.currentLocked() || len(p.data) != 0 {
171+ return
172+ }
92 p.commit()173 p.commit()
174+ p.sent = true
93 }175 }
94 176
95 func (p *preparedOutput) commit() {177 func (p *preparedOutput) commit() {
@@ -97,12 +179,85 @@ func (p *preparedOutput) commit() {
97 p.stream.forceSnapshot = false179 p.stream.forceSnapshot = false
98 }180 }
99 181
100-func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {182+func (s *outputStateStream) currentEpoch() uint64 {
101- return frameOutputState(data, 0, 0, echoAck)183+ s.lockView()
184+ defer s.unlockView()
185+ return s.currentEpochLocked()
186+}
187+
188+func (s *outputStateStream) currentEpochLocked() uint64 {
189+ if s.epoch == 0 {
190+ s.epoch = 1
191+ }
192+ return s.epoch
193+}
194+
195+func (s *outputStateStream) fenceLocked() (uint64, uint64, uint64) {
196+ if s == nil {
197+ return 0, 1, 0
198+ }
199+ generation, revision := uint64(0), uint64(0)
200+ if s.attachment != nil {
201+ generation = s.attachment.connectionGeneration.Load()
202+ revision = s.attachment.view.revision
203+ }
204+ return generation, s.currentEpochLocked(), revision
205+}
206+
207+func (s *outputStateStream) preparedCurrentLocked(generation, epoch, viewRevision, base uint64) bool {
208+ if s == nil || s.currentEpochLocked() != epoch || s.generation != generation || s.next != base {
209+ return false
210+ }
211+ if s.attachment == nil {
212+ return true
213+ }
214+ return s.attachment.view.revision == viewRevision
215+}
216+
217+func (p *preparedOutput) currentLocked() bool {
218+ return p != nil && p.stream != nil && p.stream.preparedCurrentLocked(p.generation, p.epoch, p.viewRevision, p.base) &&
219+ (p.stream.attachment == nil || p.stream.attachment.connectionGeneration.Load() == p.connectionGeneration)
220+}
221+
222+func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) (ports.Frame, error) {
223+ payload, err := ports.MarshalOutput(ports.Output{
224+ Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision,
225+ Size: size, Full: full, Data: data,
226+ })
227+ if err != nil {
228+ return ports.Frame{}, err
229+ }
230+ return ports.Frame{Type: ports.MsgOutput, Payload: payload}, nil
231+}
232+
233+// sideEffect builds an output frame without advancing the state stream. When
234+// an attachment is present, callers must hold its sendMu; attachment-bound
235+// sends use sideEffectLocked after validating their transport incarnation.
236+func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {
237+ s.lockView()
238+ defer s.unlockView()
239+ return s.sideEffectLocked(data, echoAck)
102 }240 }
103 241
104-func (s *outputStateStream) ack(state uint64) {242+// sideEffectLocked requires the attachment view lock for both frame creation
105- if state > s.next || state <= s.acked {243+// and transport emission. Keeping that lock across the send prevents a view
244+// publication from overtaking a frame that was fenced just before it.
245+func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports.Frame, error) {
246+ _, epoch, viewRevision := s.fenceLocked()
247+ size := domain.Size{Cols: 1, Rows: 1}
248+ if s != nil && s.attachment != nil {
249+ size = s.attachment.size
250+ }
251+ return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
252+}
253+
254+func (s *outputStateStream) ack(epoch, state uint64) {
255+ if s == nil {
256+ return
257+ }
258+ s.lockView()
259+ defer s.unlockView()
260+ if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
106 return261 return
107 }262 }
108 s.acked = state263 s.acked = state
@@ -110,11 +265,33 @@ func (s *outputStateStream) ack(state uint64) {
110 }265 }
111 266
112 func (s *outputStateStream) rebase() {267 func (s *outputStateStream) rebase() {
113- s.acked = s.next268+ if s == nil {
114- s.publishOutstanding()269+ return
270+ }
271+ s.lockView()
272+ defer s.unlockView()
273+ s.rebaseLocked()
274+}
275+
276+// rebaseLocked is called while the attachment view lock is held. Publishing
277+// the next view revision takes this path before making that revision visible.
278+func (s *outputStateStream) rebaseLocked() {
279+ if s == nil {
280+ return
281+ }
282+ epoch := s.currentEpochLocked()
283+ if epoch == ^uint64(0) {
284+ return
285+ }
286+ s.epoch = epoch + 1
287+ s.next = 0
288+ s.acked = 0
289+ s.generation++
115 s.maxOutstandingAtomic.Store(s.maxOutstanding)290 s.maxOutstandingAtomic.Store(s.maxOutstanding)
291+ s.publishOutstanding()
116 s.renderer.Reset()292 s.renderer.Reset()
117 s.forceSnapshot = true293 s.forceSnapshot = true
294+ s.initialized = false
118 }295 }
119 296
120 func (s *outputStateStream) publishOutstanding() {297 func (s *outputStateStream) publishOutstanding() {
@@ -130,6 +307,8 @@ func (s *outputStateStream) syncCapacityLocked() {
130 if s == nil {307 if s == nil {
131 return308 return
132 }309 }
310+ s.lockView()
311+ defer s.unlockView()
133 s.maxOutstandingAtomic.Store(s.maxOutstanding)312 s.maxOutstandingAtomic.Store(s.maxOutstanding)
134 s.publishOutstanding()313 s.publishOutstanding()
135 }314 }
@@ -141,4 +320,11 @@ func (s *outputStateStream) atCapacity() bool {
141 return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()320 return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()
142 }321 }
143 322
144-func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }323+func (s *outputStateStream) outstanding() uint64 {
324+ if s == nil {
325+ return 0
326+ }
327+ s.lockView()
328+ defer s.unlockView()
329+ return s.next - s.acked
330+}
M internal/usecase/daemon/output_state_stream_test.go +153 -12
@@ -5,6 +5,7 @@ import (
5 "runtime"5 "runtime"
6 "testing"6 "testing"
7 7
8+ "github.com/bnema/vev/internal/domain"
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
10 "github.com/bnema/vev/pkg/vt"11 "github.com/bnema/vev/pkg/vt"
@@ -20,9 +21,9 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
20 require.True(t, ok)21 require.True(t, ok)
21 firstOut, err := ports.UnmarshalOutput(first.Payload)22 firstOut, err := ports.UnmarshalOutput(first.Payload)
22 require.NoError(t, err)23 require.NoError(t, err)
23- require.Equal(t, uint64(0), firstOut.BaseStateNum)24+ require.Equal(t, uint64(0), firstOut.Base)
24- require.Equal(t, uint64(1), firstOut.NewStateNum)25+ require.Equal(t, uint64(1), firstOut.New)
25- require.Equal(t, uint64(7), firstOut.EchoAck)26+ require.Equal(t, uint64(7), firstOut.Echo)
26 27
27 secondFrame := firstFrame.Clone()28 secondFrame := firstFrame.Clone()
28 secondFrame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})29 secondFrame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
@@ -31,8 +32,8 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
31 require.True(t, ok)32 require.True(t, ok)
32 secondOut, err := ports.UnmarshalOutput(second.Payload)33 secondOut, err := ports.UnmarshalOutput(second.Payload)
33 require.NoError(t, err)34 require.NoError(t, err)
34- require.Equal(t, firstOut.NewStateNum, secondOut.BaseStateNum)35+ require.Equal(t, firstOut.New, secondOut.Base)
35- require.Equal(t, uint64(2), secondOut.NewStateNum)36+ require.Equal(t, uint64(2), secondOut.New)
36 require.Equal(t, uint64(2), stream.outstanding())37 require.Equal(t, uint64(2), stream.outstanding())
37 }38 }
38 39
@@ -68,12 +69,148 @@ func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {
68 <-probed69 <-probed
69 require.True(t, stream.atCapacity())70 require.True(t, stream.atCapacity())
70 71
71- stream.ack(1)72+ stream.ack(1, 1)
72 require.False(t, stream.atCapacity())73 require.False(t, stream.atCapacity())
73 stream.rebase()74 stream.rebase()
74 require.False(t, stream.atCapacity())75 require.False(t, stream.atCapacity())
75 }76 }
76 77
78+func TestOutputStateStreamEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *testing.T) {
79+ frame := renderer.NewFrame(3, 1)
80+ fillOutputStateRows(frame, []string{"abc"})
81+ newAttachment := func() *outputStateStream {
82+ stream := newOutputStateStream()
83+ ac := &attachedClient{output: stream, size: domain.Size{Cols: 3, Rows: 1}}
84+ stream.attachment = ac
85+ return stream
86+ }
87+
88+ first := newAttachment()
89+ second := newAttachment()
90+ firstPrepared, err := first.prepare(frame, nil, true)
91+ require.NoError(t, err)
92+ var firstFrame ports.Frame
93+ require.NoError(t, firstPrepared.send(firstPrepared.data, 0, func(frame ports.Frame) error {
94+ firstFrame = frame
95+ return nil
96+ }))
97+ firstOutput, err := ports.UnmarshalOutput(firstFrame.Payload)
98+ require.NoError(t, err)
99+ require.Equal(t, uint64(1), firstOutput.Epoch)
100+ require.Equal(t, uint64(1), firstOutput.New)
101+ require.True(t, firstOutput.Full)
102+ first.ack(firstOutput.Epoch, firstOutput.New)
103+
104+ secondPrepared, err := second.prepare(frame, nil, true)
105+ require.NoError(t, err)
106+ require.NoError(t, secondPrepared.send(secondPrepared.data, 0, func(ports.Frame) error { return nil }))
107+ require.Equal(t, uint64(1), second.epoch)
108+ require.Equal(t, uint64(1), second.next)
109+ require.Equal(t, uint64(1), first.next)
110+
111+ changed := frame.Clone()
112+ changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
113+ pending, err := first.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
114+ require.NoError(t, err)
115+ first.rebase()
116+ require.Equal(t, uint64(2), first.epoch)
117+ require.Zero(t, first.next)
118+ require.Zero(t, first.acked)
119+ var staleSent bool
120+ require.NoError(t, pending.send(pending.data, 0, func(ports.Frame) error {
121+ staleSent = true
122+ return nil
123+ }))
124+ require.False(t, staleSent)
125+ require.Zero(t, first.next)
126+ first.ack(1, 1)
127+ require.Zero(t, first.acked, "an ACK from the retired epoch must not advance the new epoch")
128+
129+ reset, err := first.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
130+ require.NoError(t, err)
131+ var resetFrame ports.Frame
132+ require.NoError(t, reset.send(reset.data, 0, func(frame ports.Frame) error {
133+ resetFrame = frame
134+ return nil
135+ }))
136+ resetOutput, err := ports.UnmarshalOutput(resetFrame.Payload)
137+ require.NoError(t, err)
138+ require.Equal(t, uint64(2), resetOutput.Epoch)
139+ require.Zero(t, resetOutput.Base)
140+ require.Equal(t, uint64(1), resetOutput.New)
141+ require.True(t, resetOutput.Full)
142+}
143+
144+func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
145+ for _, tt := range []struct {
146+ name string
147+ change func(*attachedClient)
148+ }{
149+ {name: "connection generation", change: func(ac *attachedClient) { ac.connectionGeneration.Add(1) }},
150+ {name: "view revision", change: func(ac *attachedClient) {
151+ ac.viewMu.Lock()
152+ ac.view.revision++
153+ ac.viewMu.Unlock()
154+ }},
155+ } {
156+ t.Run(tt.name, func(t *testing.T) {
157+ stream := newOutputStateStream()
158+ ac := &attachedClient{output: stream, size: domain.Size{Cols: 3, Rows: 1}}
159+ stream.attachment = ac
160+ frame := renderer.NewFrame(3, 1)
161+ fillOutputStateRows(frame, []string{"abc"})
162+ initial, err := stream.prepare(frame, nil, true)
163+ require.NoError(t, err)
164+ require.NoError(t, initial.send(initial.data, 0, func(ports.Frame) error { return nil }))
165+ changed := frame.Clone()
166+ changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
167+ pending, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
168+ require.NoError(t, err)
169+ tt.change(ac)
170+ var sent bool
171+ require.NoError(t, pending.send(pending.data, 0, func(ports.Frame) error {
172+ sent = true
173+ return nil
174+ }))
175+ require.False(t, sent)
176+ require.Equal(t, uint64(1), stream.next)
177+
178+ retry, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false)
179+ require.NoError(t, err)
180+ require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error { return nil }))
181+ require.Equal(t, uint64(2), stream.next)
182+ })
183+ }
184+}
185+
186+func TestOutputStateAckRequiresCurrentEpoch(t *testing.T) {
187+ stream := newOutputStateStream()
188+ stream.next = 2
189+ stream.ack(2, 1)
190+ require.Zero(t, stream.acked, "a stale epoch must not retire output state")
191+ stream.ack(1, 1)
192+ require.Equal(t, uint64(1), stream.acked)
193+ stream.ack(1, 3)
194+ require.Equal(t, uint64(1), stream.acked, "a future state must not advance the ACK window")
195+}
196+
197+func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) {
198+ stream := newOutputStateStream()
199+ stream.ack(1, 0)
200+ beforeEpoch, beforeNext, beforeAck := stream.epoch, stream.next, stream.acked
201+ frame, err := stream.sideEffect([]byte("pty"), 9)
202+ require.NoError(t, err)
203+ out, err := ports.UnmarshalOutput(frame.Payload)
204+ require.NoError(t, err)
205+ require.Equal(t, beforeEpoch, out.Epoch)
206+ require.Zero(t, out.Base)
207+ require.Zero(t, out.New)
208+ require.False(t, out.Full)
209+ require.Equal(t, uint64(9), out.Echo)
210+ require.Equal(t, beforeNext, stream.next)
211+ require.Equal(t, beforeAck, stream.acked)
212+}
213+
77 func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {214 func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
78 stream := newOutputStateStream()215 stream := newOutputStateStream()
79 initial := renderer.NewFrame(3, 1)216 initial := renderer.NewFrame(3, 1)
@@ -108,8 +245,8 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
108 }))245 }))
109 out, err := ports.UnmarshalOutput(sent.Payload)246 out, err := ports.UnmarshalOutput(sent.Payload)
110 require.NoError(t, err)247 require.NoError(t, err)
111- require.Zero(t, out.BaseStateNum, "retry after ambiguous send failure must be dependency-free")248+ require.Zero(t, out.Base, "retry after ambiguous send failure must be dependency-free")
112- require.Equal(t, uint64(2), out.NewStateNum, "state numbers remain monotonic across rebases")249+ require.Equal(t, uint64(2), out.New, "state numbers remain monotonic across rebases")
113 require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")250 require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")
114 require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error {251 require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error {
115 t.Fatal("completed output sent twice")252 t.Fatal("completed output sent twice")
@@ -150,7 +287,7 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
150 require.NoError(t, err)287 require.NoError(t, err)
151 require.True(t, ok)288 require.True(t, ok)
152 mustApplyOutput(t, client, first)289 mustApplyOutput(t, client, first)
153- stream.ack(1)290+ stream.ack(1, 1)
154 291
155 scrolled := renderer.NewFrame(4, 3)292 scrolled := renderer.NewFrame(4, 3)
156 fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "})293 fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "})
@@ -197,7 +334,11 @@ func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAc
197 if reset {334 if reset {
198 base = 0335 base = 0
199 }336 }
200- return frameOutputState(data, base, stream.next, echoAck)337+ frame, err := frameOutputState(data, base, stream.next, echoAck)
338+ if err != nil {
339+ panic(err)
340+ }
341+ return frame
201 }342 }
202 343
203 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {344 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
@@ -234,7 +375,7 @@ func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing
234 require.True(t, ok)375 require.True(t, ok)
235 resizeOutput, err := ports.UnmarshalOutput(resized.Payload)376 resizeOutput, err := ports.UnmarshalOutput(resized.Payload)
236 require.NoError(t, err)377 require.NoError(t, err)
237- require.Zero(t, resizeOutput.BaseStateNum, "resize must emit the one reset frame")378+ require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame")
238 screen.ClearDamage()379 screen.ClearDamage()
239 380
240 _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0)381 _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0)
@@ -247,7 +388,7 @@ func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing
247 require.True(t, ok)388 require.True(t, ok)
248 damageOutput, err := ports.UnmarshalOutput(damaged.Payload)389 damageOutput, err := ports.UnmarshalOutput(damaged.Payload)
249 require.NoError(t, err)390 require.NoError(t, err)
250- require.Equal(t, resizeOutput.NewStateNum, damageOutput.BaseStateNum, "later damage must remain incremental")391+ require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental")
251 }392 }
252 393
253 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {394 func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
M internal/usecase/daemon/output_state_sync_test.go +22 -13
@@ -81,26 +81,35 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
81 require.NoError(t, err)81 require.NoError(t, err)
82 require.Same(t, sess, routed)82 require.Same(t, sess, routed)
83 83
84+ // route publishes membership only; mark the attachment ready before the
85+ // direct pipeline paints so coordinator admission is deterministic.
86+ rc := sess.renderCoordinator()
87+ require.NotNil(t, rc)
88+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
89+
84 sess.tabs[0].focusedPane().screen.Write([]byte("A"))90 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
85 d.paint(sess, ac, false, nil)91 d.paint(sess, ac, false, nil)
86 first := awaitFrame(t, tr.sends, ports.MsgOutput)92 first := awaitFrame(t, tr.sends, ports.MsgOutput)
87 out, err := ports.UnmarshalOutput(first.Payload)93 out, err := ports.UnmarshalOutput(first.Payload)
88 require.NoError(t, err)94 require.NoError(t, err)
89- require.Equal(t, uint64(1), out.NewStateNum)95+ require.Equal(t, uint64(1), out.New)
90 96
91- // Before the MsgAck, the renderer has already advanced along the ordered97+ sess.tabs[0].focusedPane().screen.Write([]byte("\rB"))
92- // output dependency chain, so an unchanged repaint is a no-op.
93 d.paint(sess, ac, false, nil)98 d.paint(sess, ac, false, nil)
94- requireNoOutputFrame(t, tr.sends)99+ updated := awaitFrame(t, tr.sends, ports.MsgOutput)
95-100+ out, err = ports.UnmarshalOutput(updated.Payload)
96- tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}101+ require.NoError(t, err)
102+ require.Equal(t, uint64(2), out.New)
103+ // Both states were emitted before the MsgAck; the ordered dependency chain
104+ // must therefore remain valid without waiting for renderer acknowledgement.
105+ tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: out.Epoch, State: out.New})}
97 require.NoError(t, tr.Close())106 require.NoError(t, tr.Close())
98 d.runConnLoop(ac)107 d.runConnLoop(ac)
99 108
100 // The production MsgAck path retires retained states without moving the109 // The production MsgAck path retires retained states without moving the
101 // renderer baseline backward.110 // renderer baseline backward.
102 d.paint(sess, ac, false, nil)111 d.paint(sess, ac, false, nil)
103- requireNoOutputFrame(t, tr.sends)112+ require.Empty(t, tr.sends)
104 }113 }
105 114
106 func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {115 func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {
@@ -146,7 +155,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
146 client := vt.NewScreen(80, 25)155 client := vt.NewScreen(80, 25)
147 d.paint(sess, ac, true, nil)156 d.paint(sess, ac, true, nil)
148 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))157 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
149- ac.ackOutputState(first.NewStateNum)158+ ac.ackOutputState(first.Epoch, first.New)
150 159
151 // Preserve the frame after one scroll while inducing a real VT scroll160 // Preserve the frame after one scroll while inducing a real VT scroll
152 // damage event before each production paint. On main, both unacknowledged161 // damage event before each production paint. On main, both unacknowledged
@@ -226,7 +235,7 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
226 d.paint(sess, ac, false, nil)235 d.paint(sess, ac, false, nil)
227 awaitFrame(t, sends, ports.MsgOutput)236 awaitFrame(t, sends, ports.MsgOutput)
228 237
229- ac.ackOutputState(out.NewStateNum)238+ ac.ackOutputState(out.Epoch, out.New)
230 d.paint(sess, ac, false, nil)239 d.paint(sess, ac, false, nil)
231 requireNoOutputFrame(t, sends)240 requireNoOutputFrame(t, sends)
232 }241 }
@@ -244,8 +253,8 @@ func TestRawTerminalSideEffectDoesNotEnterFullOutputWindow(t *testing.T) {
244 require.Len(t, sends, 1)253 require.Len(t, sends, 1)
245 out, err := ports.UnmarshalOutput(sends[0].Payload)254 out, err := ports.UnmarshalOutput(sends[0].Payload)
246 require.NoError(t, err)255 require.NoError(t, err)
247- require.Zero(t, out.BaseStateNum)256+ require.Zero(t, out.Base)
248- require.Zero(t, out.NewStateNum)257+ require.Zero(t, out.New)
249 }258 }
250 259
251 func TestOwnedSynchronousSideEffectSkipsOuterWatchdog(t *testing.T) {260 func TestOwnedSynchronousSideEffectSkipsOuterWatchdog(t *testing.T) {
@@ -295,7 +304,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
295 d.paint(sess, ac, true, nil)304 d.paint(sess, ac, true, nil)
296 client := vt.NewScreen(80, 24)305 client := vt.NewScreen(80, 24)
297 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))306 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
298- ac.ackOutputState(initial.NewStateNum)307+ ac.ackOutputState(initial.Epoch, initial.New)
299 308
300 d.sessWg.Add(1)309 d.sessWg.Add(1)
301 pane.onExit = func() {}310 pane.onExit = func() {}
@@ -349,7 +358,7 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
349 frame := awaitFrame(t, sends, ports.MsgOutput)358 frame := awaitFrame(t, sends, ports.MsgOutput)
350 client := vt.NewScreen(120, 24)359 client := vt.NewScreen(120, 24)
351 out := mustApplyOutput(t, client, frame)360 out := mustApplyOutput(t, client, frame)
352- require.Zero(t, out.BaseStateNum)361+ require.Zero(t, out.Base)
353 require.Contains(t, screenLineText(client, 0), "1")362 require.Contains(t, screenLineText(client, 0), "1")
354 require.Contains(t, screenLineText(client, 23), "work")363 require.Contains(t, screenLineText(client, 23), "work")
355 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)364 require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size)
M internal/usecase/daemon/palette_test.go +2 -2
@@ -373,7 +373,7 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) {
373 firstPaint := awaitFrame(t, sends, ports.MsgOutput)373 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
374 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)374 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
375 require.NoError(t, err)375 require.NoError(t, err)
376- require.Zero(t, firstOutput.BaseStateNum, "stopped-session first paint must reset moving output state")376+ require.Zero(t, firstOutput.Base, "stopped-session first paint must reset moving output state")
377 awaitFrame(t, sends, ports.MsgOutput)377 awaitFrame(t, sends, ports.MsgOutput)
378 }378 }
379 379
@@ -679,7 +679,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
679 firstPaint := awaitFrame(t, sends, ports.MsgOutput)679 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
680 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)680 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
681 require.NoError(t, err)681 require.NoError(t, err)
682- require.Zero(t, firstOutput.BaseStateNum, "new-session first paint must reset moving output state")682+ require.Zero(t, firstOutput.Base, "new-session first paint must reset moving output state")
683 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)683 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)
684 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)684 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)
685 require.NoError(t, err)685 require.NoError(t, err)
M internal/usecase/daemon/performance_benchmark_test.go +28 -299
@@ -218,9 +218,9 @@ func TestProxyANSIBenchmarkFixtures(t *testing.T) {
218 state := newProxyANSIApplyState(t)218 state := newProxyANSIApplyState(t)
219 219
220 ack, reset, changed := state.apply(ports.Output{220 ack, reset, changed := state.apply(ports.Output{
221- BaseStateNum: 0,221+ Base: 0,
222- NewStateNum: 1,222+ New: 1,
223- Data: fixture.data,223+ Data: fixture.data,
224 })224 })
225 225
226 require.Equal(t, uint64(1), ack)226 require.Equal(t, uint64(1), ack)
@@ -251,21 +251,21 @@ func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset,
251 data := append([]byte(nil), out.Data...)251 data := append([]byte(nil), out.Data...)
252 s.mu.Lock()252 s.mu.Lock()
253 defer s.mu.Unlock()253 defer s.mu.Unlock()
254- if out.NewStateNum == 0 {254+ if out.New == 0 {
255 s.screen.Write(data)255 s.screen.Write(data)
256 return 0, false, len(data) != 0256 return 0, false, len(data) != 0
257 }257 }
258- if out.BaseStateNum == 0 {258+ if out.Base == 0 {
259- if !s.awaitingReset && out.NewStateNum <= s.appliedState {259+ if !s.awaitingReset && out.New <= s.appliedState {
260 return 0, false, false260 return 0, false, false
261 }261 }
262 s.screen = vt.NewScreen(s.screen.Frame.Width, s.screen.Frame.Height)262 s.screen = vt.NewScreen(s.screen.Frame.Width, s.screen.Frame.Height)
263 s.screen.Write(data)263 s.screen.Write(data)
264- s.appliedState = out.NewStateNum264+ s.appliedState = out.New
265 s.awaitingReset = false265 s.awaitingReset = false
266- return out.NewStateNum, false, true266+ return out.New, false, true
267 }267 }
268- if s.awaitingReset || out.BaseStateNum != s.appliedState || out.NewStateNum != out.BaseStateNum+1 {268+ if s.awaitingReset || out.Base != s.appliedState || out.New != out.Base+1 {
269 if !s.awaitingReset {269 if !s.awaitingReset {
270 s.awaitingReset = true270 s.awaitingReset = true
271 requestReset = true271 requestReset = true
@@ -273,8 +273,8 @@ func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset,
273 return 0, requestReset, false273 return 0, requestReset, false
274 }274 }
275 s.screen.Write(data)275 s.screen.Write(data)
276- s.appliedState = out.NewStateNum276+ s.appliedState = out.New
277- return out.NewStateNum, false, len(data) != 0277+ return out.New, false, len(data) != 0
278 }278 }
279 279
280 type proxyANSIBenchmarkFixture struct {280 type proxyANSIBenchmarkFixture struct {
@@ -306,277 +306,6 @@ func proxyANSIBenchmarkFixtures() []proxyANSIBenchmarkFixture {
306 }306 }
307 }307 }
308 308
309-type proxyPipelineFixture struct {
310- name string
311- newFrame func() renderer.Frame
312- damage []renderer.Damage
313- mutate func(renderer.Frame, int)
314-}
315-
316-func proxyPipelineFixtures() []proxyPipelineFixture {
317- const width, height = 120, 40
318- return []proxyPipelineFixture{
319- {
320- name: "one-cell",
321- newFrame: func() renderer.Frame { return renderer.NewFrame(width, height) },
322- damage: []renderer.Damage{{Kind: renderer.DamageText, X: 59, Y: 19, Width: 1, Height: 1}},
323- mutate: func(frame renderer.Frame, i int) {
324- cell := frame.At(59, 19)
325- cell.Rune = rune('a' + i%26)
326- frame.Set(59, 19, cell)
327- },
328- },
329- {
330- name: "full-line",
331- newFrame: func() renderer.Frame { return renderer.NewFrame(width, height) },
332- damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}},
333- mutate: func(frame renderer.Frame, i int) {
334- for x := range frame.Width {
335- frame.Set(x, 0, renderer.Cell{Rune: rune('a' + (x+i)%26), Style: renderer.DefaultStyle()})
336- }
337- },
338- },
339- {
340- name: "styled-line",
341- newFrame: func() renderer.Frame { return renderer.NewFrame(width, height) },
342- damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}},
343- mutate: func(frame renderer.Frame, i int) {
344- for fragment := range 12 {
345- style := renderer.DefaultStyle()
346- style.Bold = fragment%2 == 0
347- style.HasForegroundRGB = true
348- style.ForegroundRGB = renderer.RGB{R: uint8(fragment * 17), G: uint8(fragment * 13), B: uint8(fragment * 7)}
349- for x := fragment * 10; x < (fragment+1)*10; x++ {
350- frame.Set(x, 0, renderer.Cell{Rune: rune('a' + (fragment+i)%26), Style: style})
351- }
352- }
353- },
354- },
355- {
356- name: "full-width-scroll",
357- newFrame: func() renderer.Frame {
358- frame := renderer.NewFrame(width, height)
359- for y := range height {
360- for x, r := range performanceFullWidthRow(width, 0, y) {
361- frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
362- }
363- }
364- return frame
365- },
366- damage: []renderer.Damage{
367- {Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: width, Height: height, Count: 1},
368- {Kind: renderer.DamageText, X: 0, Y: height - 1, Width: width, Height: 1},
369- },
370- mutate: func(frame renderer.Frame, i int) {
371- frame.ScrollUp(0, frame.Height-1, 1)
372- for x := range frame.Width {
373- frame.Set(x, frame.Height-1, renderer.Cell{Rune: rune('a' + i%26), Style: renderer.DefaultStyle()})
374- }
375- },
376- },
377- }
378-}
379-
380-func TestProxyPipelineFixturesProduceEquivalentFrames(t *testing.T) {
381- for _, fixture := range proxyPipelineFixtures() {
382- t.Run(fixture.name, func(t *testing.T) {
383- ansiFrame := fixture.newFrame()
384- structuredFrame := ansiFrame.Clone()
385- ansiRenderer := renderer.New(renderer.Capabilities{})
386- ansiState := newProxyANSIApplyState(t)
387- ansiSender := proxyPipelineANSISender(ansiState)
388- initialANSI, err := ansiRenderer.Prepare(ansiFrame, nil, true)
389- require.NoError(t, err)
390- require.NoError(t, ansiSender(ports.Frame{
391- Type: ports.MsgOutput,
392- Payload: ports.MarshalOutput(ports.Output{
393- BaseStateNum: 0,
394- NewStateNum: 1,
395- Data: initialANSI.Bytes(),
396- }),
397- }))
398- initialANSI.Commit()
399-
400- structuredState := newProxyScreenState(frameSize(structuredFrame))
401- structuredStream := newStructuredOutputStream(newOutputStateStream())
402- initialStructured, err := structuredStream.prepare(structuredFrame, nil, cursorOut{}, true, 0)
403- require.NoError(t, err)
404- require.NoError(t, initialStructured.send(proxyPipelineStructuredSender(structuredState)))
405- require.Equal(t, uint64(1), ansiState.appliedState)
406- require.Equal(t, uint64(1), structuredState.stateNum)
407- requireProxyPipelineFramesEqual(t, ansiState.screen.Frame, structuredState.frame)
408-
409- for i := 0; i < 3; i++ {
410- fixture.mutate(ansiFrame, i)
411- fixture.mutate(structuredFrame, i)
412- ansiDraw, err := ansiRenderer.Prepare(ansiFrame, fixture.damage, false)
413- require.NoError(t, err)
414- next := ansiState.appliedState + 1
415- require.NoError(t, ansiSender(ports.Frame{
416- Type: ports.MsgOutput,
417- Payload: ports.MarshalOutput(ports.Output{
418- BaseStateNum: ansiState.appliedState,
419- NewStateNum: next,
420- Data: ansiDraw.Bytes(),
421- }),
422- }))
423- ansiDraw.Commit()
424-
425- structuredDraw, err := structuredStream.prepare(structuredFrame, fixture.damage, cursorOut{}, false, 0)
426- require.NoError(t, err)
427- require.NoError(t, structuredDraw.send(proxyPipelineStructuredSender(structuredState)))
428- require.Equal(t, next, ansiState.appliedState)
429- require.Equal(t, next, structuredState.stateNum)
430- requireProxyPipelineFramesEqual(t, ansiState.screen.Frame, structuredState.frame)
431- requireProxyPipelineFramesEqual(t, ansiFrame, ansiState.screen.Frame)
432- requireProxyPipelineFramesEqual(t, structuredFrame, structuredState.frame)
433- }
434- })
435- }
436-}
437-
438-func requireProxyPipelineFramesEqual(t *testing.T, want, got renderer.Frame) {
439- t.Helper()
440- require.Equal(t, want.Width, got.Width)
441- require.Equal(t, want.Height, got.Height)
442- for y := range want.Height {
443- for x := range want.Width {
444- require.Truef(t, want.At(x, y).Equal(got.At(x, y)), "cell %d,%d differs: want %+v got %+v", x, y, want.At(x, y), got.At(x, y))
445- }
446- }
447-}
448-
449-func proxyPipelineANSISender(state *proxyANSIApplyState) func(ports.Frame) error {
450- return func(frame ports.Frame) error {
451- if frame.Type != ports.MsgOutput {
452- return fmt.Errorf("ANSI benchmark sender received frame type %d", frame.Type)
453- }
454- out, err := ports.UnmarshalOutput(frame.Payload)
455- if err != nil {
456- return err
457- }
458- ack, reset, changed := state.apply(out)
459- if ack != out.NewStateNum || reset || !changed {
460- return fmt.Errorf("ANSI benchmark apply = ack %d, reset %t, changed %t; want ack %d", ack, reset, changed, out.NewStateNum)
461- }
462- return nil
463- }
464-}
465-
466-func proxyPipelineStructuredSender(state *proxyScreenState) func(ports.Frame) error {
467- return func(frame ports.Frame) error {
468- if frame.Type != ports.MsgScreenUpdate {
469- return fmt.Errorf("structured benchmark sender received frame type %d", frame.Type)
470- }
471- update, err := ports.UnmarshalScreenUpdate(frame.Payload)
472- if err != nil {
473- return err
474- }
475- if err := state.Apply(update); err != nil {
476- return err
477- }
478- generation := state.generation
479- if !state.AcknowledgeDamage(generation) {
480- return fmt.Errorf("structured benchmark damage acknowledgement failed at generation %d", generation)
481- }
482- return nil
483- }
484-}
485-
486-func BenchmarkProxyPipeline(b *testing.B) {
487- for _, fixture := range proxyPipelineFixtures() {
488- b.Run(fixture.name, func(b *testing.B) {
489- b.Run("ansi-pipeline", func(b *testing.B) {
490- benchmarkProxyANSIPipeline(b, fixture)
491- })
492- b.Run("structured-pipeline", func(b *testing.B) {
493- benchmarkProxyStructuredPipeline(b, fixture)
494- })
495- })
496- }
497-}
498-
499-func benchmarkProxyANSIPipeline(b *testing.B, fixture proxyPipelineFixture) {
500- b.Helper()
501- frame := fixture.newFrame()
502- ansiRenderer := renderer.New(renderer.Capabilities{})
503- ansiState := newProxyANSIApplyState(b)
504- ansiSender := proxyPipelineANSISender(ansiState)
505- initial, err := ansiRenderer.Prepare(frame, nil, true)
506- if err != nil {
507- b.Fatal(err)
508- }
509- if err := ansiSender(ports.Frame{
510- Type: ports.MsgOutput,
511- Payload: ports.MarshalOutput(ports.Output{
512- BaseStateNum: 0,
513- NewStateNum: 1,
514- Data: initial.Bytes(),
515- }),
516- }); err != nil {
517- b.Fatal(err)
518- }
519- initial.Commit()
520-
521- var wireBytes int
522- b.ReportAllocs()
523- b.ResetTimer()
524- for i := 0; i < b.N; i++ {
525- fixture.mutate(frame, i)
526- draw, err := ansiRenderer.Prepare(frame, fixture.damage, false)
527- if err != nil {
528- b.Fatal(err)
529- }
530- next := ansiState.appliedState + 1
531- payload := ports.MarshalOutput(ports.Output{
532- BaseStateNum: ansiState.appliedState,
533- NewStateNum: next,
534- Data: draw.Bytes(),
535- })
536- if err := ansiSender(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
537- b.Fatal(err)
538- }
539- draw.Commit()
540- wireBytes += len(payload)
541- }
542- b.StopTimer()
543- b.ReportMetric(float64(wireBytes)/float64(b.N), "wirebytes/op")
544-}
545-
546-func benchmarkProxyStructuredPipeline(b *testing.B, fixture proxyPipelineFixture) {
547- b.Helper()
548- frame := fixture.newFrame()
549- stream := newStructuredOutputStream(newOutputStateStream())
550- state := newProxyScreenState(frameSize(frame))
551- sender := proxyPipelineStructuredSender(state)
552- initial, err := stream.prepare(frame, nil, cursorOut{}, true, 0)
553- if err != nil {
554- b.Fatal(err)
555- }
556- if err := initial.send(sender); err != nil {
557- b.Fatal(err)
558- }
559-
560- var wireBytes, spans int
561- b.ReportAllocs()
562- b.ResetTimer()
563- for i := 0; i < b.N; i++ {
564- fixture.mutate(frame, i)
565- prepared, err := stream.prepare(frame, fixture.damage, cursorOut{}, false, 0)
566- if err != nil {
567- b.Fatal(err)
568- }
569- wireBytes += len(prepared.data)
570- spans += len(prepared.update.Spans)
571- if err := prepared.send(sender); err != nil {
572- b.Fatal(err)
573- }
574- }
575- b.StopTimer()
576- b.ReportMetric(float64(wireBytes)/float64(b.N), "wirebytes/op")
577- b.ReportMetric(float64(spans)/float64(b.N), "spans/op")
578-}
579-
580 func TestPerformanceFixtureCounters(t *testing.T) {309 func TestPerformanceFixtureCounters(t *testing.T) {
581 fixture := newPerformanceFixture(t, performanceConfig{})310 fixture := newPerformanceFixture(t, performanceConfig{})
582 311
@@ -751,9 +480,9 @@ func BenchmarkProxyANSIApply(b *testing.B) {
751 for b.Loop() {480 for b.Loop() {
752 next := state.appliedState + 1481 next := state.appliedState + 1
753 ack, reset, changed := state.apply(ports.Output{482 ack, reset, changed := state.apply(ports.Output{
754- BaseStateNum: state.appliedState,483+ Base: state.appliedState,
755- NewStateNum: next,484+ New: next,
756- Data: fixture.data,485+ Data: fixture.data,
757 })486 })
758 if ack != next || reset || !changed {487 if ack != next || reset || !changed {
759 b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next)488 b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next)
@@ -846,7 +575,7 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
846 fixture.resetMetrics()575 fixture.resetMetrics()
847 for b.Loop() {576 for b.Loop() {
848 run(fixture)577 run(fixture)
849- fixture.ac.ackOutputState(fixture.ac.output.next)578+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
850 }579 }
851 metrics := fixture.metrics()580 metrics := fixture.metrics()
852 if payload := fixture.output.lastPayload(); payload != nil {581 if payload := fixture.output.lastPayload(); payload != nil {
@@ -865,12 +594,12 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
865 func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) {594 func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) {
866 b.Helper()595 b.Helper()
867 fixture.d.enterCopyMode(fixture.sess, fixture.ac)596 fixture.d.enterCopyMode(fixture.sess, fixture.ac)
868- fixture.ac.ackOutputState(fixture.ac.output.next)597+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
869 b.ReportAllocs()598 b.ReportAllocs()
870 b.ResetTimer()599 b.ResetTimer()
871 for range b.N {600 for range b.N {
872 run(fixture)601 run(fixture)
873- fixture.ac.ackOutputState(fixture.ac.output.next)602+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
874 }603 }
875 b.StopTimer()604 b.StopTimer()
876 if fixture.searchMatches() == 0 {605 if fixture.searchMatches() == 0 {
@@ -1240,32 +969,32 @@ func TestLivePaintAllocationBudget(t *testing.T) {
1240 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})969 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
1241 allocs := testing.AllocsPerRun(20, func() {970 allocs := testing.AllocsPerRun(20, func() {
1242 fixture.paintLive()971 fixture.paintLive()
1243- fixture.ac.ackOutputState(fixture.ac.output.next)972+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
1244 })973 })
1245 require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")974 require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")
1246 })975 })
1247 }976 }
1248 }977 }
1249 978
1250-// TestCopyEnterAllocationBudget protects the attachment-aware baseline plus979+// TestCopyEnterAllocationBudget protects the byte-stream attachment baseline
1251-// 10%. Copy rendering borrows sealed VT history rows; allocating a copy per980+// plus 10%. Copy rendering borrows sealed VT history rows; allocating a copy
1252-// viewport row is a production regression even though capture is immutable.981+// per viewport row is a production regression even though capture is immutable.
1253 func TestCopyEnterAllocationBudget(t *testing.T) {982 func TestCopyEnterAllocationBudget(t *testing.T) {
1254 for _, tt := range []struct {983 for _, tt := range []struct {
1255 name string984 name string
1256 tabs, panes, max int985 tabs, panes, max int
1257 }{986 }{
1258- {name: "1tab-1pane", tabs: 1, panes: 1, max: 55},987+ {name: "1tab-1pane", tabs: 1, panes: 1, max: 66},
1259- {name: "1tab-4panes", tabs: 1, panes: 4, max: 55},988+ {name: "1tab-4panes", tabs: 1, panes: 4, max: 85},
1260- {name: "4tabs-1pane", tabs: 4, panes: 1, max: 60},989+ {name: "4tabs-1pane", tabs: 4, panes: 1, max: 71},
1261- {name: "4tabs-4panes", tabs: 4, panes: 4, max: 60},990+ {name: "4tabs-4panes", tabs: 4, panes: 4, max: 90},
1262- {name: "8tabs-1pane", tabs: 8, panes: 1, max: 68},991+ {name: "8tabs-1pane", tabs: 8, panes: 1, max: 79},
1263 } {992 } {
1264 t.Run(tt.name, func(t *testing.T) {993 t.Run(tt.name, func(t *testing.T) {
1265 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})994 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
1266 run := func() {995 run := func() {
1267 fixture.d.enterCopyMode(fixture.sess, fixture.ac)996 fixture.d.enterCopyMode(fixture.sess, fixture.ac)
1268- fixture.ac.ackOutputState(fixture.ac.output.next)997+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
1269 }998 }
1270 999
1271 // Always exercise and validate copy entry. The race detector adds1000 // Always exercise and validate copy entry. The race detector adds
@@ -1278,7 +1007,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) {
1278 }1007 }
1279 1008
1280 allocs := testing.AllocsPerRun(20, run)1009 allocs := testing.AllocsPerRun(20, run)
1281- require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the attachment-aware allocation baseline")1010+ require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the byte-stream attachment allocation baseline")
1282 })1011 })
1283 }1012 }
1284 }1013 }
M internal/usecase/daemon/picker_test.go +2 -2
@@ -915,7 +915,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
915 client := vt.NewScreen(80, 25)915 client := vt.NewScreen(80, 25)
916 d.firstPaint(source, ac, ac.size)916 d.firstPaint(source, ac, ac.size)
917 sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))917 sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
918- ac.ackOutputState(sourceOutput.NewStateNum)918+ ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New)
919 919
920 targetPane.screen.Write([]byte("TARGET"))920 targetPane.screen.Write([]byte("TARGET"))
921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
@@ -927,7 +927,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
927 d.backSession(source, ac)927 d.backSession(source, ac)
928 firstReset := awaitFrame(t, sends, ports.MsgOutput)928 firstReset := awaitFrame(t, sends, ports.MsgOutput)
929 out := mustApplyOutput(t, client, firstReset)929 out := mustApplyOutput(t, client, firstReset)
930- require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair")930+ require.Zero(t, out.Base, "the first target frame must be the reset, not an eventual repair")
931 frame := strings.Join(frameRows(client.Frame), "\n")931 frame := strings.Join(frameRows(client.Frame), "\n")
932 require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")932 require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
933 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")933 require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state")
D internal/usecase/daemon/proxied_contract_test.go +0 -353
@@ -1,353 +0,0 @@
1-package daemon
2-
3-import (
4- "sync"
5- "testing"
6-
7- "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/internal/usecase/layout"
10- "github.com/bnema/vev/pkg/renderer"
11- "github.com/bnema/vev/pkg/vt"
12- "github.com/stretchr/testify/require"
13-)
14-
15-func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) {
16- d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
17- contentSize := domain.Size{Cols: 24, Rows: 4}
18- hello := ports.Hello{
19- Version: ports.ProtocolVersion,
20- Intent: ports.IntentNew,
21- Proxied: true,
22- Name: "remote-work",
23- Size: contentSize,
24- MaxOutputInFlight: 4,
25- }
26- tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
27- defer release()
28-
29- var handlers sync.WaitGroup
30- handlers.Go(func() { d.handleConn(tr) })
31-
32- welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome)
33- welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
34- require.NoError(t, err)
35- require.Equal(t, ports.CapabilityResume|ports.CapabilityProxied, welcome.Capabilities)
36-
37- metaFrame := awaitFrame(t, sends, ports.MsgSessionMeta)
38- meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload)
39- require.NoError(t, err)
40- require.Equal(t, "remote-work", meta.SessionName)
41- require.Equal(t, uint16(0), meta.Active)
42- require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs)
43-
44- screenFrame := awaitFrame(t, sends, ports.MsgScreenUpdate)
45- update, err := ports.UnmarshalScreenUpdate(screenFrame.Payload)
46- require.NoError(t, err)
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")
56- }
57-
58- sess := firstSession(d)
59- require.NotNil(t, sess)
60- tb := sess.tabs[0]
61- tb.mu.Lock()
62- actualContentSize := tb.size
63- tb.mu.Unlock()
64- require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time")
65-
66- release()
67- handlers.Wait()
68-}
69-
70-func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) {
71- d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
72- viewport := domain.Size{Cols: 24, Rows: 6}
73- hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "ordinary", Size: viewport}
74- tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)})
75- defer release()
76-
77- var handlers sync.WaitGroup
78- handlers.Go(func() { d.handleConn(tr) })
79-
80- welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome)
81- welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload)
82- require.NoError(t, err)
83- require.Equal(t, ports.CapabilityResume, welcome.Capabilities)
84-
85- outputFrame := awaitTestValue(t, sends, "ordinary handshake produced no post-welcome frame")
86- require.NotEqual(t, ports.MsgSessionMeta, outputFrame.Type, "an ordinary attachment must never receive session metadata")
87- require.Equal(t, ports.MsgOutput, outputFrame.Type)
88- output, err := ports.UnmarshalOutput(outputFrame.Payload)
89- require.NoError(t, err)
90- require.Zero(t, output.BaseStateNum)
91- client := vt.NewScreen(viewport.Cols, viewport.Rows)
92- client.Write(output.Data)
93- require.Contains(t, screenLineText(client, 0), "1")
94- require.Contains(t, screenLineText(client, viewport.Rows-1), "ordinary")
95-
96- sess := firstSession(d)
97- require.NotNil(t, sess)
98- tb := sess.tabs[0]
99- tb.mu.Lock()
100- actualContentSize := tb.size
101- tb.mu.Unlock()
102- require.Equal(t, tabSize(viewport), actualContentSize)
103- requireNoOutputFrame(t, sends)
104-
105- release()
106- handlers.Wait()
107-}
108-
109-func TestDesiredCapturedCursorPublishesVisibleAbsoluteCursor(t *testing.T) {
110- visible := desiredCapturedCursor(capturedCursorInputs{
111- row: 2, col: 3, style: 0, visible: true, renderable: true,
112- content: domain.Rect{X: 4, Y: 5, Width: 8, Height: 4},
113- }, 1)
114- require.Equal(t, cursorOut{valid: true, row: 8, col: 7, style: 1, hasStyle: true}, visible)
115-
116- hidden := desiredCapturedCursor(capturedCursorInputs{
117- row: 2, col: 3, visible: false, renderable: true,
118- content: domain.Rect{X: 4, Y: 5, Width: 8, Height: 4},
119- }, 1)
120- require.Equal(t, cursorOut{hidden: true}, hidden)
121-}
122-
123-func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) {
124- d := newTestDaemon(t, nil, stubClock{})
125- client, sends := newCapturingTransport(t)
126- proxy, ac := newAttachedProxyFixture(t, d, client, newProxyTestTransport())
127- ac.proxied = true
128- ac.screenOutput = newStructuredOutputStream(ac.output)
129- require.True(t, applyTestScreenText(proxy, 0, 0, "remote"))
130-
131- ac.sendMu.Lock()
132- state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true})
133- require.True(t, ok)
134- composed := composeFrame(*state, composeCacheInput{})
135- require.True(t, d.emitFrame(proxy, ac, state, composed))
136-
137- metadata := awaitTestValue(t, sends, "proxied remote metadata was not sent")
138- require.Equal(t, ports.MsgSessionMeta, metadata.Type)
139- meta, err := ports.UnmarshalSessionMeta(metadata.Payload)
140- require.NoError(t, err)
141- require.Equal(t, "work", meta.SessionName)
142- require.Equal(t, ports.MsgScreenUpdate, awaitTestValue(t, sends, "proxied remote screen was not sent").Type)
143-}
144-
145-func TestProxiedPaintPublishesAbsoluteVisibleCursorStyle(t *testing.T) {
146- d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
147- ac.proxied = true
148- pane := testAttachmentTab(sess).focusedPane()
149- pane.mu.Lock()
150- pane.screen.Write([]byte("\x1b[3;6H\x1b[0 q"))
151- pane.mu.Unlock()
152-
153- d.paint(sess, ac, true, nil)
154- require.Equal(t, ports.MsgSessionMeta, awaitTestValue(t, sends, "proxied paint did not emit session metadata").Type)
155- frame := awaitTestValue(t, sends, "proxied paint did not emit structured screen update")
156- require.Equal(t, ports.MsgScreenUpdate, frame.Type)
157- update, err := ports.UnmarshalScreenUpdate(frame.Payload)
158- require.NoError(t, err)
159- require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind)
160- require.True(t, update.Cursor.Visible)
161- require.Equal(t, uint16(2), update.Cursor.Row)
162- require.Equal(t, uint16(5), update.Cursor.Col)
163- require.True(t, update.Cursor.StyleSet)
164- require.Zero(t, update.Cursor.Style)
165-}
166-
167-func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) {
168- paneFrame := renderer.NewFrame(8, 2)
169- fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"})
170- placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 2}}
171- state := capturedRenderState{
172- contentOnly: true,
173- reset: true,
174- layout: capturedTabLayout{
175- area: domain.Rect{Width: 8, Height: 2}, focus: "pane", placements: []layout.Placement{placement}, valid: true,
176- },
177- panes: []capturedPaneRenderState{{
178- id: "pane", frame: paneFrame, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
179- }},
180- bars: barState{status: statusSnapshot{session: "REMOTE-CHROME"}, bottomRight: "BOTTOM-CHROME"},
181- cursor: capturedCursorInputs{visible: true, renderable: true, content: placement.Content},
182- }
183-
184- proxied := composeFrame(state, composeCacheInput{})
185- require.Equal(t, []string{"CONTENT1", "CONTENT2"}, frameRows(proxied.frame))
186- require.Equal(t, 0, proxied.cursor.row)
187-
188- state.contentOnly = false
189- ordinary := composeFrame(state, composeCacheInput{})
190- require.Equal(t, 4, ordinary.frame.Height)
191- require.Equal(t, "CONTENT1", rowText(ordinary.frame.Row(1)))
192- require.Contains(t, rowText(ordinary.frame.Row(3)), "REMOTE-")
193- require.Equal(t, 1, ordinary.cursor.row)
194-}
195-
196-func TestProxyResizeUpdatesAttachmentViewport(t *testing.T) {
197- d := newTestDaemon(t, nil, stubClock{})
198- client, _ := newCapturingTransport(t)
199- proxy, ac := newAttachedProxyFixture(t, d, client, newProxyTestTransport())
200-
201- d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 24, Rows: 10})
202- require.Equal(t, domain.Size{Cols: 24, Rows: 10}, ac.size)
203-}
204-
205-func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) {
206- d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
207- ac.proxied = true
208- rc := d.attachCoordinator(sess, ac, true)
209- lease := rc.attachmentLease(ac)
210- require.NotNil(t, lease)
211-
212- want := domain.Size{Cols: 100, Rows: 12}
213- require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true))
214- tb := testAttachmentTab(sess)
215- tb.mu.Lock()
216- require.Equal(t, want, tb.size)
217- tb.mu.Unlock()
218- require.Equal(t, want, ac.size)
219-}
220-
221-func TestProxiedModeCannotChangeAcrossResume(t *testing.T) {
222- tests := []struct {
223- name string
224- initialProxied bool
225- resumedProxied bool
226- }{
227- {name: "proxied attachment cannot resume as ordinary", initialProxied: true, resumedProxied: false},
228- {name: "ordinary attachment cannot resume as proxied", initialProxied: false, resumedProxied: true},
229- }
230- for _, tt := range tests {
231- t.Run(tt.name, func(t *testing.T) {
232- d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{})
233- var clientID [16]byte
234- clientID[0] = 1
235- originalTransport := &closeTrackingTransport{}
236- hello := ports.Hello{
237- Version: ports.ProtocolVersion, Intent: ports.IntentNew, Proxied: tt.initialProxied,
238- ClientID: clientID, Name: "immutable", Size: domain.Size{Cols: 40, Rows: 8},
239- }
240- sess, ac, err := d.route(hello, originalTransport)
241- require.NoError(t, err)
242- token := ac.resumeToken
243- d.clientGone(sess, ac, originalTransport, false)
244-
245- resume := hello
246- resume.Intent = ports.IntentResume
247- resume.ResumeToken = token
248- resume.Proxied = tt.resumedProxied
249- _, resumed, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, resume.Size)
250- require.Error(t, err)
251- require.False(t, ok)
252- require.Nil(t, resumed)
253- require.Equal(t, tt.initialProxied, ac.proxied)
254- d.mu.Lock()
255- parked := d.parked[token]
256- d.mu.Unlock()
257- require.NotNil(t, parked, "a mode-mismatched resume must leave the original attachment parked")
258- })
259- }
260-}
261-
262-func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) {
263- d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
264- ac.proxied = true
265- rc := d.attachCoordinator(sess, ac, true)
266- lease := rc.attachmentLease(ac)
267- require.NotNil(t, lease)
268-
269- ac.sendMu.Lock()
270- ac.output.next = ac.output.maxOutstanding
271- ac.output.acked = 0
272- ac.sendMu.Unlock()
273-
274- token := sess.attachmentToken(ac, ac.transport())
275- require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{
276- Type: ports.MsgOutputResetRequest,
277- Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
278- }))
279-
280- frame := awaitFrame(t, sends, ports.MsgScreenUpdate)
281- out, err := ports.UnmarshalScreenUpdate(frame.Payload)
282- require.NoError(t, err)
283- require.Equal(t, ports.ScreenUpdateSnapshot, out.Kind)
284- require.Zero(t, out.BaseStateNum)
285- ac.sendMu.Lock()
286- require.Equal(t, ac.output.maxOutstanding, ac.output.acked, "reset must retire the previously full output window")
287- require.Equal(t, uint64(1), ac.output.outstanding(), "only the authoritative reset paint remains outstanding")
288- require.Equal(t, ac.output.next, out.NewStateNum)
289- ac.sendMu.Unlock()
290-}
291-
292-func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) {
293- tests := []struct {
294- name string
295- proxied bool
296- payload []byte
297- staleRole bool
298- }{
299- {name: "ordinary attachment", proxied: false, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})},
300- {name: "malformed request", proxied: true, payload: []byte{0}},
301- {name: "stale active role", proxied: true, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), staleRole: true},
302- }
303- for _, tt := range tests {
304- t.Run(tt.name, func(t *testing.T) {
305- d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
306- ac.proxied = tt.proxied
307- d.attachCoordinator(sess, ac, true)
308- ac.sendMu.Lock()
309- ac.output.next, ac.output.acked = 4, 0
310- ac.sendMu.Unlock()
311-
312- token := sess.attachmentToken(ac, ac.transport())
313- if tt.staleRole {
314- token.generation++
315- }
316- require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload}))
317- ac.sendMu.Lock()
318- acked := ac.output.acked
319- ac.sendMu.Unlock()
320- require.Zero(t, acked)
321- })
322- }
323-}
324-
325-func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) {
326- d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
327- ac.proxied = true
328- d.attachCoordinator(sess, ac, true)
329- ac.sendMu.Lock()
330- ac.output.next, ac.output.acked = 4, 0
331- ac.sendMu.Unlock()
332- token := sess.attachmentToken(ac, ac.transport())
333-
334- admitted := make(chan struct{})
335- var once sync.Once
336- d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { once.Do(func() { close(admitted) }) }
337- ac.sendMu.Lock()
338- done := make(chan bool, 1)
339- go func() {
340- done <- d.handleAttachmentClientFrame(token, ports.Frame{
341- Type: ports.MsgOutputResetRequest,
342- Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
343- })
344- }()
345- awaitTestCompletion(t, admitted, "reset did not admit its role effect")
346- ac.replaceTransport(&closeTrackingTransport{})
347- ac.sendMu.Unlock()
348-
349- require.False(t, awaitTestValue(t, done, "reset did not finish after send lock release"))
350- ac.sendMu.Lock()
351- require.Zero(t, ac.output.acked, "a stale transport must not rebase the output stream")
352- ac.sendMu.Unlock()
353-}
M internal/usecase/daemon/proxy_input.go +0 -37
@@ -6,7 +6,6 @@ import (
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8 "github.com/bnema/vev/internal/usecase/keys"8 "github.com/bnema/vev/internal/usecase/keys"
9- "github.com/bnema/vev/internal/usecase/layout"
10 "github.com/bnema/vev/internal/usecase/mouse"9 "github.com/bnema/vev/internal/usecase/mouse"
11 "github.com/bnema/vev/internal/usecase/palette"10 "github.com/bnema/vev/internal/usecase/palette"
12 )11 )
@@ -222,39 +221,3 @@ func proxiedNavConfig(cfg domain.NavConfig) domain.NavConfig {
222 }221 }
223 222
224 func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied }223 func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied }
225-
226-// focusDirProxied performs ordinary pane and tab overflow on the remote daemon,
227-// but never follows OverflowSessions. The local proxy daemon is the only owner
228-// allowed to change the selected session.
229-func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error {
230- target := resolveDaemonActionTargetForAttachment(sess, ac)
231- oldFocus := layout.PaneID("")
232- if target.pane != nil {
233- oldFocus = target.pane.id
234- }
235- span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac)
236- if err == nil {
237- if ac != nil {
238- d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "proxy_input.go")
239- }
240- return nil
241- }
242- if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, ac, target.tab) {
243- return err
244- }
245-
246- position, count := sess.tabIndexForAttachment(ac)
247- step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count)
248- if step.kind != overflowTabs {
249- return errNoNeighbor
250- }
251- candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta)
252- if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) {
253- return errNoNeighbor
254- }
255- sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID))
256- if ac != nil {
257- d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go")
258- }
259- return nil
260-}
M internal/usecase/daemon/proxy_input_test.go +8 -24
@@ -216,7 +216,7 @@ func TestProxyInputAttentionOwnership(t *testing.T) {
216 })216 })
217 }217 }
218 218
219-func TestProxyInputProxiedRemoteOwnershipGuards(t *testing.T) {219+func TestProxyInputRemoteOwnershipGuards(t *testing.T) {
220 _, proxy, _, link, _ := newProxyInputHarness(t)220 _, proxy, _, link, _ := newProxyInputHarness(t)
221 require.True(t, proxy.caps.cannotAcceptMoves, "a proxy must reject move destinations locally")221 require.True(t, proxy.caps.cannotAcceptMoves, "a proxy must reject move destinations locally")
222 require.True(t, proxy.caps.cannotYieldMoves, "a proxy must reject move sources locally")222 require.True(t, proxy.caps.cannotYieldMoves, "a proxy must reject move sources locally")
@@ -355,9 +355,6 @@ func TestProxyAttachedCommandCorrelatesInterleavedWrongAndLateResults(t *testing
355 require.NoError(t, err)355 require.NoError(t, err)
356 require.Equal(t, proxyLinkResume, result)356 require.Equal(t, proxyLinkResume, result)
357 require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type)357 require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type)
358- result, err = d.handleLinkFrame(proxy, generation, proxyMeta(proxy.key.Name))
359- require.NoError(t, err)
360- require.Equal(t, proxyLinkResume, result)
361 result, err = d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})})358 result, err = d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})})
362 require.NoError(t, err)359 require.NoError(t, err)
363 require.Equal(t, proxyLinkResume, result)360 require.Equal(t, proxyLinkResume, result)
@@ -499,7 +496,7 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T)
499 request := requireProxyCommandRequest(t, link)496 request := requireProxyCommandRequest(t, link)
500 _ = awaitTestValue(t, clock.timers, "command did not arm timeout")497 _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
501 oldGeneration := proxy.linkGeneration498 oldGeneration := proxy.linkGeneration
502- newGeneration, _ := proxy.installTransport(newProxyTestTransport(), false)499+ newGeneration, _ := proxy.installTransport(newProxyTestTransport())
503 require.Greater(t, newGeneration, oldGeneration)500 require.Greater(t, newGeneration, oldGeneration)
504 require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))501 require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))
505 _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))502 _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
@@ -509,7 +506,6 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T)
509 506
510 func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *testing.T) {507 func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *testing.T) {
511 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())508 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
512- ac.proxied = true
513 second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})509 second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
514 publishTiledPaneOwners(sess, second)510 publishTiledPaneOwners(sess, second)
515 sess.mu.Lock()511 sess.mu.Lock()
@@ -600,9 +596,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
600 d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())596 d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
601 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}597 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
602 transport := newProxyTestTransport()598 transport := newProxyTestTransport()
603- transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}599+ transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
604- transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
605- transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
606 factory := portsmocks.NewMockRemoteDialerFactory(t)600 factory := portsmocks.NewMockRemoteDialerFactory(t)
607 dialer := portsmocks.NewMockDialer(t)601 dialer := portsmocks.NewMockDialer(t)
608 var daemonLockAvailable, sourceLockAvailable bool602 var daemonLockAvailable, sourceLockAvailable bool
@@ -659,9 +653,7 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
659 d.attachCoordinator(local, ac, true)653 d.attachCoordinator(local, ac, true)
660 key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}654 key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}
661 transport := newProxyTestTransport()655 transport := newProxyTestTransport()
662- transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}656+ transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
663- transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
664- transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
665 d.remoteDialerFactory = newProxyConstructionFactory(transport)657 d.remoteDialerFactory = newProxyConstructionFactory(transport)
666 d.remoteTransportMode = ports.RemoteTransportUDP658 d.remoteTransportMode = ports.RemoteTransportUDP
667 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{659 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -806,9 +798,7 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
806 d.attachCoordinator(local, ac, true)798 d.attachCoordinator(local, ac, true)
807 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}799 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
808 remote := newProxyTestTransport()800 remote := newProxyTestTransport()
809- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}801+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
810- remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
811- remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
812 d.remoteDialerFactory = newProxyConstructionFactory(remote)802 d.remoteDialerFactory = newProxyConstructionFactory(remote)
813 d.remoteTransportMode = ports.RemoteTransportUDP803 d.remoteTransportMode = ports.RemoteTransportUDP
814 804
@@ -899,9 +889,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
899 889
900 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}890 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
901 remote := newProxyTestTransport()891 remote := newProxyTestTransport()
902- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}892+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
903- remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
904- remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
905 d.remoteDialerFactory = newProxyConstructionFactory(remote)893 d.remoteDialerFactory = newProxyConstructionFactory(remote)
906 d.remoteTransportMode = ports.RemoteTransportUDP894 d.remoteTransportMode = ports.RemoteTransportUDP
907 token := attachmentEffectForTest(t, local.attachmentToken(ac, client))895 token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
@@ -938,9 +926,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
938 926
939 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}927 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
940 remote := newProxyTestTransport()928 remote := newProxyTestTransport()
941- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}929+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
942- remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
943- remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
944 d.remoteDialerFactory = newProxyConstructionFactory(remote)930 d.remoteDialerFactory = newProxyConstructionFactory(remote)
945 d.remoteTransportMode = ports.RemoteTransportUDP931 d.remoteTransportMode = ports.RemoteTransportUDP
946 932
@@ -995,9 +981,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
995 981
996 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}982 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
997 remote := newProxyTestTransport()983 remote := newProxyTestTransport()
998- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}984+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
999- remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1000- remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1001 d.remoteDialerFactory = newProxyConstructionFactory(remote)985 d.remoteDialerFactory = newProxyConstructionFactory(remote)
1002 d.remoteTransportMode = ports.RemoteTransportUDP986 d.remoteTransportMode = ports.RemoteTransportUDP
1003 987
M internal/usecase/daemon/proxy_lifecycle_test.go +14 -46
@@ -146,30 +146,6 @@ func newRegisteredLifecycleProxy(t *testing.T, clock ports.Clock) (*Daemon, *pro
146 return d, proxy, transport146 return d, proxy, transport
147 }147 }
148 148
149-func TestNewProxySessionUsesProxyScreenSizeLimit(t *testing.T) {
150- for _, test := range []struct {
151- name string
152- size domain.Size
153- valid bool
154- }{
155- {name: "largest valid content", size: domain.Size{Cols: 512, Rows: 514}, valid: true},
156- {name: "one cell over content limit", size: domain.Size{Cols: 512, Rows: 515}},
157- {name: "1000x1000", size: domain.Size{Cols: 1000, Rows: 1000}},
158- } {
159- t.Run(test.name, func(t *testing.T) {
160- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, test.size)
161- if !test.valid {
162- require.Error(t, err)
163- return
164- }
165- require.NoError(t, err)
166- require.Equal(t, contentSize(test.size, false), proxy.contentSize)
167- require.Equal(t, test.size.Cols, proxy.screen.frame.Width)
168- require.Equal(t, test.size.Rows-tabChromeRows, proxy.screen.frame.Height)
169- })
170- }
171-}
172-
173 func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {149 func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
174 clock := newProxyLifecycleClock()150 clock := newProxyLifecycleClock()
175 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())151 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
@@ -255,7 +231,7 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
255 231
256 ac := &attachedClient{}232 ac := &attachedClient{}
257 proxy.sessionCore.mu.Lock()233 proxy.sessionCore.mu.Lock()
258- proxy.sessionCore.registerAttachmentLocked(ac)234+ proxy.registerAttachmentLocked(ac)
259 proxy.sessionCore.mu.Unlock()235 proxy.sessionCore.mu.Unlock()
260 d.cancelProxyWarmForAttachment(proxy, ac)236 d.cancelProxyWarmForAttachment(proxy, ac)
261 awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")237 awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")
@@ -266,8 +242,8 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
266 d.mu.Unlock()242 d.mu.Unlock()
267 243
268 proxy.sessionCore.mu.Lock()244 proxy.sessionCore.mu.Lock()
269- for _, attachment := range proxy.sessionCore.snapshotAttachmentsLocked() {245+ for _, attachment := range proxy.snapshotAttachmentsLocked() {
270- proxy.sessionCore.unregisterAttachmentLocked(attachment)246+ proxy.unregisterAttachmentLocked(attachment)
271 }247 }
272 proxy.sessionCore.mu.Unlock()248 proxy.sessionCore.mu.Unlock()
273 d.armProxyWarm(proxy)249 d.armProxyWarm(proxy)
@@ -425,9 +401,7 @@ func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) {
425 handshakeTimer := awaitTestValue(t, clock.timers, "stable resume handshake did not arm its timeout")401 handshakeTimer := awaitTestValue(t, clock.timers, "stable resume handshake did not arm its timeout")
426 require.Equal(t, proxyHandshakeTimeout, handshakeTimer.delay)402 require.Equal(t, proxyHandshakeTimeout, handshakeTimer.delay)
427 _ = requireProxyHello(t, resumed)403 _ = requireProxyHello(t, resumed)
428- resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume|ports.CapabilityProxied)}404+ resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume)}
429- resumed.recv <- proxyRecv{frame: proxyMeta(proxy.key.Name)}
430- resumed.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
431 require.Eventually(t, func() bool {405 require.Eventually(t, func() bool {
432 proxy.mu.Lock()406 proxy.mu.Lock()
433 defer proxy.mu.Unlock()407 defer proxy.mu.Unlock()
@@ -466,11 +440,11 @@ func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) {
466 require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)440 require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)
467 }441 }
468 442
469-func TestIncomingDirectAttachPreservesProxiedRemoteAttachment(t *testing.T) {443+func TestIncomingDirectAttachTerminatesRemoteAttachment(t *testing.T) {
470 d := newTestDaemon(t, nil, newProxyLifecycleClock())444 d := newTestDaemon(t, nil, newProxyLifecycleClock())
471 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}445 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}
472 oldTransport := newProxyTestTransport()446 oldTransport := newProxyTestTransport()
473- old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true}447+ old := &attachedClient{tr: oldTransport, output: newOutputStateStream()}
474 old.initOverlays()448 old.initOverlays()
475 old.setSession(sess)449 old.setSession(sess)
476 sess.registerAttachment(old)450 sess.registerAttachment(old)
@@ -1192,9 +1166,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
1192 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}1166 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
1193 first := newProxyTestTransport()1167 first := newProxyTestTransport()
1194 second := newProxyTestTransport()1168 second := newProxyTestTransport()
1195- second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityProxied)}1169+ second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityResume)}
1196- second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1197- second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1198 factory := newProxyConstructionFactory(first, second)1170 factory := newProxyConstructionFactory(first, second)
1199 d := newProxyTestDaemon(t, factory, nil)1171 d := newProxyTestDaemon(t, factory, nil)
1200 1172
@@ -1219,9 +1191,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
1219 _ = requireProxyHello(t, second)1191 _ = requireProxyHello(t, second)
1220 }1192 }
1221 1193
1222- first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}1194+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
1223- first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1224- first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1225 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")1195 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1226 waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish")1196 waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish")
1227 require.NoError(t, leaderResult.err)1197 require.NoError(t, leaderResult.err)
@@ -1246,7 +1216,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
1246 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))1216 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1247 _ = requireProxyHello(t, first)1217 _ = requireProxyHello(t, first)
1248 1218
1249- base, cancel := context.WithCancel(context.Background())1219+ base, cancel := context.WithCancel(t.Context())
1250 observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})}1220 observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})}
1251 waiter := openProxyForTest(d, observed, key)1221 waiter := openProxyForTest(d, observed, key)
1252 select {1222 select {
@@ -1261,9 +1231,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
1261 require.Nil(t, waiterResult.proxy)1231 require.Nil(t, waiterResult.proxy)
1262 require.Equal(t, 1, factory.callCount())1232 require.Equal(t, 1, factory.callCount())
1263 1233
1264- first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}1234+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
1265- first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1266- first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1267 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")1235 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
1268 require.NoError(t, leaderResult.err)1236 require.NoError(t, leaderResult.err)
1269 stopProxy(t, leaderResult.proxy)1237 stopProxy(t, leaderResult.proxy)
@@ -1325,7 +1293,7 @@ func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) {
1325 1293
1326 func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {1294 func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {
1327 p.sessionCore.mu.Lock()1295 p.sessionCore.mu.Lock()
1328- p.sessionCore.registerAttachmentLocked(ac)1296+ p.registerAttachmentLocked(ac)
1329 p.sessionCore.mu.Unlock()1297 p.sessionCore.mu.Unlock()
1330 }1298 }
1331 1299
@@ -1441,7 +1409,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1441 d, p, clock := newProxyLifecycleFixture(t)1409 d, p, clock := newProxyLifecycleFixture(t)
1442 transport := newProxyTestTransport()1410 transport := newProxyTestTransport()
1443 t.Cleanup(func() { _ = transport.Close() })1411 t.Cleanup(func() { _ = transport.Close() })
1444- generation, _ := p.installTransport(transport, false)1412+ generation, _ := p.installTransport(transport)
1445 token, timer := armWarmTimer(t, d, p, clock)1413 token, timer := armWarmTimer(t, d, p, clock)
1446 1414
1447 require.True(t, d.markProxyReplaced(p, generation, transport))1415 require.True(t, d.markProxyReplaced(p, generation, transport))
@@ -1465,7 +1433,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1465 d, p, clock := newProxyLifecycleFixture(t)1433 d, p, clock := newProxyLifecycleFixture(t)
1466 transport := newProxyTestTransport()1434 transport := newProxyTestTransport()
1467 t.Cleanup(func() { _ = transport.Close() })1435 t.Cleanup(func() { _ = transport.Close() })
1468- generation, _ := p.installTransport(transport, false)1436+ generation, _ := p.installTransport(transport)
1469 1437
1470 require.True(t, d.markProxyReplaced(p, generation, transport))1438 require.True(t, d.markProxyReplaced(p, generation, transport))
1471 timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")1439 timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
@@ -1482,7 +1450,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1482 d, p, _ := newProxyLifecycleFixture(t)1450 d, p, _ := newProxyLifecycleFixture(t)
1483 transport := newProxyTestTransport()1451 transport := newProxyTestTransport()
1484 t.Cleanup(func() { _ = transport.Close() })1452 t.Cleanup(func() { _ = transport.Close() })
1485- generation, _ := p.installTransport(transport, false)1453+ generation, _ := p.installTransport(transport)
1486 1454
1487 require.False(t, d.markProxyReplaced(p, generation-1, transport))1455 require.False(t, d.markProxyReplaced(p, generation-1, transport))
1488 require.False(t, proxyExpired(p))1456 require.False(t, proxyExpired(p))
M internal/usecase/daemon/proxy_link.go +48 -150
@@ -7,7 +7,6 @@ import (
7 "io"7 "io"
8 "time"8 "time"
9 9
10- "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
12 )11 )
13 12
@@ -54,9 +53,9 @@ const (
54 proxyLinkReplaced53 proxyLinkReplaced
55 )54 )
56 55
57-// dialProxyHandshake installs a candidate transport, sends the proxied Hello56+// dialProxyHandshake installs a candidate transport, sends an ordinary Hello
58-// through the session's sole sender, and requires Welcome followed immediately57+// through the session's sole sender, and requires a matching Welcome. The
59-// by valid authoritative metadata. The candidate is never registry-visible.58+// candidate is never registry-visible.
60 func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, intent uint8) (err error) {59 func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, intent uint8) (err error) {
61 if parent == nil {60 if parent == nil {
62 //nolint:contextcheck // nil is intentionally supported for direct internal callers; Background is the root fallback.61 //nolint:contextcheck // nil is intentionally supported for direct internal callers; Background is the root fallback.
@@ -98,14 +97,12 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int
98 resumeToken := p.resumeToken97 resumeToken := p.resumeToken
99 clientID := p.clientID98 clientID := p.clientID
100 size := p.contentSize99 size := p.contentSize
101- stateFloor := p.appliedState
102 p.mu.Unlock()100 p.mu.Unlock()
103 resume := intent == ports.IntentResume101 resume := intent == ports.IntentResume
104 if !resume {102 if !resume {
105 resumeToken = 0103 resumeToken = 0
106- stateFloor = 0
107 }104 }
108- generation, previous := p.installTransport(transport, resume)105+ generation, previous := p.installTransport(transport)
109 if previous != nil && previous != transport {106 if previous != nil && previous != transport {
110 _ = previous.Close()107 _ = previous.Close()
111 }108 }
@@ -121,7 +118,6 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int
121 hello := ports.Hello{118 hello := ports.Hello{
122 Version: ports.ProtocolVersion,119 Version: ports.ProtocolVersion,
123 Intent: intent,120 Intent: intent,
124- Proxied: true,
125 ClientID: clientID,121 ClientID: clientID,
126 ResumeToken: resumeToken,122 ResumeToken: resumeToken,
127 Name: p.key.Name,123 Name: p.key.Name,
@@ -132,23 +128,13 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int
132 return fmt.Errorf("proxy session: send hello: %w", err)128 return fmt.Errorf("proxy session: send hello: %w", err)
133 }129 }
134 130
135- welcome, meta, snapshot, err := recvProxyHandshake(ctx, transport, p.key.Name, size, stateFloor)131+ welcome, err := recvProxyHandshake(ctx, transport, p.key.Name)
136 if err != nil {132 if err != nil {
137 return err133 return err
138 }134 }
139 if err := ctx.Err(); err != nil {135 if err := ctx.Err(); err != nil {
140 return err136 return err
141 }137 }
142- ack, reset, _ := p.applyScreenUpdateForGeneration(generation, snapshot)
143- if reset || ack != snapshot.NewStateNum {
144- return errors.New("proxy session: initial screen snapshot was not applied")
145- }
146- if err := p.sendGeneration(generation, ports.Frame{
147- Type: ports.MsgAck,
148- Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}),
149- }); err != nil {
150- return fmt.Errorf("proxy session: send initial screen ACK: %w", err)
151- }
152 138
153 p.mu.Lock()139 p.mu.Lock()
154 if p.linkGeneration != generation || p.transport != transport {140 if p.linkGeneration != generation || p.transport != transport {
@@ -156,7 +142,6 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int
156 return errors.New("proxy session: link replaced during handshake")142 return errors.New("proxy session: link replaced during handshake")
157 }143 }
158 p.resumeToken = welcome.ResumeToken144 p.resumeToken = welcome.ResumeToken
159- p.replaceSessionMetaLocked(meta, d.clock.Now())
160 p.linkState = ports.LinkStateConnected145 p.linkState = ports.LinkStateConnected
161 p.expired = false146 p.expired = false
162 p.mu.Unlock()147 p.mu.Unlock()
@@ -230,43 +215,16 @@ func proxyHandshakeContextError(parent context.Context, timedOut <-chan struct{}
230 return fallback215 return fallback
231 }216 }
232 217
233-func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string, size domain.Size, stateFloor uint64) (ports.Welcome, ports.SessionMeta, ports.ScreenUpdate, error) {218+func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string) (ports.Welcome, error) {
234 frame, err := recvProxyHandshakeFrame(ctx, transport)219 frame, err := recvProxyHandshakeFrame(ctx, transport)
235 if err != nil {220 if err != nil {
236- return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive welcome: %w", err)221+ return ports.Welcome{}, fmt.Errorf("proxy session: receive welcome: %w", err)
237 }222 }
238 welcome, err := validateProxyWelcome(frame, sessionName)223 welcome, err := validateProxyWelcome(frame, sessionName)
239 if err != nil {224 if err != nil {
240- return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, err225+ return ports.Welcome{}, err
241- }
242-
243- frame, err = recvProxyHandshakeFrame(ctx, transport)
244- if err != nil {
245- return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive initial metadata: %w", err)
246- }
247- meta, err := validateProxyInitialMeta(frame, sessionName)
248- if err != nil {
249- return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, err
250- }
251-
252- frame, err = recvProxyHandshakeFrame(ctx, transport)
253- if err != nil {
254- return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive initial screen snapshot: %w", err)
255- }
256- if frame.Type != ports.MsgScreenUpdate {
257- return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: third server frame is %d, want ScreenUpdate", frame.Type)
258- }
259- snapshot, err := ports.UnmarshalScreenUpdate(frame.Payload)
260- if err != nil {
261- return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: decode initial screen snapshot: %w", err)
262- }
263- if snapshot.Kind != ports.ScreenUpdateSnapshot || snapshot.BaseStateNum != 0 || snapshot.NewStateNum <= stateFloor {
264- return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, errors.New("proxy session: initial screen frame is not a moving base-zero snapshot")
265 }226 }
266- if snapshot.Size != size {227+ return welcome, nil
267- 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)
268- }
269- return welcome, meta, snapshot, nil
270 }228 }
271 229
272 func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {230 func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {
@@ -284,29 +242,12 @@ func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome,
284 if err != nil {242 if err != nil {
285 return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err)243 return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err)
286 }244 }
287- if welcome.Capabilities&ports.CapabilityProxied == 0 {
288- return ports.Welcome{}, errors.New("proxy session: remote did not negotiate proxied capability")
289- }
290 if welcome.SessionID == "" || welcome.SessionName != sessionName {245 if welcome.SessionID == "" || welcome.SessionName != sessionName {
291 return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName)246 return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName)
292 }247 }
293 return welcome, nil248 return welcome, nil
294 }249 }
295 250
296-func validateProxyInitialMeta(frame ports.Frame, sessionName string) (ports.SessionMeta, error) {
297- if frame.Type != ports.MsgSessionMeta {
298- return ports.SessionMeta{}, fmt.Errorf("proxy session: second server frame is %d, want SessionMeta", frame.Type)
299- }
300- meta, err := ports.UnmarshalSessionMeta(frame.Payload)
301- if err != nil {
302- return ports.SessionMeta{}, fmt.Errorf("proxy session: decode initial metadata: %w", err)
303- }
304- if meta.SessionName != sessionName {
305- return ports.SessionMeta{}, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
306- }
307- return meta, nil
308-}
309-
310 func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {251 func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
311 result := make(chan proxyRecvResult, 1)252 result := make(chan proxyRecvResult, 1)
312 go func() {253 go func() {
@@ -326,7 +267,7 @@ func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (po
326 }267 }
327 }268 }
328 269
329-func (p *proxySession) installTransport(transport ports.Transport, resume bool) (uint64, ports.Transport) {270+func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
330 // Link replacement participates in the same sender serialization as every271 // Link replacement participates in the same sender serialization as every
331 // frame. A request can therefore only be published wholly before the272 // frame. A request can therefore only be published wholly before the
332 // generation changes or wholly after it.273 // generation changes or wholly after it.
@@ -336,17 +277,12 @@ func (p *proxySession) installTransport(transport ports.Transport, resume bool)
336 previous := p.transport277 previous := p.transport
337 p.linkGeneration++278 p.linkGeneration++
338 generation := p.linkGeneration279 generation := p.linkGeneration
339- if resume {280+ // Each transport must establish its ordinary output dependency with a
340- // Resume keeps the applied remote state as the handshake floor, but the281+ // dependency-free full frame before incremental bytes are forwarded.
341- // replacement must prove that state with a fresh base-zero snapshot.282+ p.resetOutputStateLocked()
342- p.screenReady = false
343- p.resetRequested = false
344- } else {
345- p.resetOutputStateLocked()
346- }
347 p.transport = transport283 p.transport = transport
348- // Authentication is not complete until the authoritative screen snapshot284+ // Authentication is complete after the remote Welcome; ordinary output
349- // has been applied and acknowledged.285+ // establishes the dependency chain asynchronously.
350 p.linkState = ports.LinkStateProbing286 p.linkState = ports.LinkStateProbing
351 p.mu.Unlock()287 p.mu.Unlock()
352 p.failPendingCommandsLocked(errProxyCommandGeneration)288 p.failPendingCommandsLocked(errProxyCommandGeneration)
@@ -367,17 +303,6 @@ func (p *proxySession) retireTransport(generation uint64, transport ports.Transp
367 }303 }
368 }304 }
369 305
370-// Every proxy Transport.Send path is serialized by sendMu: Hello, input,
371-// resize, ACK/reset, ping/pong, and command traffic. The leaf proxy lock is
372-// held only long enough to revalidate
373-// the immutable link snapshot; I/O happens after releasing it.
374-func (p *proxySession) sendCurrent(frame ports.Frame) error {
375- p.mu.Lock()
376- generation := p.linkGeneration
377- p.mu.Unlock()
378- return p.sendGeneration(generation, frame)
379-}
380-
381 func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {306 func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
382 p.sendMu.Lock()307 p.sendMu.Lock()
383 defer p.sendMu.Unlock()308 defer p.sendMu.Unlock()
@@ -651,54 +576,48 @@ func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generat
651 }576 }
652 }577 }
653 578
654-// handleProxySideEffect fans out a stateless remote effect to every attachment579+// handleProxySideEffect forwards a stateless remote effect to every live local
655-// currently owning this proxy. Each attachment gets an independent capability,580+// attachment. Connection admission and transport incarnation checks make a stale
656-// effect ticket, output snapshot, and exact transport-incarnation fence.581+// handoff a harmless drop rather than a send to the client's next session.
657 func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {582 func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {
658 if p == nil || !p.currentLinkGeneration(generation) {583 if p == nil || !p.currentLinkGeneration(generation) {
659 return nil584 return nil
660 }585 }
586+ attachments := snapshotAttachmentSession(p)
661 var sendErr error587 var sendErr error
662- for _, ac := range snapshotAttachmentSession(p) {588+ for _, ac := range attachments {
589+ if ac == nil || ac.output == nil {
590+ continue
591+ }
592+ expected := ac.transportSnapshot()
593+ if expected.transport == nil {
594+ continue
595+ }
596+ token := attachmentToken(p, ac, expected.transport)
597+ if token.sess != p {
598+ continue
599+ }
600+ ticket, admitted := ac.beginAttachmentEffect(token)
601+ if !admitted {
602+ continue
603+ }
663 if !p.currentLinkGeneration(generation) {604 if !p.currentLinkGeneration(generation) {
664- break605+ ticket.End()
606+ return nil
665 }607 }
666- if err := d.sendProxySideEffect(p, generation, ac, out); err != nil && sendErr == nil {608+ err := ac.sendExpectedSideEffectForAttachment(expected, out.Data, ac.echoAck.Load(), ticket)
609+ ticket.End()
610+ if err == nil || errors.Is(err, errAttachmentTransition) {
611+ continue
612+ }
613+ // Try every live attachment before retiring the remote link for a failed
614+ // client send; one blocked or dead peer must not starve the others.
615+ if sendErr == nil {
667 sendErr = err616 sendErr = err
668 }617 }
669 }618 }
670- return sendErr619+ if sendErr != nil {
671-}620+ return fmt.Errorf("proxy session: side-effect send: %w: %w", errProxyLinkSend, sendErr)
672-
673-func (d *Daemon) sendProxySideEffect(p *proxySession, generation uint64, ac *attachedClient, out ports.Output) error {
674- if p == nil || ac == nil || ac.output == nil || !p.currentLinkGeneration(generation) {
675- return nil
676- }
677- expected := ac.transportSnapshot()
678- if expected.transport == nil {
679- return nil
680- }
681- token := attachmentToken(p, ac, expected.transport)
682- if token.sess != p || !token.current() {
683- return nil
684- }
685- ticket, admitted := ac.beginAttachmentEffect(token)
686- if !admitted {
687- return nil
688- }
689- defer ticket.End()
690- if !p.currentLinkGeneration(generation) || !token.current() {
691- return nil
692- }
693- frame := ac.output.sideEffect(out.Data, ac.echoAck.Load())
694- if !p.currentLinkGeneration(generation) || !token.current() {
695- return nil
696- }
697- if err := ac.sendExpectedTransportForAttachment(expected, frame, ticket); err != nil {
698- if errors.Is(err, errAttachmentTransition) {
699- return nil
700- }
701- return fmt.Errorf("proxy session: side-effect send: %w: %w", errProxyLinkSend, err)
702 }621 }
703 return nil622 return nil
704 }623 }
@@ -712,28 +631,7 @@ func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports
712 if err != nil {631 if err != nil {
713 return proxyLinkStop, err632 return proxyLinkStop, err
714 }633 }
715- if out.BaseStateNum != 0 || out.NewStateNum != 0 {634+ if err := d.handleProxyOutput(p, generation, out); err != nil {
716- return proxyLinkStop, fmt.Errorf("proxy session: state-bearing MsgOutput is not valid on a proxied link")
717- }
718- if err := d.handleProxySideEffect(p, generation, out); err != nil {
719- return proxyLinkStop, err
720- }
721- return proxyLinkResume, nil
722- case ports.MsgScreenUpdate:
723- update, err := ports.UnmarshalScreenUpdate(frame.Payload)
724- if err != nil {
725- return proxyLinkStop, err
726- }
727- if err := d.handleProxyScreenUpdate(p, generation, update); err != nil {
728- return proxyLinkStop, err
729- }
730- return proxyLinkResume, nil
731- case ports.MsgSessionMeta:
732- meta, err := ports.UnmarshalSessionMeta(frame.Payload)
733- if err != nil {
734- return proxyLinkStop, err
735- }
736- if _, err := d.applyProxySessionMeta(p, generation, meta); err != nil {
737 return proxyLinkStop, err635 return proxyLinkStop, err
738 }636 }
739 return proxyLinkResume, nil637 return proxyLinkResume, nil
M internal/usecase/daemon/proxy_output.go +68 -72
@@ -6,104 +6,100 @@ import (
6 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
7 )7 )
8 8
9-// applyScreenUpdateForGeneration applies one semantic screen update while9+// applyOutputForGeneration validates the remote ordinary output dependency
10-// holding only the proxy leaf lock. A link generation fence prevents a stale10+// chain without retaining a second terminal or renderer state. Bytes are
11-// receive goroutine from advancing a replacement link's screen.11+// forwarded only after a full frame establishes the current remote stream.
12-func (p *proxySession) applyScreenUpdateForGeneration(generation uint64, update ports.ScreenUpdate) (ack uint64, requestReset, changed bool) {12+func (p *proxySession) applyOutputForGeneration(generation uint64, out ports.Output) (ack uint64, requestReset, changed bool) {
13- if p == nil {
14- return 0, false, false
15- }
16 p.mu.Lock()13 p.mu.Lock()
17 defer p.mu.Unlock()14 defer p.mu.Unlock()
18- if p.linkGeneration != generation || p.screen == nil {15+ if p.linkGeneration != generation {
19 return 0, false, false16 return 0, false, false
20 }17 }
21-18+ invalid := func() (uint64, bool, bool) {
22- // The wire codec validates all semantic bounds. Geometry is still checked19+ if p.outputResetRequested {
23- // here because it is attachment state, not merely a wire property: a20+ return 0, false, false
24- // remote paint for the wrong content rectangle cannot be applied locally.21+ }
25- if update.Size != p.contentSize {22+ p.outputResetRequested = true
26- return 0, p.requestScreenResetLocked(), false23+ return 0, true, false
27 }24 }
28- if update.Kind == ports.ScreenUpdateSnapshot {25+ if out.Epoch == 0 {
29- // A base-zero paint may only move the consumer forward. This floor is26+ return invalid()
30- // retained across resume and resize, so resetRequested never authorizes27+ }
31- // replaying a stale snapshot.28+ if out.New == 0 {
32- if update.NewStateNum <= p.appliedState {29+ // New == 0 is an ordinary byte side effect, not a state transition.
33- return 0, p.requestScreenResetLocked(), false30+ // Match the thin client: it is accepted before the first state-bearing
31+ // frame, and thereafter only within the current epoch.
32+ if out.Base != 0 || out.Full || (p.outputReady && out.Epoch != p.outputEpoch) {
33+ return invalid()
34 }34 }
35- if err := p.screen.Apply(update); err != nil {35+ return 0, false, len(out.Data) != 0
36- return 0, p.requestScreenResetLocked(), false36+ }
37+ if !p.outputReady {
38+ if out.Base != 0 || !out.Full {
39+ return invalid()
37 }40 }
38- p.appliedState = update.NewStateNum41+ p.outputEpoch = out.Epoch
39- p.screenReady = true42+ p.outputState = out.New
40- p.resetRequested = false43+ p.outputReady = true
41- return update.NewStateNum, false, true44+ p.outputResetRequested = false
45+ return out.New, false, len(out.Data) != 0
42 }46 }
43-47+ if out.Epoch < p.outputEpoch {
44- if !p.screenReady || p.resetRequested || update.BaseStateNum != p.appliedState || update.NewStateNum != update.BaseStateNum+1 {48+ return invalid()
45- return 0, p.requestScreenResetLocked(), false
46 }49 }
47- before := p.screen.cursorOut50+ if out.Epoch == p.outputEpoch {
48- if err := p.screen.Apply(update); err != nil {51+ if p.outputResetRequested || out.Full || out.Base != p.outputState || out.New != out.Base+1 {
49- return 0, p.requestScreenResetLocked(), false52+ return invalid()
53+ }
54+ p.outputState = out.New
55+ return out.New, false, len(out.Data) != 0
50 }56 }
51- p.appliedState = update.NewStateNum57+ if out.Base != 0 || !out.Full {
52- return update.NewStateNum, false, update.Scroll != nil || len(update.Spans) != 0 || before != update.Cursor58+ return invalid()
53-}
54-
55-func (p *proxySession) requestScreenResetLocked() bool {
56- if p.resetRequested {
57- p.screenReady = false
58- return false
59 }59 }
60- p.screenReady = false60+ p.outputEpoch = out.Epoch
61- p.resetRequested = true61+ p.outputState = out.New
62- return true62+ p.outputResetRequested = false
63+ return out.New, false, len(out.Data) != 0
63 }64 }
64 65
65-// handleProxyScreenUpdate completes screen handling after apply has released66+// handleProxyOutput forwards ordinary remote terminal bytes to the local
66-// proxy.mu. Transport I/O and render invalidation never run under that lock.67+// attachment and acknowledges a state-bearing frame only after forwarding.
67-func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, update ports.ScreenUpdate) error {68+func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {
68- marks := d.newRuntimeMarkBatch()69+ ack, requestReset, changed := p.applyOutputForGeneration(generation, out)
69- ack, requestReset, changed := p.applyScreenUpdateForGeneration(generation, update)
70- if ack != 0 {
71- if err := p.sendGeneration(generation, ports.Frame{
72- Type: ports.MsgAck,
73- Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}),
74- }); err != nil {
75- return fmt.Errorf("proxy screen ACK: %w: %w", errProxyLinkSend, err)
76- }
77- }
78 if requestReset {70 if requestReset {
79 if err := p.sendGeneration(generation, ports.Frame{71 if err := p.sendGeneration(generation, ports.Frame{
80 Type: ports.MsgOutputResetRequest,72 Type: ports.MsgOutputResetRequest,
81 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),73 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
82 }); err != nil {74 }); err != nil {
83- return fmt.Errorf("proxy screen reset request: %w: %w", errProxyLinkSend, err)75+ return fmt.Errorf("proxy output reset request: %w: %w", errProxyLinkSend, err)
84 }76 }
85- marks.diagnostic(ports.RuntimeScreenResetRequested, 0, 0)
86 }77 }
87- // The reset send released proxy sendMu and apply released proxy.mu before78+ if changed {
88- // observer I/O; a blocked sink cannot hold either protocol ownership lock.79+ if err := d.handleProxySideEffect(p, generation, out); err != nil {
89- marks.flush()80+ return err
90- if !changed || !p.currentLinkGeneration(generation) {81+ }
91- return nil
92 }82 }
93-83+ if ack != 0 {
94- for _, ac := range snapshotAttachmentSession(p) {84+ payload, err := ports.MarshalAck(ports.Ack{Epoch: out.Epoch, State: ack})
95- d.invalidateRender(p, ac, false, "proxy_output.go")85+ if err != nil {
86+ return fmt.Errorf("proxy output ACK: %w", err)
87+ }
88+ if err := p.sendGeneration(generation, ports.Frame{
89+ Type: ports.MsgAck,
90+ Payload: payload,
91+ }); err != nil {
92+ return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err)
93+ }
96 }94 }
97 return nil95 return nil
98 }96 }
99 97
100 func (p *proxySession) resetOutputStateLocked() {98 func (p *proxySession) resetOutputStateLocked() {
101- p.appliedState = 099+ p.outputEpoch = 0
102- p.screenReady = false100+ p.outputState = 0
103- p.resetRequested = false101+ p.outputReady = false
104- if p.screen != nil {102+ p.outputResetRequested = false
105- p.screen.stateNum = 0
106- }
107 }103 }
108 104
109 func (p *proxySession) currentLinkGeneration(generation uint64) bool {105 func (p *proxySession) currentLinkGeneration(generation uint64) bool {
M internal/usecase/daemon/proxy_output_test.go +113 -1069
@@ -1,1122 +1,166 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4- "context"
5- "io"
6- "log/slog"
7- "maps"
8- "sync"
9- "sync/atomic"
10 "testing"4 "testing"
11- "time"
12 5
13 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
14 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
15- portsmocks "github.com/bnema/vev/internal/ports/mocks"
16- "github.com/bnema/vev/pkg/renderer"
17- "github.com/stretchr/testify/mock"
18 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
19 )9 )
20 10
21-var proxyHandshakeState atomic.Uint6411+func newProxyOutputAttachment(t *testing.T, proxy *proxySession, id byte) (*attachedClient, *proxyTestTransport) {
22-
23-type proxyRecv struct {
24- frame ports.Frame
25- err error
26-}
27-
28-type proxyTestTransport struct {
29- recv chan proxyRecv
30- closed chan struct{}
31- closeOnce sync.Once
32- sent chan ports.Frame
33-
34- sendGate <-chan struct{}
35- sendEntered chan struct{}
36- ungatedSends atomic.Int32
37- sending atomic.Int32
38- concurrent atomic.Bool
39- receiving atomic.Int32
40- // sendFails rejects every write while leaving Recv able to deliver queued
41- // frames, so a reply failure can be observed without also ending the receive
42- // pump.
43- sendFails atomic.Bool
44-}
45-
46-func newProxyTestTransport() *proxyTestTransport {
47- return newProxyTestTransportWithSendGate(nil, nil, 0)
48-}
49-
50-func newProxyTestTransportWithSendGate(gate <-chan struct{}, entered chan struct{}, ungatedSends int32) *proxyTestTransport {
51- transport := &proxyTestTransport{
52- recv: make(chan proxyRecv, 16),
53- closed: make(chan struct{}),
54- sent: make(chan ports.Frame, 32),
55- sendGate: gate,
56- sendEntered: entered,
57- }
58- transport.ungatedSends.Store(ungatedSends)
59- return transport
60-}
61-
62-func (t *proxyTestTransport) Send(frame ports.Frame) error {
63- if t.sending.Add(1) != 1 {
64- t.concurrent.Store(true)
65- }
66- defer t.sending.Add(-1)
67- if t.sendFails.Load() {
68- return io.ErrClosedPipe
69- }
70- if t.sendGate != nil && t.ungatedSends.Add(-1) < 0 {
71- if t.sendEntered != nil {
72- select {
73- case t.sendEntered <- struct{}{}:
74- default:
75- }
76- }
77- select {
78- case <-t.sendGate:
79- case <-t.closed:
80- return io.ErrClosedPipe
81- }
82- }
83- select {
84- case t.sent <- frame:
85- return nil
86- case <-t.closed:
87- return io.ErrClosedPipe
88- }
89-}
90-
91-func (t *proxyTestTransport) Recv() (ports.Frame, error) {
92- t.receiving.Add(1)
93- defer t.receiving.Add(-1)
94- select {
95- case item := <-t.recv:
96- return item.frame, item.err
97- case <-t.closed:
98- return ports.Frame{}, io.EOF
99- }
100-}
101-
102-func applyTestScreenText(p *proxySession, row, col int, text string) bool {
103- p.mu.Lock()
104- generation := p.linkGeneration
105- size := p.contentSize
106- p.mu.Unlock()
107- frame := renderer.NewFrame(size.Cols, size.Rows)
108- for x, r := range []rune(text) {
109- if col+x < frame.Width {
110- frame.Set(col+x, row, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
111- }
112- }
113- spans := make([]ports.ScreenSpan, size.Rows)
114- for y := range size.Rows {
115- spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: append([]renderer.Cell(nil), frame.Row(y)...)}
116- }
117- _, _, changed := p.applyScreenUpdateForGeneration(generation, ports.ScreenUpdate{
118- NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: size,
119- Cursor: ports.ScreenCursor{Row: uint16(row), Col: uint16(col), Visible: true}, Spans: spans,
120- })
121- return changed
122-}
123-
124-func (t *proxyTestTransport) Close() error {
125- t.closeOnce.Do(func() { close(t.closed) })
126- return nil
127-}
128-
129-func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
130- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
131- SessionID: "remote-id",
132- SessionName: name,
133- ResumeToken: token,
134- Capabilities: capabilities,
135- })}
136-}
137-
138-func proxyHandshakeSnapshot() ports.Frame {
139- const cols, rows = 80, 22
140- spans := make([]ports.ScreenSpan, rows)
141- for y := range rows {
142- cells := make([]renderer.Cell, cols)
143- for x := range cells {
144- cells[x] = renderer.BlankCell()
145- }
146- spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cells}
147- }
148- payload, err := ports.MarshalScreenUpdate(ports.ScreenUpdate{
149- NewStateNum: proxyHandshakeState.Add(1), Kind: ports.ScreenUpdateSnapshot, Size: domain.Size{Cols: cols, Rows: rows},
150- Cursor: ports.ScreenCursor{Visible: true}, Spans: spans,
151- })
152- if err != nil {
153- panic(err)
154- }
155- return ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload}
156-}
157-
158-func proxyMeta(name string) ports.Frame {
159- payload, err := ports.MarshalSessionMeta(ports.SessionMeta{
160- SessionName: name,
161- Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}},
162- })
163- if err != nil {
164- panic(err)
165- }
166- return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}
167-}
168-
169-// newProxyTestDaemon always installs a controllable clock so no proxy test can
170-// depend on wall-clock deadlines. Pass a signalClock to drive resume backoff.
171-func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk ports.Clock) *Daemon {
172- t.Helper()
173- if clk == nil {
174- clk = stubClock{}
175- }
176- return New(nil, clk, slog.New(slog.NewTextHandler(io.Discard, nil)),
177- WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP))
178-}
179-
180-// proxySessionSnapshot clones the registry under d.mu so assertions never read
181-// d.sessions while a registration writes it. Assertions run on the clone, after
182-// the lock has been released.
183-func proxySessionSnapshot(d *Daemon) map[domain.SessionID]attachmentSession {
184- d.mu.Lock()
185- defer d.mu.Unlock()
186- return maps.Clone(d.sessions)
187-}
188-
189-func proxyFactoryFor(t *testing.T, key domain.RemoteSessionKey, transports ...ports.Transport) ports.RemoteDialerFactory {
190 t.Helper()12 t.Helper()
191- factory := portsmocks.NewMockRemoteDialerFactory(t)13+ transport := newProxyTestTransport()
192- for _, transport := range transports {14+ ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: proxy.contentSize}
193- dialer := portsmocks.NewMockDialer(t)15+ ac.output.attachment = ac
194- factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()16+ ac.clientID[0] = id
195- dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()17+ ac.setSession(proxy)
196- }18+ proxy.sessionCore.mu.Lock()
197- return factory19+ require.True(t, proxy.registerAttachmentLocked(ac))
198-}20+ proxy.sessionCore.mu.Unlock()
199-21+ return ac, transport
200-func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello {
201- t.Helper()
202- select {
203- case frame := <-tr.sent:
204- require.Equal(t, ports.MsgHello, frame.Type)
205- hello, err := ports.UnmarshalHello(frame.Payload)
206- require.NoError(t, err)
207- return hello
208- case <-time.After(time.Second):
209- t.Fatal("timed out waiting for proxy Hello")
210- return ports.Hello{}
211- }
212-}
213-
214-func stopProxy(t *testing.T, p *proxySession) {
215- t.Helper()
216- p.stop()
217- select {
218- case <-p.done:
219- case <-time.After(time.Second):
220- t.Fatal("timed out waiting for proxy link to stop")
221- }
222-}
223-
224-func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) {
225- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
226- tr := newProxyTestTransport()
227- d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
228-
229- result := make(chan struct {
230- proxy *proxySession
231- err error
232- }, 1)
233- go func() {
234- proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
235- result <- struct {
236- proxy *proxySession
237- err error
238- }{proxy, err}
239- }()
240-
241- hello := requireProxyHello(t, tr)
242- require.Equal(t, ports.ProtocolVersion, hello.Version)
243- require.Equal(t, ports.IntentAttach, hello.Intent)
244- require.True(t, hello.Proxied)
245- require.Equal(t, key.Name, hello.Name)
246- require.Equal(t, contentSize(domain.Size{Cols: 80, Rows: 24}, false), hello.Size)
247- require.Equal(t, uint8(maxUnackedOutputStates), hello.MaxOutputInFlight)
248- require.Empty(t, proxySessionSnapshot(d), "Hello alone must not publish a proxy")
249-
250- tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)}
251- require.Empty(t, proxySessionSnapshot(d), "Welcome alone must not publish a proxy")
252- tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
253- tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
254-
255- created := awaitTestValue(t, result, "proxy handshake did not complete")
256- require.NoError(t, created.err)
257- require.Same(t, created.proxy, proxySessionSnapshot(d)[key.ID()])
258- require.Equal(t, key, created.proxy.key)
259- require.Equal(t, uint64(44), created.proxy.resumeToken)
260- stopProxy(t, created.proxy)
261-}
262-
263-func TestProxyHandshakeTimesOutBeforePublication(t *testing.T) {
264- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
265- tr := newProxyTestTransport()
266- clock := &signalClock{timers: make(chan *signalTimer, 1)}
267- d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), clock)
268-
269- result := make(chan struct {
270- proxy *proxySession
271- err error
272- }, 1)
273- go func() {
274- proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
275- result <- struct {
276- proxy *proxySession
277- err error
278- }{proxy: proxy, err: err}
279- }()
280-
281- requireProxyHello(t, tr)
282- timer := awaitTestValue(t, clock.timers, "proxy handshake did not arm its timeout")
283- require.Equal(t, proxyHandshakeTimeout, timer.duration)
284- tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
285- tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
286- timer.ch <- time.Time{}
287-
288- opened := awaitTestValue(t, result, "proxy handshake timeout did not finish")
289- require.ErrorIs(t, opened.err, errProxyHandshakeTimeout)
290- require.Nil(t, opened.proxy)
291- require.Empty(t, proxySessionSnapshot(d), "timed-out handshake must not publish a proxy")
292- select {
293- case <-tr.closed:
294- default:
295- t.Fatal("timed-out handshake transport was not closed")
296- }
297- require.Zero(t, tr.receiving.Load(), "timed-out handshake receive pump outlived transport cleanup")
298-}
299-
300-func TestProxyHandshakeTimeoutClosesBlockedSends(t *testing.T) {
301- tests := []struct {
302- name string
303- ungatedSends int32
304- queueHandshake bool
305- }{
306- {name: "hello", queueHandshake: false},
307- {name: "initial ACK", ungatedSends: 1, queueHandshake: true},
308- }
309- for _, tt := range tests {
310- t.Run(tt.name, func(t *testing.T) {
311- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
312- entered := make(chan struct{}, 1)
313- tr := newProxyTestTransportWithSendGate(make(chan struct{}), entered, tt.ungatedSends)
314- if tt.queueHandshake {
315- tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
316- tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
317- tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
318- }
319- clock := &signalClock{timers: make(chan *signalTimer, 1)}
320- d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), clock)
321- result := make(chan error, 1)
322- go func() {
323- _, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
324- result <- err
325- }()
326-
327- if tt.queueHandshake {
328- requireProxyHello(t, tr)
329- }
330- awaitTestCompletion(t, entered, "proxy handshake send did not block")
331- timer := awaitTestValue(t, clock.timers, "proxy handshake did not arm its timeout")
332- require.Equal(t, proxyHandshakeTimeout, timer.duration)
333- timer.ch <- time.Time{}
334-
335- require.ErrorIs(t, awaitTestValue(t, result, "blocked handshake send did not finish"), errProxyHandshakeTimeout)
336- select {
337- case <-tr.closed:
338- default:
339- t.Fatal("timed-out handshake transport was not closed")
340- }
341- require.Zero(t, tr.receiving.Load(), "timed-out handshake receive pump outlived transport cleanup")
342- })
343- }
344-}
345-
346-func TestProxyHandshakeContextUsesSystemClockWhenClockMissing(t *testing.T) {
347- d := &Daemon{}
348- ctx, timedOut, stop := d.proxyHandshakeContext(context.Background())
349- defer stop()
350- require.NoError(t, ctx.Err())
351- select {
352- case <-timedOut:
353- t.Fatal("missing clock caused an immediate handshake timeout")
354- default:
355- }
356 }22 }
357 23
358-func TestProxyHandshakeRejectsInvalidWelcomeWithoutPublication(t *testing.T) {24+func TestProxyOutputRejectsStaleStateAndRequestsOneReset(t *testing.T) {
359 tests := []struct {25 tests := []struct {
360 name string26 name string
361- first ports.Frame27+ state ports.Output
28+ stale ports.Output
362 }{29 }{
363- {name: "missing proxied capability", first: proxyWelcome("work", 1, ports.CapabilityResume)},30+ {
364- {name: "malformed welcome", first: ports.Frame{Type: ports.MsgWelcome, Payload: []byte{0xff}}},31+ name: "duplicate full",
365- {name: "wrong first type", first: proxyMeta("work")},32+ state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true},
33+ stale: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true, Data: []byte("duplicate")},
34+ },
35+ {
36+ name: "older epoch",
37+ state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true},
38+ stale: ports.Output{Epoch: 2, Base: 0, New: 5, Full: true, Data: []byte("older")},
39+ },
40+ {
41+ name: "base-zero non-full",
42+ state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true},
43+ stale: ports.Output{Epoch: 3, Base: 0, New: 5, Data: []byte("not full")},
44+ },
366 }45 }
367 for _, tt := range tests {46 for _, tt := range tests {
368 t.Run(tt.name, func(t *testing.T) {47 t.Run(tt.name, func(t *testing.T) {
369- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}48+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
370- tr := newProxyTestTransport()49+ require.NoError(t, err)
371- tr.recv <- proxyRecv{frame: tt.first}50+ proxy.linkGeneration = 1
372- d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})51+ ack, reset, changed := proxy.applyOutputForGeneration(1, tt.state)
373-52+ require.Equal(t, uint64(4), ack)
374- proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})53+ require.False(t, reset)
375- require.Error(t, err)54+ require.Equal(t, len(tt.state.Data) != 0, changed)
376- require.Nil(t, proxy)55+
377- require.Empty(t, proxySessionSnapshot(d))56+ ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale)
378- select {57+ require.Zero(t, ack)
379- case <-tr.closed:58+ require.True(t, reset)
380- default:59+ require.False(t, changed)
381- t.Fatal("failed handshake transport was not closed")60+ ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale)
382- }61+ require.Zero(t, ack)
62+ require.False(t, reset)
63+ require.False(t, changed)
383 })64 })
384 }65 }
385 }66 }
386 67
387-func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) {68+func TestProxyOutputFansOutToEveryLiveAttachment(t *testing.T) {
388- firstKey := domain.RemoteSessionKey{Host: "build.example", Name: "work"}69+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
389- secondKey := domain.RemoteSessionKey{Host: "build", Name: "example-work"}
390- firstTransport := newProxyTestTransport()
391- firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)}
392- firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)}
393- firstTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
394- secondTransport := newProxyTestTransport()
395- secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)}
396- secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)}
397- secondTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
398- factory := portsmocks.NewMockRemoteDialerFactory(t)
399- for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} {
400- dialer := portsmocks.NewMockDialer(t)
401- factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
402- dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
403- }
404- d := newProxyTestDaemon(t, factory, stubClock{})
405-
406- first, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 80, Rows: 24})
407- require.NoError(t, err)
408- deduped, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 100, Rows: 30})
409- require.NoError(t, err)
410- require.Same(t, first, deduped)
411- second, err := d.openProxySession(context.Background(), secondKey, domain.Size{Cols: 80, Rows: 24})
412- require.NoError(t, err)
413- require.NotSame(t, first, second)
414- require.Len(t, proxySessionSnapshot(d), 2)
415-
416- stopProxy(t, first)
417- stopProxy(t, second)
418-}
419-
420-func TestProxyCancelDuringDialOrHandshakeNeverPublishes(t *testing.T) {
421- t.Run("dial", func(t *testing.T) {
422- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
423- factory := portsmocks.NewMockRemoteDialerFactory(t)
424- dialer := portsmocks.NewMockDialer(t)
425- factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
426- dialEntered := make(chan struct{})
427- dialer.EXPECT().Dial(mock.Anything).RunAndReturn(func(ctx context.Context) (ports.Transport, error) {
428- close(dialEntered)
429- <-ctx.Done()
430- return nil, ctx.Err()
431- }).Once()
432- d := newProxyTestDaemon(t, factory, stubClock{})
433- ctx, cancel := context.WithCancel(context.Background())
434- result := make(chan struct {
435- proxy *proxySession
436- err error
437- }, 1)
438- go func() {
439- proxy, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
440- result <- struct {
441- proxy *proxySession
442- err error
443- }{proxy: proxy, err: err}
444- }()
445- awaitTestCompletion(t, dialEntered, "proxy dial did not start")
446- cancel()
447-
448- opened := awaitTestValue(t, result, "canceled proxy dial did not finish")
449- proxy, err := opened.proxy, opened.err
450- require.ErrorIs(t, err, context.Canceled)
451- require.Nil(t, proxy)
452- require.Empty(t, proxySessionSnapshot(d))
453- })
454-
455- t.Run("handshake", func(t *testing.T) {
456- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
457- tr := newProxyTestTransport()
458- d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
459- ctx, cancel := context.WithCancel(context.Background())
460- result := make(chan error, 1)
461- go func() {
462- _, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24})
463- result <- err
464- }()
465- requireProxyHello(t, tr)
466- cancel()
467- require.ErrorIs(t, awaitTestValue(t, result, "canceled proxy handshake did not finish"), context.Canceled)
468- require.Empty(t, proxySessionSnapshot(d))
469- select {
470- case <-tr.closed:
471- default:
472- t.Fatal("cancellation did not close the handshaking transport")
473- }
474- })
475-}
476-
477-func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) {
478- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
479- first := newProxyTestTransport()
480- first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)}
481- first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
482- first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
483- second := newProxyTestTransport()
484- factory := proxyFactoryFor(t, key, first, second)
485- // The resume backoff is a ports.Clock deadline, so it is fired rather than
486- // waited out. The buffer also absorbs the warm timer armed by replacement.
487- clock := &signalClock{timers: make(chan *signalTimer, 8)}
488- d := newProxyTestDaemon(t, factory, clock)
489-
490- proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
491 require.NoError(t, err)70 require.NoError(t, err)
492- firstHello := requireProxyHello(t, first)71+ remote := newProxyTestTransport()
493- require.Equal(t, ports.IntentAttach, firstHello.Intent)72+ proxy.transport = remote
494- handshakeTimer := awaitTestValue(t, clock.timers, "proxy handshake timeout was not registered")73+ proxy.linkGeneration = 1
495- require.Equal(t, proxyHandshakeTimeout, handshakeTimer.duration)74+ d := &Daemon{}
496-
497- first.recv <- proxyRecv{err: io.EOF}
498- backoff := awaitTestValue(t, clock.timers, "proxy resume backoff timer was not armed")
499- require.Equal(t, proxyResumeInitialBackoff, backoff.duration)
500- backoff.ch <- time.Time{}
501- resumeHello := requireProxyHello(t, second)
502- require.Equal(t, ports.IntentResume, resumeHello.Intent)
503- require.Equal(t, uint64(55), resumeHello.ResumeToken)
504- require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
505- second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)}
506- second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
507- second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
508- second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}}
509 75
510- select {76+ attachments := make([]*proxyTestTransport, 0, 2)
511- case <-proxy.done:77+ for id := byte(1); id <= 2; id++ {
512- case <-time.After(time.Second):78+ _, clientTransport := newProxyOutputAttachment(t, proxy, id)
513- t.Fatal("replacement did not terminate proxy link loop")79+ attachments = append(attachments, clientTransport)
514 }80 }
515- proxy.mu.Lock()
516- require.True(t, proxy.expired)
517- require.Equal(t, uint64(66), proxy.resumeToken)
518- proxy.mu.Unlock()
519- require.Same(t, proxy, proxySessionSnapshot(d)[key.ID()], "replacement leaves the expired proxy available for local presentation")
520-}
521-
522-func TestRunProxyTransportStopsReceivePumpAfterInvalidFrame(t *testing.T) {
523- d, proxy, transport, generation := newProxyOutputSession(t)
524- t.Cleanup(func() { _ = transport.Close() })
525- transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
526- transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}
527- transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}}
528 81
529- result := d.runProxyTransport(context.Background(), proxy, generation, transport)82+ result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
530- require.Equal(t, proxyLinkStop, result)83+ Type: ports.MsgOutput,
531- select {84+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
532- case <-transport.closed:85+ })
533- default:
534- t.Fatal("runProxyTransport returned without closing its receive pump transport")
535- }
536- require.Zero(t, transport.receiving.Load(), "receive pump outlived transport consumer")
537-}
538-
539-func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
540- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
541- gate := make(chan struct{})
542- entered := make(chan struct{}, 1)
543- tr := newProxyTestTransportWithSendGate(gate, entered, 2)
544- tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
545- tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
546- tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
547- d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
548- proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
549 require.NoError(t, err)86 require.NoError(t, err)
550- _ = requireProxyHello(t, tr)87+ require.Equal(t, proxyLinkResume, result)
551- var wg sync.WaitGroup88+ for _, transport := range attachments {
552- errs := make(chan error, 8)89+ frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive output")
553- for range 8 {90+ require.Equal(t, ports.MsgOutput, frame.Type)
554- wg.Go(func() {91+ out, err := ports.UnmarshalOutput(frame.Payload)
555- errs <- proxy.sendCurrent(ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})})
556- })
557- }
558- // Once the first sender enters Send, the others must remain behind sendMu.
559- awaitTestCompletion(t, entered, "proxy sender did not enter transport")
560- close(gate)
561- wg.Wait()
562- close(errs)
563- for err := range errs {
564 require.NoError(t, err)92 require.NoError(t, err)
93+ require.Zero(t, out.New, "local proxy forwarding must not create a second ACK path")
94+ require.Equal(t, []byte("full"), out.Data)
565 }95 }
566- require.False(t, tr.concurrent.Load(), "Transport.Send calls overlapped")96+ ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive output acknowledgement")
567- stopProxy(t, proxy)97+ require.Equal(t, ports.MsgAck, ackFrame.Type)
568 }98 }
569 99
570-func TestProxyHandshakeRejectsMissingInitialScreenSnapshotWithoutPublication(t *testing.T) {100+func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) {
571- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}101+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
572- tr := newProxyTestTransport()
573- tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
574- tr.recv <- proxyRecv{frame: proxyMeta(key.Name)}
575- tr.recv <- proxyRecv{err: io.EOF}
576- d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
577-
578- proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
579- require.Error(t, err)
580- require.Nil(t, proxy)
581- require.Empty(t, proxySessionSnapshot(d))
582-}
583-
584-func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) {
585- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
586- tr := newProxyTestTransport()
587- tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
588- tr.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: []byte{0xff}}}
589- d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
590-
591- proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
592- require.Error(t, err)
593- require.Nil(t, proxy)
594- require.Empty(t, proxySessionSnapshot(d))
595-}
596-
597-func TestProxyOpenValidatesDependenciesAndKey(t *testing.T) {
598- d := newProxyTestDaemon(t, nil, stubClock{})
599- _, err := d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
600- require.Error(t, err)
601-
602- factory := portsmocks.NewMockRemoteDialerFactory(t)
603- d = newProxyTestDaemon(t, factory, stubClock{})
604- _, err = d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "bad:name"}, domain.Size{Cols: 80, Rows: 24})
605- require.Error(t, err)
606- require.Empty(t, proxySessionSnapshot(d))
607-}
608-
609-func newProxyOutputSession(t *testing.T) (*Daemon, *proxySession, *proxyTestTransport, uint64) {
610- t.Helper()
611- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
612- require.NoError(t, err)
613- transport := newProxyTestTransport()
614- generation, _ := proxy.installTransport(transport, false)
615- return newProxyTestDaemon(t, nil, stubClock{}), proxy, transport, generation
616-}
617-
618-func TestProxyScreenResumeAndFreshLinksRequireMovingSnapshots(t *testing.T) {
619- d, proxy, first, generation := newProxyOutputSession(t)
620- snapshot, err := ports.UnmarshalScreenUpdate(proxyHandshakeSnapshot().Payload)
621 require.NoError(t, err)102 require.NoError(t, err)
622- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))103+ remote := newProxyTestTransport()
623- _ = awaitTestValue(t, first.sent, "initial screen was not acknowledged")104+ proxy.transport = remote
624- proxy.mu.Lock()105+ proxy.linkGeneration = 1
625- applied := proxy.appliedState106+ d := &Daemon{}
626- proxy.mu.Unlock()
627-
628- second := newProxyTestTransport()
629- generation, _ = proxy.installTransport(second, true)
630- proxy.mu.Lock()
631- require.Equal(t, applied, proxy.appliedState, "resume must preserve the applied state floor")
632- require.False(t, proxy.screenReady, "resume must wait for its replacement snapshot")
633- proxy.mu.Unlock()
634- gap := snapshot
635- gap.Kind = ports.ScreenUpdateDelta
636- gap.BaseStateNum, gap.NewStateNum = applied, applied+1
637- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap))
638- require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, second.sent, "resume delta did not request a reset").Type)
639- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap))
640- select {
641- case frame := <-second.sent:
642- t.Fatalf("duplicate resume gap sent %v", frame.Type)
643- default:
644- }
645- moving := snapshot
646- moving.NewStateNum = applied + 1
647- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, moving))
648- require.Equal(t, ports.MsgAck, awaitTestValue(t, second.sent, "resume snapshot was not acknowledged").Type)
649 107
650- third := newProxyTestTransport()108+ _, firstTransport := newProxyOutputAttachment(t, proxy, 1)
651- generation, _ = proxy.installTransport(third, false)
652- proxy.mu.Lock()
653- require.Zero(t, proxy.appliedState, "fresh link must clear the state floor")
654- require.False(t, proxy.screenReady)
655- proxy.mu.Unlock()
656- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap))
657- require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, third.sent, "fresh delta did not request a reset").Type)
658- fresh := snapshot
659- fresh.NewStateNum = 1
660- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, fresh))
661- require.Equal(t, ports.MsgAck, awaitTestValue(t, third.sent, "fresh state-one snapshot was not acknowledged").Type)
662- proxy.mu.Lock()
663- require.Equal(t, uint64(1), proxy.appliedState)
664- proxy.mu.Unlock()
665-}
666 109
667-func TestProxyScreenStaleSnapshotsAndApplyFailuresRequestOneResetWithoutMutation(t *testing.T) {110+ handle := func(output ports.Output) {
668- newProxy := func(t *testing.T) (*Daemon, *proxySession, uint64) {
669 t.Helper()111 t.Helper()
670- d, proxy, _, generation := newProxyOutputSession(t)112+ result, err := d.handleLinkFrame(proxy, 1, ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)})
671- return d, proxy, generation113+ require.NoError(t, err)
672- }114+ require.Equal(t, proxyLinkResume, result)
673-
674- t.Run("stale snapshot while not ready", func(t *testing.T) {
675- d, proxy, generation := newProxy(t)
676- snapshot := screenSnapshotForProxy(t, proxy, 7)
677- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
678- proxy.mu.Lock()
679- before := proxy.screen.frame.Clone()
680- proxy.screenReady = false
681- proxy.resetRequested = false
682- proxy.mu.Unlock()
683-
684- stale := snapshot
685- stale.NewStateNum = 7
686- ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, stale)
687- require.Zero(t, ack)
688- require.True(t, reset, "a stale snapshot must request a reset while not ready")
689- require.False(t, changed)
690- proxy.mu.Lock()
691- require.Equal(t, uint64(7), proxy.appliedState)
692- require.False(t, proxy.screenReady)
693- require.True(t, proxy.resetRequested)
694- require.Equal(t, before, proxy.screen.frame)
695- proxy.mu.Unlock()
696-
697- ack, reset, changed = proxy.applyScreenUpdateForGeneration(generation, stale)
698- require.Zero(t, ack)
699- require.False(t, reset, "an already-requested reset must be deduplicated")
700- require.False(t, changed)
701- })
702-
703- t.Run("snapshot apply failure", func(t *testing.T) {
704- d, proxy, generation := newProxy(t)
705- initial := screenSnapshotForProxy(t, proxy, 7)
706- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial))
707- proxy.mu.Lock()
708- before := proxy.screen.frame.Clone()
709- proxy.mu.Unlock()
710- invalid := initial
711- invalid.NewStateNum = 8
712- invalid.Spans = invalid.Spans[:len(invalid.Spans)-1]
713- ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, invalid)
714- require.Zero(t, ack)
715- require.True(t, reset)
716- require.False(t, changed)
717- proxy.mu.Lock()
718- require.Equal(t, uint64(7), proxy.appliedState)
719- require.False(t, proxy.screenReady)
720- require.True(t, proxy.resetRequested)
721- require.Equal(t, before, proxy.screen.frame)
722- proxy.mu.Unlock()
723- _, reset, _ = proxy.applyScreenUpdateForGeneration(generation, invalid)
724- require.False(t, reset, "snapshot Apply failure reset was not deduplicated")
725- })
726-
727- t.Run("delta apply failure", func(t *testing.T) {
728- d, proxy, generation := newProxy(t)
729- initial := screenSnapshotForProxy(t, proxy, 7)
730- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial))
731- proxy.mu.Lock()
732- before := proxy.screen.frame.Clone()
733- size := proxy.contentSize
734- proxy.mu.Unlock()
735- invalid := ports.ScreenUpdate{
736- Kind: ports.ScreenUpdateDelta, BaseStateNum: 7, NewStateNum: 8, Size: size,
737- Spans: []ports.ScreenSpan{{Y: uint16(size.Rows), Cells: []renderer.Cell{{Rune: 'x'}}}},
738- }
739- ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, invalid)
740- require.Zero(t, ack)
741- require.True(t, reset)
742- require.False(t, changed)
743- proxy.mu.Lock()
744- require.Equal(t, uint64(7), proxy.appliedState)
745- require.False(t, proxy.screenReady)
746- require.True(t, proxy.resetRequested)
747- require.Equal(t, before, proxy.screen.frame)
748- proxy.mu.Unlock()
749- _, reset, _ = proxy.applyScreenUpdateForGeneration(generation, invalid)
750- require.False(t, reset, "delta Apply failure reset was not deduplicated")
751- })
752-}
753-
754-func screenSnapshotForProxy(t *testing.T, proxy *proxySession, state uint64) ports.ScreenUpdate {
755- t.Helper()
756- proxy.mu.Lock()
757- size := proxy.contentSize
758- proxy.mu.Unlock()
759- snapshot := blankSnapshot(size)
760- snapshot.NewStateNum = state
761- return snapshot
762-}
763-
764-func TestProxyScreenSnapshotReportsChanged(t *testing.T) {
765- _, proxy, _, generation := newProxyOutputSession(t)
766- snapshot := screenSnapshotForProxy(t, proxy, 1)
767- ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, snapshot)
768- require.Equal(t, uint64(1), ack)
769- require.False(t, reset)
770- require.True(t, changed, "every accepted snapshot invalidates the composed frame")
771-}
772-
773-func TestProxyScreenUpdate(t *testing.T) {
774- t.Run("initial snapshot", func(t *testing.T) {
775- d, proxy, transport, generation := newProxyOutputSession(t)
776- snapshot := screenSnapshotForProxy(t, proxy, 1)
777- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
778- require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial screen was not acknowledged").Type)
779- proxy.mu.Lock()
780- require.Equal(t, uint64(1), proxy.appliedState)
781- require.True(t, proxy.screenReady)
782- require.False(t, proxy.resetRequested)
783- proxy.mu.Unlock()
784- })
785-
786- t.Run("matching delta", func(t *testing.T) {
787- d, proxy, transport, generation := newProxyOutputSession(t)
788- snapshot := screenSnapshotForProxy(t, proxy, 1)
789- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
790- _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged")
791-
792- delta := ports.ScreenUpdate{
793- Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2,
794- Size: snapshot.Size, Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("delta")}},
795- }
796- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, delta))
797- require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "screen delta was not acknowledged").Type)
798- proxy.mu.Lock()
799- require.Equal(t, uint64(2), proxy.appliedState)
800- require.Equal(t, 'd', proxy.screen.frame.At(0, 0).Rune)
801- proxy.mu.Unlock()
802- })
803-
804- for _, tc := range []struct {
805- name string
806- base uint64
807- new uint64
808- }{
809- {name: "duplicate", base: 0, new: 1},
810- {name: "gap", base: 3, new: 4},
811- } {
812- t.Run(tc.name+" requests exactly one reset", func(t *testing.T) {
813- d, proxy, transport, generation := newProxyOutputSession(t)
814- snapshot := screenSnapshotForProxy(t, proxy, 1)
815- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
816- _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged")
817-
818- update := ports.ScreenUpdate{
819- Kind: ports.ScreenUpdateDelta, BaseStateNum: tc.base, NewStateNum: tc.new,
820- Size: snapshot.Size,
821- }
822- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, update))
823- require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "screen mismatch did not request reset").Type)
824- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, update))
825- select {
826- case frame := <-transport.sent:
827- t.Fatalf("duplicate %s sent %v", tc.name, frame.Type)
828- default:
829- }
830- proxy.mu.Lock()
831- require.Equal(t, uint64(1), proxy.appliedState)
832- require.False(t, proxy.screenReady)
833- require.True(t, proxy.resetRequested)
834- proxy.mu.Unlock()
835- })
836 }115 }
116+ handle(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("before attach")})
117+ _ = awaitTestValue(t, firstTransport.sent, "first proxy attachment did not receive output")
118+ _ = awaitTestValue(t, remote.sent, "proxy remote did not receive first acknowledgement")
837 119
838- t.Run("awaited base-zero snapshot advances the floor", func(t *testing.T) {120+ _, secondTransport := newProxyOutputAttachment(t, proxy, 2)
839- d, proxy, transport, generation := newProxyOutputSession(t)
840- initial := screenSnapshotForProxy(t, proxy, 5)
841- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial))
842- _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged")
843-
844- gap := ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, BaseStateNum: 9, NewStateNum: 10, Size: initial.Size}
845- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap))
846- require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "screen gap did not request reset").Type)
847-
848- moving := initial
849- moving.NewStateNum = 6
850- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, moving))
851- require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "awaited snapshot was not acknowledged").Type)
852- proxy.mu.Lock()
853- require.Equal(t, uint64(6), proxy.appliedState)
854- require.True(t, proxy.screenReady)
855- require.False(t, proxy.resetRequested)
856- proxy.mu.Unlock()
857- })
858-
859- t.Run("stale generation is ignored", func(t *testing.T) {
860- _, proxy, transport, oldGeneration := newProxyOutputSession(t)
861- replacement := newProxyTestTransport()
862- newGeneration, _ := proxy.installTransport(replacement, false)
863- require.NotEqual(t, oldGeneration, newGeneration)
864- ack, reset, changed := proxy.applyScreenUpdateForGeneration(oldGeneration, screenSnapshotForProxy(t, proxy, 1))
865- require.Zero(t, ack)
866- require.False(t, reset)
867- require.False(t, changed)
868- proxy.mu.Lock()
869- require.Zero(t, proxy.appliedState)
870- proxy.mu.Unlock()
871- select {
872- case frame := <-transport.sent:
873- t.Fatalf("stale screen update sent %v", frame.Type)
874- default:
875- }
876- })
877-
878- t.Run("ACK and reset send failures are resumable", func(t *testing.T) {
879- t.Run("ACK", func(t *testing.T) {
880- d, proxy, transport, generation := newProxyOutputSession(t)
881- transport.sendFails.Store(true)
882- snapshot := screenSnapshotForProxy(t, proxy, 1)
883- payload, err := ports.MarshalScreenUpdate(snapshot)
884- require.NoError(t, err)
885- transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload}}
886- require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, generation, transport))
887- })
888-
889- t.Run("reset", func(t *testing.T) {
890- d, proxy, transport, generation := newProxyOutputSession(t)
891- transport.sendFails.Store(true)
892- proxy.mu.Lock()
893- size := proxy.contentSize
894- proxy.mu.Unlock()
895- gap := ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2, Size: size}
896- payload, err := ports.MarshalScreenUpdate(gap)
897- require.NoError(t, err)
898- transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload}}
899- require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, generation, transport))
900- })
901- })
902-
903- t.Run("blocked send releases proxy.mu", func(t *testing.T) {
904- d, proxy, transport, generation := newProxyOutputSession(t)
905- gate := make(chan struct{})
906- entered := make(chan struct{}, 1)
907- transport.sendGate = gate
908- transport.sendEntered = entered
909- snapshot := screenSnapshotForProxy(t, proxy, 1)
910- result := make(chan error, 1)
911- go func() {
912- result <- d.handleProxyScreenUpdate(proxy, generation, snapshot)
913- }()
914- awaitTestCompletion(t, entered, "screen ACK sender did not enter transport")
915-
916- unlocked := make(chan ports.Transport, 1)
917- go func() {
918- proxy.mu.Lock()
919- currentTransport := proxy.transport
920- proxy.mu.Unlock()
921- unlocked <- currentTransport
922- }()
923- var currentTransport ports.Transport
924- select {
925- case currentTransport = <-unlocked:
926- case <-time.After(time.Second):
927- t.Fatal("proxy.mu was held while screen send was blocked")
928- }
929- require.Same(t, transport, currentTransport)
930- require.NoError(t, transport.Close())
931- require.Error(t, awaitTestValue(t, result, "blocked screen send did not return"))
932- })
933-}
934 121
935-// TestProxyLinkProtocolErrorStillStops fences malformed MsgOutput rejection122+ handle(ports.Output{Epoch: 4, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("current full")})
936-// separately from resumable structured screen reply failures.123+ for _, transport := range []*proxyTestTransport{firstTransport, secondTransport} {
937-func TestProxyLinkProtocolErrorStillStops(t *testing.T) {124+ frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive current output")
938- d, proxy, transport, generation := newProxyOutputSession(t)125+ require.Equal(t, ports.MsgOutput, frame.Type)
939- t.Cleanup(func() { _ = transport.Close() })126+ out, err := ports.UnmarshalOutput(frame.Payload)
940- transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}127+ require.NoError(t, err)
941-128+ require.Zero(t, out.New)
942- require.Equal(t, proxyLinkStop, d.runProxyTransport(context.Background(), proxy, generation, transport))129+ require.False(t, out.Full, "local proxy side effects must remain stateless")
943-}130+ require.Equal(t, []byte("current full"), out.Data)
944-
945-func proxySideEffectFrame(data []byte) ports.Frame {
946- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
947- BaseStateNum: 0,
948- NewStateNum: 0,
949- Data: data,
950- })}
951-}
952-
953-func TestProxySideEffectForwardsOSC52ToCurrentThinClient(t *testing.T) {
954- d := newTestDaemon(t, nil, stubClock{})
955- remote := newProxyTestTransport()
956- thin := newProxyTestTransport()
957- proxy, ac := newAttachedProxyFixture(t, d, thin, remote)
958- ac.echoAck.Store(17)
959-
960- const osc52 = "\x1b]52;c;YQ==\a"
961- result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte(osc52)))
962- require.Equal(t, proxyLinkResume, result)
963- require.NoError(t, err)
964-
965- forwarded := awaitTestValue(t, thin.sent, "proxy OSC52 side effect was not forwarded")
966- require.Equal(t, ports.MsgOutput, forwarded.Type)
967- out, err := ports.UnmarshalOutput(forwarded.Payload)
968- require.NoError(t, err)
969- require.Zero(t, out.BaseStateNum)
970- require.Zero(t, out.NewStateNum)
971- require.Equal(t, uint64(17), out.EchoAck)
972- require.Equal(t, []byte(osc52), out.Data)
973- select {
974- case frame := <-thin.sent:
975- t.Fatalf("proxy OSC52 side effect forwarded more than once: %v", frame.Type)
976- default:
977- }
978- proxy.mu.Lock()
979- require.Zero(t, proxy.appliedState)
980- require.False(t, proxy.resetRequested)
981- require.True(t, proxy.screenReady, "side effect must not alter proxy screen readiness")
982- proxy.mu.Unlock()
983- select {
984- case frame := <-remote.sent:
985- t.Fatalf("side effect unexpectedly replied on proxy link: %v", frame.Type)
986- default:
987 }131 }
132+ ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive current acknowledgement")
133+ require.Equal(t, ports.MsgAck, ackFrame.Type)
988 }134 }
989 135
990-func TestProxySideEffectFansOutToEveryActiveAttachment(t *testing.T) {136+func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) {
991- d := newTestDaemon(t, nil, stubClock{})137+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
992- remote := newProxyTestTransport()
993- firstTransport := newProxyTestTransport()
994- proxy, first := newAttachedProxyFixture(t, d, firstTransport, remote)
995- secondTransport := newProxyTestTransport()
996- second := &attachedClient{tr: secondTransport, output: newOutputStateStream()}
997- second.setSession(proxy)
998- proxy.sessionCore.mu.Lock()
999- require.True(t, proxy.sessionCore.registerAttachmentLocked(second))
1000- proxy.sessionCore.mu.Unlock()
1001- first.echoAck.Store(11)
1002- second.echoAck.Store(22)
1003-
1004- result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect")))
1005- require.Equal(t, proxyLinkResume, result)
1006 require.NoError(t, err)138 require.NoError(t, err)
1007- for _, tc := range []struct {
1008- name string
1009- ch <-chan ports.Frame
1010- ack uint64
1011- }{
1012- {name: "first", ch: firstTransport.sent, ack: 11},
1013- {name: "second", ch: secondTransport.sent, ack: 22},
1014- } {
1015- t.Run(tc.name, func(t *testing.T) {
1016- frame := awaitTestValue(t, tc.ch, tc.name+" attachment did not receive side effect")
1017- out, err := ports.UnmarshalOutput(frame.Payload)
1018- require.NoError(t, err)
1019- require.Equal(t, tc.ack, out.EchoAck)
1020- require.Equal(t, []byte("effect"), out.Data)
1021- })
1022- }
1023-}
1024-
1025-func TestProxySideEffectSkipsStaleAttachmentAndForwardsLiveAttachment(t *testing.T) {
1026- d := newTestDaemon(t, nil, stubClock{})
1027 remote := newProxyTestTransport()139 remote := newProxyTestTransport()
1028- staleTransport := newProxyTestTransport()140+ proxy.transport = remote
1029- proxy, stale := newAttachedProxyFixture(t, d, staleTransport, remote)141+ proxy.linkGeneration = 1
1030- liveTransport := newProxyTestTransport()142+ _, clientTransport := newProxyOutputAttachment(t, proxy, 1)
1031- live := &attachedClient{tr: liveTransport, output: newOutputStateStream()}143+ d := &Daemon{}
1032- live.setSession(proxy)
1033- proxy.sessionCore.mu.Lock()
1034- require.True(t, proxy.sessionCore.registerAttachmentLocked(live))
1035- proxy.sessionCore.mu.Unlock()
1036- staleToken := attachmentToken(proxy, stale, stale.transport())
1037- staleEffect, admitted := stale.beginAttachmentEffect(staleToken)
1038- require.True(t, admitted)
1039- staleEffect.End()
1040- stale.connectionGeneration.Add(1)
1041 144
1042- result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect")))145+ _, err = d.handleLinkFrame(proxy, 1, ports.Frame{
1043- require.Equal(t, proxyLinkResume, result)146+ Type: ports.MsgOutput,
147+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
148+ })
1044 require.NoError(t, err)149 require.NoError(t, err)
1045- awaitTestValue(t, liveTransport.sent, "live attachment did not receive side effect")150+ _ = awaitTestValue(t, clientTransport.sent, "proxy attachment did not receive initial output")
1046- select {151+ _ = awaitTestValue(t, remote.sent, "proxy remote did not receive initial acknowledgement")
1047- case frame := <-staleTransport.sent:
1048- t.Fatalf("stale attachment received side effect: %v", frame.Type)
1049- default:
1050- }
1051-}
1052-
1053-func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) {
1054- tests := []struct {
1055- name string
1056- frame ports.Frame
1057- }{
1058- {name: "state bearing", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("screen")})}},
1059- {name: "malformed", frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}},
1060- }
1061- for _, tt := range tests {
1062- t.Run(tt.name, func(t *testing.T) {
1063- d := newTestDaemon(t, nil, stubClock{})
1064- remote := newProxyTestTransport()
1065- thin := newProxyTestTransport()
1066- proxy, _ := newAttachedProxyFixture(t, d, thin, remote)
1067-
1068- result, err := d.handleLinkFrame(proxy, 1, tt.frame)
1069- require.Equal(t, proxyLinkStop, result)
1070- require.Error(t, err)
1071- select {
1072- case frame := <-thin.sent:
1073- t.Fatalf("invalid proxy output forwarded: %v", frame.Type)
1074- default:
1075- }
1076- })
1077- }
1078-}
1079-
1080-func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) {
1081- d := newTestDaemon(t, nil, stubClock{})
1082- remote := newProxyTestTransport()
1083- thin := newProxyTestTransport()
1084- proxy, ac := newAttachedProxyFixture(t, d, thin, remote)
1085- frame := proxySideEffectFrame([]byte("effect"))
1086 152
1087- result, err := d.handleLinkFrame(proxy, 0, frame)153+ result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
1088- require.Equal(t, proxyLinkResume, result)154+ Type: ports.MsgOutput,
155+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("stale")}),
156+ })
1089 require.NoError(t, err)157 require.NoError(t, err)
1090- select {
1091- case forwarded := <-thin.sent:
1092- t.Fatalf("stale proxy generation forwarded side effect: %v", forwarded.Type)
1093- default:
1094- }
1095-
1096- token := attachmentToken(proxy, ac, ac.transport())
1097- ticket, admitted := ac.beginAttachmentEffect(token)
1098- require.True(t, admitted)
1099- ticket.End()
1100- ac.connectionGeneration.Add(1)
1101- result, err = d.handleLinkFrame(proxy, 1, frame)
1102 require.Equal(t, proxyLinkResume, result)158 require.Equal(t, proxyLinkResume, result)
1103- require.NoError(t, err)
1104 select {159 select {
1105- case forwarded := <-thin.sent:160+ case frame := <-clientTransport.sent:
1106- t.Fatalf("stale attachment role forwarded side effect: %v", forwarded.Type)161+ t.Fatalf("stale proxy output forwarded as %v", frame.Type)
1107 default:162 default:
1108 }163 }
1109-}164+ reset := awaitTestValue(t, remote.sent, "proxy remote did not receive reset request")
1110-165+ require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
1111-func TestProxySideEffectSendFailureResumesLink(t *testing.T) {
1112- d := newTestDaemon(t, nil, stubClock{})
1113- remote := newProxyTestTransport()
1114- thin := newProxyTestTransport()
1115- thin.sendFails.Store(true)
1116- proxy, _ := newAttachedProxyFixture(t, d, thin, remote)
1117- err := d.handleProxySideEffect(proxy, 1, ports.Output{Data: []byte("effect")})
1118- require.ErrorContains(t, err, "link reply send failed")
1119- remote.recv <- proxyRecv{frame: proxySideEffectFrame([]byte("effect"))}
1120-
1121- require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, 1, remote))
1122 }166 }
D internal/usecase/daemon/proxy_render.go +0 -166
@@ -1,166 +0,0 @@
1-package daemon
2-
3-import (
4- "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/ports"
6- "github.com/bnema/vev/internal/usecase/layout"
7- "github.com/bnema/vev/pkg/renderer"
8-)
9-
10-const proxyRenderPaneID layout.PaneID = "proxy-content"
11-
12-type proxyCapture struct {
13- screen *proxyScreenState
14- frame renderer.Frame
15- damage []renderer.Damage
16- generation uint64
17- cursor capturedCursorInputs
18-}
19-
20-// captureRenderState snapshots the remote VT while holding only proxy.mu. The
21-// snapshot is then composed by the ordinary local frame pipeline, which owns
22-// the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore
23-// never handed to a renderer.
24-func (p *proxySession) captureRenderState(ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) {
25- if p == nil || ac == nil {
26- return nil, false
27- }
28-
29- p.mu.Lock()
30- if p.screen == nil {
31- p.mu.Unlock()
32- return nil, false
33- }
34- screen := p.screen
35- capture := &ac.proxyCapture
36- damage := screen.CaptureDamage()
37- if capture.screen != screen {
38- // A new 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,
54- renderable: frame.Width > 0 && frame.Height > 0,
55- content: domain.Rect{Width: frame.Width, Height: frame.Height},
56- }
57- cursor := capture.cursor
58- paneDamage := append([]renderer.Damage(nil), capture.damage...)
59- p.mu.Unlock()
60-
61- content := domain.Rect{Width: frame.Width, Height: frame.Height}
62- if uncertainDamage(paneDamage, frame.Width, frame.Height) {
63- paneDamage = []renderer.Damage{renderer.FullRedraw()}
64- }
65- state := &capturedRenderState{
66- attachment: ac,
67- lease: request.lease,
68- reset: request.reset,
69- layout: capturedTabLayout{area: content, focus: proxyRenderPaneID, valid: true, fingerprint: "proxy"},
70- bars: request.bars,
71- theme: request.bars.theme,
72- styles: request.styles,
73- styleGeneration: request.styleGeneration,
74- overlays: request.overlays,
75- preview: request.preview,
76- cursor: cursor,
77- receipts: []damageReceipt{{proxy: p, proxyScreen: screen, generation: damage.Generation}},
78- panes: []capturedPaneRenderState{{
79- id: proxyRenderPaneID,
80- frame: frame,
81- damage: paneDamage,
82- placement: layout.Placement{ID: proxyRenderPaneID, Content: content},
83- focused: true,
84- }},
85- }
86- return state, true
87-}
88-
89-// firstProxyPaintWithLease emits the same full first paint as a local
90-// transition, after making the remote content geometry match the local viewport.
91-func (d *Daemon) firstProxyPaintWithLease(p *proxySession, ac *attachedClient, lease *attachmentLease) {
92- if p == nil || ac == nil {
93- return
94- }
95- _, _ = p.resize(ac.size)
96- if rc := attachmentRenderCoordinator(p); rc != nil {
97- if rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"}) {
98- rc.fireCurrent(false)
99- }
100- return
101- }
102- d.invalidateRenderNow(p, ac, true, "proxy_render.go")
103-}
104-
105-// resizeProxyForLease accepts a local client resize only for its exact active
106-// attachment lease. Local chrome is repainted whenever the local content
107-// rectangle moved, including when the remote never learned about it: the local
108-// VT has already been resized either way, so skipping the repaint would leave
109-// the client showing chrome for the old geometry.
110-func (d *Daemon) resizeProxyForLease(p *proxySession, ac *attachedClient, lease *attachmentLease, size domain.Size) {
111- if p == nil || ac == nil || !size.Valid() {
112- return
113- }
114- if rc := attachmentRenderCoordinator(p); rc != nil && !rc.leaseCurrent(lease, false) {
115- return
116- }
117- if changed, _ := p.resize(size); !changed {
118- return
119- }
120- ac.sendMu.Lock()
121- ac.size = size
122- ac.sendMu.Unlock()
123- if rc := attachmentRenderCoordinator(p); rc != nil {
124- rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"})
125- return
126- }
127- d.invalidateRender(p, ac, true, "proxy_render.go")
128-}
129-
130-// resize updates the local VT to the local content rectangle and sends that
131-// reduced geometry once to the remote daemon. The sender performs transport
132-// I/O only after proxy.mu has been released.
133-//
134-// changed reports whether the local content rectangle actually moved, which is
135-// what local chrome repaints depend on. sent reports whether the remote was
136-// told. The two are deliberately separate: a failed send leaves the local VT
137-// already resized, so the attached client still owes a repaint.
138-func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
139- if p == nil || !size.Valid() {
140- return false, false
141- }
142- content := contentSize(size, false)
143- if !validProxyScreenSize(content) {
144- return false, false
145- }
146- p.mu.Lock()
147- if p.screen == nil || p.contentSize == content {
148- available := p.screen != nil
149- p.mu.Unlock()
150- return false, available
151- }
152- p.screen.ResizePlaceholder(content)
153- p.contentSize = content
154- // A resize invalidates the local replay chain, but the last applied state
155- // remains the moving snapshot floor for the new geometry.
156- p.screenReady = false
157- p.resetRequested = false
158- p.screen.stateNum = 0
159- generation := p.linkGeneration
160- p.mu.Unlock()
161-
162- return true, p.sendGeneration(generation, ports.Frame{
163- Type: ports.MsgResize,
164- Payload: ports.MarshalResize(ports.Resize{Size: content}),
165- }) == nil
166-}
D internal/usecase/daemon/proxy_render_test.go +0 -409
@@ -1,409 +0,0 @@
1-package daemon
2-
3-import (
4- "fmt"
5- "io"
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/bnema/vev/pkg/renderer"
12- "github.com/stretchr/testify/mock"
13- "github.com/stretchr/testify/require"
14-)
15-
16-func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) {
17- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 4, Rows: 3})
18- require.NoError(t, err)
19- proxy.mu.Lock()
20- generation := proxy.linkGeneration
21- snapshot := screenSnapshot(4, 3, "abcd", "efgh", "ijkl")
22- require.NoError(t, proxy.screen.Apply(snapshot))
23- proxy.screenReady = true
24- proxy.appliedState = snapshot.NewStateNum
25- proxy.mu.Unlock()
26-
27- ac := &attachedClient{}
28- state, ok := proxy.captureRenderState(ac, renderCaptureRequest{})
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.captureRenderState(ac, renderCaptureRequest{})
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-
53-func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) {
54- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
55- require.NoError(t, err)
56- proxy.mu.Lock()
57- proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
58- proxy.mu.Unlock()
59- changed := applyTestScreenText(proxy, 1, 2, "remote")
60- require.True(t, changed)
61-
62- state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{
63- bars: barState{status: proxy.statusSegments(false)},
64- reset: true,
65- })
66- require.True(t, ok)
67- composed := composeFrame(*state, composeCacheInput{})
68-
69- require.Equal(t, 6, composed.frame.Height)
70- require.Contains(t, proxyRowText(composed.frame.Row(0)), "shell", "one local top bar")
71- require.Contains(t, proxyRowText(composed.frame.Row(5)), "work@arch", "one local bottom bar")
72- require.NotContains(t, proxyRowText(composed.frame.Row(1)), "shell")
73- require.NotContains(t, proxyRowText(composed.frame.Row(4)), "work@arch")
74- require.Equal(t, 'r', composed.frame.At(2, 2).Rune, "ANSI was applied only by the local VT")
75- require.False(t, composed.cursor.hidden)
76- require.Equal(t, 2, composed.cursor.row, "cursor includes exactly the one local top bar")
77- require.Contains(t, composed.damage, renderer.FullRedraw())
78-}
79-
80-func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) {
81- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
82- require.NoError(t, err)
83- proxy.mu.Lock()
84- proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
85- proxy.mu.Unlock()
86- changed := applyTestScreenText(proxy, 0, 0, "remote")
87- require.True(t, changed)
88-
89- d := newTestDaemon(t, nil, stubClock{})
90- transport, sent := newCapturingTransport(t)
91- ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
92- ac.setSession(proxy)
93- proxy.sessionCore.mu.Lock()
94- proxy.sessionCore.registerAttachmentLocked(ac)
95- proxy.sessionCore.mu.Unlock()
96-
97- d.paint(proxy, ac, true, nil)
98- frame := awaitTestValue(t, sent, "handoff to remote proxy did not paint")
99- require.Equal(t, ports.MsgOutput, frame.Type, "handoff to remote proxy gets a local first paint")
100-}
101-
102-func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) {
103- d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY())
104- localCoordinator := d.attachCoordinator(local, ac, true)
105- localToken := attachmentToken(local, ac, ac.transport())
106- localToken.lease = localCoordinator.attachmentLease(ac)
107-
108- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size)
109- require.NoError(t, err)
110- proxy.mu.Lock()
111- proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
112- proxy.mu.Unlock()
113- changed := applyTestScreenText(proxy, 0, 0, "remote")
114- require.True(t, changed)
115- d.mu.Lock()
116- require.True(t, d.registerSessionLocked(proxy))
117- d.mu.Unlock()
118-
119- toProxy, err := d.transitionAttachment(attachmentTransitionRequest{
120- source: local, target: proxy, next: ac,
121-
122- expectedTransport: ac.transportSnapshot(), sourceToken: &localToken,
123- action: "test transition", ready: true,
124- })
125- require.NoError(t, err)
126- require.NotNil(t, toProxy.published.lease, "proxy transition installs a coordinator lease")
127- require.True(t, d.firstPaintForTransition(toProxy.published))
128- require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to proxy did not paint").Type)
129-
130- toLocal, err := d.transitionAttachment(attachmentTransitionRequest{
131- source: proxy, target: local, next: ac,
132-
133- expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published,
134- action: "test transition", ready: true,
135- })
136- require.NoError(t, err)
137- require.True(t, d.firstPaintForTransition(toLocal.published))
138- require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to local session did not paint").Type)
139-}
140-
141-func TestProxyResizeRejectsOversizedGeometryBeforeMutationOrForwarding(t *testing.T) {
142- for _, size := range []domain.Size{
143- {Cols: 512, Rows: 515},
144- {Cols: 1000, Rows: 1000},
145- } {
146- t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) {
147- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24})
148- require.NoError(t, err)
149- transport := newProxyTestTransport()
150- proxy.mu.Lock()
151- proxy.transport = transport
152- proxy.linkGeneration = 1
153- initial := proxy.contentSize
154- proxy.mu.Unlock()
155-
156- changed, sent := proxy.resize(size)
157- require.False(t, changed)
158- require.False(t, sent)
159- proxy.mu.Lock()
160- require.Equal(t, initial, proxy.contentSize)
161- proxy.mu.Unlock()
162- select {
163- case frame := <-transport.sent:
164- t.Fatalf("oversized resize was forwarded: %#v", frame)
165- default:
166- }
167- })
168- }
169-}
170-
171-func TestProxyResizeForLeaseRejectsOversizedGeometryBeforeMutationOrPaint(t *testing.T) {
172- d := newTestDaemon(t, nil, stubClock{})
173- client, clientSent := newCapturingTransport(t)
174- remote := newProxyTestTransport()
175- proxy, ac := newAttachedProxyFixture(t, d, client, remote)
176- initialContent := proxy.contentSize
177- initialViewport := ac.size
178-
179- d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 1000, Rows: 1000})
180-
181- proxy.mu.Lock()
182- require.Equal(t, initialContent, proxy.contentSize)
183- proxy.mu.Unlock()
184- require.Equal(t, initialViewport, ac.size)
185- select {
186- case frame := <-clientSent:
187- t.Fatalf("oversized resize painted the client: %#v", frame)
188- default:
189- }
190- select {
191- case frame := <-remote.sent:
192- t.Fatalf("oversized resize was forwarded: %#v", frame)
193- default:
194- }
195-}
196-
197-func TestProxyResizeKeepsPlaceholderUntilSnapshot(t *testing.T) {
198- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 4, Rows: 4})
199- require.NoError(t, err)
200- transport := newProxyTestTransport()
201- proxy.mu.Lock()
202- initial := screenSnapshot(4, 2, "abcd", "efgh")
203- initial.NewStateNum = 7
204- require.NoError(t, proxy.screen.Apply(initial))
205- proxy.transport = transport
206- proxy.linkGeneration = 1
207- proxy.screenReady = true
208- proxy.appliedState = initial.NewStateNum
209- proxy.mu.Unlock()
210-
211- changed, sent := proxy.resize(domain.Size{Cols: 3, Rows: 5})
212- require.True(t, changed)
213- require.True(t, sent)
214- awaitTestValue(t, transport.sent, "proxy resize did not reach remote transport")
215-
216- proxy.mu.Lock()
217- require.Equal(t, domain.Size{Cols: 3, Rows: 3}, proxy.contentSize)
218- require.Equal(t, "abcefg ", proxyFrameText(proxy.screen.frame))
219- require.Equal(t, uint64(7), proxy.appliedState, "resize must preserve the moving snapshot floor")
220- require.False(t, proxy.screenReady)
221- require.False(t, proxy.resetRequested)
222- require.Zero(t, proxy.screen.stateNum)
223- proxy.mu.Unlock()
224-
225- _, requestReset, changed := proxy.applyScreenUpdateForGeneration(1, ports.ScreenUpdate{
226- Kind: ports.ScreenUpdateDelta,
227- BaseStateNum: 7,
228- NewStateNum: 8,
229- Size: domain.Size{Cols: 3, Rows: 3},
230- Cursor: ports.ScreenCursor{Visible: true},
231- })
232- require.True(t, requestReset)
233- require.False(t, changed)
234-
235- moving := screenSnapshot(3, 3, "123", "456", "789")
236- moving.NewStateNum = 8
237- _, requestReset, changed = proxy.applyScreenUpdateForGeneration(1, moving)
238- require.False(t, requestReset)
239- require.True(t, changed)
240- proxy.mu.Lock()
241- require.Equal(t, uint64(8), proxy.appliedState)
242- require.Greater(t, proxy.appliedState, uint64(7))
243- require.True(t, proxy.screenReady)
244- require.False(t, proxy.resetRequested)
245- proxy.mu.Unlock()
246-}
247-
248-func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) {
249- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8})
250- require.NoError(t, err)
251- transport := newProxyTestTransport()
252- proxy.mu.Lock()
253- proxy.transport = transport
254- proxy.linkGeneration = 1
255- proxy.screenReady = true
256- proxy.mu.Unlock()
257-
258- changed, sent := proxy.resize(domain.Size{Cols: 20, Rows: 10})
259- require.True(t, changed)
260- require.True(t, sent)
261- frame := awaitTestValue(t, transport.sent, "proxy resize did not reach remote transport")
262- require.Equal(t, ports.MsgResize, frame.Type)
263- resize, err := ports.UnmarshalResize(frame.Payload)
264- require.NoError(t, err)
265- require.Equal(t, domain.Size{Cols: 20, Rows: 8}, resize.Size)
266- select {
267- case extra := <-transport.sent:
268- t.Fatalf("unexpected second remote resize: %#v", extra)
269- default:
270- }
271-
272- state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{reset: true})
273- require.True(t, ok)
274- composed := composeFrame(*state, composeCacheInput{})
275- require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized")
276- require.Equal(t, 8, state.layout.area.Height)
277-}
278-
279-// newAttachedProxyFixture publishes one proxy with a local thin client bound to
280-// clientTr and its remote link bound to remoteTr, using no render coordinator so
281-// invalidation paints synchronously.
282-func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport, remoteTr ports.Transport) (*proxySession, *attachedClient) {
283- t.Helper()
284- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6})
285- require.NoError(t, err)
286- proxy.mu.Lock()
287- proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}}
288- proxy.transport = remoteTr
289- proxy.linkGeneration = 1
290- proxy.screenReady = true
291- proxy.mu.Unlock()
292- d.mu.Lock()
293- require.True(t, d.registerSessionLocked(proxy))
294- d.mu.Unlock()
295-
296- ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}}
297- ac.setSession(proxy)
298- proxy.sessionCore.mu.Lock()
299- proxy.sessionCore.registerAttachmentLocked(ac)
300- proxy.sessionCore.mu.Unlock()
301- return proxy, ac
302-}
303-
304-func newFailingTransport(t *testing.T) *portsmocks.MockTransport {
305- t.Helper()
306- tr := portsmocks.NewMockTransport(t)
307- tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
308- tr.EXPECT().Close().Return(nil).Maybe()
309- return tr
310-}
311-
312-func TestProxyStructuredSendFailureRetainsProxyDamage(t *testing.T) {
313- d := newTestDaemon(t, nil, stubClock{})
314- clientTr := newFailingTransport(t)
315- proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
316- ac.proxied = true
317- ac.screenOutput = newStructuredOutputStream(ac.output)
318- require.True(t, applyTestScreenText(proxy, 0, 0, "pending"))
319-
320- ac.sendMu.Lock()
321- state, ok := proxy.captureRenderState(ac, renderCaptureRequest{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-
332-// TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails covers the split
333-// between "the local content rectangle moved" and "the remote was told". The
334-// local VT has already been resized, so the client owes a repaint either way.
335-func TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails(t *testing.T) {
336- d := newTestDaemon(t, nil, stubClock{})
337- clientTr, sent := newCapturingTransport(t)
338- remote := newProxyTestTransport()
339- remote.sendFails.Store(true)
340- proxy, ac := newAttachedProxyFixture(t, d, clientTr, remote)
341-
342- d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 24, Rows: 10})
343-
344- frame := awaitTestValue(t, sent, "failed remote resize did not repaint local chrome")
345- require.Equal(t, ports.MsgOutput, frame.Type)
346- proxy.mu.Lock()
347- content := proxy.contentSize
348- proxy.mu.Unlock()
349- require.Equal(t, contentSize(domain.Size{Cols: 24, Rows: 10}, false), content, "local VT resized despite the failed send")
350-}
351-
352-// TestProxyResizeWithoutGeometryChangeDoesNotRepaint is the other half: an
353-// identical size is not a reason to repaint.
354-func TestProxyResizeWithoutGeometryChangeDoesNotRepaint(t *testing.T) {
355- d := newTestDaemon(t, nil, stubClock{})
356- clientTr, sent := newCapturingTransport(t)
357- proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
358-
359- d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 16, Rows: 6})
360-
361- select {
362- case frame := <-sent:
363- t.Fatalf("no-op resize repainted: %v", frame.Type)
364- default:
365- }
366-}
367-
368-// TestProxyPrepareFailureRepaintsWithoutLocalSession covers a failed render
369-// transaction on an attachment that has no local session to report through.
370-func TestProxyPrepareFailureRepaintsWithoutLocalSession(t *testing.T) {
371- d := newTestDaemon(t, nil, stubClock{})
372- clientTr, sent := newCapturingTransport(t)
373- proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
374-
375- ac.sendMu.Lock() // emitFrame releases the transaction lock.
376- state, ok := proxy.captureRenderState(ac, renderCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true})
377- require.True(t, ok)
378- composed := composeFrame(*state, composeCacheInput{})
379- // A malformed frame is what makes outputStateStream.prepare fail.
380- composed.frame = renderer.Frame{Width: 1}
381-
382- require.True(t, d.emitFrame(proxy, ac, state, composed))
383- frame := awaitTestValue(t, sent, "prepare failure on a proxy did not schedule a repaint")
384- require.Equal(t, ports.MsgOutput, frame.Type)
385-}
386-
387-// TestProxySendErrorDetachesProxyAttachment covers the transport-failure
388-// cleanup path for an attachment with no local session lifecycle.
389-func TestProxySendErrorDetachesProxyAttachment(t *testing.T) {
390- d := newTestDaemon(t, nil, stubClock{})
391- clientTr := newFailingTransport(t)
392- proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport())
393-
394- d.paint(proxy, ac, true, nil)
395-
396- proxy.sessionCore.mu.Lock()
397- attachments := proxy.sessionCore.snapshotAttachmentsLocked()
398- proxy.sessionCore.mu.Unlock()
399- require.Empty(t, attachments, "a failed send must release the proxy attachment")
400- require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy")
401-}
402-
403-func proxyRowText(row []renderer.Cell) string {
404- runes := make([]rune, len(row))
405- for i, cell := range row {
406- runes[i] = cell.Rune
407- }
408- return string(runes)
409-}
D internal/usecase/daemon/proxy_screen_state.go +0 -374
@@ -1,374 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "fmt"
6- "math"
7- "unicode/utf8"
8-
9- "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/ports"
11- "github.com/bnema/vev/pkg/renderer"
12-)
13-
14-const (
15- maxProxyScreenCells = 1 << 18
16- maxProxyScreenDamage = 1024
17- maxProxyScreenSpans = 4096
18-)
19-
20-type proxyScreenDamageCapture struct {
21- Damage []renderer.Damage
22- Generation uint64
23-}
24-
25-// proxyScreenState is the replayable semantic screen owned by a proxy link.
26-// frame and scratch are independent frames and equal whenever Apply returns.
27-// A delta is applied to scratch, the frames are swapped, and the same delta is
28-// applied to the old frame. This avoids copying a complete frame on deltas.
29-type proxyScreenState struct {
30- frame renderer.Frame
31- scratch renderer.Frame
32- cursorOut ports.ScreenCursor
33-
34- damage []renderer.Damage
35- generation uint64
36- damageFullRedrawSticky bool
37- stateNum uint64
38-}
39-
40-func newProxyScreenState(size domain.Size) *proxyScreenState {
41- if !validProxyScreenSize(size) {
42- return &proxyScreenState{}
43- }
44- state := &proxyScreenState{
45- frame: renderer.NewFrame(size.Cols, size.Rows),
46- scratch: renderer.NewFrame(size.Cols, size.Rows),
47- cursorOut: ports.ScreenCursor{Visible: true},
48- generation: 1,
49- }
50- state.setFullRedraw()
51- return state
52-}
53-
54-// Apply validates the complete semantic update before mutating either frame.
55-func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error {
56- if s == nil {
57- return errors.New("proxy screen state is nil")
58- }
59- if err := s.validateUpdate(update); err != nil {
60- return err
61- }
62-
63- if update.Kind == ports.ScreenUpdateSnapshot {
64- next := renderer.NewFrame(update.Size.Cols, update.Size.Rows)
65- nextScratch := renderer.NewFrame(update.Size.Cols, update.Size.Rows)
66- applyScreenUpdate(&next, update)
67- applyScreenUpdate(&nextScratch, update)
68- s.frame, s.scratch = next, nextScratch
69- } else {
70- applyScreenUpdate(&s.scratch, update)
71- s.frame, s.scratch = s.scratch, s.frame
72- applyScreenUpdate(&s.scratch, update)
73- }
74- s.cursorOut = update.Cursor
75- if update.NewStateNum != 0 {
76- s.stateNum = update.NewStateNum
77- }
78- s.recordUpdate(update)
79- return nil
80-}
81-
82-func (s *proxyScreenState) validateUpdate(update ports.ScreenUpdate) error {
83- if update.Kind != ports.ScreenUpdateSnapshot && update.Kind != ports.ScreenUpdateDelta {
84- return fmt.Errorf("proxy screen: unknown update kind %d", update.Kind)
85- }
86- if !validProxyScreenSize(update.Size) {
87- return fmt.Errorf("proxy screen: invalid size %dx%d", update.Size.Cols, update.Size.Rows)
88- }
89- if update.Kind == ports.ScreenUpdateDelta {
90- if s.frame.Width != update.Size.Cols || s.frame.Height != update.Size.Rows {
91- 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)
92- }
93- } else if update.Scroll != nil {
94- return fmt.Errorf("proxy screen: snapshot cannot contain scroll")
95- }
96- if update.Cursor.Row >= uint16(update.Size.Rows) || update.Cursor.Col >= uint16(update.Size.Cols) {
97- return fmt.Errorf("proxy screen: cursor %d,%d outside %dx%d", update.Cursor.Col, update.Cursor.Row, update.Size.Cols, update.Size.Rows)
98- }
99- if !update.Cursor.StyleSet && update.Cursor.Style != 0 {
100- return fmt.Errorf("proxy screen: cursor style is not set")
101- }
102- if update.Cursor.StyleSet && update.Cursor.Style > 6 {
103- return fmt.Errorf("proxy screen: cursor style is out of range")
104- }
105- if len(update.Spans) > maxProxyScreenSpans {
106- return fmt.Errorf("proxy screen: too many spans")
107- }
108- if update.Kind == ports.ScreenUpdateSnapshot {
109- if len(update.Spans) != update.Size.Rows {
110- return fmt.Errorf("proxy screen: snapshot must cover every row")
111- }
112- for y, span := range update.Spans {
113- if int(span.Y) != y || span.X != 0 || len(span.Cells) != update.Size.Cols {
114- return fmt.Errorf("proxy screen: snapshot span %d is not a complete row", y)
115- }
116- }
117- }
118- if err := s.validateStateNumbers(update); err != nil {
119- return err
120- }
121- if scroll := update.Scroll; scroll != nil {
122- if scroll.Height == 0 || scroll.Count == 0 || scroll.Count >= scroll.Height {
123- return fmt.Errorf("proxy screen: invalid scroll %d,%d,%d", scroll.Top, scroll.Height, scroll.Count)
124- }
125- if uint64(scroll.Top)+uint64(scroll.Height) > uint64(update.Size.Rows) {
126- return fmt.Errorf("proxy screen: scroll outside screen")
127- }
128- }
129- var previousY uint16
130- var previousEnd uint32
131- for i, span := range update.Spans {
132- if len(span.Cells) == 0 {
133- return fmt.Errorf("proxy screen: span %d is empty", i)
134- }
135- if uint64(span.Y) >= uint64(update.Size.Rows) || uint64(span.X) >= uint64(update.Size.Cols) ||
136- uint64(span.X)+uint64(len(span.Cells)) > uint64(update.Size.Cols) {
137- return fmt.Errorf("proxy screen: span %d outside screen", i)
138- }
139- if i > 0 && (span.Y < previousY || (span.Y == previousY && uint32(span.X) < previousEnd)) {
140- return fmt.Errorf("proxy screen: spans overlap or are out of order")
141- }
142- for _, cell := range span.Cells {
143- if cell.Continuation && cell.Rune != 0 || !cell.Continuation && !utf8.ValidRune(cell.Rune) {
144- return fmt.Errorf("proxy screen: invalid cell rune")
145- }
146- if !validProxyScreenStyle(cell.Style) {
147- return fmt.Errorf("proxy screen: invalid cell style")
148- }
149- }
150- previousY = span.Y
151- previousEnd = uint32(span.X) + uint32(len(span.Cells))
152- }
153- return nil
154-}
155-
156-func (s *proxyScreenState) validateStateNumbers(update ports.ScreenUpdate) error {
157- if update.NewStateNum == 0 || update.NewStateNum <= update.BaseStateNum {
158- return fmt.Errorf("proxy screen: invalid state transition %d to %d", update.BaseStateNum, update.NewStateNum)
159- }
160- if update.Kind == ports.ScreenUpdateSnapshot {
161- if update.BaseStateNum != 0 {
162- return fmt.Errorf("proxy screen: snapshot base state must be zero")
163- }
164- return nil
165- }
166- if update.BaseStateNum == 0 || update.NewStateNum != update.BaseStateNum+1 {
167- return fmt.Errorf("proxy screen: delta state transition %d to %d is not consecutive", update.BaseStateNum, update.NewStateNum)
168- }
169- if s.stateNum == 0 || update.BaseStateNum != s.stateNum {
170- return fmt.Errorf("proxy screen: delta base state %d does not match %d", update.BaseStateNum, s.stateNum)
171- }
172- return nil
173-}
174-
175-func applyScreenUpdate(frame *renderer.Frame, update ports.ScreenUpdate) {
176- if update.Scroll != nil {
177- scroll := update.Scroll
178- frame.ScrollUp(int(scroll.Top), int(scroll.Top)+int(scroll.Height)-1, int(scroll.Count))
179- }
180- for _, span := range update.Spans {
181- copy(frame.Row(int(span.Y))[int(span.X):], span.Cells)
182- }
183-}
184-
185-func (s *proxyScreenState) recordUpdate(update ports.ScreenUpdate) {
186- if update.Kind == ports.ScreenUpdateSnapshot {
187- s.generation++
188- s.setFullRedraw()
189- return
190- }
191- needed := len(update.Spans)
192- if update.Scroll != nil {
193- needed++
194- }
195- if needed == 0 {
196- return
197- }
198- s.generation++
199- if s.damageFullRedrawSticky {
200- return
201- }
202- if update.Scroll != nil && len(s.damage) > 0 && (len(s.damage) != 1 || s.damage[0].Kind != renderer.DamageFullRedraw) {
203- s.setFullRedraw()
204- return
205- }
206- if needed > maxProxyScreenDamage-len(s.damage) {
207- s.setFullRedraw()
208- return
209- }
210- if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
211- s.damage = s.damage[:0]
212- }
213- if update.Scroll != nil {
214- s.damage = append(s.damage, renderer.Damage{
215- Kind: renderer.DamageScrollUp,
216- X: 0,
217- Y: int(update.Scroll.Top),
218- Width: s.frame.Width,
219- Height: int(update.Scroll.Height),
220- Count: int(update.Scroll.Count),
221- })
222- }
223- for _, span := range update.Spans {
224- s.damage = append(s.damage, renderer.Damage{
225- Kind: renderer.DamageText,
226- X: int(span.X),
227- Y: int(span.Y),
228- Width: len(span.Cells),
229- Height: 1,
230- Count: 1,
231- })
232- }
233-}
234-
235-func (s *proxyScreenState) setFullRedraw() {
236- s.damage = []renderer.Damage{renderer.FullRedraw()}
237- s.damageFullRedrawSticky = true
238-}
239-
240-// CaptureDamage returns an owned view of pending damage at one generation.
241-func (s *proxyScreenState) CaptureDamage() proxyScreenDamageCapture {
242- if s == nil {
243- return proxyScreenDamageCapture{}
244- }
245- return proxyScreenDamageCapture{
246- Damage: append([]renderer.Damage(nil), s.damage...),
247- Generation: s.generation,
248- }
249-}
250-
251-// AcknowledgeDamage consumes damage only for the exact generation captured.
252-// A stale acknowledgement forces a sticky full redraw so intervening updates
253-// cannot be lost.
254-func (s *proxyScreenState) AcknowledgeDamage(generation uint64) bool {
255- if s == nil {
256- return false
257- }
258- if generation != s.generation {
259- s.generation++
260- s.setFullRedraw()
261- return false
262- }
263- s.damage = s.damage[:0]
264- s.damageFullRedrawSticky = false
265- return true
266-}
267-
268-// CaptureInto updates dst from the live frame. Safe scroll/span damage is
269-// replayed incrementally; snapshots, uncertain damage, and dimensions use a
270-// complete clone.
271-func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) {
272- if s == nil || dst == nil || !validProxyFrame(s) {
273- return
274- }
275- if dst.Width != s.frame.Width || dst.Height != s.frame.Height || dst.Validate() != nil {
276- *dst = s.frame.Clone()
277- return
278- }
279- if len(s.damage) == 0 {
280- return
281- }
282- for _, damage := range s.damage {
283- switch damage.Kind {
284- case renderer.DamageScrollUp:
285- if damage.X != 0 || damage.Width != s.frame.Width || damage.Y < 0 || damage.Height <= 0 || damage.Count <= 0 || damage.Count > damage.Height || damage.Y+damage.Height > s.frame.Height {
286- *dst = s.frame.Clone()
287- return
288- }
289- dst.ScrollUp(damage.Y, damage.Y+damage.Height-1, damage.Count)
290- case renderer.DamageText, renderer.DamageClear:
291- if damage.X < 0 || damage.Y < 0 || damage.Width <= 0 || damage.Height <= 0 || damage.X+damage.Width > s.frame.Width || damage.Y+damage.Height > s.frame.Height {
292- *dst = s.frame.Clone()
293- return
294- }
295- for y := damage.Y; y < damage.Y+damage.Height; y++ {
296- copy(dst.Row(y)[damage.X:damage.X+damage.Width], s.frame.Row(y)[damage.X:damage.X+damage.Width])
297- }
298- default:
299- *dst = s.frame.Clone()
300- return
301- }
302- }
303-}
304-
305-// ResizePlaceholder changes geometry while preserving the overlapping logical
306-// cells. It deliberately requests a full redraw and resets the state chain.
307-func (s *proxyScreenState) ResizePlaceholder(size domain.Size) bool {
308- if s == nil || !validProxyScreenSize(size) {
309- return false
310- }
311- if s.frame.Width == size.Cols && s.frame.Height == size.Rows {
312- return false
313- }
314- next := renderer.NewFrame(size.Cols, size.Rows)
315- nextScratch := renderer.NewFrame(size.Cols, size.Rows)
316- copyFrameOverlap(&next, s.frame)
317- copyFrameOverlap(&nextScratch, s.frame)
318- s.frame, s.scratch = next, nextScratch
319- if int(s.cursorOut.Row) >= size.Rows {
320- s.cursorOut.Row = uint16(size.Rows - 1)
321- }
322- if int(s.cursorOut.Col) >= size.Cols {
323- s.cursorOut.Col = uint16(size.Cols - 1)
324- }
325- s.stateNum = 0
326- s.generation++
327- s.setFullRedraw()
328- return true
329-}
330-
331-func copyFrameOverlap(dst *renderer.Frame, src renderer.Frame) {
332- width := min(src.Width, dst.Width)
333- height := min(src.Height, dst.Height)
334- for y := range height {
335- copy(dst.Row(y)[:width], src.Row(y)[:width])
336- }
337-}
338-
339-func validProxyScreenSize(size domain.Size) bool {
340- if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 {
341- return false
342- }
343- return size.Rows <= maxProxyScreenCells/size.Cols
344-}
345-
346-func validProxyFrame(s *proxyScreenState) bool {
347- return s != nil && s.frame.Validate() == nil && s.scratch.Validate() == nil
348-}
349-
350-// validProxyScreenStyle mirrors the wire validator without exporting its
351-// wire-specific error API; inactive color indices are canonicalized on encode.
352-func validProxyScreenStyle(style renderer.Style) bool {
353- if style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 || style.UnderlineStyle > renderer.UnderlineDashed {
354- return false
355- }
356- if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > math.MaxUint8 || style.ForegroundRGB != (renderer.RGB{})) {
357- return false
358- }
359- if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > math.MaxUint8 || style.BackgroundRGB != (renderer.RGB{})) {
360- return false
361- }
362- if style.HasUnderlineColorRGB && style.HasUnderlineColor {
363- return false
364- } else if style.HasUnderlineColorRGB {
365- // The indexed value is inactive when RGB is selected.
366- } else if style.HasUnderlineColor {
367- if style.UnderlineColor < 0 || style.UnderlineColor > math.MaxUint8 || style.UnderlineColorRGB != (renderer.RGB{}) {
368- return false
369- }
370- } else if style.UnderlineColorRGB != (renderer.RGB{}) {
371- return false
372- }
373- return true
374-}
D internal/usecase/daemon/proxy_screen_state_test.go +0 -531
@@ -1,531 +0,0 @@
1-package daemon
2-
3-import (
4- "reflect"
5- "testing"
6-
7- "github.com/bnema/vev/internal/domain"
8- "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/pkg/renderer"
10-)
11-
12-func TestProxyScreenStateApplyIsAtomicAndReplaysScrolls(t *testing.T) {
13- s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3})
14- if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil {
15- t.Fatal(err)
16- }
17- if !reflect.DeepEqual(s.frame, s.scratch) {
18- t.Fatal("live and scratch frames diverged after snapshot")
19- }
20- applyProxyDelta(t, s, ports.ScreenUpdate{
21- Size: domain.Size{Cols: 4, Rows: 3},
22- Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1},
23- Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("WXYZ")}},
24- })
25- for range 7 {
26- applyProxyDelta(t, s, ports.ScreenUpdate{
27- Size: domain.Size{Cols: 4, Rows: 3},
28- Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1},
29- Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("WXYZ")}},
30- })
31- }
32- if !reflect.DeepEqual(s.frame, s.scratch) {
33- t.Fatal("live and scratch frames diverged after repeated scrolls")
34- }
35- if got := proxyFrameText(s.frame); got != "WXYZWXYZWXYZ" {
36- t.Fatalf("replayed scroll state = %q", got)
37- }
38-}
39-
40-func TestProxyScreenStateInvalidUpdateIsAtomic(t *testing.T) {
41- s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2})
42- if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil {
43- t.Fatal(err)
44- }
45- beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
46- beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum
47- invalid := ports.ScreenUpdate{
48- Kind: ports.ScreenUpdateDelta,
49- BaseStateNum: 1,
50- NewStateNum: 2,
51- Size: domain.Size{Cols: 4, Rows: 2},
52- Scroll: &ports.ScreenScroll{Top: 1, Height: 2, Count: 1},
53- Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("x")}},
54- Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
55- }
56- if err := s.Apply(invalid); err == nil {
57- t.Fatal("invalid geometry was accepted")
58- }
59- if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) {
60- t.Fatal("invalid update mutated a frame")
61- }
62- if s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
63- t.Fatal("invalid update mutated metadata")
64- }
65-
66- invalid = ports.ScreenUpdate{
67- Kind: ports.ScreenUpdateDelta,
68- BaseStateNum: 1,
69- NewStateNum: 2,
70- Size: domain.Size{Cols: 4, Rows: 2},
71- Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 2},
72- Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
73- }
74- if err := s.Apply(invalid); err == nil {
75- t.Fatal("full-height scroll was accepted")
76- }
77- if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
78- t.Fatal("invalid full-height scroll mutated state")
79- }
80-}
81-
82-func TestProxyScreenStateScreenAreaLimitIsAtomic(t *testing.T) {
83- const boundaryCols = 512
84- boundaryRows := maxProxyScreenCells / boundaryCols
85- boundary := newProxyScreenState(domain.Size{Cols: boundaryCols, Rows: boundaryRows})
86- if boundary.frame.Width != boundaryCols || boundary.frame.Height != boundaryRows || boundary.scratch.Width != boundaryCols || boundary.scratch.Height != boundaryRows {
87- t.Fatalf("boundary state dimensions = %dx%d and %dx%d", boundary.frame.Width, boundary.frame.Height, boundary.scratch.Width, boundary.scratch.Height)
88- }
89- if boundary.ResizePlaceholder(domain.Size{Cols: boundaryCols, Rows: boundaryRows + 1}) {
90- t.Fatal("over-cap resize was accepted")
91- }
92- if boundary.frame.Width != boundaryCols || boundary.frame.Height != boundaryRows || boundary.scratch.Width != boundaryCols || boundary.scratch.Height != boundaryRows {
93- t.Fatal("over-cap resize changed boundary state")
94- }
95- if !boundary.damageFullRedrawSticky {
96- t.Fatal("initial full redraw must remain sticky until acknowledged")
97- }
98-
99- s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
100- if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
101- t.Fatal(err)
102- }
103- beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
104- beforeCursor, beforeGeneration, beforeState, beforeDamage := s.cursorOut, s.generation, s.stateNum, s.CaptureDamage()
105- if err := s.Apply(ports.ScreenUpdate{
106- NewStateNum: 2,
107- Kind: ports.ScreenUpdateSnapshot,
108- Size: domain.Size{Cols: 513, Rows: 512},
109- Cursor: ports.ScreenCursor{Visible: true},
110- }); err == nil {
111- t.Fatal("over-cap snapshot was accepted")
112- }
113- if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState || !reflect.DeepEqual(s.CaptureDamage(), beforeDamage) {
114- t.Fatal("over-cap snapshot mutated state")
115- }
116- if got := newProxyScreenState(domain.Size{Cols: 513, Rows: 512}); got.frame.Validate() == nil || got.scratch.Validate() == nil {
117- t.Fatal("over-cap constructor allocated a valid frame")
118- }
119-}
120-
121-func TestProxyScreenStateRejectsInvalidStateNumbersAtomically(t *testing.T) {
122- s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
123- if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
124- t.Fatal(err)
125- }
126- if got := proxyFrameText(s.frame); got != "abcd" || !reflect.DeepEqual(s.frame, s.scratch) {
127- t.Fatalf("2x2 snapshot = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch))
128- }
129- updates := []ports.ScreenUpdate{
130- func() ports.ScreenUpdate {
131- m := screenSnapshot(2, 2, "ef", "gh")
132- m.NewStateNum = 0
133- return m
134- }(),
135- func() ports.ScreenUpdate {
136- m := screenSnapshot(2, 2, "ef", "gh")
137- m.BaseStateNum, m.NewStateNum = 1, 2
138- return m
139- }(),
140- {Kind: ports.ScreenUpdateDelta, BaseStateNum: 0, NewStateNum: 0, Size: domain.Size{Cols: 2, Rows: 2}},
141- {Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}},
142- {Kind: ports.ScreenUpdateDelta, BaseStateNum: 2, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}},
143- }
144- for i, update := range updates {
145- beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
146- beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum
147- if err := s.Apply(update); err == nil {
148- t.Fatalf("invalid state update %d was accepted", i)
149- }
150- if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
151- t.Fatalf("invalid state update %d mutated live state", i)
152- }
153- }
154-}
155-
156-func TestProxyScreenStateRejectsWireInvalidSemanticUpdatesAtomically(t *testing.T) {
157- s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
158- if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
159- t.Fatal(err)
160- }
161- assertUnchanged := func(beforeFrame, beforeScratch renderer.Frame, beforeCursor ports.ScreenCursor, beforeGeneration, beforeState uint64, beforeDamage proxyScreenDamageCapture) {
162- t.Helper()
163- if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState || !reflect.DeepEqual(s.CaptureDamage(), beforeDamage) {
164- t.Fatal("invalid semantic update mutated live state")
165- }
166- }
167-
168- beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
169- beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum
170- beforeDamage := s.CaptureDamage()
171- invalidCursor := nextProxyDelta(s, ports.ScreenUpdate{
172- Size: domain.Size{Cols: 2, Rows: 2},
173- Cursor: ports.ScreenCursor{Style: 7, StyleSet: true},
174- })
175- if err := s.Apply(invalidCursor); err == nil {
176- t.Fatal("cursor style 7 was accepted")
177- }
178- assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage)
179-
180- beforeFrame, beforeScratch = s.frame.Clone(), s.scratch.Clone()
181- beforeCursor, beforeGeneration, beforeState = s.cursorOut, s.generation, s.stateNum
182- beforeDamage = s.CaptureDamage()
183- invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{
184- Size: domain.Size{Cols: 2, Rows: 2},
185- Spans: make([]ports.ScreenSpan, maxProxyScreenSpans+1),
186- })
187- if err := s.Apply(invalidSpans); err == nil {
188- t.Fatal("too many spans were accepted")
189- }
190- assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage)
191-}
192-
193-func TestProxyScreenStateAcceptsConsecutiveHorizontalSpans(t *testing.T) {
194- s := newProxyScreenState(domain.Size{Cols: 4, Rows: 1})
195- if err := s.Apply(screenSnapshot(4, 1, "abcd")); err != nil {
196- t.Fatal(err)
197- }
198- applyProxyDelta(t, s, ports.ScreenUpdate{
199- Size: domain.Size{Cols: 4, Rows: 1},
200- Spans: []ports.ScreenSpan{
201- {Y: 0, X: 0, Cells: cells("xy")},
202- {Y: 0, X: 2, Cells: cells("Zq")},
203- },
204- })
205- if got := proxyFrameText(s.frame); got != "xyZq" || !reflect.DeepEqual(s.frame, s.scratch) {
206- t.Fatalf("adjacent spans replay = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch))
207- }
208-}
209-
210-func TestProxyScreenStateDamageGenerationAndCapture(t *testing.T) {
211- s := newProxyScreenState(domain.Size{Cols: 3, Rows: 2})
212- if err := s.Apply(screenSnapshot(3, 2, " ", " ")); err != nil {
213- t.Fatal(err)
214- }
215- first := s.CaptureDamage()
216- if len(first.Damage) != 1 || first.Damage[0].Kind != renderer.DamageFullRedraw {
217- t.Fatalf("initial damage = %+v", first.Damage)
218- }
219- if !s.AcknowledgeDamage(first.Generation) {
220- t.Fatal("exact initial acknowledgement failed")
221- }
222- if len(s.CaptureDamage().Damage) != 0 {
223- t.Fatal("exact acknowledgement left damage")
224- }
225- applyProxyDelta(t, s, ports.ScreenUpdate{
226- Size: domain.Size{Cols: 3, Rows: 2},
227- Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("xyz")}},
228- })
229- captured := s.CaptureDamage()
230- if len(captured.Damage) != 1 || captured.Damage[0].Width != 3 {
231- t.Fatalf("span damage = %+v", captured.Damage)
232- }
233- if s.AcknowledgeDamage(captured.Generation - 1) {
234- t.Fatal("stale acknowledgement succeeded")
235- }
236- forced := s.CaptureDamage()
237- if len(forced.Damage) != 1 || forced.Damage[0].Kind != renderer.DamageFullRedraw {
238- t.Fatalf("stale acknowledgement damage = %+v", forced.Damage)
239- }
240- if !s.AcknowledgeDamage(forced.Generation) || len(s.CaptureDamage().Damage) != 0 {
241- t.Fatal("full redraw did not clear on exact acknowledgement")
242- }
243-}
244-
245-func TestProxyScreenStateCaptureAndResize(t *testing.T) {
246- s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2})
247- if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil {
248- t.Fatal(err)
249- }
250- _ = s.AcknowledgeDamage(s.CaptureDamage().Generation)
251- var dst renderer.Frame
252- s.CaptureInto(&dst)
253- if got := proxyFrameText(dst); got != "abcdefgh" {
254- t.Fatalf("captured frame = %q", got)
255- }
256- applyProxyDelta(t, s, ports.ScreenUpdate{
257- Size: domain.Size{Cols: 4, Rows: 2},
258- Spans: []ports.ScreenSpan{{Y: 0, X: 1, Cells: cells("Z")}},
259- })
260- s.CaptureInto(&dst)
261- if got := proxyFrameText(dst); got != "aZcdefgh" {
262- t.Fatalf("incrementally captured frame = %q", got)
263- }
264- if !s.ResizePlaceholder(domain.Size{Cols: 3, Rows: 3}) {
265- t.Fatal("resize placeholder rejected valid size")
266- }
267- if got := proxyFrameText(s.frame); got != "aZcefg " {
268- t.Fatalf("overlap-preserving resize = %q", got)
269- }
270- capture := s.CaptureDamage()
271- if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw {
272- t.Fatalf("resize damage = %+v", capture.Damage)
273- }
274-}
275-
276-func TestProxyScreenStateCaptureFallsBackAfterSpanThenScroll(t *testing.T) {
277- s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2})
278- if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil {
279- t.Fatal(err)
280- }
281- _ = s.AcknowledgeDamage(s.CaptureDamage().Generation)
282- dst := s.frame.Clone()
283- applyProxyDelta(t, s, ports.ScreenUpdate{
284- Size: domain.Size{Cols: 4, Rows: 2},
285- Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("Z")}},
286- })
287- applyProxyDelta(t, s, ports.ScreenUpdate{
288- Size: domain.Size{Cols: 4, Rows: 2},
289- Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 1},
290- Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("IJKL")}},
291- })
292- capture := s.CaptureDamage()
293- if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw {
294- t.Fatalf("span-then-scroll damage = %+v", capture.Damage)
295- }
296- s.CaptureInto(&dst)
297- if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want {
298- t.Fatalf("span-then-scroll capture = %q, want %q", got, want)
299- }
300-}
301-
302-func TestProxyScreenStateCaptureFallsBackAfterRepeatedScroll(t *testing.T) {
303- s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3})
304- if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil {
305- t.Fatal(err)
306- }
307- _ = s.AcknowledgeDamage(s.CaptureDamage().Generation)
308- dst := s.frame.Clone()
309- scroll := func(line string) {
310- t.Helper()
311- applyProxyDelta(t, s, ports.ScreenUpdate{
312- Size: domain.Size{Cols: 4, Rows: 3},
313- Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1},
314- Spans: []ports.ScreenSpan{{Y: 2, Cells: cells(line)}},
315- })
316- }
317- scroll("WXYZ")
318- if len(s.CaptureDamage().Damage) != 2 {
319- t.Fatalf("first scroll damage = %+v", s.CaptureDamage().Damage)
320- }
321- scroll("QRST")
322- capture := s.CaptureDamage()
323- if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw {
324- t.Fatalf("repeated-scroll damage = %+v", capture.Damage)
325- }
326- s.CaptureInto(&dst)
327- if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want {
328- t.Fatalf("repeated-scroll capture = %q, want %q", got, want)
329- }
330-}
331-
332-func TestProxyScreenStateCursorOnlyPreservesCapturedDamageGeneration(t *testing.T) {
333- s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
334- if err := s.Apply(screenSnapshot(2, 2, " ", " ")); err != nil {
335- t.Fatal(err)
336- }
337- snapshotDamage := s.CaptureDamage()
338- applyProxyDelta(t, s, ports.ScreenUpdate{
339- Size: domain.Size{Cols: 2, Rows: 2},
340- Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
341- })
342- if s.generation != snapshotDamage.Generation {
343- t.Fatalf("cursor-only update changed damage generation from %d to %d", snapshotDamage.Generation, s.generation)
344- }
345- if !s.AcknowledgeDamage(snapshotDamage.Generation) || len(s.damage) != 0 || s.damageFullRedrawSticky {
346- t.Fatalf("cursor-only interleaving invalidated captured damage: generation=%d damage=%+v sticky=%v", s.generation, s.damage, s.damageFullRedrawSticky)
347- }
348-
349- applyProxyDelta(t, s, ports.ScreenUpdate{
350- Size: domain.Size{Cols: 2, Rows: 2},
351- Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}},
352- })
353- actualDamage := s.CaptureDamage()
354- if actualDamage.Generation <= snapshotDamage.Generation {
355- t.Fatalf("actual damage did not advance generation: snapshot=%d actual=%d", snapshotDamage.Generation, actualDamage.Generation)
356- }
357- applyProxyDelta(t, s, ports.ScreenUpdate{
358- Size: domain.Size{Cols: 2, Rows: 2},
359- Cursor: ports.ScreenCursor{Row: 0, Col: 1, Visible: true},
360- })
361- if s.generation != actualDamage.Generation || !s.AcknowledgeDamage(actualDamage.Generation) || len(s.damage) != 0 || s.damageFullRedrawSticky {
362- t.Fatalf("cursor-only update invalidated actual damage: generation=%d want=%d damage=%+v sticky=%v", s.generation, actualDamage.Generation, s.damage, s.damageFullRedrawSticky)
363- }
364-}
365-
366-func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) {
367- s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
368- if err := s.Apply(screenSnapshot(2, 2, " ", " ")); err != nil {
369- t.Fatal(err)
370- }
371- _ = s.AcknowledgeDamage(s.generation)
372- applyProxyDelta(t, s, ports.ScreenUpdate{
373- Size: domain.Size{Cols: 2, Rows: 2},
374- Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
375- })
376- if s.cursorOut.Row != 1 || s.cursorOut.Col != 1 || len(s.damage) != 0 {
377- t.Fatalf("cursor-only update = cursor=%+v damage=%+v", s.cursorOut, s.damage)
378- }
379- for range maxProxyScreenDamage {
380- applyProxyDelta(t, s, ports.ScreenUpdate{
381- Size: domain.Size{Cols: 2, Rows: 2},
382- Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}},
383- })
384- }
385- if len(s.damage) != maxProxyScreenDamage {
386- t.Fatalf("damage length = %d, want %d", len(s.damage), maxProxyScreenDamage)
387- }
388- applyProxyDelta(t, s, ports.ScreenUpdate{
389- Size: domain.Size{Cols: 2, Rows: 2},
390- Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("y")}},
391- })
392- if len(s.damage) != 1 || s.damage[0].Kind != renderer.DamageFullRedraw {
393- t.Fatalf("saturated damage = %+v", s.damage)
394- }
395-}
396-
397-func TestProxyScreenStateStyleValidationMatchesWireSemantics(t *testing.T) {
398- styles := []struct {
399- name string
400- style renderer.Style
401- }{
402- {name: "indexed foreground", style: renderer.Style{Foreground: 255, Background: -1}},
403- {name: "indexed foreground out of range", style: renderer.Style{Foreground: 256, Background: -1}},
404- {name: "foreground RGB ignores index", style: renderer.Style{Foreground: 1 << 20, Background: -1, HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1}}},
405- {name: "background RGB ignores index", style: renderer.Style{Foreground: -1, Background: 1 << 20, HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{G: 2}}},
406- {name: "indexed underline", style: renderer.Style{Foreground: -1, Background: -1, HasUnderlineColor: true, UnderlineColor: 255}},
407- {name: "indexed underline with RGB", style: renderer.Style{Foreground: -1, Background: -1, HasUnderlineColor: true, UnderlineColor: 1, UnderlineColorRGB: renderer.RGB{B: 3}}},
408- {name: "underline RGB ignores index", style: renderer.Style{Foreground: -1, Background: -1, HasUnderlineColorRGB: true, UnderlineColor: 1 << 20, UnderlineColorRGB: renderer.RGB{R: 4}}},
409- {name: "both underline modes", style: renderer.Style{Foreground: -1, Background: -1, HasUnderlineColor: true, HasUnderlineColorRGB: true}},
410- {name: "unset underline ignores index", style: renderer.Style{Foreground: -1, Background: -1, UnderlineColor: 1 << 20}},
411- }
412- for _, tt := range styles {
413- t.Run(tt.name, func(t *testing.T) {
414- update := ports.ScreenUpdate{
415- NewStateNum: 1,
416- Kind: ports.ScreenUpdateSnapshot,
417- Size: domain.Size{Cols: 1, Rows: 1},
418- Cursor: ports.ScreenCursor{Visible: true},
419- Spans: []ports.ScreenSpan{{Cells: []renderer.Cell{{Rune: 'x', Style: tt.style}}}},
420- }
421- _, wireErr := ports.MarshalScreenUpdate(update)
422- stateErr := newProxyScreenState(update.Size).Apply(update)
423- if (wireErr == nil) != (stateErr == nil) {
424- t.Fatalf("daemon/wire style acceptance differs: wire=%v daemon=%v", wireErr, stateErr)
425- }
426- })
427- }
428-}
429-
430-// BenchmarkProxyScreenStateApply is a component diagnostic for applying an
431-// already-decoded update; it is not a pipeline comparison with ANSI apply.
432-func BenchmarkProxyScreenStateApply(b *testing.B) {
433- size := domain.Size{Cols: 120, Rows: 40}
434- cases := []struct {
435- name string
436- update ports.ScreenUpdate
437- cells, spans int
438- }{
439- {name: "one-cell", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 1, X: 0, Cells: cells("x")}}}, cells: 1, spans: 1},
440- {name: "full-line", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 2, Cells: cellsRepeat('x', 120)}}}, cells: 120, spans: 1},
441- {name: "styled-fragments", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 3, Cells: styledCells(120)}}}, cells: 120, spans: 1},
442- {name: "full-screen-scroll", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Scroll: &ports.ScreenScroll{Top: 0, Height: 40, Count: 1}, Spans: []ports.ScreenSpan{{Y: 39, Cells: cellsRepeat('x', 120)}}}, cells: 120 * 40, spans: 2},
443- }
444- for _, tc := range cases {
445- b.Run(tc.name, func(b *testing.B) {
446- s := newProxyScreenState(size)
447- if err := s.Apply(blankSnapshot(size)); err != nil {
448- b.Fatal(err)
449- }
450- _ = s.AcknowledgeDamage(s.generation)
451- // Warm the bounded damage buffer before timing the replay loop.
452- if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil {
453- b.Fatal(err)
454- }
455- _ = s.AcknowledgeDamage(s.generation)
456- b.ResetTimer()
457- b.ReportMetric(float64(tc.cells), "cells/op")
458- b.ReportMetric(float64(tc.spans), "spans/op")
459- for b.Loop() {
460- if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil {
461- b.Fatal(err)
462- }
463- _ = s.AcknowledgeDamage(s.generation)
464- }
465- })
466- }
467-}
468-
469-func applyProxyDelta(t *testing.T, s *proxyScreenState, update ports.ScreenUpdate) {
470- t.Helper()
471- if err := s.Apply(nextProxyDelta(s, update)); err != nil {
472- t.Fatal(err)
473- }
474-}
475-
476-func nextProxyDelta(s *proxyScreenState, update ports.ScreenUpdate) ports.ScreenUpdate {
477- update.Kind = ports.ScreenUpdateDelta
478- update.BaseStateNum = s.stateNum
479- update.NewStateNum = s.stateNum + 1
480- return update
481-}
482-
483-func screenSnapshot(cols, rows int, lines ...string) ports.ScreenUpdate {
484- spans := make([]ports.ScreenSpan, len(lines))
485- for y, line := range lines {
486- spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cells(line)}
487- }
488- return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: domain.Size{Cols: cols, Rows: rows}, Spans: spans}
489-}
490-
491-func blankSnapshot(size domain.Size) ports.ScreenUpdate {
492- spans := make([]ports.ScreenSpan, size.Rows)
493- for y := range spans {
494- spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cellsRepeat(' ', size.Cols)}
495- }
496- return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: size, Spans: spans}
497-}
498-
499-func cells(text string) []renderer.Cell {
500- out := make([]renderer.Cell, 0, len(text))
501- for _, r := range text {
502- out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
503- }
504- return out
505-}
506-
507-func cellsRepeat(r rune, n int) []renderer.Cell {
508- out := make([]renderer.Cell, n)
509- for i := range out {
510- out[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
511- }
512- return out
513-}
514-
515-func styledCells(n int) []renderer.Cell {
516- out := cellsRepeat('s', n)
517- for i := range out {
518- out[i].Style.Bold = i%2 == 0
519- }
520- return out
521-}
522-
523-func proxyFrameText(frame renderer.Frame) string {
524- out := make([]rune, 0, frame.Width*frame.Height)
525- for y := 0; y < frame.Height; y++ {
526- for _, cell := range frame.Row(y) {
527- out = append(out, cell.Rune)
528- }
529- }
530- return string(out)
531-}
M internal/usecase/daemon/proxy_session.go +51 -85
@@ -13,35 +13,32 @@ import (
13 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
14 )14 )
15 15
16-// proxySession is the local attachment identity for one structured remote16+// proxySession is the local attachment identity for one remote session key. mu is a leaf lock: callers may take it below d.mu or sessionCore
17-// session key. mu is a leaf lock: callers may take it below d.mu or sessionCore
18 // locks, but must not acquire another architecture lock or perform transport I/O17 // locks, but must not acquire another architecture lock or perform transport I/O
19 // while holding it. All Transport.Send calls are owned by sendMu.18 // while holding it. All Transport.Send calls are owned by sendMu.
20 type proxySession struct {19 type proxySession struct {
21 sessionCore20 sessionCore
22 key domain.RemoteSessionKey21 key domain.RemoteSessionKey
23 22
24- mu sync.Mutex23+ mu sync.Mutex
25- generation uint6424+ generation uint64
26- linkGeneration uint6425+ linkGeneration uint64
27- transport ports.Transport26+ transport ports.Transport
28- resumeToken uint6427+ resumeToken uint64
29- appliedState uint6428+ outputEpoch uint64
30- // screenReady is true only after a moving snapshot establishes the29+ outputState uint64
31- // dependency required by subsequent deltas.30+ outputReady bool
32- screenReady bool31+ outputResetRequested bool
33- resetRequested bool32+ meta ports.SessionMeta
34- screen *proxyScreenState33+ attentionAt []time.Time
35- meta ports.SessionMeta34+ linkState ports.LinkState
36- attentionAt []time.Time35+ expired bool
37- linkState ports.LinkState36+ warm *proxyWarmTimer
38- expired bool37+ cancel context.CancelFunc
39- warm *proxyWarmTimer38+ done chan struct{}
40- cancel context.CancelFunc39+ doneOnce sync.Once
41- done chan struct{}40+ clientID [16]byte
42- doneOnce sync.Once41+ contentSize domain.Size
43- clientID [16]byte
44- contentSize domain.Size
45 42
46 sendMu sync.Mutex43 sendMu sync.Mutex
47 inputNext uint6444 inputNext uint64
@@ -61,73 +58,44 @@ func (p *proxySession) core() *sessionCore {
61 58
62 func (p *proxySession) isProxy() bool { return true }59 func (p *proxySession) isProxy() bool { return true }
63 60
64-func (p *proxySession) sessionMetaSnapshot() (ports.SessionMeta, bool) {61+func (p *proxySession) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
65- if p == nil {62+ return nil, false
66- return ports.SessionMeta{}, false
67- }
68- p.mu.Lock()
69- defer p.mu.Unlock()
70- if len(p.meta.Tabs) == 0 || int(p.meta.Active) >= len(p.meta.Tabs) {
71- return ports.SessionMeta{}, false
72- }
73- return cloneSessionMeta(p.meta), true
74-}
75-
76-// replaceSessionMetaLocked atomically replaces the relayed snapshot while
77-// preserving the first-observed timestamp of tabs that remain attentive.
78-// Callers must validate meta and hold p.mu.
79-func (p *proxySession) replaceSessionMetaLocked(meta ports.SessionMeta, now time.Time) {
80- attentionAt := make([]time.Time, len(meta.Tabs))
81- for i, tab := range meta.Tabs {
82- if !tab.Attention {
83- continue
84- }
85- if i < len(p.meta.Tabs) && p.meta.Tabs[i].Attention && i < len(p.attentionAt) {
86- attentionAt[i] = p.attentionAt[i]
87- continue
88- }
89- attentionAt[i] = now
90- }
91- p.meta = cloneSessionMeta(meta)
92- p.attentionAt = attentionAt
93 }63 }
94 64
95-// applyProxySessionMeta validates and atomically applies relayed metadata only65+func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
96-// for the exact current link generation. A remote session rename cannot mutate66+ if p == nil || !size.Valid() {
97-// the immutable local identity, so it expires that exact proxy instead.67+ return false, false
98-func (d *Daemon) applyProxySessionMeta(p *proxySession, generation uint64, meta ports.SessionMeta) (bool, error) {
99- if d == nil || p == nil {
100- return false, errors.New("proxy session: metadata target is unavailable")
101 }68 }
102- if _, err := ports.MarshalSessionMeta(meta); err != nil {69+ payload, err := ports.MarshalResize(ports.Resize{Size: size})
103- return false, fmt.Errorf("proxy session: invalid metadata: %w", err)70+ if err != nil {
71+ return false, false
104 }72 }
105- meta = cloneSessionMeta(meta)
106- now := d.clock.Now()
107-
108 p.mu.Lock()73 p.mu.Lock()
109- if p.linkGeneration != generation {74+ if p.contentSize == size {
110- p.mu.Unlock()75+ available := p.transport != nil
111- return false, nil
112- }
113- if meta.SessionName != p.key.Name {
114- transport := p.transport
115 p.mu.Unlock()76 p.mu.Unlock()
116- if transport != nil {77+ return false, available
117- d.markProxyReplaced(p, generation, transport)
118- }
119- return false, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
120 }78 }
121- p.replaceSessionMetaLocked(meta, now)79+ p.contentSize = size
80+ generation := p.linkGeneration
122 p.mu.Unlock()81 p.mu.Unlock()
82+ return true, p.sendGeneration(generation, ports.Frame{
83+ Type: ports.MsgResize,
84+ Payload: payload,
85+ }) == nil
86+}
123 87
124- d.pokeAttentionTicker()88+func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
125- for _, ac := range snapshotAttachmentSession(p) {89+ meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...)
126- d.invalidateRender(p, ac, false, "proxy_session.go")90+ return meta
127- }
128- return true, nil
129 }91 }
130 92
93+// replaceSessionMetaLocked atomically replaces the relayed snapshot while
94+// preserving the first-observed timestamp of tabs that remain attentive.
95+// Callers must validate meta and hold p.mu.
96+// applyProxySessionMeta validates and atomically applies relayed metadata only
97+// for the exact current link generation. A remote session rename cannot mutate
98+// the immutable local identity, so it expires that exact proxy instead.
131 func (p *proxySession) snapshotView(opts viewOptions) sessionView {99 func (p *proxySession) snapshotView(opts viewOptions) sessionView {
132 if p == nil {100 if p == nil {
133 return sessionView{}101 return sessionView{}
@@ -204,9 +172,8 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi
204 if err := key.Validate(); err != nil {172 if err := key.Validate(); err != nil {
205 return nil, fmt.Errorf("proxy session key: %w", err)173 return nil, fmt.Errorf("proxy session key: %w", err)
206 }174 }
207- content := contentSize(size, false)175+ if !size.Valid() {
208- if size.Valid() && !validProxyScreenSize(content) {176+ return nil, fmt.Errorf("proxy session size: invalid size %dx%d", size.Cols, size.Rows)
209- return nil, fmt.Errorf("proxy session size: invalid content size %dx%d", content.Cols, content.Rows)
210 }177 }
211 var clientID [16]byte178 var clientID [16]byte
212 if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {179 if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {
@@ -220,11 +187,10 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi
220 },187 },
221 key: key,188 key: key,
222 generation: 1,189 generation: 1,
223- screen: newProxyScreenState(content),
224 linkState: ports.LinkStateConnected,190 linkState: ports.LinkStateConnected,
225 done: make(chan struct{}),191 done: make(chan struct{}),
226 clientID: clientID,192 clientID: clientID,
227- contentSize: content,193+ contentSize: size,
228 commandPending: make(map[uint64]proxyCommandPending),194 commandPending: make(map[uint64]proxyCommandPending),
229 }195 }
230 return p, nil196 return p, nil
@@ -238,7 +204,7 @@ type proxyConstruction struct {
238 var errProxySessionExpired = errors.New("proxy session: remote attachment has expired")204 var errProxySessionExpired = errors.New("proxy session: remote attachment has expired")
239 205
240 // openProxySession returns the exact already-published proxy for key, or elects206 // openProxySession returns the exact already-published proxy for key, or elects
241-// one constructor for that structured key. Waiters never dial: they wait207+// one constructor for that remote key. Waiters never dial: they wait
242 // cancellably, then revalidate the exact registry winner under d.mu. The208 // cancellably, then revalidate the exact registry winner under d.mu. The
243 // reservation itself is held only under d.mu; dial and handshake I/O run after209 // reservation itself is held only under d.mu; dial and handshake I/O run after
244 // releasing every daemon, core, and proxy architecture lock.210 // releasing every daemon, core, and proxy architecture lock.
A internal/usecase/daemon/proxy_test_helpers_test.go +127 -0
@@ -0,0 +1,127 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "log/slog"
6+ "sync"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/stretchr/testify/require"
14+)
15+
16+type proxyRecv struct {
17+ frame ports.Frame
18+ err error
19+}
20+
21+type proxyTestTransport struct {
22+ recv chan proxyRecv
23+ closed chan struct{}
24+ sent chan ports.Frame
25+ once sync.Once
26+
27+ sendFails atomic.Bool
28+ sendGate <-chan struct{}
29+ sendEntered chan struct{}
30+ receiving atomic.Int32
31+ concurrent atomic.Bool
32+ sending atomic.Int32
33+}
34+
35+func newProxyTestTransport() *proxyTestTransport {
36+ return &proxyTestTransport{recv: make(chan proxyRecv, 16), closed: make(chan struct{}), sent: make(chan ports.Frame, 32)}
37+}
38+
39+func (t *proxyTestTransport) Send(frame ports.Frame) error {
40+ if t.sending.Add(1) != 1 {
41+ t.concurrent.Store(true)
42+ }
43+ defer t.sending.Add(-1)
44+ if t.sendFails.Load() {
45+ return io.ErrClosedPipe
46+ }
47+ if t.sendGate != nil {
48+ if t.sendEntered != nil {
49+ select {
50+ case t.sendEntered <- struct{}{}:
51+ default:
52+ }
53+ }
54+ select {
55+ case <-t.sendGate:
56+ case <-t.closed:
57+ return io.ErrClosedPipe
58+ }
59+ }
60+ select {
61+ case t.sent <- frame:
62+ return nil
63+ case <-t.closed:
64+ return io.ErrClosedPipe
65+ }
66+}
67+
68+func (t *proxyTestTransport) Recv() (ports.Frame, error) {
69+ t.receiving.Add(1)
70+ defer t.receiving.Add(-1)
71+ select {
72+ case item := <-t.recv:
73+ return item.frame, item.err
74+ case <-t.closed:
75+ return ports.Frame{}, io.EOF
76+ }
77+}
78+
79+func (t *proxyTestTransport) Close() error {
80+ t.once.Do(func() { close(t.closed) })
81+ return nil
82+}
83+
84+func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
85+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
86+ SessionID: "remote-id", SessionName: name, ResumeToken: token, Capabilities: capabilities,
87+ })}
88+}
89+
90+func proxyHandshakeSnapshot() ports.Frame {
91+ return ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
92+ Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("remote initial\n"),
93+ })}
94+}
95+
96+func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk ports.Clock) *Daemon {
97+ t.Helper()
98+ if clk == nil {
99+ clk = stubClock{}
100+ }
101+ return New(nil, clk, slog.New(slog.NewTextHandler(io.Discard, nil)),
102+ WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP))
103+}
104+
105+func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello {
106+ t.Helper()
107+ select {
108+ case frame := <-tr.sent:
109+ require.Equal(t, ports.MsgHello, frame.Type)
110+ hello, err := ports.UnmarshalHello(frame.Payload)
111+ require.NoError(t, err)
112+ return hello
113+ case <-time.After(time.Second):
114+ t.Fatal("timed out waiting for proxy Hello")
115+ return ports.Hello{}
116+ }
117+}
118+
119+func stopProxy(t *testing.T, p *proxySession) {
120+ t.Helper()
121+ p.stop()
122+ select {
123+ case <-p.done:
124+ case <-time.After(time.Second):
125+ t.Fatal("timed out waiting for proxy link to stop")
126+ }
127+}
M internal/usecase/daemon/render.go +20 -20
@@ -281,17 +281,6 @@ func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64)
281 }281 }
282 }282 }
283 283
284-// diagnostic records a completed measurement for observer I/O after the
285-// render transaction releases attachment ownership.
286-func (b *runtimeMarkBatch) diagnostic(kind ports.RuntimeMarkKind, bytes, fragments uint64) {
287- if b == nil || b.observer == nil {
288- return
289- }
290- mark := ports.NewRuntimeMark("daemon", kind, bytes, true)
291- mark.Fragments = fragments
292- b.marks = append(b.marks, mark)
293-}
294-
295 func (b *runtimeMarkBatch) flush() {284 func (b *runtimeMarkBatch) flush() {
296 if b == nil || b.observer == nil {285 if b == nil || b.observer == nil {
297 return286 return
@@ -479,15 +468,23 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
479 }468 }
480 469
481 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {470 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
482- rows := min(r.Height, src.Height)471+ startX, startY := max(r.X, 0), max(r.Y, 0)
483- cols := min(r.Width, src.Width)472+ endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
484- for y := range rows {473+ for y := startY; y < endY; y++ {
485- for x := range cols {474+ sy := y - r.Y
486- cell := src.At(x, y)475+ if sy < 0 || sy >= src.Height {
476+ continue
477+ }
478+ for x := startX; x < endX; x++ {
479+ sx := x - r.X
480+ if sx < 0 || sx >= src.Width {
481+ continue
482+ }
483+ cell := src.At(sx, sy)
487 if dim {484 if dim {
488 cell.Style = dimmer.Dim(cell.Style)485 cell.Style = dimmer.Dim(cell.Style)
489 }486 }
490- dst.Set(r.X+x, r.Y+y, cell)487+ dst.Set(x, y, cell)
491 }488 }
492 }489 }
493 }490 }
@@ -502,9 +499,12 @@ func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style
502 if d.Dir == layout.Vertical {499 if d.Dir == layout.Vertical {
503 glyph = '─'500 glyph = '─'
504 }501 }
505- y0 := d.Rect.Y + dy502+ y0 := max(d.Rect.Y+dy, 0)
506- for y := y0; y < y0+d.Rect.Height; y++ {503+ y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
507- for x := d.Rect.X; x < d.Rect.X+d.Rect.Width; x++ {504+ x0 := max(d.Rect.X, 0)
505+ x1 := min(d.Rect.X+d.Rect.Width, frame.Width)
506+ for y := y0; y < y1; y++ {
507+ for x := x0; x < x1; x++ {
508 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})508 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
509 }509 }
510 }510 }
M internal/usecase/daemon/render_capture.go +18 -6
@@ -35,17 +35,16 @@ type renderCaptureScratch struct {
35 }35 }
36 36
37 type damageReceipt struct {37 type damageReceipt struct {
38- pane *pane38+ pane *pane
39- proxy *proxySession39+ generation uint64
40- proxyScreen *proxyScreenState
41- generation uint64
42 }40 }
43 41
44 type capturedRenderState struct {42 type capturedRenderState struct {
45 attachment *attachedClient // identity only; never dereferenced by composition43 attachment *attachedClient // identity only; never dereferenced by composition
46 lease *attachmentLease44 lease *attachmentLease
45+ view attachmentView
46+ window domain.Size
47 reset bool47 reset bool
48- contentOnly bool
49 layout capturedTabLayout48 layout capturedTabLayout
50 panes []capturedPaneRenderState49 panes []capturedPaneRenderState
51 floating capturedFloatingRenderState50 floating capturedFloatingRenderState
@@ -225,6 +224,13 @@ func captureLocalRenderState(
225 return nil, false224 return nil, false
226 }225 }
227 226
227+ // A reset is an attachment-local capture boundary. The shared pane damage
228+ // receipt may have been acknowledged by another fan-out attachment before
229+ // this capture runs, so retaining this attachment's prior pane snapshots
230+ // would compose an old frame with a fresh reset epoch.
231+ if reset {
232+ ac.captureFrames = nil
233+ }
228 scratch := &ac.renderScratch234 scratch := &ac.renderScratch
229 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)235 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
230 bars.status.tabs = scratch.statusTabs236 bars.status.tabs = scratch.statusTabs
@@ -266,8 +272,14 @@ func captureLocalRenderState(
266 layoutSnap.dividers = scratch.dividers272 layoutSnap.dividers = scratch.dividers
267 }273 }
268 state := &scratch.state274 state := &scratch.state
275+ view := ac.viewSnapshot()
276+ window := domain.Size{}
277+ if view.windowSet {
278+ window = ac.size
279+ }
269 *state = capturedRenderState{280 *state = capturedRenderState{
270- attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme,281+ attachment: ac, lease: lease, view: view, window: window,
282+ reset: reset, bars: bars, theme: bars.theme,
271 styles: request.styles, styleGeneration: request.styleGeneration,283 styles: request.styles, styleGeneration: request.styleGeneration,
272 overlays: overlays, preview: preview,284 overlays: overlays, preview: preview,
273 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},285 layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok},
M internal/usecase/daemon/render_coordinator_test.go +2 -3
@@ -1161,11 +1161,10 @@ func TestProducerInvalidations(t *testing.T) {
1161 },1161 },
1162 {1162 {
1163 file: "input.go",1163 file: "input.go",
1164- name: "proxied jump attention switches a local tab",1164+ name: "jump attention switches a local tab",
1165 tabs: 2,1165 tabs: 2,
1166- producer: "input.go",1166+ producer: "attention.go",
1167 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {1167 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1168- ac.proxied = true
1169 sess.tabs[1].attention = true1168 sess.tabs[1].attention = true
1170 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil)1169 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil)
1171 require.Equal(t, 1, testAttachmentTabIndex(sess))1170 require.Equal(t, 1, testAttachmentTabIndex(sess))
M internal/usecase/daemon/render_damage_receipt_test.go +27 -0
@@ -11,6 +11,33 @@ import (
11 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
12 )12 )
13 13
14+func TestResetCaptureRefreshesPaneAfterPeerAcknowledgesSharedDamage(t *testing.T) {
15+ _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
16+ p := sess.tabs[0].focusedPane()
17+ p.mu.Lock()
18+ p.screen.ClearDamage()
19+ p.screen.Write([]byte("before"))
20+ p.mu.Unlock()
21+
22+ ac.sendMu.Lock()
23+ state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: false})
24+ require.True(t, ok)
25+ before := state.panes[0].frame.Clone()
26+ ac.sendMu.Unlock()
27+
28+ p.mu.Lock()
29+ p.screen.Write([]byte("after"))
30+ damage := p.screen.CaptureDamage()
31+ p.screen.AcknowledgeDamage(damage.Generation)
32+ p.mu.Unlock()
33+
34+ ac.sendMu.Lock()
35+ state, ok = captureRenderState(sess, ac, renderCaptureRequest{reset: true})
36+ ac.sendMu.Unlock()
37+ require.True(t, ok)
38+ require.NotEqual(t, before, state.panes[0].frame, "reset capture must not reuse the pane snapshot retained before shared damage was acknowledged")
39+}
40+
14 func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {41 func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
15 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)42 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
16 p := sess.tabs[0].focusedPane()43 p := sess.tabs[0].focusedPane()
M internal/usecase/daemon/render_pipeline.go +53 -109
@@ -68,6 +68,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
68 scratch = scratchIn[0]68 scratch = scratchIn[0]
69 }69 }
70 width, rows := state.layout.area.Width, state.layout.area.Height70 width, rows := state.layout.area.Width, state.layout.area.Height
71+ if state.window.Valid() {
72+ window := contentSize(state.window)
73+ width, rows = window.Cols, window.Rows
74+ }
71 if width <= 0 || rows < 0 {75 if width <= 0 || rows < 0 {
72 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}76 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
73 }77 }
@@ -75,10 +79,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
75 // copied before incremental drawing so prepare/send failures cannot change it.79 // copied before incremental drawing so prepare/send failures cannot change it.
76 contentY := 180 contentY := 1
77 frameHeight := rows + tabChromeRows81 frameHeight := rows + tabChromeRows
78- if state.contentOnly {
79- contentY = 0
80- frameHeight = rows
81- }
82 canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight82 canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight
83 frame := scratch.frame83 frame := scratch.frame
84 if !canReuseFrame {84 if !canReuseFrame {
@@ -99,10 +99,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
99 defaultDimmer := themeui.NewDimmer(state.theme)99 defaultDimmer := themeui.NewDimmer(state.theme)
100 neutralBorder := styles.NeutralBorder100 neutralBorder := styles.NeutralBorder
101 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))101 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
102- if !state.contentOnly {102+ drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
103- drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)103+ drawStatusBarState(frame.Row(rows+1), state.bars, styles)
104- drawStatusBarState(frame.Row(rows+1), state.bars, styles)
105- }
106 content := domain.Rect{Y: contentY, Width: width, Height: rows}104 content := domain.Rect{Y: contentY, Width: width, Height: rows}
107 if state.layout.valid {105 if state.layout.valid {
108 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))106 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
@@ -166,7 +164,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
166 frame, damage = composeCapturedCopyMode(state, frame, damage, content)164 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
167 frame, damage = composeCapturedOverlays(state, frame, damage)165 frame, damage = composeCapturedOverlays(state, frame, damage)
168 }166 }
169- if !state.contentOnly && !full {167+ if !full {
170 if !sameCells(in.bars.top, baseFrame.Row(0)) {168 if !sameCells(in.bars.top, baseFrame.Row(0)) {
171 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})169 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
172 }170 }
@@ -188,23 +186,22 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
188 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive186 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
189 cursor := desiredCapturedCursor(cursorInputs, contentY)187 cursor := desiredCapturedCursor(cursorInputs, contentY)
190 outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}188 outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars}
191- if !state.contentOnly {189+ outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
192- outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
193- } else {
194- outCache.bars.Reset()
195- }
196 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}190 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
197 }191 }
198 192
199 func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {193 func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) {
194+ if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height {
195+ return
196+ }
200 style := styles.StatusBar197 style := styles.StatusBar
201 if !focused {198 if !focused {
202 style = dimmer.Dim(neutralBorder)199 style = dimmer.Dim(neutralBorder)
203 }200 }
204- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {201+ for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ {
205 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})202 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
206 }203 }
207- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)204+ ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style)
208 }205 }
209 206
210 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {207 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
@@ -235,9 +232,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
235 styles = fallbackChromeStyles232 styles = fallbackChromeStyles
236 }233 }
237 rows := state.layout.area.Height234 rows := state.layout.area.Height
238- if !state.contentOnly {235+ rows += tabChromeRows
239- rows += tabChromeRows
240- }
241 size := domain.Size{Cols: state.layout.area.Width, Rows: rows}236 size := domain.Size{Cols: state.layout.area.Width, Rows: rows}
242 if snap.copySearchModel != nil {237 if snap.copySearchModel != nil {
243 presentation := copySearchModal.Resolve(size)238 presentation := copySearchModal.Resolve(size)
@@ -428,13 +423,6 @@ func commitDamageReceipts(receipts []damageReceipt) {
428 receipt.pane.mu.Unlock()423 receipt.pane.mu.Unlock()
429 continue424 continue
430 }425 }
431- if receipt.proxy != nil {
432- receipt.proxy.mu.Lock()
433- if receipt.proxy.screen != nil && receipt.proxy.screen == receipt.proxyScreen {
434- receipt.proxy.screen.AcknowledgeDamage(receipt.generation)
435- }
436- receipt.proxy.mu.Unlock()
437- }
438 }426 }
439 }427 }
440 428
@@ -474,33 +462,19 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
474 }462 }
475 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)463 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
476 var (464 var (
477- preparedANSI *preparedOutput465+ preparedANSI *preparedOutput
478- preparedScreen *preparedStructuredOutput466+ err error
479- err error467+ cursor cursorCandidate
480- cursor cursorCandidate468+ data []byte
481- data []byte
482 )469 )
483- if ac.proxied {470+ preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset)
484- screenOutput := ac.ensureScreenOutput()471+ if err == nil {
485- if screenOutput == nil {472+ cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0)
486- err = errors.New("proxied attachment has no structured output stream")473+ data = append([]byte(nil), preparedANSI.data...)
487- } else {474+ data = append(data, cursor.data...)
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 }475 }
501 endDiff(0, err == nil)476 endDiff(0, err == nil)
502 if err != nil {477 if err != nil {
503- ac.discardProxyCapture()
504 ac.sendMu.Unlock()478 ac.sendMu.Unlock()
505 d.log.Error("render draw failed", "err", err, "session", entry.core().name)479 d.log.Error("render draw failed", "err", err, "session", entry.core().name)
506 // Without a coordinator reportError repaints synchronously. Suppress only480 // Without a coordinator reportError repaints synchronously. Suppress only
@@ -525,81 +499,54 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
525 }499 }
526 var sendTr ports.Transport500 var sendTr ports.Transport
527 var sendErr error501 var sendErr error
528- // Metadata precedes any output bytes and is published on its own when the502+ if len(data) > 0 {
529- // terminal frame is empty, so a proxied client never keeps a stale snapshot.
530- if len(data) > 0 || ac.proxied {
531 sendTransport := ac.transportSnapshot()503 sendTransport := ac.transportSnapshot()
532 sendTr = sendTransport.transport504 sendTr = sendTransport.transport
533- if ac.proxied {505+ endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
534- if sess, ok := localSession(entry); ok {506+ if sendTr == nil {
535- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.attachmentEffect)507+ sendErr = errors.New("client transport is nil")
536- } else if proxy, ok := entry.(*proxySession); ok {508+ } else {
537- meta, metaOK := proxy.sessionMetaSnapshot()509+ send := sendTr.Send
538- if !metaOK {510+ if async, ok := sendTr.(ports.AsyncTransport); ok {
539- sendErr = errSessionMetaUnavailable511+ send = async.SendAsync
540- } else {
541- sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.attachmentEffect)
542- }
543 }512 }
544- }513+ interruptible := false
545- if len(data) > 0 {514+ if marks.attachmentEffect != nil {
546- endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))515+ interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
547- if sendErr == nil && sendTr == nil {516+ if !interruptible {
548- sendErr = errors.New("client transport is nil")517+ sendErr = errAttachmentTransition
518+ }
549 }519 }
550 if sendErr == nil {520 if sendErr == nil {
551- interruptible := false521+ sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
552- if marks.attachmentEffect != nil {
553- interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
554- if !interruptible {
555- sendErr = errAttachmentTransition
556- }
557- }
558- if sendErr == nil {
559- send := sendTr.Send
560- if async, ok := sendTr.(ports.AsyncTransport); ok {
561- send = async.SendAsync
562- }
563- if ac.proxied {
564- sendErr = preparedScreen.send(send)
565- } else {
566- sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
567- }
568- }
569- if marks.attachmentEffect != nil && interruptible {
570- if sendErr != nil {
571- marks.attachmentEffect.reportTransportFailure(sendTransport)
572- }
573- marks.attachmentEffect.endTransportSend()
574- }
575 }522 }
576- endEmit(uint64(len(data)), sendErr == nil)523+ if marks.attachmentEffect != nil && interruptible {
577- if sendErr == nil && ac.proxied {524+ if sendErr != nil {
578- kind := ports.RuntimeScreenDelta525+ marks.attachmentEffect.reportTransportFailure(sendTransport)
579- if preparedScreen.update.Kind == ports.ScreenUpdateSnapshot {
580- kind = ports.RuntimeScreenSnapshot
581 }526 }
582- marks.diagnostic(kind, uint64(len(preparedScreen.data)), uint64(len(preparedScreen.update.Spans)))527+ marks.attachmentEffect.endTransportSend()
583 }528 }
584 }529 }
530+ emitted := sendErr == nil && preparedANSI.sent
531+ endEmit(uint64(len(data)), emitted)
532+ if sendErr == nil && !emitted {
533+ ac.sendMu.Unlock()
534+ return true
535+ }
585 }536 }
586 if sendErr == nil {537 if sendErr == nil {
587 if len(data) == 0 {538 if len(data) == 0 {
588- if ac.proxied {539+ preparedANSI.commitNoSend()
589- preparedScreen.commitNoSend()540+ if !preparedANSI.sent {
590- } else {541+ ac.sendMu.Unlock()
591- preparedANSI.commitNoSend()542+ return true
592 }543 }
593 }544 }
594 // Publish only after output preparation and transport emission both545 // Publish only after output preparation and transport emission both
595 // succeed. A cross-session transition may publish concurrently, but its546 // succeed. A cross-session transition may publish concurrently, but its
596 // mandatory first-paint rebase waits for sendMu and therefore follows this547 // mandatory first-paint rebase waits for sendMu and therefore follows this
597 // completed output transaction.548 // completed output transaction.
598- if ac.proxied {549+ ac.lastCursor = cursor.next
599- ac.lastCursor = composed.cursor
600- } else {
601- ac.lastCursor = cursor.next
602- }
603 ac.pipelineScratch = ac.pipelineCache550 ac.pipelineScratch = ac.pipelineCache
604 ac.pipelineCache = composed.cache551 ac.pipelineCache = composed.cache
605 552
@@ -611,9 +558,6 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
611 ac.renderStages.emit()558 ac.renderStages.emit()
612 }559 }
613 }560 }
614- if sendErr != nil {
615- ac.discardProxyCapture()
616- }
617 ac.sendMu.Unlock()561 ac.sendMu.Unlock()
618 if sendErr != nil {562 if sendErr != nil {
619 // A transport failure may invalidate the role gate. Release this render's563 // A transport failure may invalidate the role gate. Release this render's
M internal/usecase/daemon/render_pipeline_test.go +6 -3
@@ -59,7 +59,7 @@ func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
59 require.NoError(t, transport.Send(frame))59 require.NoError(t, transport.Send(frame))
60 output, err := ports.UnmarshalOutput(frame.Payload)60 output, err := ports.UnmarshalOutput(frame.Payload)
61 require.NoError(t, err)61 require.NoError(t, err)
62- require.Equal(t, frame.Payload, ports.MarshalOutput(output), "output payload must remain byte exact")62+ require.Equal(t, frame.Payload, mustMarshalOutput(output), "output payload must remain byte exact")
63 terminal.Write(output.Data)63 terminal.Write(output.Data)
64 }64 }
65 require.Equal(t, len(frames), transport.next)65 require.Equal(t, len(frames), transport.next)
@@ -128,6 +128,9 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
128 t.Cleanup(release)128 t.Cleanup(release)
129 d, sess, _, sends := newManualSessionWithPTYs(t, pty)129 d, sess, _, sends := newManualSessionWithPTYs(t, pty)
130 ac := sess.snapshotAttachments()[0]130 ac := sess.snapshotAttachments()[0]
131+ // Resolve the initial attachment view before filling the output window; a
132+ // first target repair is an epoch boundary by design.
133+ require.NotNil(t, sess.tabForAttachment(ac))
131 ac.sendMu.Lock()134 ac.sendMu.Lock()
132 ac.output.next = ac.output.maxOutstanding135 ac.output.next = ac.output.maxOutstanding
133 ac.output.acked = 0136 ac.output.acked = 0
@@ -252,8 +255,8 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
252 require.Equal(t, uint64(1), ac.output.next)255 require.Equal(t, uint64(1), ac.output.next)
253 out, err := ports.UnmarshalOutput((<-sends).Payload)256 out, err := ports.UnmarshalOutput((<-sends).Payload)
254 require.NoError(t, err)257 require.NoError(t, err)
255- require.Zero(t, out.BaseStateNum)258+ require.Zero(t, out.Base)
256- require.Equal(t, uint64(1), out.NewStateNum)259+ require.Equal(t, uint64(1), out.New)
257 }260 }
258 261
259 func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) {262 func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) {
M internal/usecase/daemon/render_test.go +1 -1
@@ -86,7 +86,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
86 frame := awaitFrame(t, sends, ports.MsgOutput)86 frame := awaitFrame(t, sends, ports.MsgOutput)
87 output, err := ports.UnmarshalOutput(frame.Payload)87 output, err := ports.UnmarshalOutput(frame.Payload)
88 require.NoError(t, err)88 require.NoError(t, err)
89- require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset")89+ require.Zero(t, output.Base, "first paint must be a mandatory reset")
90 select {90 select {
91 case extra := <-sends:91 case extra := <-sends:
92 t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra)92 t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra)
M internal/usecase/daemon/resume.go +2 -11
@@ -459,7 +459,6 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
459 oldSnap transportSnapshot459 oldSnap transportSnapshot
460 credentialMatch bool460 credentialMatch bool
461 clientMismatch bool461 clientMismatch bool
462- modeMismatch bool
463 )462 )
464 if sess != nil {463 if sess != nil {
465 sess.mu.Lock()464 sess.mu.Lock()
@@ -470,8 +469,6 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
470 ac = candidate469 ac = candidate
471 if ac.clientID != h.ClientID {470 if ac.clientID != h.ClientID {
472 clientMismatch = true471 clientMismatch = true
473- } else if ac.proxied != h.Proxied {
474- modeMismatch = true
475 } else {472 } else {
476 oldSnap = ac.transportSnapshot()473 oldSnap = ac.transportSnapshot()
477 credentialMatch = oldSnap.transport != nil474 credentialMatch = oldSnap.transport != nil
@@ -487,11 +484,8 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
487 }484 }
488 d.mu.Unlock()485 d.mu.Unlock()
489 486
490- if clientMismatch || modeMismatch {487+ if clientMismatch {
491 reason := "client id mismatch"488 reason := "client id mismatch"
492- if modeMismatch {
493- reason = "proxied mode mismatch"
494- }
495 d.log.Warn("resume rejected", "session", sess.name, "err", reason)489 d.log.Warn("resume rejected", "session", sess.name, "err", reason)
496 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}490 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
497 }491 }
@@ -603,11 +597,8 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
603 if parked.claimed {597 if parked.claimed {
604 return nil, nil, false, errResumeTokenLifecycleRace598 return nil, nil, false, errResumeTokenLifecycleRace
605 }599 }
606- if ac.clientID != h.ClientID || ac.proxied != h.Proxied {600+ if ac.clientID != h.ClientID {
607 reason := "client id mismatch"601 reason := "client id mismatch"
608- if ac.proxied != h.Proxied {
609- reason = "proxied mode mismatch"
610- }
611 d.log.Warn("resume rejected", "session", sess.name, "err", reason)602 d.log.Warn("resume rejected", "session", sess.name, "err", reason)
612 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}603 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
613 }604 }
M internal/usecase/daemon/resume_test.go +19 -18
@@ -280,7 +280,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
280 output := awaitFrame(t, tr.sends, ports.MsgOutput)280 output := awaitFrame(t, tr.sends, ports.MsgOutput)
281 first, err := ports.UnmarshalOutput(output.Payload)281 first, err := ports.UnmarshalOutput(output.Payload)
282 require.NoError(t, err)282 require.NoError(t, err)
283- require.Zero(t, first.BaseStateNum)283+ require.Zero(t, first.Base)
284 tr.finish()284 tr.finish()
285 <-done285 <-done
286 requireNoCoordinatorOutputFrame(t, tr.sends)286 requireNoCoordinatorOutputFrame(t, tr.sends)
@@ -974,9 +974,10 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
974 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {974 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
975 ac := &attachedClient{output: newOutputStateStream()}975 ac := &attachedClient{output: newOutputStateStream()}
976 ac.output.next = 3976 ac.output.next = 3
977- ac.ackOutputState(3)977+ epoch := ac.output.currentEpoch()
978- ac.ackOutputState(2)978+ ac.ackOutputState(epoch, 3)
979- ac.ackOutputState(4)979+ ac.ackOutputState(epoch, 2)
980+ ac.ackOutputState(epoch, 4)
980 require.Equal(t, uint64(3), ac.output.acked, "stale or future ACKs must not move output state incorrectly")981 require.Equal(t, uint64(3), ac.output.acked, "stale or future ACKs must not move output state incorrectly")
981 982
982 ac.sendMu.Lock()983 ac.sendMu.Lock()
@@ -988,8 +989,8 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
988 ac.sendMu.Unlock()989 ac.sendMu.Unlock()
989 out, err := ports.UnmarshalOutput(f.Payload)990 out, err := ports.UnmarshalOutput(f.Payload)
990 require.NoError(t, err)991 require.NoError(t, err)
991- require.Equal(t, uint64(5), out.BaseStateNum, "output should remain incremental unless an explicit reset is requested")992+ require.Equal(t, uint64(5), out.Base, "output should remain incremental unless an explicit reset is requested")
992- require.Equal(t, uint64(6), out.NewStateNum)993+ require.Equal(t, uint64(6), out.New)
993 994
994 ac.sendMu.Lock()995 ac.sendMu.Lock()
995 reset = true996 reset = true
@@ -998,8 +999,8 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
998 ac.sendMu.Unlock()999 ac.sendMu.Unlock()
999 fullOut, err := ports.UnmarshalOutput(full.Payload)1000 fullOut, err := ports.UnmarshalOutput(full.Payload)
1000 require.NoError(t, err)1001 require.NoError(t, err)
1001- require.Equal(t, uint64(0), fullOut.BaseStateNum)1002+ require.Equal(t, uint64(0), fullOut.Base)
1002- require.Equal(t, uint64(7), fullOut.NewStateNum)1003+ require.Equal(t, uint64(7), fullOut.New)
1003 }1004 }
1004 1005
1005 func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {1006 func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
@@ -1081,9 +1082,9 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
1081 require.Len(t, sends, 1)1082 require.Len(t, sends, 1)
1082 first, err := ports.UnmarshalOutput(sends[0].Payload)1083 first, err := ports.UnmarshalOutput(sends[0].Payload)
1083 require.NoError(t, err)1084 require.NoError(t, err)
1084- require.Zero(t, first.BaseStateNum)1085+ require.Zero(t, first.Base)
1085- require.Equal(t, uint64(maxUnackedOutputStates+1), first.NewStateNum)1086+ require.Equal(t, uint64(1), first.New)
1086- resumedAC.ackOutputState(first.NewStateNum)1087+ resumedAC.ackOutputState(first.Epoch, first.New)
1087 1088
1088 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))1089 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
1089 d.paint(resumedSess, resumedAC, false, nil)1090 d.paint(resumedSess, resumedAC, false, nil)
@@ -1091,7 +1092,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
1091 require.Len(t, sends, 2)1092 require.Len(t, sends, 2)
1092 second, err := ports.UnmarshalOutput(sends[1].Payload)1093 second, err := ports.UnmarshalOutput(sends[1].Payload)
1093 require.NoError(t, err)1094 require.NoError(t, err)
1094- require.Equal(t, first.NewStateNum, second.BaseStateNum)1095+ require.Equal(t, first.New, second.Base)
1095 }1096 }
1096 1097
1097 func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {1098 func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
@@ -1141,7 +1142,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
1141 require.Len(t, sends, 1)1142 require.Len(t, sends, 1)
1142 output, err := ports.UnmarshalOutput(sends[0].Payload)1143 output, err := ports.UnmarshalOutput(sends[0].Payload)
1143 require.NoError(t, err)1144 require.NoError(t, err)
1144- require.Zero(t, output.BaseStateNum, "resume must start with a complete frame")1145+ require.Zero(t, output.Base, "resume must start with a complete frame")
1145 terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)1146 terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)
1146 terminal.Write(output.Data)1147 terminal.Write(output.Data)
1147 contents := strings.Join(frameRows(terminal.Frame), "\n")1148 contents := strings.Join(frameRows(terminal.Frame), "\n")
@@ -1445,13 +1446,13 @@ func TestRawTerminalSideEffectsAreOutputStateNeutral(t *testing.T) {
1445 require.NoError(t, err)1446 require.NoError(t, err)
1446 second, err := ports.UnmarshalOutput(sends[1].Payload)1447 second, err := ports.UnmarshalOutput(sends[1].Payload)
1447 require.NoError(t, err)1448 require.NoError(t, err)
1448- require.Zero(t, first.BaseStateNum)1449+ require.Zero(t, first.Base)
1449- require.Zero(t, first.NewStateNum)1450+ require.Zero(t, first.New)
1450- require.Zero(t, second.BaseStateNum)1451+ require.Zero(t, second.Base)
1451- require.Zero(t, second.NewStateNum)1452+ require.Zero(t, second.New)
1452 }1453 }
1453 1454
1454-func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) {1455+func TestSequencedInputDoesNotPrematurelyEcho(t *testing.T) {
1455 p, release := newBlockingPTY(t)1456 p, release := newBlockingPTY(t)
1456 defer release()1457 defer release()
1457 d := newTestDaemon(t, newFactory(t, p), stubClock{})1458 d := newTestDaemon(t, newFactory(t, p), stubClock{})
D internal/usecase/daemon/screen_output_stream.go +0 -244
@@ -1,244 +0,0 @@
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- size := frameSize(frame)
39- if !validProxyScreenSize(size) {
40- return nil, errors.New("structured output: frame geometry is outside wire limits")
41- }
42-
43- reset = reset || s.forceSnapshot || s.state.next == 0 || s.shadow.Validate() != nil ||
44- s.shadow.Width != frame.Width || s.shadow.Height != frame.Height
45- candidate, err := renderer.PlanDelta(frame, damage, s.shadow, reset)
46- if err != nil {
47- return nil, err
48- }
49-
50- plan := candidate.Plan
51- snapshot := reset || plan.Snapshot
52- cursorChanged := cursor != s.cursorOut
53- if !snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 && !cursorChanged {
54- return &preparedStructuredOutput{
55- stream: s,
56- candidate: candidate,
57- cursor: cursor,
58- echoAck: echoAck,
59- }, nil
60- }
61- if s.state.next == ^uint64(0) {
62- return nil, errors.New("structured output state number exhausted")
63- }
64-
65- wireCursor, err := wireScreenCursor(cursor, frame)
66- if err != nil {
67- return nil, err
68- }
69- update := ports.ScreenUpdate{
70- BaseStateNum: s.state.next,
71- NewStateNum: s.state.next + 1,
72- EchoAck: echoAck,
73- Kind: ports.ScreenUpdateDelta,
74- Size: frameSize(frame),
75- Cursor: wireCursor,
76- }
77- if snapshot {
78- update.BaseStateNum = 0
79- update.Kind = ports.ScreenUpdateSnapshot
80- update.Spans = make([]ports.ScreenSpan, frame.Height)
81- for y := range frame.Height {
82- update.Spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: copyScreenCells(frame.Row(y))}
83- }
84- } else {
85- if plan.Scroll.Height != 0 {
86- if plan.Scroll.Y < 0 || plan.Scroll.Height <= 0 || plan.Scroll.Count <= 0 ||
87- plan.Scroll.Y+plan.Scroll.Height > frame.Height || plan.Scroll.Count >= plan.Scroll.Height {
88- return nil, errors.New("structured output: invalid planned scroll")
89- }
90- update.Scroll = &ports.ScreenScroll{
91- Top: uint16(plan.Scroll.Y),
92- Height: uint16(plan.Scroll.Height),
93- Count: uint16(plan.Scroll.Count),
94- }
95- }
96- update.Spans = make([]ports.ScreenSpan, 0, len(plan.Spans))
97- for _, span := range plan.Spans {
98- if span.Y < 0 || span.X < 0 || span.Width <= 0 || span.Y >= frame.Height || span.X > frame.Width-span.Width {
99- return nil, errors.New("structured output: invalid planned span")
100- }
101- update.Spans = append(update.Spans, ports.ScreenSpan{
102- Y: uint16(span.Y),
103- X: uint16(span.X),
104- Cells: copyScreenCells(frame.Row(span.Y)[span.X : span.X+span.Width]),
105- })
106- }
107- }
108- data, err := ports.MarshalScreenUpdate(update)
109- if err != nil {
110- return nil, err
111- }
112- return &preparedStructuredOutput{
113- stream: s,
114- candidate: candidate,
115- cursor: cursor,
116- update: update,
117- next: update.NewStateNum,
118- data: data,
119- echoAck: echoAck,
120- snapshot: snapshot,
121- }, nil
122-}
123-
124-func frameSize(frame renderer.Frame) domain.Size {
125- return domain.Size{Cols: frame.Width, Rows: frame.Height}
126-}
127-
128-func copyScreenCells(cells []renderer.Cell) []renderer.Cell {
129- return append([]renderer.Cell(nil), cells...)
130-}
131-
132-func wireScreenCursor(cursor cursorOut, frame renderer.Frame) (ports.ScreenCursor, error) {
133- out := ports.ScreenCursor{Visible: cursor.valid && !cursor.hidden}
134- if !cursor.valid {
135- return out, nil
136- }
137- if cursor.row < 0 || cursor.row >= frame.Height || cursor.col < 0 || cursor.col >= frame.Width {
138- return ports.ScreenCursor{}, errors.New("structured output: cursor outside frame")
139- }
140- if cursor.hasStyle {
141- if cursor.style < 0 || cursor.style > 6 {
142- return ports.ScreenCursor{}, errors.New("structured output: cursor style out of range")
143- }
144- out.Style = uint8(cursor.style)
145- out.StyleSet = true
146- } else if cursor.style != 0 {
147- return ports.ScreenCursor{}, errors.New("structured output: cursor style is not set")
148- }
149- out.Row = uint16(cursor.row)
150- out.Col = uint16(cursor.col)
151- return out, nil
152-}
153-
154-type preparedStructuredOutput struct {
155- stream *structuredOutputStream
156- candidate renderer.DeltaCandidate
157- cursor cursorOut
158- update ports.ScreenUpdate
159- next uint64
160- echoAck uint64
161- data []byte
162- snapshot bool
163- attempted bool
164- committed bool
165-}
166-
167-func (p *preparedStructuredOutput) send(sender func(ports.Frame) error) error {
168- if p == nil || p.attempted {
169- return nil
170- }
171- if len(p.data) == 0 {
172- p.attempted = true
173- p.commit()
174- return nil
175- }
176- p.attempted = true
177- if sender == nil {
178- p.stream.forceSnapshot = true
179- return errors.New("structured output: nil sender")
180- }
181- if err := sender(ports.Frame{Type: ports.MsgScreenUpdate, Payload: p.data}); err != nil {
182- p.stream.forceSnapshot = true
183- return err
184- }
185- p.commit()
186- p.stream.state.next = p.next
187- p.stream.state.publishOutstanding()
188- return nil
189-}
190-
191-// commitNoSend commits a prepared noop without adding a state-bearing frame.
192-// A failed send is already attempted, so it cannot accidentally commit after
193-// the failure and thereby destroy the required snapshot retry.
194-func (p *preparedStructuredOutput) commitNoSend() {
195- if p == nil || p.attempted {
196- return
197- }
198- p.attempted = true
199- if len(p.data) != 0 {
200- if p.stream != nil {
201- p.stream.forceSnapshot = true
202- }
203- return
204- }
205- p.commit()
206-}
207-
208-func (p *preparedStructuredOutput) commit() {
209- if p == nil || p.committed || p.stream == nil {
210- return
211- }
212- p.candidate.Commit(&p.stream.shadow)
213- p.stream.cursorOut = p.cursor
214- p.stream.forceSnapshot = false
215- p.committed = true
216-}
217-
218-func (s *structuredOutputStream) ack(state uint64) {
219- if s != nil && s.state != nil {
220- s.state.ack(state)
221- }
222-}
223-
224-func (s *structuredOutputStream) atCapacity() bool {
225- return s == nil || s.state == nil || s.state.atCapacity()
226-}
227-
228-func (s *structuredOutputStream) outstanding() uint64 {
229- if s == nil || s.state == nil {
230- return 0
231- }
232- return s.state.outstanding()
233-}
234-
235-// rebase discards only the local planner dependency. The shared state number
236-// is rebased once by attachedClient.rebaseOutput.
237-func (s *structuredOutputStream) rebase() {
238- if s == nil {
239- return
240- }
241- s.shadow = renderer.Frame{}
242- s.cursorOut = cursorOut{}
243- s.forceSnapshot = true
244-}
D internal/usecase/daemon/screen_output_stream_test.go +0 -438
@@ -1,438 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "math"
6- "testing"
7-
8- "github.com/bnema/vev/internal/ports"
9- "github.com/bnema/vev/pkg/renderer"
10- "github.com/stretchr/testify/require"
11-)
12-
13-func TestStructuredOutputStreamSnapshotsThenDeltas(t *testing.T) {
14- state := newOutputStateStream()
15- stream := newStructuredOutputStream(state)
16- frame := structuredTestFrame("abc", "def")
17- cursor := cursorOut{valid: true}
18-
19- first, err := stream.prepare(frame, nil, cursor, true, 7)
20- require.NoError(t, err)
21- firstUpdate := decodeStructured(t, first.data)
22- require.Equal(t, ports.ScreenUpdateSnapshot, firstUpdate.Kind)
23- require.Zero(t, firstUpdate.BaseStateNum)
24- require.Equal(t, uint64(1), firstUpdate.NewStateNum)
25- require.Len(t, firstUpdate.Spans, 2)
26- for y, span := range firstUpdate.Spans {
27- require.Equal(t, uint16(y), span.Y)
28- require.Zero(t, span.X)
29- require.Len(t, span.Cells, frame.Width)
30- for x, cell := range span.Cells {
31- require.Equal(t, frame.At(x, y).Rune, cell.Rune)
32- require.Equal(t, frame.At(x, y).Continuation, cell.Continuation)
33- }
34- }
35- require.NoError(t, first.send(func(got ports.Frame) error {
36- require.Equal(t, ports.MsgScreenUpdate, got.Type)
37- gotUpdate, err := ports.UnmarshalScreenUpdate(got.Payload)
38- require.NoError(t, err)
39- require.Equal(t, uint64(7), gotUpdate.EchoAck)
40- return nil
41- }))
42- require.Equal(t, uint64(1), state.next)
43-
44- changed := frame.Clone()
45- changed.Set(1, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
46- second, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, X: 1, Y: 0, Width: 1, Height: 1}}, cursor, false, 0)
47- require.NoError(t, err)
48- secondUpdate := decodeStructured(t, second.data)
49- require.Equal(t, ports.ScreenUpdateDelta, secondUpdate.Kind)
50- require.Equal(t, uint64(1), secondUpdate.BaseStateNum)
51- require.Equal(t, uint64(2), secondUpdate.NewStateNum)
52- require.Len(t, secondUpdate.Spans, 1)
53- require.Equal(t, uint16(1), secondUpdate.Spans[0].X)
54- require.NoError(t, second.send(func(ports.Frame) error { return nil }))
55- require.Equal(t, uint64(2), state.next)
56-}
57-
58-func TestStructuredOutputStreamCursorOnlyAndNoopOwnership(t *testing.T) {
59- state := newOutputStateStream()
60- stream := newStructuredOutputStream(state)
61- frame := structuredTestFrame("abc")
62- cursor := cursorOut{valid: true}
63-
64- first, err := stream.prepare(frame, nil, cursor, true, 0)
65- require.NoError(t, err)
66- require.NoError(t, first.send(func(ports.Frame) error { return nil }))
67-
68- moved := cursor
69- moved.col = 2
70- cursorOnly, err := stream.prepare(frame, nil, moved, false, 0)
71- require.NoError(t, err)
72- cursorUpdate := decodeStructured(t, cursorOnly.data)
73- require.Equal(t, ports.ScreenUpdateDelta, cursorUpdate.Kind)
74- require.Empty(t, cursorUpdate.Spans)
75- require.Nil(t, cursorUpdate.Scroll)
76- require.NoError(t, cursorOnly.send(func(ports.Frame) error { return nil }))
77- require.Equal(t, uint64(2), state.next)
78-
79- noop, err := stream.prepare(frame, nil, moved, false, 0)
80- require.NoError(t, err)
81- require.Empty(t, noop.data)
82- require.NoError(t, noop.send(func(ports.Frame) error {
83- t.Fatal("noop must not send a frame")
84- return nil
85- }))
86- require.Equal(t, uint64(2), state.next)
87- require.True(t, stream.cursorOut.valid)
88- require.Equal(t, moved.row, stream.cursorOut.row)
89- require.Equal(t, moved.col, stream.cursorOut.col)
90- require.Equal(t, !moved.hidden, !stream.cursorOut.hidden)
91-}
92-
93-func TestStructuredOutputStreamDeltaCommitReusesShadowStorage(t *testing.T) {
94- state := newOutputStateStream()
95- stream := newStructuredOutputStream(state)
96- cursor := cursorOut{valid: true}
97- frame := structuredTestFrame("abc", "def")
98-
99- initial, err := stream.prepare(frame, nil, cursor, true, 0)
100- require.NoError(t, err)
101- require.NoError(t, initial.send(func(ports.Frame) error { return nil }))
102- shadowCells := stream.shadow.Cells
103- require.NotEmpty(t, shadowCells)
104- shadowStart := &shadowCells[0]
105- shadowCap := cap(shadowCells)
106-
107- changed := frame.Clone()
108- changed.Set(1, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()})
109- delta, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, X: 1, Y: 0, Width: 1, Height: 1}}, cursor, false, 0)
110- require.NoError(t, err)
111- require.NoError(t, delta.send(func(ports.Frame) error { return nil }))
112-
113- require.Same(t, shadowStart, &stream.shadow.Cells[0])
114- require.Equal(t, shadowCap, cap(stream.shadow.Cells))
115- require.Equal(t, changed.At(1, 0), stream.shadow.At(1, 0))
116- require.Equal(t, uint64(2), state.next)
117-}
118-
119-func TestStructuredOutputStreamResetAndResizeUseBaseZero(t *testing.T) {
120- state := newOutputStateStream()
121- stream := newStructuredOutputStream(state)
122- cursor := cursorOut{valid: true}
123- frame := structuredTestFrame("abc")
124-
125- first, err := stream.prepare(frame, nil, cursor, true, 0)
126- require.NoError(t, err)
127- require.NoError(t, first.send(func(ports.Frame) error { return nil }))
128- changed := frame.Clone()
129- changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
130- delta, err := stream.prepare(changed, nil, cursor, false, 0)
131- require.NoError(t, err)
132- require.NoError(t, delta.send(func(ports.Frame) error { return nil }))
133-
134- resized := structuredTestFrame("abcd", "efgh")
135- reset, err := stream.prepare(resized, nil, cursor, true, 0)
136- require.NoError(t, err)
137- resetUpdate := decodeStructured(t, reset.data)
138- require.Equal(t, ports.ScreenUpdateSnapshot, resetUpdate.Kind)
139- require.Zero(t, resetUpdate.BaseStateNum)
140- require.Equal(t, uint64(3), resetUpdate.NewStateNum)
141- require.Len(t, resetUpdate.Spans, resized.Height)
142- for _, span := range resetUpdate.Spans {
143- require.Equal(t, uint16(resized.Width), uint16(len(span.Cells)))
144- }
145-}
146-
147-func TestStructuredOutputStreamFailedSendRetainsTransactionAndRetriesSnapshot(t *testing.T) {
148- state := newOutputStateStream()
149- stream := newStructuredOutputStream(state)
150- cursor := cursorOut{valid: true}
151- frame := structuredTestFrame("abc")
152-
153- first, err := stream.prepare(frame, nil, cursor, true, 0)
154- require.NoError(t, err)
155- require.NoError(t, first.send(func(ports.Frame) error { return nil }))
156- before := stream.shadow.Clone()
157- beforeCursor := stream.cursorOut
158-
159- changed := frame.Clone()
160- changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
161- changedCursor := cursorOut{valid: true, row: 0, col: 1, hidden: true, hasStyle: true}
162- pending, err := stream.prepare(changed, nil, changedCursor, false, 0)
163- require.NoError(t, err)
164- sendErr := errors.New("send failed")
165- require.ErrorIs(t, pending.send(func(ports.Frame) error { return sendErr }), sendErr)
166- require.Equal(t, uint64(1), state.next)
167- require.Equal(t, before, stream.shadow)
168- require.Equal(t, beforeCursor, stream.cursorOut)
169- require.True(t, stream.forceSnapshot)
170-
171- retry, err := stream.prepare(changed, nil, changedCursor, false, 0)
172- require.NoError(t, err)
173- retryUpdate := decodeStructured(t, retry.data)
174- require.Equal(t, ports.ScreenUpdateSnapshot, retryUpdate.Kind)
175- require.Zero(t, retryUpdate.BaseStateNum)
176- require.Equal(t, uint64(2), retryUpdate.NewStateNum)
177- require.Len(t, retryUpdate.Spans, changed.Height)
178- require.NoError(t, retry.send(func(ports.Frame) error { return nil }))
179- require.Equal(t, uint64(2), state.next)
180- require.False(t, stream.forceSnapshot)
181- require.NoError(t, retry.send(func(ports.Frame) error {
182- t.Fatal("completed send must not send twice")
183- return nil
184- }))
185-}
186-
187-func TestStructuredOutputStreamNonNoopCommitForcesSnapshotRetry(t *testing.T) {
188- state := newOutputStateStream()
189- stream := newStructuredOutputStream(state)
190- cursor := cursorOut{valid: true}
191- frame := structuredTestFrame("abc")
192-
193- first, err := stream.prepare(frame, nil, cursor, true, 0)
194- require.NoError(t, err)
195- require.NoError(t, first.send(func(ports.Frame) error { return nil }))
196- before := stream.shadow.Clone()
197-
198- changed := frame.Clone()
199- changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
200- pending, err := stream.prepare(changed, nil, cursor, false, 0)
201- require.NoError(t, err)
202- require.NotEmpty(t, pending.data)
203- pending.commitNoSend()
204- var senderCalled bool
205- require.NoError(t, pending.send(func(ports.Frame) error {
206- senderCalled = true
207- return nil
208- }))
209- require.False(t, senderCalled)
210- require.Equal(t, before, stream.shadow, "non-noop commit must not publish its candidate")
211- require.Equal(t, uint64(1), state.next)
212- require.True(t, stream.forceSnapshot)
213-
214- retry, err := stream.prepare(changed, nil, cursor, false, 0)
215- require.NoError(t, err)
216- retryUpdate := decodeStructured(t, retry.data)
217- require.Equal(t, ports.ScreenUpdateSnapshot, retryUpdate.Kind)
218- require.Zero(t, retryUpdate.BaseStateNum)
219- require.NoError(t, retry.send(func(ports.Frame) error { return nil }))
220- require.Equal(t, uint64(2), state.next)
221-}
222-
223-func TestStructuredOutputStreamComparesCursorValidityAndHidden(t *testing.T) {
224- state := newOutputStateStream()
225- stream := newStructuredOutputStream(state)
226- frame := structuredTestFrame("a")
227- visible := cursorOut{valid: true, row: 0, col: 0}
228- first, err := stream.prepare(frame, nil, visible, true, 0)
229- require.NoError(t, err)
230- require.NoError(t, first.send(func(ports.Frame) error { return nil }))
231-
232- hidden := visible
233- hidden.hidden = true
234- second, err := stream.prepare(frame, nil, hidden, false, 0)
235- require.NoError(t, err)
236- secondUpdate := decodeStructured(t, second.data)
237- require.Empty(t, secondUpdate.Spans)
238- require.False(t, secondUpdate.Cursor.Visible)
239- require.NoError(t, second.send(func(ports.Frame) error { return nil }))
240-
241- invalid := hidden
242- invalid.valid = false
243- third, err := stream.prepare(frame, nil, invalid, false, 0)
244- require.NoError(t, err)
245- thirdUpdate := decodeStructured(t, third.data)
246- require.False(t, thirdUpdate.Cursor.Visible)
247- require.NoError(t, third.send(func(ports.Frame) error { return nil }))
248-
249- noop, err := stream.prepare(frame, nil, invalid, false, 0)
250- require.NoError(t, err)
251- require.Empty(t, noop.data)
252-}
253-
254-func TestStructuredOutputStreamRejectsInvalidCursorAndGeometry(t *testing.T) {
255- cases := []struct {
256- name string
257- frame renderer.Frame
258- cursor cursorOut
259- }{
260- {name: "cursor row outside frame", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, row: 1}},
261- {name: "cursor col outside frame", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, col: 1}},
262- {name: "cursor style out of range", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, hasStyle: true, style: 7}},
263- {name: "screen cell cap", frame: renderer.NewFrame(513, 512), cursor: cursorOut{valid: true}},
264- {name: "uint16 dimension cap", frame: renderer.NewFrame(math.MaxUint16+1, 1), cursor: cursorOut{valid: true}},
265- }
266- for _, tc := range cases {
267- t.Run(tc.name, func(t *testing.T) {
268- stream := newStructuredOutputStream(newOutputStateStream())
269- _, err := stream.prepare(tc.frame, nil, tc.cursor, true, 0)
270- require.Error(t, err)
271- require.Zero(t, stream.state.next, "rejected geometry must not advance state")
272- })
273- }
274-}
275-
276-func TestStructuredOutputStreamPreservesExplicitZeroCursorStyle(t *testing.T) {
277- state := newOutputStateStream()
278- stream := newStructuredOutputStream(state)
279- frame := structuredTestFrame("a")
280- unset := cursorOut{valid: true}
281- first, err := stream.prepare(frame, nil, unset, true, 0)
282- require.NoError(t, err)
283- require.NoError(t, first.send(func(ports.Frame) error { return nil }))
284-
285- explicit := unset
286- explicit.hasStyle = true
287- second, err := stream.prepare(frame, nil, explicit, false, 0)
288- require.NoError(t, err)
289- got := decodeStructured(t, second.data)
290- require.True(t, got.Cursor.StyleSet)
291- require.Zero(t, got.Cursor.Style)
292- require.NoError(t, second.send(func(ports.Frame) error { return nil }))
293-}
294-
295-func TestStructuredOutputStreamSharesACKWindow(t *testing.T) {
296- state := newOutputStateStream(1)
297- stream := newStructuredOutputStream(state)
298- prepared, err := stream.prepare(structuredTestFrame("a"), nil, cursorOut{valid: true}, true, 0)
299- require.NoError(t, err)
300- require.NoError(t, prepared.send(func(ports.Frame) error { return nil }))
301- require.True(t, stream.atCapacity())
302- stream.ack(1)
303- require.False(t, stream.atCapacity())
304- require.Equal(t, uint64(0), stream.outstanding())
305-}
306-
307-// BenchmarkStructuredOutputPrepare is a component diagnostic for structured
308-// planning, encoding, and commit without decode/apply; it is not a pipeline
309-// comparison with ANSI apply.
310-func BenchmarkStructuredOutputPrepare(b *testing.B) {
311- fixtures := []struct {
312- name string
313- frame renderer.Frame
314- damage []renderer.Damage
315- mutate func(renderer.Frame, int)
316- }{
317- {
318- name: "one-cell",
319- frame: structuredRows(120, 40),
320- damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}},
321- mutate: func(frame renderer.Frame, i int) {
322- cell := frame.At(0, 0)
323- cell.Rune = rune('a' + i%26)
324- frame.Set(0, 0, cell)
325- },
326- },
327- {
328- name: "full-line",
329- frame: structuredRows(120, 40),
330- damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 120, Height: 1}},
331- mutate: func(frame renderer.Frame, i int) {
332- for x := range frame.Width {
333- cell := frame.At(x, 0)
334- cell.Rune = rune('a' + (x+i)%26)
335- frame.Set(x, 0, cell)
336- }
337- },
338- },
339- {
340- name: "styled-fragment",
341- frame: structuredStyledFrame(120, 40),
342- damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 120, Height: 1}},
343- mutate: func(frame renderer.Frame, i int) {
344- for x := range frame.Width {
345- cell := frame.At(x, 0)
346- cell.Rune = rune('a' + (x+i)%26)
347- frame.Set(x, 0, cell)
348- }
349- },
350- },
351- {
352- name: "full-screen-scroll",
353- frame: structuredRows(120, 40),
354- 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}},
355- mutate: func(frame renderer.Frame, i int) {
356- frame.ScrollUp(0, 39, 1)
357- for x := range frame.Width {
358- frame.Set(x, 39, renderer.Cell{Rune: rune('a' + i%26), Style: renderer.DefaultStyle()})
359- }
360- },
361- },
362- }
363- for _, fixture := range fixtures {
364- b.Run(fixture.name, func(b *testing.B) {
365- stream := newStructuredOutputStream(newOutputStateStream())
366- cursor := cursorOut{valid: true}
367- initial, err := stream.prepare(fixture.frame, nil, cursor, true, 0)
368- if err != nil {
369- b.Fatal(err)
370- }
371- if err := initial.send(func(ports.Frame) error { return nil }); err != nil {
372- b.Fatal(err)
373- }
374- var wireBytes, spans, snapshots, mutation int
375- b.ResetTimer()
376- for b.Loop() {
377- fixture.mutate(fixture.frame, mutation)
378- mutation++
379- prepared, err := stream.prepare(fixture.frame, fixture.damage, cursor, false, 0)
380- if err != nil {
381- b.Fatal(err)
382- }
383- wireBytes += len(prepared.data)
384- spans += len(prepared.update.Spans)
385- if prepared.update.Kind == ports.ScreenUpdateSnapshot {
386- snapshots++
387- }
388- if err := prepared.send(func(ports.Frame) error { return nil }); err != nil {
389- b.Fatal(err)
390- }
391- }
392- b.StopTimer()
393- if snapshots != 0 {
394- b.Fatalf("unexpected snapshots/op: %d", snapshots)
395- }
396- b.ReportMetric(float64(wireBytes)/float64(b.N), "wirebytes/op")
397- b.ReportMetric(float64(spans)/float64(b.N), "spans/op")
398- b.ReportMetric(float64(snapshots)/float64(b.N), "snapshots/op")
399- })
400- }
401-}
402-
403-func decodeStructured(t *testing.T, data []byte) ports.ScreenUpdate {
404- t.Helper()
405- require.NotEmpty(t, data)
406- update, err := ports.UnmarshalScreenUpdate(data)
407- require.NoError(t, err)
408- return update
409-}
410-
411-func structuredTestFrame(lines ...string) renderer.Frame {
412- width := len([]rune(lines[0]))
413- frame := renderer.NewFrame(width, len(lines))
414- for y, line := range lines {
415- for x, r := range []rune(line) {
416- frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
417- }
418- }
419- return frame
420-}
421-
422-func structuredRows(width, height int) renderer.Frame {
423- frame := renderer.NewFrame(width, height)
424- for y := range height {
425- for x := range width {
426- frame.Set(x, y, renderer.Cell{Rune: rune('0' + y%10), Style: renderer.DefaultStyle()})
427- }
428- }
429- return frame
430-}
431-
432-func structuredStyledFrame(width, height int) renderer.Frame {
433- frame := structuredRows(width, height)
434- for x := range width {
435- frame.Set(x, 0, renderer.Cell{Rune: 'x', Style: renderer.Style{Bold: x%2 == 0, Foreground: x % 8, Background: (x + 1) % 8}})
436- }
437- return frame
438-}
M internal/usecase/daemon/session.go +13 -23
@@ -184,10 +184,10 @@ func (d *Daemon) touchMRU(entry attachmentSession) {
184 }184 }
185 185
186 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {186 func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
187- return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, false, restoredTabNames...)187+ return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, restoredTabNames...)
188 }188 }
189 189
190-func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, proxied bool, restoredTabNames ...[]string) (*session, error) {190+func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) {
191 env = copyEnvironment(env)191 env = copyEnvironment(env)
192 if _, reserved := d.creating[name]; reserved {192 if _, reserved := d.creating[name]; reserved {
193 return nil, errSessionNameInUse193 return nil, errSessionNameInUse
@@ -244,7 +244,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
244 if d.ptys == nil {244 if d.ptys == nil {
245 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))245 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
246 }246 }
247- tbSize := contentSize(sz, proxied)247+ tbSize := contentSize(sz)
248 var names []string248 var names []string
249 if len(restoredTabNames) > 0 {249 if len(restoredTabNames) > 0 {
250 names = append([]string(nil), restoredTabNames[0]...)250 names = append([]string(nil), restoredTabNames[0]...)
@@ -425,7 +425,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
425 return errors.New("client detached")425 return errors.New("client detached")
426 }426 }
427 427
428- newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)428+ newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env)
429 d.mu.Unlock()429 d.mu.Unlock()
430 if err != nil {430 if err != nil {
431 return err431 return err
@@ -490,7 +490,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
490 cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)490 cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
491 source.mu.Unlock()491 source.mu.Unlock()
492 var createErr error492 var createErr error
493- created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env, token.ac.proxied)493+ created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env)
494 return created, createErr494 return created, createErr
495 },495 },
496 })496 })
@@ -523,15 +523,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
523 env := copyEnvironment(sess.env)523 env := copyEnvironment(sess.env)
524 attachments := sess.snapshotAttachmentsLocked()524 attachments := sess.snapshotAttachmentsLocked()
525 sess.mu.Unlock()525 sess.mu.Unlock()
526- // A client-requested tab must use that client's negotiated viewport and526+ tbSize := contentSize(sz)
527- // theme. Only the explicitly headless helper path falls back to the stable
528- // first attachment snapshot.
529- themeClient := ac
530- if themeClient == nil && len(attachments) != 0 {
531- themeClient = attachments[0]
532- }
533- proxied := themeClient != nil && themeClient.proxied
534- tbSize := contentSize(sz, proxied)
535 tabStableID, paneStableID, err := d.newTabPaneStableIDs()527 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
536 if err != nil {528 if err != nil {
537 return err529 return err
@@ -548,6 +540,10 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
548 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)540 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
549 }541 }
550 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)542 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
543+ themeClient := ac
544+ if themeClient == nil && len(attachments) != 0 {
545+ themeClient = attachments[0]
546+ }
551 if themeClient != nil {547 if themeClient != nil {
552 t := d.effectiveTheme(themeClient.getClientTheme())548 t := d.effectiveTheme(themeClient.getClientTheme())
553 tb.mu.Lock()549 tb.mu.Lock()
@@ -653,19 +649,14 @@ func (tb *tab) closeAllPanes() {
653 const tabChromeRows = 2649 const tabChromeRows = 2
654 650
655 func tabSize(clientSize domain.Size) domain.Size {651 func tabSize(clientSize domain.Size) domain.Size {
656- return contentSize(clientSize, false)652+ return contentSize(clientSize)
657 }653 }
658 654
659-// contentSize maps the negotiated viewport to PTY geometry. A proxied client655+// contentSize maps the negotiated viewport to PTY geometry.
660-// has already removed its local chrome rows, so the remote daemon must consume656+func contentSize(clientSize domain.Size) domain.Size {
661-// the received size verbatim instead of subtracting chrome a second time.
662-func contentSize(clientSize domain.Size, proxied bool) domain.Size {
663 if !clientSize.Valid() {657 if !clientSize.Valid() {
664 clientSize = defaultSize658 clientSize = defaultSize
665 }659 }
666- if proxied {
667- return clientSize
668- }
669 rows := max(clientSize.Rows-tabChromeRows, 1)660 rows := max(clientSize.Rows-tabChromeRows, 1)
670 return domain.Size{Cols: clientSize.Cols, Rows: rows}661 return domain.Size{Cols: clientSize.Cols, Rows: rows}
671 }662 }
@@ -713,7 +704,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
713 return704 return
714 }705 }
715 if owner.session != sess || owner.tab != tb {706 if owner.session != sess || owner.tab != tb {
716- d.log.Warn("pane owner mismatch", "pane", p.id)
717 return707 return
718 }708 }
719 sess.mu.Lock()709 sess.mu.Lock()
D internal/usecase/daemon/session_meta.go +0 -136
@@ -1,136 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5-
6- "github.com/bnema/vev/internal/domain"
7- "github.com/bnema/vev/internal/ports"
8-)
9-
10-var errSessionMetaUnavailable = errors.New("session metadata is unavailable")
11-
12-// sessionMetaSnapshot captures the authoritative tab order while session.mu
13-// owns every field represented on the wire.
14-func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) {
15- return s.sessionMetaSnapshotFor(nil)
16-}
17-
18-func (s *session) sessionMetaSnapshotFor(ac *attachedClient) (ports.SessionMeta, bool) {
19- if s == nil {
20- return ports.SessionMeta{}, false
21- }
22- s.mu.Lock()
23- defer s.mu.Unlock()
24- if len(s.tabs) == 0 {
25- return ports.SessionMeta{}, false
26- }
27- active := 0
28- if ac != nil {
29- view := ac.viewSnapshot()
30- for i, tb := range s.tabs {
31- if domain.TabStableID(tb.stableID) == view.tabID {
32- active = i
33- break
34- }
35- }
36- }
37- meta := ports.SessionMeta{
38- SessionName: s.name,
39- Active: uint16(active),
40- Tabs: make([]ports.SessionTabMeta, len(s.tabs)),
41- }
42- for i, tb := range s.tabs {
43- meta.Tabs[i] = ports.SessionTabMeta{
44- Index: uint16(i),
45- Name: tb.name,
46- Attention: tb.attention,
47- }
48- }
49- return meta, true
50-}
51-
52-func frameSessionMeta(meta ports.SessionMeta) (ports.Frame, error) {
53- payload, err := ports.MarshalSessionMeta(meta)
54- if err != nil {
55- return ports.Frame{}, err
56- }
57- return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil
58-}
59-
60-func sameSessionMeta(a, b ports.SessionMeta) bool {
61- if a.SessionName != b.SessionName || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) {
62- return false
63- }
64- for i := range a.Tabs {
65- if a.Tabs[i] != b.Tabs[i] {
66- return false
67- }
68- }
69- return true
70-}
71-
72-func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
73- meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...)
74- return meta
75-}
76-
77-// sendSessionMetaIfChanged sends metadata before a proxied output frame. The
78-// caller holds ac.sendMu, which serializes this snapshot shadow with every
79-// output write. The shadow is committed only after the transport accepts the
80-// metadata frame.
81-func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *attachmentEffectTicket) error {
82- if ac == nil || sess == nil || !ac.proxied {
83- return nil
84- }
85- meta, ok := sess.sessionMetaSnapshotFor(ac)
86- if !ok {
87- return errSessionMetaUnavailable
88- }
89- return ac.sendSessionMetaSnapshot(meta, expected, ticket)
90-}
91-
92-// sendSessionMetaSnapshot sends an already captured metadata snapshot while
93-// the caller holds ac.sendMu.
94-func (ac *attachedClient) sendSessionMetaSnapshot(meta ports.SessionMeta, expected transportSnapshot, ticket *attachmentEffectTicket) error {
95- if ac == nil || !ac.proxied {
96- return nil
97- }
98- if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) {
99- return nil
100- }
101- frame, err := frameSessionMeta(meta)
102- if err != nil {
103- return err
104- }
105- if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
106- return err
107- }
108- send := expected.transport.Send
109- if async, ok := expected.transport.(ports.AsyncTransport); ok {
110- send = async.SendAsync
111- }
112- err = send(frame)
113- if ticket != nil {
114- if err != nil {
115- ticket.reportTransportFailure(expected)
116- }
117- ticket.endTransportSend()
118- }
119- if err == nil {
120- ac.sessionMeta = cloneSessionMeta(meta)
121- ac.sessionMetaSent = true
122- }
123- return err
124-}
125-
126-// markSessionMetaSent records a successfully sent snapshot. It acquires
127-// ac.sendMu itself, so callers must not hold that lock.
128-func (ac *attachedClient) markSessionMetaSent(meta ports.SessionMeta) {
129- if ac == nil {
130- return
131- }
132- ac.sendMu.Lock()
133- ac.sessionMeta = cloneSessionMeta(meta)
134- ac.sessionMetaSent = true
135- ac.sendMu.Unlock()
136-}
D internal/usecase/daemon/session_meta_test.go +0 -230
@@ -1,230 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "io"
6- "testing"
7-
8- "github.com/bnema/vev/internal/ports"
9- "github.com/stretchr/testify/require"
10-)
11-
12-type sessionMetaFailTransport struct{}
13-
14-func (sessionMetaFailTransport) Send(ports.Frame) error { return errors.New("metadata send failed") }
15-func (sessionMetaFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
16-func (sessionMetaFailTransport) Close() error { return nil }
17-
18-func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) {
19- d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
20- ac.proxied = true
21-
22- emit := func(title string) {
23- state := cacheBarState(title, true)
24- state.attachment = ac
25- composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
26- ac.sendMu.Lock()
27- require.True(t, d.emitFrame(sess, ac, &state, composed))
28- }
29-
30- emit("initial")
31- firstMetaFrame := <-sends
32- firstScreenFrame := <-sends
33- require.Equal(t, ports.MsgSessionMeta, firstMetaFrame.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)
38- firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload)
39- require.NoError(t, err)
40- require.Equal(t, "work", firstMeta.SessionName)
41-
42- sess.mu.Lock()
43- sess.tabs[0].name = "renamed"
44- sess.mu.Unlock()
45- emit("changed")
46- changedMetaFrame := <-sends
47- changedScreenFrame := <-sends
48- require.Equal(t, ports.MsgSessionMeta, changedMetaFrame.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)
53- changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload)
54- require.NoError(t, err)
55- require.Equal(t, "renamed", changedMeta.Tabs[0].Name)
56-}
57-
58-func TestProxiedMetadataSendsWithoutOutputBytes(t *testing.T) {
59- d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
60- ac.proxied = true
61-
62- emit := func(reset bool) {
63- state := cacheBarState("steady", reset)
64- state.attachment = ac
65- composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch)
66- ac.sendMu.Lock()
67- require.True(t, d.emitFrame(sess, ac, &state, composed))
68- }
69-
70- emit(true)
71- require.Equal(t, ports.MsgSessionMeta, (<-sends).Type)
72- require.Equal(t, ports.MsgScreenUpdate, (<-sends).Type)
73-
74- sess.mu.Lock()
75- sess.tabs[0].name = "renamed"
76- sess.mu.Unlock()
77- // The recomposed frame is identical, so this transaction carries metadata
78- // only: a zero-byte terminal frame must still refresh the proxied snapshot.
79- emit(false)
80-
81- metaFrame := awaitTestValue(t, sends, "a zero-byte frame did not publish changed metadata")
82- require.Equal(t, ports.MsgSessionMeta, metaFrame.Type)
83- meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload)
84- require.NoError(t, err)
85- require.Equal(t, "renamed", meta.Tabs[0].Name)
86- select {
87- case frame := <-sends:
88- t.Fatalf("unexpected frame after metadata-only paint: %v", frame.Type)
89- default:
90- }
91-}
92-
93-func TestSessionMetaSnapshotIsImmutable(t *testing.T) {
94- _, sess, _, _ := newManualSessionWithPTYs(t, newQuietPTY())
95- meta, ok := sess.sessionMetaSnapshot()
96- require.True(t, ok)
97-
98- sess.mu.Lock()
99- sess.name = "renamed-session"
100- sess.tabs[0].name = "renamed-tab"
101- sess.mu.Unlock()
102-
103- require.Equal(t, "work", meta.SessionName)
104- require.Equal(t, "", meta.Tabs[0].Name)
105-}
106-
107-func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) {
108- _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
109- ac.proxied = true
110- ac.replaceTransport(sessionMetaFailTransport{})
111-
112- ac.sendMu.Lock()
113- err := ac.sendSessionMetaIfChanged(sess, ac.transportSnapshot(), nil)
114- ac.sendMu.Unlock()
115- require.Error(t, err)
116- require.False(t, ac.sessionMetaSent)
117-}
118-
119-func TestProxiedAttachedCommandMutationFailureDoesNotReportExecution(t *testing.T) {
120- d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
121- ac.proxied = true
122- d.attachCoordinator(sess, ac, true)
123- d.ptys = failingPTYFactory{err: errors.New("tab open failed")}
124- token := sess.attachmentToken(ac, ac.transport())
125-
126- result := d.executeAttachedCommand(token, ports.CommandRequest{
127- Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab",
128- })
129-
130- require.False(t, result.OK)
131- require.Equal(t, ports.ErrInternal, result.Code)
132- require.Contains(t, result.Text, "tab open failed")
133- sess.mu.Lock()
134- require.Len(t, sess.tabs, 1, "failed attached command must not publish a new tab")
135- sess.mu.Unlock()
136-}
137-
138-func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) {
139- tests := []struct {
140- name string
141- request ports.CommandRequest
142- ok bool
143- code uint16
144- text string
145- }{
146- {
147- name: "valid attached command",
148- request: ports.CommandRequest{
149- Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "next-tab",
150- },
151- ok: true,
152- },
153- {
154- name: "target override is rejected",
155- request: ports.CommandRequest{
156- Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "next-tab", TargetSession: "other",
157- },
158- code: ports.ErrInvalidCommandArgs,
159- text: "attached commands cannot override their active session target",
160- },
161- {
162- name: "missing attached flag is rejected",
163- request: ports.CommandRequest{
164- Version: ports.ProtocolVersion, RequestID: 43, Slug: "next-tab",
165- },
166- code: ports.ErrInvalidCommandArgs,
167- text: "attached command flag is required",
168- },
169- {
170- name: "unsupported version is rejected",
171- request: ports.CommandRequest{
172- Version: ports.ProtocolVersion + 1, RequestID: 44, Attached: true, Slug: "next-tab",
173- },
174- code: ports.ErrInvalidCommandArgs,
175- text: "unsupported command protocol version",
176- },
177- {
178- name: "missing request id is rejected",
179- request: ports.CommandRequest{
180- Version: ports.ProtocolVersion, Attached: true, Slug: "next-tab",
181- },
182- code: ports.ErrInvalidCommandArgs,
183- text: "command request id is required",
184- },
185- {
186- name: "self target is rejected",
187- request: ports.CommandRequest{
188- Version: ports.ProtocolVersion, RequestID: 45, Attached: true, Self: true, Slug: "next-tab",
189- },
190- code: ports.ErrInvalidCommandArgs,
191- text: "attached commands cannot target themselves",
192- },
193- {
194- name: "unknown slug is rejected",
195- request: ports.CommandRequest{
196- Version: ports.ProtocolVersion, RequestID: 46, Attached: true, Slug: "missing",
197- },
198- code: ports.ErrUnknownCommand,
199- text: "unknown command: missing",
200- },
201- {
202- name: "local detach is not scriptable",
203- request: ports.CommandRequest{
204- Version: ports.ProtocolVersion, RequestID: 47, Attached: true, Slug: "detach",
205- },
206- code: ports.ErrNotScriptable,
207- text: "detach is owned by the local proxy daemon",
208- },
209- }
210- for _, tt := range tests {
211- t.Run(tt.name, func(t *testing.T) {
212- d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
213- ac.proxied = true
214- d.attachCoordinator(sess, ac, true)
215- payload, err := ports.MarshalCommandRequest(tt.request)
216- require.NoError(t, err)
217-
218- token := sess.attachmentToken(ac, ac.transport())
219- require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload}))
220-
221- reply := awaitFrame(t, sends, ports.MsgCommandResult)
222- result, err := ports.UnmarshalCommandResult(reply.Payload)
223- require.NoError(t, err)
224- require.Equal(t, tt.request.RequestID, result.RequestID)
225- require.Equal(t, tt.ok, result.OK)
226- require.Equal(t, tt.code, result.Code)
227- require.Equal(t, tt.text, result.Text)
228- })
229- }
230-}
M internal/usecase/daemon/session_test.go +1 -2
@@ -171,7 +171,6 @@ func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testi
171 Intent: ports.IntentAttach,171 Intent: ports.IntentAttach,
172 Name: "work",172 Name: "work",
173 ClientID: [16]byte{2},173 ClientID: [16]byte{2},
174- Proxied: true,
175 Size: domain.Size{Cols: 80, Rows: 24},174 Size: domain.Size{Cols: 80, Rows: 24},
176 }, secondTransport)175 }, secondTransport)
177 require.NoError(t, err)176 require.NoError(t, err)
@@ -192,7 +191,7 @@ func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testi
192 gotSize := tb.size191 gotSize := tb.size
193 pane := tb.focusedPane()192 pane := tb.focusedPane()
194 tb.mu.Unlock()193 tb.mu.Unlock()
195- require.Equal(t, domain.Size{Cols: 80, Rows: 24}, gotSize, "proxied sizing must come from the requesting attachment")194+ require.Equal(t, contentSize(domain.Size{Cols: 80, Rows: 24}), gotSize, "sizing must come from the requesting attachment")
196 assertPaneDefaultColors(t, pane, requesterTheme.Foreground, requesterTheme.Background)195 assertPaneDefaultColors(t, pane, requesterTheme.Foreground, requesterTheme.Background)
197 196
198 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))197 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
M internal/usecase/daemon/status_test.go +1 -1
@@ -1507,7 +1507,7 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1507 frameInput([]byte("\x1b ")),1507 frameInput([]byte("\x1b ")),
1508 frameInput([]byte("CNT\r")),1508 frameInput([]byte("CNT\r")),
1509 frameInput([]byte("\x1b1")),1509 frameInput([]byte("\x1b1")),
1510- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},1510+ ports.Frame{Type: ports.MsgResize, Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
1511 )1511 )
1512 1512
1513 var hg sync.WaitGroup1513 var hg sync.WaitGroup
D internal/usecase/daemon/structured_diagnostics_test.go +0 -201
@@ -1,201 +0,0 @@
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.captureRenderState(ac, renderCaptureRequest{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(t, 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(t, 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.captureRenderState(ac, renderCaptureRequest{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(t, 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(t, 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(t, 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(t, d.runtimeObserver))
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.captureRenderState(ac, renderCaptureRequest{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(t *testing.T, observer ports.RuntimeObserver) []ports.RuntimeMark {
159- t.Helper()
160- collector, ok := observer.(*daemonRuntimeObserver)
161- require.True(t, ok, "runtime observer must be the daemon test collector")
162- var marks []ports.RuntimeMark
163- for _, mark := range collector.marks {
164- switch mark.Kind {
165- case ports.RuntimeScreenSnapshot, ports.RuntimeScreenDelta, ports.RuntimeScreenResetRequested:
166- marks = append(marks, mark)
167- }
168- }
169- return marks
170-}
171-
172-func mustMarshalScreenUpdate(t *testing.T, update ports.ScreenUpdate) []byte {
173- t.Helper()
174- data, err := ports.MarshalScreenUpdate(update)
175- require.NoError(t, err)
176- return data
177-}
178-
179-type blockingStructuredDiagnosticObserver struct {
180- entered chan struct{}
181- release chan struct{}
182- once sync.Once
183-}
184-
185-func (o *blockingStructuredDiagnosticObserver) ObserveRuntime(mark ports.RuntimeMark) {
186- if mark.Kind != ports.RuntimeScreenSnapshot && mark.Kind != ports.RuntimeScreenDelta {
187- return
188- }
189- o.once.Do(func() {
190- close(o.entered)
191- <-o.release
192- })
193-}
194-
195-func newStructuredDiagnosticFailingTransport(t *testing.T) *portsmocks.MockTransport {
196- t.Helper()
197- tr := portsmocks.NewMockTransport(t)
198- tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe()
199- tr.EXPECT().Close().Return(nil).Maybe()
200- return tr
201-}
M internal/usecase/daemon/transactional_resize.go +2 -6
@@ -535,17 +535,13 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) {
535 // coordinator admits the epoch before any tab size, rectangle, VT screen,535 // coordinator admits the epoch before any tab size, rectangle, VT screen,
536 // snapshot dirtiness, or resize telemetry becomes visible.536 // snapshot dirtiness, or resize telemetry becomes visible.
537 func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {537 func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) {
538- return d.applySessionLayoutWithMode(sess, size, false, current, admit)
539-}
540-
541-func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, proxied bool, current, admit func() bool) ([]resizeMember, bool) {
542 if sess == nil {538 if sess == nil {
543 return nil, false539 return nil, false
544 }540 }
545 sess.layoutApplyMu.Lock()541 sess.layoutApplyMu.Lock()
546 defer sess.layoutApplyMu.Unlock()542 defer sess.layoutApplyMu.Unlock()
547 543
548- target := contentSize(size, proxied)544+ target := contentSize(size)
549 for {545 for {
550 if sess.ctx != nil && sess.ctx.Err() != nil {546 if sess.ctx != nil && sess.ctx.Err() != nil {
551 return nil, false547 return nil, false
@@ -676,7 +672,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
676 return false672 return false
677 }673 }
678 d.exitCopyMode(ac)674 d.exitCopyMode(ac)
679- failed, ok := d.applySessionLayoutWithMode(sess, snap.size, ac.proxied, current, func() bool {675+ failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool {
680 return rc.resizeCurrentForLease(epoch, ac, lease, true)676 return rc.resizeCurrentForLease(epoch, ac, lease, true)
681 })677 })
682 if !ok {678 if !ok {
M internal/usecase/daemon/transactional_resize_test.go +1 -1
@@ -838,7 +838,7 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
838 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")838 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
839 output, err := ports.UnmarshalOutput(frame.Payload)839 output, err := ports.UnmarshalOutput(frame.Payload)
840 require.NoError(t, err)840 require.NoError(t, err)
841- require.Zero(t, output.BaseStateNum, "the accepted resize frame must reset the renderer state")841+ require.Zero(t, output.Base, "the accepted resize frame must reset the renderer state")
842 select {842 select {
843 case extra := <-frames:843 case extra := <-frames:
844 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)844 t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra)
M internal/usecase/daemon/transport_observability_test.go +2 -2
@@ -36,7 +36,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
36 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) {36 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) {
37 t.Fatal("second resize was not accepted")37 t.Fatal("second resize was not accepted")
38 }38 }
39- ac.ackOutputState(output.NewStateNum)39+ ac.ackOutputState(output.Epoch, output.New)
40 rc.notifyAck()40 rc.notifyAck()
41 second := awaitFrame(t, sends, ports.MsgOutput)41 second := awaitFrame(t, sends, ports.MsgOutput)
42 if second.Type != ports.MsgOutput {42 if second.Type != ports.MsgOutput {
@@ -123,7 +123,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
123 if err != nil {123 if err != nil {
124 t.Fatalf("decode emitted output: %v", err)124 t.Fatalf("decode emitted output: %v", err)
125 }125 }
126- ac.ackOutputState(output.NewStateNum)126+ ac.ackOutputState(output.Epoch, output.New)
127 127
128 pane := testAttachmentTab(sess).focusedPane()128 pane := testAttachmentTab(sess).focusedPane()
129 for _, lock := range []struct {129 for _, lock := range []struct {
M internal/usecase/daemon/wire.go +11 -7
@@ -2,19 +2,16 @@
2 package daemon2 package daemon
3 3
4 import (4 import (
5+ "github.com/bnema/vev/internal/domain"
5 "github.com/bnema/vev/internal/ports"6 "github.com/bnema/vev/internal/ports"
6 )7 )
7 8
8 func frameWelcome(s *session, ac *attachedClient) ports.Frame {9 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
9- capabilities := ports.CapabilityResume
10- if ac.proxied {
11- capabilities |= ports.CapabilityProxied
12- }
13 w := ports.Welcome{10 w := ports.Welcome{
14 SessionID: string(s.id),11 SessionID: string(s.id),
15 SessionName: s.name,12 SessionName: s.name,
16 Ephemeral: s.ephemeral,13 Ephemeral: s.ephemeral,
17- Capabilities: capabilities,14+ Capabilities: ports.CapabilityResume,
18 ResumeToken: ac.resumeToken,15 ResumeToken: ac.resumeToken,
19 }16 }
20 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}17 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
@@ -24,8 +21,15 @@ func frameError(code uint16, text string) ports.Frame {
24 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}21 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
25 }22 }
26 23
27-func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) ports.Frame {24+func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) (ports.Frame, error) {
28- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: baseState, NewStateNum: state, EchoAck: echoAck, Data: b})}25+ payload, err := ports.MarshalOutput(ports.Output{
26+ Epoch: 1, Base: baseState, New: state, Echo: echoAck,
27+ Size: domain.Size{Cols: 1, Rows: 1}, Full: state != 0 && baseState == 0, Data: b,
28+ })
29+ if err != nil {
30+ return ports.Frame{}, err
31+ }
32+ return ports.Frame{Type: ports.MsgOutput, Payload: payload}, nil
29 }33 }
30 34
31 func frameDetached(reason uint8) ports.Frame {35 func frameDetached(reason uint8) ports.Frame {
A internal/usecase/daemon/wire_test_helpers_test.go +27 -0
@@ -0,0 +1,27 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalOutput(m ports.Output) []byte {
6+ payload, err := ports.MarshalOutput(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
12+
13+func mustMarshalAck(m ports.Ack) []byte {
14+ payload, err := ports.MarshalAck(m)
15+ if err != nil {
16+ panic(err)
17+ }
18+ return payload
19+}
20+
21+func mustMarshalResize(m ports.Resize) []byte {
22+ payload, err := ports.MarshalResize(m)
23+ if err != nil {
24+ panic(err)
25+ }
26+ return payload
27+}

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