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.
4848
4949 ### Historical proxy ANSI baseline
5050
51-Before the structured path, proxied state-bearing screen updates transported ANSI
52-and were applied through the local proxy VT parser. In the candidate, proxied
53-state-bearing frames are `MsgScreenUpdate`; this parser benchmark remains a
54-historical diagnostic. Ordinary local output remains the ANSI `MsgOutput`
51+Before this refactor, proxied state-bearing screen updates transported ANSI
52+and were applied through the local proxy VT parser. This parser benchmark remains
53+a historical diagnostic. Ordinary local output remains the ANSI `MsgOutput`
5554 guardrail.
5655
5756 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
101100 The candidate guardrail completed with Go 1.26.5; its ten-repetition output is
102101 retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`.
103102
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-
165103 ### Public-CLI transport runs
166104
167105 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 (
99 "testing"
1010 "time"
1111
12+ "github.com/bnema/vev/internal/domain"
1213 "github.com/bnema/vev/internal/ports"
1314 pdgram "github.com/bnema/vev/pkg/dgram"
1415 )
@@ -451,7 +452,7 @@ func TestTransportFloodClassification(t *testing.T) {
451452 }
452453 }
453454 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")})
455456 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: primer}); err != nil {
456457 t.Fatal(err)
457458 }
@@ -528,10 +529,12 @@ func TestTransportFloodClassification(t *testing.T) {
528529 }
529530 })
530531 for state := 1; state < outputCount; state++ {
531- payload := ports.MarshalOutput(ports.Output{
532- BaseStateNum: uint64(state),
533- NewStateNum: uint64(state + 1),
534- Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes),
532+ payload := mustMarshalOutput(ports.Output{
533+ Epoch: 1,
534+ Base: uint64(state),
535+ New: uint64(state + 1),
536+ Size: domain.Size{Cols: 1, Rows: 1},
537+ Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes),
535538 })
536539 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
537540 t.Fatal(err)
@@ -631,7 +634,7 @@ func TestTransportFloodClassification(t *testing.T) {
631634 if err != nil {
632635 t.Fatal(err)
633636 }
634- latestState = max(latestState, output.NewStateNum)
637+ latestState = max(latestState, output.New)
635638 default:
636639 goto drainedOutput
637640 }
@@ -851,10 +854,13 @@ func floodState(sender, receiver *Transport, link *simulatedLink) floodTransport
851854 }
852855
853856 func floodOutputPayload(baseState, mtu int) []byte {
854- return ports.MarshalOutput(ports.Output{
855- BaseStateNum: uint64(baseState),
856- NewStateNum: uint64(baseState + 1),
857- Data: make([]byte, floodRecordMTUs*mtu),
857+ return mustMarshalOutput(ports.Output{
858+ Epoch: 1,
859+ Base: uint64(baseState),
860+ New: uint64(baseState + 1),
861+ Size: domain.Size{Cols: 1, Rows: 1},
862+ Full: baseState == 0,
863+ Data: make([]byte, floodRecordMTUs*mtu),
858864 })
859865 }
860866
@@ -866,8 +872,8 @@ func TestFloodOutputPayloadIsIncrementalStateBearingOutput(t *testing.T) {
866872 if err != nil {
867873 t.Fatalf("state %d: %v", state, err)
868874 }
869- if output.BaseStateNum != uint64(state) || output.NewStateNum != uint64(state+1) {
870- t.Fatalf("output state = %d -> %d, want %d -> %d", output.BaseStateNum, output.NewStateNum, state, state+1)
875+ if output.Base != uint64(state) || output.New != uint64(state+1) {
876+ t.Fatalf("output state = %d -> %d, want %d -> %d", output.Base, output.New, state, state+1)
871877 }
872878 minimumBytes := floodRecordMTUs * mtu
873879 if len(output.Data) < minimumBytes || len(payload) < minimumBytes {
M internal/adapters/dgram/proxy_test.go +5 -4
@@ -10,6 +10,7 @@ import (
1010 "testing"
1111 "time"
1212
13+ "github.com/bnema/vev/internal/domain"
1314 "github.com/bnema/vev/internal/ports"
1415 )
1516
@@ -87,7 +88,7 @@ func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testi
8788 daemon := newFakeTransport()
8889 errCh := make(chan error, 1)
8990 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}
9192 client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}}
9293
9394 select {
@@ -104,7 +105,7 @@ func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testi
104105 }
105106
106107 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})}
108109 client.recv <- recvResult{frame: input}
109110 client.recv <- recvResult{frame: ack}
110111 for _, want := range []ports.Frame{input, ack} {
@@ -488,7 +489,7 @@ func TestProxyCopierStopsRetryingWhenContextCanceled(t *testing.T) {
488489
489490 func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T) {
490491 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)
492493 frame := ports.Frame{Type: ports.MsgHello, Payload: payload}
493494 got := clampDatagramHelloOutputWindow(frame)
494495 if !reflect.DeepEqual(got, frame) {
@@ -502,7 +503,7 @@ func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T)
502503 t.Run("mismatched version remains mismatched", func(t *testing.T) {
503504 wantVersion := ports.ProtocolVersion + 1
504505 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,
506507 })}
507508 got, err := ports.UnmarshalHello(clampDatagramHelloOutputWindow(frame).Payload)
508509 if err != nil {
M internal/adapters/dgram/transport_send_test.go +7 -6
@@ -7,6 +7,7 @@ import (
77 "testing"
88 "time"
99
10+ "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/ports"
1112 pdgram "github.com/bnema/vev/pkg/dgram"
1213 "github.com/stretchr/testify/require"
@@ -113,7 +114,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
113114 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil {
114115 t.Fatal(err)
115116 }
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)})
117118 for range 2 {
118119 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil {
119120 t.Fatal(err)
@@ -121,7 +122,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
121122 }
122123 waitForManualTimers(t, clk, 4)
123124 previousPackets := len(aPC.in)
124- for tick := 0; tick < 128; tick++ {
125+ for tick := range 128 {
125126 clk.advance(initialPacingRTT)
126127 time.Sleep(time.Millisecond)
127128 packets := len(aPC.in)
@@ -154,7 +155,7 @@ func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) {
154155 done := make(chan error, 1)
155156 go func() { done <- a.Send(large) }()
156157 start := clk.Now()
157- for tick := 0; tick < 128; tick++ {
158+ for range 128 {
158159 a.mu.Lock()
159160 p := a.pending[1]
160161 if p != nil && p.initialInFlight && !p.last.IsZero() {
@@ -220,11 +221,11 @@ func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *tes
220221 }
221222 a.mu.Unlock()
222223 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)})}
224225 done := make(chan error, 1)
225226 go func() { done <- a.SendSynchronous(sideEffect) }()
226227
227- for tick := 0; tick < 512; tick++ {
228+ for range 512 {
228229 clk.advance(initialPacingRTT)
229230 time.Sleep(time.Millisecond)
230231 select {
@@ -328,7 +329,7 @@ func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) {
328329 frame ports.Frame
329330 }{
330331 {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")})}},
332333 }
333334 for _, tt := range frames {
334335 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) {
140140 }
141141 // Vary a couple to prove ordering and content are both preserved.
142142 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}})}
144144
145145 var wg sync.WaitGroup
146146 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 (
1212 "testing"
1313 "time"
1414
15+ "github.com/bnema/vev/internal/domain"
1516 "github.com/bnema/vev/internal/ports"
1617 )
1718
@@ -120,7 +121,7 @@ func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
120121 defer func() { _ = client.Close() }()
121122 defer func() { _ = server.Close() }()
122123
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}}
124125 go func() {
125126 f, err := server.Recv()
126127 if err != nil {
M internal/ports/frame.go +2 -2
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 21
4+const ProtocolVersion uint16 = 23
55
66 // MaxFrameLen is the largest permitted frame length, including the type byte
77 // and excluding the four-byte length prefix.
@@ -36,7 +36,7 @@ const (
3636 MsgSessions MsgType = 21
3737 MsgCommandResult MsgType = 22
3838 MsgSessionMeta MsgType = 23
39- MsgScreenUpdate MsgType = 24
39+ MsgAttachTarget MsgType = 25
4040 )
4141
4242 // 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
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 21 {
7- t.Fatalf("ProtocolVersion = %d, want 21", 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)
6+ if ProtocolVersion != 23 {
7+ t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion)
178 }
189 }
1910
@@ -30,9 +21,6 @@ func TestControlMsgTypes(t *testing.T) {
3021 if MsgSessionMeta != 23 {
3122 t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta)
3223 }
33- if CapabilityProxied != 1<<3 {
34- t.Fatalf("CapabilityProxied = %d, want 8", CapabilityProxied)
35- }
3624 }
3725
3826 func TestMsgTypeUnique(t *testing.T) {
@@ -40,28 +28,15 @@ func TestMsgTypeUnique(t *testing.T) {
4028 name string
4129 typ MsgType
4230 }{
43- {"MsgHello", MsgHello},
44- {"MsgInput", MsgInput},
45- {"MsgResize", MsgResize},
46- {"MsgDetach", MsgDetach},
47- {"MsgPing", MsgPing},
48- {"MsgList", MsgList},
49- {"MsgKill", MsgKill},
50- {"MsgTheme", MsgTheme},
51- {"MsgAck", MsgAck},
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},
31+ {"MsgHello", MsgHello}, {"MsgInput", MsgInput}, {"MsgResize", MsgResize},
32+ {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList},
33+ {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck},
34+ {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice},
35+ {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest},
36+ {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput},
37+ {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions},
38+ {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta},
39+ {"MsgAttachTarget", MsgAttachTarget},
6540 }
6641
6742 seen := make(map[MsgType]string, len(tests))
@@ -71,7 +46,6 @@ func TestMsgTypeUnique(t *testing.T) {
7146 }
7247 seen[tt.typ] = tt.name
7348 }
74-
7549 if len(seen) != len(tests) {
7650 t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen))
7751 }
M internal/ports/wire.go +361 -43
@@ -9,6 +9,7 @@ package ports
99 import (
1010 "encoding/binary"
1111 "errors"
12+ "fmt"
1213 "math"
1314
1415 "github.com/bnema/vev/internal/domain"
@@ -26,6 +27,27 @@ var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
2627
2728 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
2829
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+
2951 // ErrInvalidSessionMeta reports metadata that cannot be represented by the
3052 // protocol's ordered uint16-indexed tab layout.
3153 var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata")
@@ -47,7 +69,6 @@ const (
4769 CapabilityResume uint32 = 1 << 0
4870 CapabilityUDP uint32 = 1 << 1
4971 CapabilityPredict uint32 = 1 << 2
50- CapabilityProxied uint32 = 1 << 3
5172 )
5273
5374 // ErrorMsg codes.
@@ -77,7 +98,6 @@ const (
7798 type Hello struct {
7899 Version uint16
79100 Intent uint8
80- Proxied bool
81101 ClientID [16]byte
82102 ResumeToken uint64
83103 Name string
@@ -160,9 +180,10 @@ type Ping struct{}
160180 // Pong answers a Ping.
161181 type Pong struct{}
162182
163-// Ack acknowledges receipt/application of an output state number.
183+// Ack acknowledges receipt/application of an output state in one output epoch.
164184 type Ack struct {
165- AckedStateNum uint64
185+ Epoch uint64
186+ State uint64
166187 }
167188
168189 // Welcome is the daemon's reply to a successful Hello.
@@ -180,14 +201,26 @@ type ErrorMsg struct {
180201 Text string
181202 }
182203
183-// Output carries raw PTY output, destined for the client's terminal.
204+// Output carries one ordered output publication for an attachment.
184205 type Output struct {
185- BaseStateNum uint64
186- NewStateNum uint64
187- EchoAck uint64
206+ Epoch uint64
207+ Base uint64
208+ New uint64
209+ Echo uint64
210+ ViewRevision uint64
211+ Size domain.Size
212+ Full bool
188213 Data []byte
189214 }
190215
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+
191224 // Detached tells a client it has been disconnected from its session and why.
192225 type Detached struct {
193226 Reason uint8
@@ -227,18 +260,17 @@ type Sessions struct {
227260 Sessions []SessionInfo
228261 }
229262
230-// OutputResetRequest asks a proxied daemon to rebase its output stream.
263+// OutputResetRequest asks a remote output stream to rebase.
231264 type OutputResetRequest struct{}
232265
233-// SessionTabMeta describes one tab in a proxied session's authoritative
234-// metadata snapshot.
266+// SessionTabMeta describes one tab in a remote session's metadata snapshot.
235267 type SessionTabMeta struct {
236268 Index uint16
237269 Name string
238270 Attention bool
239271 }
240272
241-// SessionMeta is the authoritative tab metadata sent to a proxied client.
273+// SessionMeta is authoritative tab metadata for a remote session.
242274 type SessionMeta struct {
243275 SessionName string
244276 Active uint16
@@ -296,6 +328,11 @@ func (w *payloadWriter) putLongString(s string) {
296328 w.b = append(w.b, s...)
297329 }
298330
331+func (w *payloadWriter) putLongBytes(b []byte) {
332+ w.putUint32(uint32(len(b)))
333+ w.b = append(w.b, b...)
334+}
335+
299336 // payloadReader consumes a message payload field by field in wire order,
300337 // erroring instead of panicking on any short read.
301338 type payloadReader struct {
@@ -384,6 +421,43 @@ func (r *payloadReader) getLongString() (string, error) {
384421 return s, nil
385422 }
386423
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+
387461 // rest consumes and returns all remaining bytes, copied so the result is
388462 // independent of the reader's backing array.
389463 func (r *payloadReader) rest() []byte {
@@ -408,12 +482,72 @@ func PeekHelloVersion(b []byte) (uint16, bool) {
408482 return binary.BigEndian.Uint16(b), true
409483 }
410484
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+
411546 // MarshalHello encodes h into a Hello message payload.
412547 func MarshalHello(h Hello) []byte {
413548 w := payloadWriter{}
414549 w.putUint16(h.Version)
415550 w.putUint8(h.Intent)
416- w.putBool(h.Proxied)
417551 w.putBytes(h.ClientID[:])
418552 w.putUint64(h.ResumeToken)
419553 w.putString(h.Name)
@@ -430,8 +564,69 @@ func MarshalHello(h Hello) []byte {
430564 return w.b
431565 }
432566
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+
433622 // UnmarshalHello decodes a Hello message payload.
434623 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+ }
435630 r := payloadReader{b: b}
436631 var h Hello
437632 var err error
@@ -442,9 +637,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
442637 if h.Intent, err = r.getUint8(); err != nil {
443638 return Hello{}, err
444639 }
445- if h.Proxied, err = r.getBool(); err != nil {
446- return Hello{}, err
447- }
448640 clientID, err := r.getBytes(len(h.ClientID))
449641 if err != nil {
450642 return Hello{}, err
@@ -471,11 +663,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
471663 if h.Cwd, err = r.getString(); err != nil {
472664 return Hello{}, err
473665 }
474- trueColor, err := r.getUint8()
475- if err != nil {
666+ if h.TrueColor, err = r.getBool(); err != nil {
476667 return Hello{}, err
477668 }
478- h.TrueColor = trueColor != 0
479669 if h.MaxOutputInFlight, err = r.getUint8(); err != nil {
480670 return Hello{}, err
481671 }
@@ -501,6 +691,9 @@ func UnmarshalHello(b []byte) (Hello, error) {
501691 if err := r.done(); err != nil {
502692 return Hello{}, err
503693 }
694+ if err := ValidateHello(h); err != nil {
695+ return Hello{}, err
696+ }
504697 return h, nil
505698 }
506699
@@ -571,11 +764,14 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
571764 }
572765
573766 // 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+ }
575771 w := payloadWriter{}
576772 w.putUint16(uint16(m.Size.Cols))
577773 w.putUint16(uint16(m.Size.Rows))
578- return w.b
774+ return w.b, nil
579775 }
580776
581777 // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.
@@ -691,7 +887,11 @@ func UnmarshalResize(b []byte) (Resize, error) {
691887 if err := r.done(); err != nil {
692888 return Resize{}, err
693889 }
694- return Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}, nil
890+ 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
695895 }
696896
697897 // MarshalDetach encodes a Detach message payload (always empty).
@@ -736,24 +936,35 @@ func UnmarshalPong(b []byte) (Pong, error) {
736936 return Pong{}, nil
737937 }
738938
739-// MarshalAck encodes m into an Ack message payload.
740-func MarshalAck(m Ack) []byte {
939+// MarshalAck encodes m into an epoch/state Ack payload.
940+func MarshalAck(m Ack) ([]byte, error) {
941+ if err := ValidateAck(m); err != nil {
942+ return nil, err
943+ }
741944 w := payloadWriter{}
742- w.putUint64(m.AckedStateNum)
743- return w.b
945+ w.putUint64(m.Epoch)
946+ w.putUint64(m.State)
947+ return w.b, nil
744948 }
745949
746-// UnmarshalAck decodes an Ack message payload.
950+// UnmarshalAck decodes an epoch/state Ack payload.
747951 func UnmarshalAck(b []byte) (Ack, error) {
748952 r := payloadReader{b: b}
749- acked, err := r.getUint64()
750- if err != nil {
953+ var m Ack
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 {
751959 return Ack{}, err
752960 }
753961 if err := r.done(); err != nil {
754962 return Ack{}, err
755963 }
756- return Ack{AckedStateNum: acked}, nil
964+ if err := ValidateAck(m); err != nil {
965+ return Ack{}, err
966+ }
967+ return m, nil
757968 }
758969
759970 // MarshalWelcome encodes m into a Welcome message payload.
@@ -826,33 +1037,140 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
8261037 return m, nil
8271038 }
8281039
829-// MarshalOutput encodes m into an Output message payload.
830-func MarshalOutput(m Output) []byte {
1040+// MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/
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+ }
8311046 w := payloadWriter{}
832- w.putUint64(m.BaseStateNum)
833- w.putUint64(m.NewStateNum)
834- w.putUint64(m.EchoAck)
835- w.putBytes(m.Data)
836- return w.b
1047+ w.putUint64(m.Epoch)
1048+ w.putUint64(m.Base)
1049+ w.putUint64(m.New)
1050+ w.putUint64(m.Echo)
1051+ 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
8371057 }
8381058
839-// UnmarshalOutput decodes an Output message payload. After fixed state
840-// numbers, the rest of the payload is data; there is no length prefix.
1059+// UnmarshalOutput strictly decodes one final Output payload. Header semantics
1060+// are validated before the length-prefixed data is copied.
8411061 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+ }
8421068 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()
8441087 if err != nil {
8451088 return Output{}, err
8461089 }
847- newState, err := r.getUint64()
1090+ rows, err := r.getUint16()
8481091 if err != nil {
8491092 return Output{}, err
8501093 }
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()
8521112 if err != nil {
8531113 return Output{}, err
8541114 }
855- return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil
1115+ 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
8561174 }
8571175
8581176 // 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) {
7373 TrueColor: true,
7474 MaxOutputInFlight: 8,
7575 },
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},
7777 },
7878 {
7979 name: "empty strings",
@@ -81,12 +81,12 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8181 Version: 1,
8282 Intent: IntentEphemeral,
8383 Name: "",
84- Size: domain.Size{Cols: 0, Rows: 0},
84+ Size: domain.Size{Cols: 1, Rows: 1},
8585 TermEnv: "",
8686 Cwd: "",
8787 TrueColor: false,
8888 },
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},
9090 },
9191 }
9292
@@ -117,17 +117,19 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
117117
118118 func TestHelloEnvironmentCodec(t *testing.T) {
119119 t.Run("literal wire layout", func(t *testing.T) {
120- got := MarshalHello(Hello{
120+ msg := Hello{
121121 Version: ProtocolVersion,
122+ Size: domain.Size{Cols: 1, Rows: 1},
122123 Env: []string{"A=B", "XY=123"},
123- })
124+ }
125+ got := MarshalHello(msg)
124126 want := []byte{
125- 0x00, 0x15, 0x00, 0x00, // version, intent, proxied
127+ 0x00, 0x17, 0x00, // version, intent
126128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
129131 0x00, 0x00, // name
130- 0x00, 0x00, 0x00, 0x00, // size
132+ 0x00, 0x01, 0x00, 0x01, // size
131133 0x00, 0x00, // TERM
132134 0x00, 0x00, // cwd
133135 0x00, 0x00, // true color, max output in flight
@@ -136,27 +138,30 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
137139 }
138140 require.Equal(t, want, got)
139- assertAllPrefixesFail(t, got, UnmarshalHello)
140- assertTrailingGarbageFails(t, got, UnmarshalHello)
141+ decoded, err := UnmarshalHello(want)
142+ require.NoError(t, err)
143+ require.Equal(t, msg, decoded)
144+ assertAllPrefixesFail(t, want, UnmarshalHello)
145+ assertTrailingGarbageFails(t, want, UnmarshalHello)
141146 })
142147
143148 t.Run("lossless order and values", func(t *testing.T) {
144149 entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536))
145150 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})
147152 got, err := UnmarshalHello(payload)
148153 require.NoError(t, err)
149154 require.Equal(t, want, got.Env)
150155 })
151156
152157 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{}})
154159 got, err := UnmarshalHello(payload)
155160 require.NoError(t, err)
156161 require.Empty(t, got.Env)
157162 })
158163
159- base := MarshalHello(Hello{Version: ProtocolVersion})
164+ base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}})
160165 withCount := func(count byte) []byte {
161166 payload := append([]byte(nil), base...)
162167 payload[len(payload)-1] = count
@@ -247,7 +252,7 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) {
247252 func TestThemeGoldenAndRoundTrip(t *testing.T) {
248253 palette := [16]renderer.RGB{
249254 {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},
251256 {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21},
252257 {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27},
253258 {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d},
@@ -296,7 +301,7 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) {
296301 },
297302 want: []byte{
298303 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,
300305 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21,
301306 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a,
302307 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33,
@@ -336,14 +341,17 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336341 }
337342 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338343 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(21), ProtocolVersion)
344+ require.Equal(t, uint16(23), ProtocolVersion)
340345 }
341346
342347 func TestResizeGoldenAndRoundTrip(t *testing.T) {
343348 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
344349 want := []byte{0x00, 0x64, 0x00, 0x28}
345350
346- got := MarshalResize(msg)
351+ got, err := MarshalResize(msg)
352+ if err != nil {
353+ t.Fatalf("MarshalResize() error = %v", err)
354+ }
347355 if !bytes.Equal(got, want) {
348356 t.Fatalf("MarshalResize() = %#v, want %#v", got, want)
349357 }
@@ -461,7 +469,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461469 name: "minimal",
462470 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463471 want: []byte{
464- 0x00, 0x15,
472+ 0x00, 0x17,
465473 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
466474 0x00, // attached
467475 0x00, // self
@@ -484,7 +492,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
484492 JSON: true,
485493 },
486494 want: []byte{
487- 0x00, 0x15,
495+ 0x00, 0x17,
488496 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
489497 0x00, // attached
490498 0x01, // self
@@ -609,10 +617,13 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
609617 }
610618
611619 func TestOutputGoldenAndRoundTrip(t *testing.T) {
612- msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, 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}
620+ msg := Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n")}
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}
614622
615- got := MarshalOutput(msg)
623+ got, err := MarshalOutput(msg)
624+ if err != nil {
625+ t.Fatalf("MarshalOutput() error = %v", err)
626+ }
616627 if !bytes.Equal(got, want) {
617628 t.Fatalf("MarshalOutput() = %#v, want %#v", got, want)
618629 }
@@ -624,25 +635,18 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
624635 t.Fatalf("round trip = %#v, want %#v", back, msg)
625636 }
626637
627- assertAllPrefixesFail(t, got[:24], UnmarshalOutput)
628-
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- }
638+ assertAllPrefixesFail(t, got, UnmarshalOutput)
639+ assertTrailingGarbageFails(t, got, UnmarshalOutput)
639640 }
640641
641642 func TestAckGoldenAndRoundTrip(t *testing.T) {
642- msg := Ack{AckedStateNum: 0x0102030405060708}
643- want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
643+ msg := Ack{Epoch: 1, State: 0x0102030405060708}
644+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
644645
645- got := MarshalAck(msg)
646+ got, err := MarshalAck(msg)
647+ if err != nil {
648+ t.Fatalf("MarshalAck() error = %v", err)
649+ }
646650 if !bytes.Equal(got, want) {
647651 t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
648652 }
@@ -884,11 +888,11 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
884888 func TestHelloOutputWindowByteExactValues(t *testing.T) {
885889 for _, window := range []uint8{0, 1, 8} {
886890 t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) {
887- hello := Hello{Version: 14, MaxOutputInFlight: window}
888- // The empty Hello has 39 zero bytes before the negotiated output window,
889- // followed by a uint32 zero environment-entry count.
890- want := append(make([]byte, 39), window, 0, 0, 0, 0)
891+ hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window}
892+ // The empty Hello has a fixed 1x1 size before the negotiated output window.
893+ want := append(make([]byte, 38), window, 0, 0, 0, 0)
891894 want[1] = 14
895+ want[30], want[32] = 1, 1
892896 got := MarshalHello(hello)
893897 requireBytesEqual(t, want, got)
894898 back, err := UnmarshalHello(got)
@@ -906,44 +910,6 @@ func requireBytesEqual(t *testing.T, want, got []byte) {
906910 t.Fatalf("bytes = %#v, want %#v", got, want)
907911 }
908912 }
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-
947913 func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
948914 tests := []struct {
949915 name string
@@ -954,7 +920,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
954920 name: "attached",
955921 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
956922 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,
958924 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
959925 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
960926 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -964,7 +930,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
964930 name: "control",
965931 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
966932 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,
968934 0x00, 0x02, 'l', 's', 0x00, 0x00,
969935 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
970936 },
M internal/testutil/replaytest/replay.go +11 -2
@@ -7,12 +7,21 @@ import (
77 "reflect"
88 "testing"
99
10+ "github.com/bnema/vev/internal/domain"
1011 "github.com/bnema/vev/internal/ports"
1112 )
1213
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+
1322 var transcript = []ports.Frame{
14- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
15- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})},
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")})},
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")})},
1625 }
1726
1827 // 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) {
2121 for range cap(normal) {
2222 normal <- ports.Frame{Type: ports.MsgInput}
2323 }
24+ const epoch = 7
2425 acks := newCumulativeAckQueue()
2526 for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ {
26- acks.offer(state)
27+ acks.offer(epoch, state)
2728 }
2829
2930 ctx, cancel := context.WithCancel(context.Background())
@@ -36,7 +37,8 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) {
3637 require.Equal(t, ports.MsgAck, first.Type)
3738 ack, err := ports.UnmarshalAck(first.Payload)
3839 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)
4042 }
4143
4244 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)
251251 }
252252 rememberOnce.Do(func() {
253253 learnerStarted = true
254- rememberWG.Add(1)
255- go func() {
256- defer rememberWG.Done()
254+ rememberWG.Go(func() {
257255 if err := r.remoteHostLearner.RememberRemoteHost(); err != nil {
258256 r.logger.Warn("remembering remote host failed", "err", err)
259257 }
260- }()
258+ })
261259 })
262260 }
263261 defer func() {
@@ -832,12 +830,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
832830 clip = clipboard
833831 }
834832 awaitingReconnectReset := false
833+ outputState := outputApplyState{}
834+ outputResetRequested := false
835835 if reconnect.showing {
836836 if reconnect.remote {
837837 // Before the asynchronous sender starts, preserve transport ordering by
838838 // requesting the reset synchronously after the initial Theme publication.
839839 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})
841845 })
842846 if resetErr != nil {
843847 resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr))
@@ -881,8 +885,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
881885 if serr != nil {
882886 return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr)
883887 }
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+ }
884892 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}:
886894 awaitingReconnectReset = true
887895 return nil
888896 case <-loopCtx.Done():
@@ -990,19 +998,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
990998 if derr != nil {
991999 return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr))
9921000 }
993- // Dismissing or changing a local overlay requests a same-size resize,
994- // whose stateful base-zero Output is the daemon's authoritative reset.
995- // ACK intervening increments so the output window keeps moving, but do
996- // not apply diffs based on cells hidden by the client-local overlay.
997- resetFrame := isReconnectResetFrame(o)
998- if awaitingReconnectReset && !resetFrame && o.NewStateNum != 0 {
999- ackQueue.offer(o.NewStateNum)
1001+ nextState, accepted := outputState.next(o)
1002+ if !accepted {
1003+ // A discarded frame was never written and must never be ACKed.
1004+ // Ask the daemon for one authoritative full reset, coalescing
1005+ // repeated gaps while that reset is in flight.
1006+ if !outputResetRequested {
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+ }
10001014 continue
10011015 }
10021016 if _, werr := term.Out().Write(o.Data); werr != nil {
10031017 return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr))
10041018 }
1005- if awaitingReconnectReset && resetFrame {
1019+ if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 {
10061020 awaitingReconnectReset = false
10071021 }
10081022 if reconnect.showing && reconnect.remote {
@@ -1016,13 +1030,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10161030 } else if ferr := term.Flush(); ferr != nil {
10171031 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
10181032 }
1033+ outputState = nextState
1034+ if o.New != 0 {
1035+ ackQueue.offer(o.Epoch, o.New)
1036+ outputResetRequested = false
1037+ }
10191038 // The terminal boundary is after a successful flush and before ACK.
10201039 if observer != nil {
10211040 observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true))
10221041 }
1023- if o.NewStateNum != 0 {
1024- ackQueue.offer(o.NewStateNum)
1025- }
10261042 if !ms.firstOutput {
10271043 ms.firstOutput = true
10281044 log.Debug("received first output")
@@ -1049,13 +1065,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
10491065 }
10501066 }
10511067
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-
10591068 // recvResult carries one framed message (or a read error) from the receive
10601069 // pump to the main loop.
10611070 type recvResult struct {
@@ -1084,27 +1093,39 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu
10841093 // resize pumps through one goroutine keeps the transport's single-writer
10851094 // contract intact. A send failure cancels the loop and is surfaced once.
10861095 type cumulativeAckQueue struct {
1087- latest atomic.Uint64
1088- wake chan struct{}
1096+ mu sync.Mutex
1097+ latestEpoch uint64
1098+ latestState uint64
1099+ wake chan struct{}
10891100 }
10901101
10911102 func newCumulativeAckQueue() *cumulativeAckQueue {
10921103 return &cumulativeAckQueue{wake: make(chan struct{}, 1)}
10931104 }
10941105
1095-func (q *cumulativeAckQueue) offer(state uint64) {
1096- for {
1097- current := q.latest.Load()
1098- if state <= current || q.latest.CompareAndSwap(current, state) {
1099- break
1100- }
1106+func (q *cumulativeAckQueue) offer(epoch, state uint64) {
1107+ if epoch == 0 || state == 0 {
1108+ return
1109+ }
1110+ q.mu.Lock()
1111+ if epoch > q.latestEpoch || (epoch == q.latestEpoch && state > q.latestState) {
1112+ q.latestEpoch, q.latestState = epoch, state
11011113 }
1114+ q.mu.Unlock()
11021115 select {
11031116 case q.wake <- struct{}{}:
11041117 default:
11051118 }
11061119 }
11071120
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+
11081129 func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) {
11091130 defer log.Debug("sender pump exited")
11101131 send := func(f ports.Frame) bool {
@@ -1119,8 +1140,20 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
11191140 return true
11201141 }
11211142 sendAck := func() bool {
1122- state := acks.latest.Swap(0)
1123- return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: state})})
1143+ epoch, state := acks.take()
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})
11241157 }
11251158 for {
11261159 select {
@@ -1718,7 +1751,12 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.
17181751 if !ok {
17191752 return
17201753 }
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}
17221760 select {
17231761 case out <- frame:
17241762 case <-ctx.Done():
M internal/usecase/client/client_test.go +1 -1
@@ -408,7 +408,7 @@ func TestAttachHappyPath(t *testing.T) {
408408 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
409409 unblock := scriptRecv(tr,
410410 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")}))},
412412 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
413413 )
414414 defer unblock()
M internal/usecase/client/clipboard_intercept_test.go +1 -1
@@ -198,7 +198,7 @@ func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testi
198198 }
199199 }
200200 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")}))
202202 transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
203203
204204 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) {
375375 require.Contains(t, firstToast, "┌")
376376 require.Contains(t, firstToast, "probing UDP path")
377377
378- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
379- BaseStateNum: 1,
380- NewStateNum: 2,
381- Data: []byte("daemon incremental"),
378+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
379+ Epoch: 1,
380+ Base: 0,
381+ New: 2,
382+ Size: domain.Size{Cols: 1, Rows: 1},
383+ Full: true,
384+ Data: []byte("daemon incremental"),
382385 })}}
383386 redrawn := requireReconnectToastOutput(t, out.completed)
384387 require.Contains(t, redrawn, "daemon incremental")
@@ -391,44 +394,53 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
391394
392395 beforeAwaitingReset := out.String()
393396 beforeStatelessFlushes := flushes.Load()
394- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
395- BaseStateNum: 0,
396- NewStateNum: 0,
397- Data: []byte("stateless side effect"),
397+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
398+ Epoch: 1,
399+ Base: 0,
400+ New: 0,
401+ Size: domain.Size{Cols: 1, Rows: 1},
402+ Data: []byte("stateless side effect"),
398403 })}}
399404 require.Eventually(t, func() bool {
400405 return strings.Contains(out.String(), "stateless side effect") && flushes.Load() > beforeStatelessFlushes
401406 }, time.Second, time.Millisecond)
402407
403408 afterStateless := out.String()
404- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
405- BaseStateNum: 2,
406- NewStateNum: 3,
407- Data: []byte("intervening incremental"),
409+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
410+ Epoch: 1,
411+ Base: 2,
412+ New: 3,
413+ Size: domain.Size{Cols: 1, Rows: 1},
414+ Data: []byte("intervening incremental"),
408415 })}}
409- requireAckedState(t, tr.sends, 3)
410- require.Equal(t, afterStateless, out.String(), "stateless output must not satisfy pending reset reconciliation")
416+ requireAckedState(t, tr.sends, 1, 3)
417+ require.Contains(t, out.String(), "intervening incremental")
411418 require.Contains(t, afterStateless[len(beforeAwaitingReset):], "stateless side effect")
412419
413- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
414- BaseStateNum: 0,
415- NewStateNum: 4,
416- Data: []byte("authoritative reset"),
420+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
421+ Epoch: 2,
422+ Base: 0,
423+ New: 4,
424+ Size: domain.Size{Cols: 1, Rows: 1},
425+ Full: true,
426+ Data: []byte("authoritative reset"),
417427 })}}
418- requireAckedState(t, tr.sends, 4)
428+ requireAckedState(t, tr.sends, 2, 4)
419429 require.Eventually(t, func() bool { return strings.Contains(out.String(), "authoritative reset") }, time.Second, time.Millisecond)
420430 require.NotContains(t, out.String()[len(beforeAwaitingReset):], strings.Repeat(" ", reconnectToastBoundsFor(term.size, "probing UDP path").Width))
421431
422432 beforeIncrementFlushes := flushes.Load()
423- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
424- BaseStateNum: 4,
425- NewStateNum: 5,
426- Data: []byte("increment after reset"),
433+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
434+ Epoch: 2,
435+ Base: 4,
436+ New: 5,
437+ Size: domain.Size{Cols: 1, Rows: 1},
438+ Data: []byte("increment after reset"),
427439 })}}
428440 require.Eventually(t, func() bool {
429441 return strings.Contains(out.String(), "increment after reset") && flushes.Load() > beforeIncrementFlushes
430442 }, time.Second, time.Millisecond)
431- requireAckedState(t, tr.sends, 5)
443+ requireAckedState(t, tr.sends, 2, 5)
432444
433445 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
434446 result := requireAttachResult(t, resultCh)
@@ -462,37 +474,46 @@ func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T)
462474
463475 beforeReset := out.String()
464476 for _, output := range []ports.Output{
465- {BaseStateNum: 1, NewStateNum: 2, Data: []byte("first skipped increment")},
466- {BaseStateNum: 2, NewStateNum: 3, Data: []byte("second skipped increment")},
477+ {Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("first skipped increment")},
478+ {Epoch: 1, Base: 2, New: 3, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("second skipped increment")},
467479 } {
468- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(output)}}
469- requireAckedState(t, tr.sends, output.NewStateNum)
480+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)}}
470481 require.Equal(t, beforeReset, out.String())
471482 }
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")
472488
473489 beforeResetFlushes := flushes.Load()
474- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
475- BaseStateNum: 0,
476- NewStateNum: 4,
477- Data: []byte("stage transition reset"),
490+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
491+ Epoch: 2,
492+ Base: 0,
493+ New: 4,
494+ Size: domain.Size{Cols: 1, Rows: 1},
495+ Full: true,
496+ Data: []byte("stage transition reset"),
478497 })}}
479498 redrawn := requireReconnectToastOutput(t, out.completed)
480499 require.Contains(t, redrawn, "stage transition reset")
481500 require.Contains(t, redrawn, offlineMessage)
482501 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)
484503
485504 beforeIncrementFlushes := flushes.Load()
486- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
487- BaseStateNum: 4,
488- NewStateNum: 5,
489- Data: []byte("stage increment after reset"),
505+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
506+ Epoch: 2,
507+ Base: 4,
508+ New: 5,
509+ Size: domain.Size{Cols: 1, Rows: 1},
510+ Data: []byte("stage increment after reset"),
490511 })}}
491512 incremental := requireReconnectToastOutput(t, out.completed)
492513 require.Contains(t, incremental, "stage increment after reset")
493514 require.Contains(t, incremental, offlineMessage)
494515 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)
496517
497518 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
498519 result := requireAttachResult(t, resultCh)
@@ -537,17 +558,19 @@ func requireResize(t *testing.T, sends *reconnectToastSentFrames) ports.Resize {
537558 return resize
538559 }
539560
540-func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, state uint64) {
561+func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, epoch, state uint64) {
541562 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 {
543564 if frame.Type != ports.MsgAck {
544565 return false
545566 }
546567 ack, err := ports.UnmarshalAck(frame.Payload)
547- return err == nil && ack.AckedStateNum >= state
568+ return err == nil && ack.Epoch == epoch && ack.State >= state
548569 })
549- _, err := ports.UnmarshalAck(frame.Payload)
570+ ack, err := ports.UnmarshalAck(frame.Payload)
550571 require.NoError(t, err)
572+ require.Equal(t, epoch, ack.Epoch)
573+ require.GreaterOrEqual(t, ack.State, state)
551574 }
552575
553576 func requireAttachResult(t *testing.T, results <-chan attachResult) attachResult {
@@ -652,10 +675,13 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi
652675 require.Eventually(t, func() bool { return flushes.Load() > beforeStatus }, time.Second, time.Millisecond)
653676 beforeOutput := flushes.Load()
654677
655- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
656- BaseStateNum: 1,
657- NewStateNum: 2,
658- Data: []byte("daemon output behind local reconnect status"),
678+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
679+ Epoch: 1,
680+ Base: 0,
681+ New: 2,
682+ Size: domain.Size{Cols: 1, Rows: 1},
683+ Full: true,
684+ Data: []byte("daemon output behind local reconnect status"),
659685 })}}
660686
661687 select {
@@ -664,7 +690,7 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi
664690 case <-time.After(time.Second):
665691 t.Fatal("timed out waiting for terminal-flushed observation")
666692 }
667- requireAckedState(t, tr.sends, 2)
693+ requireAckedState(t, tr.sends, 1, 2)
668694 require.Greater(t, flushes.Load(), beforeOutput, "daemon output must be flushed before ACK")
669695
670696 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
@@ -701,13 +727,16 @@ func TestLocalReconnectStatusTransitionsDoNotRequestAuthoritativeReset(t *testin
701727 }
702728
703729 const output = "local status output remains visible"
704- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
705- BaseStateNum: 1,
706- NewStateNum: 2,
707- Data: []byte(output),
730+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
731+ Epoch: 1,
732+ Base: 0,
733+ New: 2,
734+ Size: domain.Size{Cols: 1, Rows: 1},
735+ Full: true,
736+ Data: []byte(output),
708737 })}}
709738 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)
711740 _, sentResize := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgResize })
712741 require.False(t, sentResize, "local reconnect status must not request a daemon reset")
713742
@@ -766,10 +795,13 @@ func TestReconnectOverlayRedrawFailureDoesNotObserveOrAckOutput(t *testing.T) {
766795 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
767796 requireReconnectToastOutput(t, out.completed)
768797 arm()
769- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
770- BaseStateNum: 1,
771- NewStateNum: 2,
772- Data: []byte("daemon output before failed redraw"),
798+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
799+ Epoch: 1,
800+ Base: 0,
801+ New: 2,
802+ Size: domain.Size{Cols: 1, Rows: 1},
803+ Full: true,
804+ Data: []byte("daemon output before failed redraw"),
773805 })}}
774806
775807 result := requireAttachResult(t, resultCh)
M internal/usecase/client/transport_observability_test.go +2 -2
@@ -50,7 +50,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
5050 transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
5151 unblock := scriptRecv(transport,
5252 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}))},
5454 )
5555 defer unblock()
5656 transport.EXPECT().Close().Return(nil).Once()
@@ -90,7 +90,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
9090 transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
9191 unblock := scriptRecv(transport,
9292 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}))},
9494 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
9595 )
9696 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
614614 setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) {
615615 ac.output.next = 3
616616 },
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})},
618618 assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
619619 ac.sendMu.Lock()
620620 defer ac.sendMu.Unlock()
M internal/usecase/daemon/attachment_registry.go +19 -3
@@ -17,6 +17,7 @@ type attachmentView struct {
1717 paneID domain.PaneStableID
1818 windowTop int
1919 windowRows int
20+ windowSet bool
2021 bookmark vt.RowID
2122 liveBottom bool
2223 revision uint64
@@ -45,6 +46,19 @@ func (ac *attachedClient) publishView(view attachmentView) {
4546 return
4647 }
4748 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+ }
4862 ac.view = view
4963 ac.viewMu.Unlock()
5064 }
@@ -241,6 +255,7 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment
241255 view.paneID = ""
242256 view.windowTop = 0
243257 view.windowRows = 0
258+ view.windowSet = false
244259 view.bookmark = 0
245260 view.liveBottom = true
246261 return view
@@ -293,7 +308,7 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool {
293308 }
294309 before := ac.viewSnapshot()
295310 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 {
297312 return false
298313 }
299314 after.revision++
@@ -324,6 +339,7 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index
324339 view.paneID = ""
325340 view.bookmark = 0
326341 view.liveBottom = true
342+ view.revision++
327343 ac.publishView(view)
328344 }
329345 }
@@ -369,7 +385,7 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab {
369385 }
370386 view := ac.viewSnapshot()
371387 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 {
373389 repaired.revision++
374390 ac.publishView(repaired)
375391 }
@@ -476,7 +492,7 @@ func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachme
476492 next = s.repairAttachmentViewLocked(ac, next)
477493 next.revision++
478494 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.liveBottom
495+ 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
480496 return nil
481497 })
482498 return changed
M internal/usecase/daemon/attachment_registry_test.go +63 -0
@@ -5,6 +5,8 @@ import (
55 "testing"
66
77 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
810 )
911
1012 func registryTestSession() *session {
@@ -73,6 +75,67 @@ func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) {
7375 }
7476 }
7577
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+
76139 func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) {
77140 sess := registryTestSession()
78141 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 {
3636 tr ports.Transport
3737 transportIncarnation uint64
3838 output *outputStateStream
39- // screenOutput is allocated only for proxied attachments and shares output's
40- // state-number/ACK window. Both streams are serialized by sendMu.
41- screenOutput *structuredOutputStream
42- overlays *overlayRuntime
43- overlayOnce sync.Once
44- clientID [16]byte
39+ overlays *overlayRuntime
40+ overlayOnce sync.Once
41+ clientID [16]byte
4542 // connectionGeneration is the wire-facing capability generation. attachmentEffects is
4643 // the linearization gate for every attachment-bound observable operation. An attachment
4744 // transition freezes and drains this gate before changing either generation
@@ -55,10 +52,7 @@ type attachedClient struct {
5552 // credential instead of consuming it.
5653 resumeClaimToken uint64
5754 parked bool
58- // proxied is negotiated once by Hello and remains immutable for this
59- // attachment, including across transport resume.
60- proxied bool
61- echoAck atomic.Uint64
55+ echoAck atomic.Uint64
6256 // prepareFailureFallback prevents a direct fallback paint from recursively
6357 // reporting the same failed prepare through its notice repaint. It is only
6458 // needed while no render coordinator is installed.
@@ -69,13 +63,6 @@ type attachedClient struct {
6963 pipelineCache composeCacheInput
7064 pipelineScratch composeCacheInput
7165 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
7966 // captureFrames is keyed by pane ownership, not the tab-local PaneID, so
8067 // snapshots cannot leak when an attachment switches tabs or sessions.
8168 captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held
@@ -289,50 +276,23 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
289276 return tr != nil && ac.transportIs(tr)
290277 }
291278
292-func (ac *attachedClient) ackOutputState(state uint64) {
279+func (ac *attachedClient) ackOutputState(epoch, state uint64) {
293280 ac.sendMu.Lock()
294281 defer ac.sendMu.Unlock()
295- ac.output.ack(state)
296-}
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{}
282+ if ac.output != nil {
283+ ac.output.ack(epoch, state)
317284 }
318285 }
319286
320-// rebaseOutput resets both wire representations while retaining the shared
321-// state-number chain. Callers hold sendMu (or the activation barrier).
287+// rebaseOutput resets only this attachment's output representations. Callers
288+// hold sendMu (or the activation barrier).
322289 func (ac *attachedClient) rebaseOutput() {
323290 if ac == nil {
324291 return
325292 }
326- if ac.proxied {
327- ac.ensureScreenOutput()
328- }
329293 if ac.output != nil {
330294 ac.output.rebase()
331295 }
332- if ac.screenOutput != nil {
333- ac.screenOutput.rebase()
334- }
335- ac.proxyCapture = proxyCapture{}
336296 }
337297
338298 var errTransportReplaced = errors.New("client transport was replaced")
@@ -384,6 +344,31 @@ func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportS
384344 return err
385345 }
386346
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+
387372 // boundedSend sends f to ac with a deadline watchdog: if the send (including
388373 // waiting on sendMu behind a wedged paint) does not complete within
389374 // detachNotifyTimeout, the transport is force-closed, failing the in-flight
@@ -413,24 +398,37 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
413398 }
414399
415400 func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
416- frame := ac.output.sideEffect(b, ac.echoAck.Load())
417401 expected := ac.transportSnapshot()
418402 if expected.transport == nil {
419403 return nil, errors.New("client transport is nil")
420404 }
421- ac.sendMu.Lock()
422- if !ac.transportSnapshotCurrent(expected) {
423- ac.sendMu.Unlock()
424- return expected.transport, errTransportReplaced
425- }
426405 if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok {
427- err := owned.SendSynchronous(frame)
428- ac.sendMu.Unlock()
406+ ac.sendMu.Lock()
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+ }
429417 return expected.transport, err
430418 }
431- ac.sendMu.Unlock()
432419 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)
434432 })
435433 }
436434
@@ -523,7 +521,6 @@ type attachClientOptions struct {
523521 clientID [16]byte
524522 resumeCapable bool
525523 maxOutputInFlight uint8
526- proxied bool
527524 }
528525
529526 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,
567564 tr: tr,
568565 output: output,
569566 size: sz,
567+ view: attachmentView{windowRows: sz.Rows, windowSet: true},
570568 clientID: opts.clientID,
571569 resumeCapable: opts.resumeCapable,
572570 resumeToken: resumeToken,
573- proxied: opts.proxied,
574- }
575- if opts.proxied {
576- ac.screenOutput = newStructuredOutputStream(output)
577571 }
572+ output.attachment = ac
578573 ac.initOverlays()
579574 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
580575 return ac
@@ -648,6 +643,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess
648643 d.paint(entry, attachment, fanoutReset, lease)
649644 } else if len(attachments) > 1 {
650645 go d.paint(entry, attachment, fanoutReset, lease)
646+
651647 } else {
652648 d.paint(entry, attachment, fanoutReset, lease)
653649 }
@@ -709,8 +705,8 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
709705 token.ac.captureFrames = nil
710706 token.ac.sendMu.Unlock()
711707 }
712- if proxy, ok := token.sess.(*proxySession); ok {
713- d.firstProxyPaintWithLease(proxy, token.ac, token.lease)
708+ if _, ok := token.sess.(*proxySession); ok {
709+ // Remote ordinary output arrives independently after the proxy Welcome.
714710 return true
715711 }
716712 sess, ok := localSession(token.sess)
@@ -721,15 +717,14 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
721717 return true
722718 }
723719
724-// firstPaint guarantees the freshly attached client sees the full screen: if
725-// the tab size differs from the client's it resizes first, then immediately
726-// emits a full redraw. It is retained for direct test/headless setup; active
727-// attachment transitions use firstPaintForTransition with their exact lease.
720+// firstPaint guarantees the freshly attached client sees the full screen. The
721+// session content geometry is fixed by the first valid Hello; an attachment's
722+// window is rendered without resizing shared tabs or PTYs.
728723 func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) {
729724 d.firstPaintWithLease(sess, ac, clientSize, nil)
730725 }
731726
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) {
733728 // Global notices raised while nothing was attached surface on this client.
734729 // Drained before the early return below so a session without an active tab
735730 // cannot swallow the queue.
@@ -738,29 +733,14 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi
738733 if tb == nil {
739734 return
740735 }
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- }
753736 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
754- // Activation can synchronously resize a retained floating pane. An accepted
755- // synchronous outer request already includes that pane and emits the reset,
756- // but activation still performs its warmup work.
757- activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease)
758- if !outerResizeAccepted && !activationResized {
759- if lease == nil {
760- d.invalidateRenderNow(sess, ac, true, "client.go")
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- }
737+ // Activation still performs attachment-local warmup, but it must not turn a
738+ // new window into a shared session resize.
739+ d.ensureFloatingWarm(sess, tb)
740+ if lease == nil {
741+ d.invalidateRenderNow(sess, ac, true, "client.go")
742+ } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
743+ rc.fireCurrentForLease(lease)
764744 }
765745 }
766746
@@ -814,6 +794,46 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
814794 d.requestTransactionalResize(sess, ac, sz, false)
815795 }
816796
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+
817837 // handleClientNotice maps the closed client-event enum to daemon-owned notice
818838 // content. routingMu makes ownership validation and toast mutation one atomic
819839 // 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
102102 }
103103 case ports.MsgResize:
104104 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() {
105- if sess, ok := localSession(token.sess); ok {
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- }
105+ d.resizeAttachmentForLease(token, rz.Size)
110106 }
111107 case ports.MsgTheme:
112108 if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil {
@@ -129,24 +125,22 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f
129125 }
130126 case ports.MsgAck:
131127 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
132- d.ackOutput(token, ack.AckedStateNum)
128+ d.ackOutput(token, ack.Epoch, ack.State)
133129 }
134130 case ports.MsgOutputResetRequest:
135131 if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil {
136132 d.resetOutput(token)
137133 }
138134 case ports.MsgCommand:
139- if token.ac.proxied {
140- request, derr := ports.UnmarshalCommandRequest(f.Payload)
141- if derr != nil {
142- return false
143- }
144- result := d.executeAttachedCommand(token, request)
145- if err := token.sendControl(frameCommandResult(result)); err != nil {
146- token.endAttachmentEffect()
147- d.detachOnAttachmentSendError(token, token.transport.transport)
148- return true
149- }
135+ request, derr := ports.UnmarshalCommandRequest(f.Payload)
136+ if derr != nil {
137+ return false
138+ }
139+ result := d.executeAttachedCommand(token, request)
140+ if err := token.sendControl(frameCommandResult(result)); err != nil {
141+ token.endAttachmentEffect()
142+ d.detachOnAttachmentSendError(token, token.transport.transport)
143+ return true
150144 }
151145 case ports.MsgPing:
152146 if err := token.sendControl(framePong()); err != nil {
@@ -246,30 +240,29 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool {
246240 // transport revalidation under that lock rejects a link replaced while waiting.
247241 func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
248242 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() {
250244 return false
251245 }
252246 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 {
254248 ac.sendMu.Unlock()
255249 return false
256250 }
257251 ac.rebaseOutput()
252+ ac.pipelineCache = composeCacheInput{}
253+ ac.pipelineScratch = composeCacheInput{}
258254 ac.sendMu.Unlock()
259-
260- rc := token.sess.core().coordinator.Load()
261- return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{
262- class: invalidateUrgent, reset: true, producer: "output-reset-request",
263- })
255+ go d.paint(token.sess, ac, true, token.lease)
256+ return true
264257 }
265258
266-func (d *Daemon) ackOutput(token attachmentConnectionToken, state uint64) bool {
259+func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) bool {
267260 ac := token.ac
268261 if token.effect == nil || token.effect.ended.Load() {
269262 return false
270263 }
271264 ac.sendMu.Lock()
272- ac.output.ack(state)
265+ ac.output.ack(epoch, state)
273266 ac.sendMu.Unlock()
274267 if rc := token.sess.core().coordinator.Load(); rc != nil {
275268 rc.notifyAckForLease(token.lease)
M internal/usecase/daemon/clipboard.go +9 -6
@@ -271,12 +271,15 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
271271 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
272272 return errAttachmentTransition
273273 }
274- frame := ac.output.sideEffect(item.seq, ac.echoAck.Load())
275- var err error
276- if owned {
277- err = ownedTransport.SendSynchronous(frame)
278- } else {
279- err = expected.transport.Send(frame)
274+ ac.output.lockView()
275+ defer ac.output.unlockView()
276+ frame, err := ac.output.sideEffectLocked(item.seq, ac.echoAck.Load())
277+ if err == nil {
278+ if owned {
279+ err = ownedTransport.SendSynchronous(frame)
280+ } else {
281+ err = expected.transport.Send(frame)
282+ }
280283 }
281284 if err != nil {
282285 ticket.reportTransportFailure(expected)
M internal/usecase/daemon/daemon.go +8 -19
@@ -64,7 +64,8 @@ func normalizeOutputWindow(window uint8) uint8 {
6464
6565 const defaultResumeParkGrace = 15 * time.Minute
6666
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.
6869 var defaultSize = domain.Size{Cols: 80, Rows: 24}
6970
7071 type Daemon struct {
@@ -73,7 +74,7 @@ type Daemon struct {
7374 stopped map[string]stoppedSession
7475 // creating reserves names while durable creation I/O runs without mu.
7576 creating map[string]struct{}
76- // proxyConstructions serializes remote IntentAttach handshakes by structured
77+ // proxyConstructions serializes remote IntentAttach handshakes by remote
7778 // key. Entries are guarded by mu and never retain it across dial or link I/O.
7879 proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction
7980 nextID uint64
@@ -1142,16 +1143,6 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
11421143 d.clientGone(sess, ac, tr, false)
11431144 return
11441145 }
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- }
11551146 if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) {
11561147 postWelcomeTicket.End()
11571148 // 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,
11931184 clientID: h.ClientID,
11941185 resumeCapable: true,
11951186 maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight),
1196- proxied: h.Proxied,
11971187 }
11981188 ac := d.prepareAttachedClientLocked(tr, sz, opts)
11991189 d.mu.Unlock()
@@ -1267,7 +1257,7 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
12671257 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
12681258 sz := h.Size
12691259 if !sz.Valid() {
1270- sz = defaultSize
1260+ return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
12711261 }
12721262 term := terminalEnv{TrueColor: h.TrueColor}
12731263
@@ -1317,7 +1307,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13171307 return nil, nil, err
13181308 }
13191309 }
1320-
13211310 d.mu.Lock()
13221311 // Shutdown/create interlock: once shutdown has begun (last session removed,
13231312 // 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
13391328 }
13401329 cwd := d.dirOrHome(stopped.cwd)
13411330 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)
13431332 if err != nil {
13441333 d.mu.Unlock()
13451334 return nil, nil, err
@@ -1350,7 +1339,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13501339
13511340 case ports.IntentEphemeral:
13521341 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)
13541343 if err != nil {
13551344 d.mu.Unlock()
13561345 return nil, nil, err
@@ -1371,7 +1360,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13711360 d.mu.Unlock()
13721361 return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name}
13731362 }
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)
13751364 if err != nil {
13761365 d.mu.Unlock()
13771366 return nil, nil, err
@@ -1389,7 +1378,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13891378 }
13901379 cwd := d.dirOrHome(stopped.cwd)
13911380 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)
13931382 if err != nil {
13941383 d.mu.Unlock()
13951384 return nil, nil, err
M internal/usecase/daemon/daemon_test.go +41 -1
@@ -574,6 +574,46 @@ func tabCount(sess *session) int {
574574
575575 // --- handshake --------------------------------------------------------------
576576
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+
577617 func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
578618 done := make(chan struct{})
579619 go func() {
@@ -651,7 +691,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) {
651691 output := awaitFrame(t, tr.sends, ports.MsgOutput)
652692 first, err := ports.UnmarshalOutput(output.Payload)
653693 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")
655695 tr.finish()
656696 awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion")
657697 requireNoCoordinatorOutputFrame(t, tr.sends)
M internal/usecase/daemon/durable_recovery_test.go +1 -1
@@ -812,7 +812,7 @@ func TestAttachWaitsForRestore(t *testing.T) {
812812
813813 result := make(chan error, 1)
814814 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)
816816 result <- err
817817 }()
818818
M internal/usecase/daemon/floating_test.go +1 -1
@@ -543,7 +543,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) {
543543 repaint := awaitFrame(t, sends, ports.MsgOutput)
544544 output, err := ports.UnmarshalOutput(repaint.Payload)
545545 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")
547547 require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup")
548548 require.NotNil(t, floating)
549549 })
M internal/usecase/daemon/input.go +2 -17
@@ -16,7 +16,7 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6
1616 // Do not acknowledge client-side echo prediction here: input has only been
1717 // accepted/routed, not necessarily echoed by the PTY and incorporated into a
1818 // rendered screen state. Until prediction is implemented against rendered
19- // output state, EchoAck must remain conservative.
19+ // output state, Echo must remain conservative.
2020 d.handleInput(sess, ac, data)
2121 }
2222
@@ -486,16 +486,6 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
486486 case keys.ActionOpenPalette:
487487 h.d.enterPalette(sess, h.ac)
488488 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- }
499489 if effect == nil {
500490 if err := h.d.jumpAttention(sess, h.ac); err != nil {
501491 h.d.reportError(sess, err)
@@ -550,12 +540,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) {
550540 }
551541
552542 func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) {
553- var err error
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- }
543+ err := h.d.focusDir(sess, h.ac, dir, effect)
559544 if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) {
560545 h.d.reportError(sess, err)
561546 }
M internal/usecase/daemon/input_test.go +1 -1
@@ -214,7 +214,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) {
214214 second := awaitFrame(t, sends, ports.MsgOutput)
215215 secondOutput, err := ports.UnmarshalOutput(second.Payload)
216216 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")
218218 terminal.Write(secondOutput.Data)
219219 require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells")
220220 }
M internal/usecase/daemon/output_state_stream.go +211 -25
@@ -1,8 +1,11 @@
11 package daemon
22
33 import (
4+ "errors"
5+ "sync"
46 "sync/atomic"
57
8+ "github.com/bnema/vev/internal/domain"
69 "github.com/bnema/vev/internal/ports"
710 "github.com/bnema/vev/pkg/renderer"
811 )
@@ -11,19 +14,50 @@ import (
1114 // attached client. Each incremental output is rendered from the preceding
1215 // emitted frame and therefore remains pipelineable without waiting for an
1316 // ACK. Cumulative ACKs advance the bounded paint window.
14-// A reset frame is the only dependency-free output and advertises state 0 as
15-// its base.
17+// A reset frame starts a new epoch, clears the dependency chain, and advertises
18+// state 0 as its base.
1619 //
1720 // Callers serialize access with attachedClient.sendMu. A prepared transaction
1821 // must be sent or committed as a no-op before the next transaction is prepared.
1922 type outputStateStream struct {
2023 renderer *renderer.Renderer
24+ epoch uint64
2125 next uint64
2226 acked uint64
27+ generation uint64
2328 maxOutstanding uint64
2429 outstandingAtomic atomic.Uint64
2530 maxOutstandingAtomic atomic.Uint64
2631 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()
2761 }
2862
2963 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
@@ -33,6 +67,7 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
3367 }
3468 stream := &outputStateStream{
3569 renderer: renderer.New(renderer.Capabilities{}),
70+ epoch: 1,
3671 maxOutstanding: uint64(window),
3772 }
3873 stream.maxOutstandingAtomic.Store(uint64(window))
@@ -41,39 +76,79 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream {
4176 }
4277
4378 type preparedOutput struct {
44- stream *outputStateStream
45- draw renderer.PreparedDraw
46- next uint64
47- data []byte
48- reset bool
49- attempted bool
79+ stream *outputStateStream
80+ draw renderer.PreparedDraw
81+ base uint64
82+ next uint64
83+ epoch uint64
84+ generation uint64
85+ connectionGeneration uint64
86+ viewRevision uint64
87+ size domain.Size
88+ data []byte
89+ reset bool
90+ attempted bool
91+ sent bool
5092 }
5193
5294 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
53- reset = reset || s.forceSnapshot
95+ 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+ }
54107 draw, err := s.renderer.Prepare(frame, damage, reset)
55108 if err != nil {
56109 return nil, err
57110 }
111+ s.initialized = true
112+ generation, epoch, viewRevision := s.fenceLocked()
58113 return &preparedOutput{
59- stream: s,
60- draw: draw,
61- next: s.next + 1,
62- data: draw.Bytes(),
63- reset: reset,
114+ stream: s,
115+ draw: draw,
116+ base: s.next,
117+ next: s.next + 1,
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,
64125 }, nil
65126 }
66127
67128 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 {
69130 return nil
70131 }
132+ p.stream.lockView()
133+ defer p.stream.unlockView()
71134 p.attempted = true
72- base := p.stream.next
135+ 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
73144 if p.reset {
74145 base = 0
75146 }
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+ }
77152 if err := send(out); err != nil {
78153 p.stream.forceSnapshot = true
79154 return err
@@ -81,15 +156,22 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
81156 p.commit()
82157 p.stream.next = p.next
83158 p.stream.publishOutstanding()
159+ p.sent = true
84160 return nil
85161 }
86162
87163 func (p *preparedOutput) commitNoSend() {
88- if p == nil || p.attempted || len(p.data) != 0 {
164+ if p == nil || p.stream == nil || p.attempted {
89165 return
90166 }
167+ p.stream.lockView()
168+ defer p.stream.unlockView()
91169 p.attempted = true
170+ if !p.currentLocked() || len(p.data) != 0 {
171+ return
172+ }
92173 p.commit()
174+ p.sent = true
93175 }
94176
95177 func (p *preparedOutput) commit() {
@@ -97,12 +179,85 @@ func (p *preparedOutput) commit() {
97179 p.stream.forceSnapshot = false
98180 }
99181
100-func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {
101- return frameOutputState(data, 0, 0, echoAck)
182+func (s *outputStateStream) currentEpoch() uint64 {
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)
102240 }
103241
104-func (s *outputStateStream) ack(state uint64) {
105- if state > s.next || state <= s.acked {
242+// sideEffectLocked requires the attachment view lock for both frame creation
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 {
106261 return
107262 }
108263 s.acked = state
@@ -110,11 +265,33 @@ func (s *outputStateStream) ack(state uint64) {
110265 }
111266
112267 func (s *outputStateStream) rebase() {
113- s.acked = s.next
114- s.publishOutstanding()
268+ if s == nil {
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++
115290 s.maxOutstandingAtomic.Store(s.maxOutstanding)
291+ s.publishOutstanding()
116292 s.renderer.Reset()
117293 s.forceSnapshot = true
294+ s.initialized = false
118295 }
119296
120297 func (s *outputStateStream) publishOutstanding() {
@@ -130,6 +307,8 @@ func (s *outputStateStream) syncCapacityLocked() {
130307 if s == nil {
131308 return
132309 }
310+ s.lockView()
311+ defer s.unlockView()
133312 s.maxOutstandingAtomic.Store(s.maxOutstanding)
134313 s.publishOutstanding()
135314 }
@@ -141,4 +320,11 @@ func (s *outputStateStream) atCapacity() bool {
141320 return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()
142321 }
143322
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 (
55 "runtime"
66 "testing"
77
8+ "github.com/bnema/vev/internal/domain"
89 "github.com/bnema/vev/internal/ports"
910 "github.com/bnema/vev/pkg/renderer"
1011 "github.com/bnema/vev/pkg/vt"
@@ -20,9 +21,9 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
2021 require.True(t, ok)
2122 firstOut, err := ports.UnmarshalOutput(first.Payload)
2223 require.NoError(t, err)
23- require.Equal(t, uint64(0), firstOut.BaseStateNum)
24- require.Equal(t, uint64(1), firstOut.NewStateNum)
25- require.Equal(t, uint64(7), firstOut.EchoAck)
24+ require.Equal(t, uint64(0), firstOut.Base)
25+ require.Equal(t, uint64(1), firstOut.New)
26+ require.Equal(t, uint64(7), firstOut.Echo)
2627
2728 secondFrame := firstFrame.Clone()
2829 secondFrame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
@@ -31,8 +32,8 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
3132 require.True(t, ok)
3233 secondOut, err := ports.UnmarshalOutput(second.Payload)
3334 require.NoError(t, err)
34- require.Equal(t, firstOut.NewStateNum, secondOut.BaseStateNum)
35- require.Equal(t, uint64(2), secondOut.NewStateNum)
35+ require.Equal(t, firstOut.New, secondOut.Base)
36+ require.Equal(t, uint64(2), secondOut.New)
3637 require.Equal(t, uint64(2), stream.outstanding())
3738 }
3839
@@ -68,12 +69,148 @@ func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {
6869 <-probed
6970 require.True(t, stream.atCapacity())
7071
71- stream.ack(1)
72+ stream.ack(1, 1)
7273 require.False(t, stream.atCapacity())
7374 stream.rebase()
7475 require.False(t, stream.atCapacity())
7576 }
7677
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+
77214 func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
78215 stream := newOutputStateStream()
79216 initial := renderer.NewFrame(3, 1)
@@ -108,8 +245,8 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
108245 }))
109246 out, err := ports.UnmarshalOutput(sent.Payload)
110247 require.NoError(t, err)
111- require.Zero(t, out.BaseStateNum, "retry after ambiguous send failure must be dependency-free")
112- require.Equal(t, uint64(2), out.NewStateNum, "state numbers remain monotonic across rebases")
248+ require.Zero(t, out.Base, "retry after ambiguous send failure must be dependency-free")
249+ require.Equal(t, uint64(2), out.New, "state numbers remain monotonic across rebases")
113250 require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once")
114251 require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error {
115252 t.Fatal("completed output sent twice")
@@ -150,7 +287,7 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
150287 require.NoError(t, err)
151288 require.True(t, ok)
152289 mustApplyOutput(t, client, first)
153- stream.ack(1)
290+ stream.ack(1, 1)
154291
155292 scrolled := renderer.NewFrame(4, 3)
156293 fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "})
@@ -197,7 +334,11 @@ func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAc
197334 if reset {
198335 base = 0
199336 }
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
201342 }
202343
203344 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
234375 require.True(t, ok)
235376 resizeOutput, err := ports.UnmarshalOutput(resized.Payload)
236377 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")
238379 screen.ClearDamage()
239380
240381 _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0)
@@ -247,7 +388,7 @@ func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing
247388 require.True(t, ok)
248389 damageOutput, err := ports.UnmarshalOutput(damaged.Payload)
249390 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")
251392 }
252393
253394 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) {
8181 require.NoError(t, err)
8282 require.Same(t, sess, routed)
8383
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+
8490 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
8591 d.paint(sess, ac, false, nil)
8692 first := awaitFrame(t, tr.sends, ports.MsgOutput)
8793 out, err := ports.UnmarshalOutput(first.Payload)
8894 require.NoError(t, err)
89- require.Equal(t, uint64(1), out.NewStateNum)
95+ require.Equal(t, uint64(1), out.New)
9096
91- // Before the MsgAck, the renderer has already advanced along the ordered
92- // output dependency chain, so an unchanged repaint is a no-op.
97+ sess.tabs[0].focusedPane().screen.Write([]byte("\rB"))
9398 d.paint(sess, ac, false, nil)
94- requireNoOutputFrame(t, tr.sends)
95-
96- tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}
99+ updated := awaitFrame(t, tr.sends, ports.MsgOutput)
100+ out, err = ports.UnmarshalOutput(updated.Payload)
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})}
97106 require.NoError(t, tr.Close())
98107 d.runConnLoop(ac)
99108
100109 // The production MsgAck path retires retained states without moving the
101110 // renderer baseline backward.
102111 d.paint(sess, ac, false, nil)
103- requireNoOutputFrame(t, tr.sends)
112+ require.Empty(t, tr.sends)
104113 }
105114
106115 func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {
@@ -146,7 +155,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
146155 client := vt.NewScreen(80, 25)
147156 d.paint(sess, ac, true, nil)
148157 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
149- ac.ackOutputState(first.NewStateNum)
158+ ac.ackOutputState(first.Epoch, first.New)
150159
151160 // Preserve the frame after one scroll while inducing a real VT scroll
152161 // damage event before each production paint. On main, both unacknowledged
@@ -226,7 +235,7 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
226235 d.paint(sess, ac, false, nil)
227236 awaitFrame(t, sends, ports.MsgOutput)
228237
229- ac.ackOutputState(out.NewStateNum)
238+ ac.ackOutputState(out.Epoch, out.New)
230239 d.paint(sess, ac, false, nil)
231240 requireNoOutputFrame(t, sends)
232241 }
@@ -244,8 +253,8 @@ func TestRawTerminalSideEffectDoesNotEnterFullOutputWindow(t *testing.T) {
244253 require.Len(t, sends, 1)
245254 out, err := ports.UnmarshalOutput(sends[0].Payload)
246255 require.NoError(t, err)
247- require.Zero(t, out.BaseStateNum)
248- require.Zero(t, out.NewStateNum)
256+ require.Zero(t, out.Base)
257+ require.Zero(t, out.New)
249258 }
250259
251260 func TestOwnedSynchronousSideEffectSkipsOuterWatchdog(t *testing.T) {
@@ -295,7 +304,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
295304 d.paint(sess, ac, true, nil)
296305 client := vt.NewScreen(80, 24)
297306 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
298- ac.ackOutputState(initial.NewStateNum)
307+ ac.ackOutputState(initial.Epoch, initial.New)
299308
300309 d.sessWg.Add(1)
301310 pane.onExit = func() {}
@@ -349,7 +358,7 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) {
349358 frame := awaitFrame(t, sends, ports.MsgOutput)
350359 client := vt.NewScreen(120, 24)
351360 out := mustApplyOutput(t, client, frame)
352- require.Zero(t, out.BaseStateNum)
361+ require.Zero(t, out.Base)
353362 require.Contains(t, screenLineText(client, 0), "1")
354363 require.Contains(t, screenLineText(client, 23), "work")
355364 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) {
373373 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
374374 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
375375 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")
377377 awaitFrame(t, sends, ports.MsgOutput)
378378 }
379379
@@ -679,7 +679,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) {
679679 firstPaint := awaitFrame(t, sends, ports.MsgOutput)
680680 firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload)
681681 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")
683683 finalRepaint := awaitFrame(t, sends, ports.MsgOutput)
684684 finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload)
685685 require.NoError(t, err)
M internal/usecase/daemon/performance_benchmark_test.go +28 -299
@@ -218,9 +218,9 @@ func TestProxyANSIBenchmarkFixtures(t *testing.T) {
218218 state := newProxyANSIApplyState(t)
219219
220220 ack, reset, changed := state.apply(ports.Output{
221- BaseStateNum: 0,
222- NewStateNum: 1,
223- Data: fixture.data,
221+ Base: 0,
222+ New: 1,
223+ Data: fixture.data,
224224 })
225225
226226 require.Equal(t, uint64(1), ack)
@@ -251,21 +251,21 @@ func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset,
251251 data := append([]byte(nil), out.Data...)
252252 s.mu.Lock()
253253 defer s.mu.Unlock()
254- if out.NewStateNum == 0 {
254+ if out.New == 0 {
255255 s.screen.Write(data)
256256 return 0, false, len(data) != 0
257257 }
258- if out.BaseStateNum == 0 {
259- if !s.awaitingReset && out.NewStateNum <= s.appliedState {
258+ if out.Base == 0 {
259+ if !s.awaitingReset && out.New <= s.appliedState {
260260 return 0, false, false
261261 }
262262 s.screen = vt.NewScreen(s.screen.Frame.Width, s.screen.Frame.Height)
263263 s.screen.Write(data)
264- s.appliedState = out.NewStateNum
264+ s.appliedState = out.New
265265 s.awaitingReset = false
266- return out.NewStateNum, false, true
266+ return out.New, false, true
267267 }
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 {
269269 if !s.awaitingReset {
270270 s.awaitingReset = true
271271 requestReset = true
@@ -273,8 +273,8 @@ func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset,
273273 return 0, requestReset, false
274274 }
275275 s.screen.Write(data)
276- s.appliedState = out.NewStateNum
277- return out.NewStateNum, false, len(data) != 0
276+ s.appliedState = out.New
277+ return out.New, false, len(data) != 0
278278 }
279279
280280 type proxyANSIBenchmarkFixture struct {
@@ -306,277 +306,6 @@ func proxyANSIBenchmarkFixtures() []proxyANSIBenchmarkFixture {
306306 }
307307 }
308308
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-
580309 func TestPerformanceFixtureCounters(t *testing.T) {
581310 fixture := newPerformanceFixture(t, performanceConfig{})
582311
@@ -751,9 +480,9 @@ func BenchmarkProxyANSIApply(b *testing.B) {
751480 for b.Loop() {
752481 next := state.appliedState + 1
753482 ack, reset, changed := state.apply(ports.Output{
754- BaseStateNum: state.appliedState,
755- NewStateNum: next,
756- Data: fixture.data,
483+ Base: state.appliedState,
484+ New: next,
485+ Data: fixture.data,
757486 })
758487 if ack != next || reset || !changed {
759488 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
846575 fixture.resetMetrics()
847576 for b.Loop() {
848577 run(fixture)
849- fixture.ac.ackOutputState(fixture.ac.output.next)
578+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
850579 }
851580 metrics := fixture.metrics()
852581 if payload := fixture.output.lastPayload(); payload != nil {
@@ -865,12 +594,12 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
865594 func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) {
866595 b.Helper()
867596 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)
869598 b.ReportAllocs()
870599 b.ResetTimer()
871600 for range b.N {
872601 run(fixture)
873- fixture.ac.ackOutputState(fixture.ac.output.next)
602+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
874603 }
875604 b.StopTimer()
876605 if fixture.searchMatches() == 0 {
@@ -1240,32 +969,32 @@ func TestLivePaintAllocationBudget(t *testing.T) {
1240969 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
1241970 allocs := testing.AllocsPerRun(20, func() {
1242971 fixture.paintLive()
1243- fixture.ac.ackOutputState(fixture.ac.output.next)
972+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
1244973 })
1245974 require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")
1246975 })
1247976 }
1248977 }
1249978
1250-// TestCopyEnterAllocationBudget protects the attachment-aware baseline plus
1251-// 10%. Copy rendering borrows sealed VT history rows; allocating a copy per
1252-// viewport row is a production regression even though capture is immutable.
979+// TestCopyEnterAllocationBudget protects the byte-stream attachment baseline
980+// plus 10%. Copy rendering borrows sealed VT history rows; allocating a copy
981+// per viewport row is a production regression even though capture is immutable.
1253982 func TestCopyEnterAllocationBudget(t *testing.T) {
1254983 for _, tt := range []struct {
1255984 name string
1256985 tabs, panes, max int
1257986 }{
1258- {name: "1tab-1pane", tabs: 1, panes: 1, max: 55},
1259- {name: "1tab-4panes", tabs: 1, panes: 4, max: 55},
1260- {name: "4tabs-1pane", tabs: 4, panes: 1, max: 60},
1261- {name: "4tabs-4panes", tabs: 4, panes: 4, max: 60},
1262- {name: "8tabs-1pane", tabs: 8, panes: 1, max: 68},
987+ {name: "1tab-1pane", tabs: 1, panes: 1, max: 66},
988+ {name: "1tab-4panes", tabs: 1, panes: 4, max: 85},
989+ {name: "4tabs-1pane", tabs: 4, panes: 1, max: 71},
990+ {name: "4tabs-4panes", tabs: 4, panes: 4, max: 90},
991+ {name: "8tabs-1pane", tabs: 8, panes: 1, max: 79},
1263992 } {
1264993 t.Run(tt.name, func(t *testing.T) {
1265994 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
1266995 run := func() {
1267996 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)
1269998 }
1270999
12711000 // Always exercise and validate copy entry. The race detector adds
@@ -1278,7 +1007,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) {
12781007 }
12791008
12801009 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")
12821011 })
12831012 }
12841013 }
M internal/usecase/daemon/picker_test.go +2 -2
@@ -915,7 +915,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
915915 client := vt.NewScreen(80, 25)
916916 d.firstPaint(source, ac, ac.size)
917917 sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
918- ac.ackOutputState(sourceOutput.NewStateNum)
918+ ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New)
919919
920920 targetPane.screen.Write([]byte("TARGET"))
921921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
@@ -927,7 +927,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
927927 d.backSession(source, ac)
928928 firstReset := awaitFrame(t, sends, ports.MsgOutput)
929929 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")
931931 frame := strings.Join(frameRows(client.Frame), "\n")
932932 require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture")
933933 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 (
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/ports"
88 "github.com/bnema/vev/internal/usecase/keys"
9- "github.com/bnema/vev/internal/usecase/layout"
109 "github.com/bnema/vev/internal/usecase/mouse"
1110 "github.com/bnema/vev/internal/usecase/palette"
1211 )
@@ -222,39 +221,3 @@ func proxiedNavConfig(cfg domain.NavConfig) domain.NavConfig {
222221 }
223222
224223 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) {
216216 })
217217 }
218218
219-func TestProxyInputProxiedRemoteOwnershipGuards(t *testing.T) {
219+func TestProxyInputRemoteOwnershipGuards(t *testing.T) {
220220 _, proxy, _, link, _ := newProxyInputHarness(t)
221221 require.True(t, proxy.caps.cannotAcceptMoves, "a proxy must reject move destinations locally")
222222 require.True(t, proxy.caps.cannotYieldMoves, "a proxy must reject move sources locally")
@@ -355,9 +355,6 @@ func TestProxyAttachedCommandCorrelatesInterleavedWrongAndLateResults(t *testing
355355 require.NoError(t, err)
356356 require.Equal(t, proxyLinkResume, result)
357357 require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type)
358- result, err = d.handleLinkFrame(proxy, generation, proxyMeta(proxy.key.Name))
359- require.NoError(t, err)
360- require.Equal(t, proxyLinkResume, result)
361358 result, err = d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})})
362359 require.NoError(t, err)
363360 require.Equal(t, proxyLinkResume, result)
@@ -499,7 +496,7 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T)
499496 request := requireProxyCommandRequest(t, link)
500497 _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
501498 oldGeneration := proxy.linkGeneration
502- newGeneration, _ := proxy.installTransport(newProxyTestTransport(), false)
499+ newGeneration, _ := proxy.installTransport(newProxyTestTransport())
503500 require.Greater(t, newGeneration, oldGeneration)
504501 require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))
505502 _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
@@ -509,7 +506,6 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T)
509506
510507 func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *testing.T) {
511508 d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY())
512- ac.proxied = true
513509 second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
514510 publishTiledPaneOwners(sess, second)
515511 sess.mu.Lock()
@@ -600,9 +596,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t
600596 d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
601597 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
602598 transport := newProxyTestTransport()
603- transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
604- transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
605- transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
599+ transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
606600 factory := portsmocks.NewMockRemoteDialerFactory(t)
607601 dialer := portsmocks.NewMockDialer(t)
608602 var daemonLockAvailable, sourceLockAvailable bool
@@ -659,9 +653,7 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T)
659653 d.attachCoordinator(local, ac, true)
660654 key := domain.RemoteSessionKey{Host: "arch", Name: "enter"}
661655 transport := newProxyTestTransport()
662- transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
663- transport.recv <- proxyRecv{frame: proxyMeta(key.Name)}
664- transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
656+ transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
665657 d.remoteDialerFactory = newProxyConstructionFactory(transport)
666658 d.remoteTransportMode = ports.RemoteTransportUDP
667659 d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{
@@ -806,9 +798,7 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) {
806798 d.attachCoordinator(local, ac, true)
807799 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
808800 remote := newProxyTestTransport()
809- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
810- remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
811- remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
801+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
812802 d.remoteDialerFactory = newProxyConstructionFactory(remote)
813803 d.remoteTransportMode = ports.RemoteTransportUDP
814804
@@ -899,9 +889,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) {
899889
900890 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
901891 remote := newProxyTestTransport()
902- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
903- remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
904- remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
892+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
905893 d.remoteDialerFactory = newProxyConstructionFactory(remote)
906894 d.remoteTransportMode = ports.RemoteTransportUDP
907895 token := attachmentEffectForTest(t, local.attachmentToken(ac, client))
@@ -938,9 +926,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T)
938926
939927 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
940928 remote := newProxyTestTransport()
941- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
942- remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
943- remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
929+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
944930 d.remoteDialerFactory = newProxyConstructionFactory(remote)
945931 d.remoteTransportMode = ports.RemoteTransportUDP
946932
@@ -995,9 +981,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) {
995981
996982 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
997983 remote := newProxyTestTransport()
998- remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
999- remote.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1000- remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
984+ remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
1001985 d.remoteDialerFactory = newProxyConstructionFactory(remote)
1002986 d.remoteTransportMode = ports.RemoteTransportUDP
1003987
M internal/usecase/daemon/proxy_lifecycle_test.go +14 -46
@@ -146,30 +146,6 @@ func newRegisteredLifecycleProxy(t *testing.T, clock ports.Clock) (*Daemon, *pro
146146 return d, proxy, transport
147147 }
148148
149-func TestNewProxySessionUsesProxyScreenSizeLimit(t *testing.T) {
150- for _, test := range []struct {
151- name string
152- size domain.Size
153- valid bool
154- }{
155- {name: "largest valid content", size: domain.Size{Cols: 512, Rows: 514}, valid: true},
156- {name: "one cell over content limit", size: domain.Size{Cols: 512, Rows: 515}},
157- {name: "1000x1000", size: domain.Size{Cols: 1000, Rows: 1000}},
158- } {
159- t.Run(test.name, func(t *testing.T) {
160- proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, test.size)
161- if !test.valid {
162- require.Error(t, err)
163- return
164- }
165- require.NoError(t, err)
166- require.Equal(t, contentSize(test.size, false), proxy.contentSize)
167- require.Equal(t, test.size.Cols, proxy.screen.frame.Width)
168- require.Equal(t, test.size.Rows-tabChromeRows, proxy.screen.frame.Height)
169- })
170- }
171-}
172-
173149 func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) {
174150 clock := newProxyLifecycleClock()
175151 d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY())
@@ -255,7 +231,7 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
255231
256232 ac := &attachedClient{}
257233 proxy.sessionCore.mu.Lock()
258- proxy.sessionCore.registerAttachmentLocked(ac)
234+ proxy.registerAttachmentLocked(ac)
259235 proxy.sessionCore.mu.Unlock()
260236 d.cancelProxyWarmForAttachment(proxy, ac)
261237 awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete")
@@ -266,8 +242,8 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) {
266242 d.mu.Unlock()
267243
268244 proxy.sessionCore.mu.Lock()
269- for _, attachment := range proxy.sessionCore.snapshotAttachmentsLocked() {
270- proxy.sessionCore.unregisterAttachmentLocked(attachment)
245+ for _, attachment := range proxy.snapshotAttachmentsLocked() {
246+ proxy.unregisterAttachmentLocked(attachment)
271247 }
272248 proxy.sessionCore.mu.Unlock()
273249 d.armProxyWarm(proxy)
@@ -425,9 +401,7 @@ func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) {
425401 handshakeTimer := awaitTestValue(t, clock.timers, "stable resume handshake did not arm its timeout")
426402 require.Equal(t, proxyHandshakeTimeout, handshakeTimer.delay)
427403 _ = requireProxyHello(t, resumed)
428- resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume|ports.CapabilityProxied)}
429- resumed.recv <- proxyRecv{frame: proxyMeta(proxy.key.Name)}
430- resumed.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
404+ resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume)}
431405 require.Eventually(t, func() bool {
432406 proxy.mu.Lock()
433407 defer proxy.mu.Unlock()
@@ -466,11 +440,11 @@ func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) {
466440 require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session)
467441 }
468442
469-func TestIncomingDirectAttachPreservesProxiedRemoteAttachment(t *testing.T) {
443+func TestIncomingDirectAttachTerminatesRemoteAttachment(t *testing.T) {
470444 d := newTestDaemon(t, nil, newProxyLifecycleClock())
471445 sess := &session{sessionCore: sessionCore{id: "work", name: "work"}}
472446 oldTransport := newProxyTestTransport()
473- old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true}
447+ old := &attachedClient{tr: oldTransport, output: newOutputStateStream()}
474448 old.initOverlays()
475449 old.setSession(sess)
476450 sess.registerAttachment(old)
@@ -1192,9 +1166,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
11921166 key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
11931167 first := newProxyTestTransport()
11941168 second := newProxyTestTransport()
1195- second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityProxied)}
1196- second.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1197- second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1169+ second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityResume)}
11981170 factory := newProxyConstructionFactory(first, second)
11991171 d := newProxyTestDaemon(t, factory, nil)
12001172
@@ -1219,9 +1191,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) {
12191191 _ = requireProxyHello(t, second)
12201192 }
12211193
1222- first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
1223- first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1224- first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1194+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
12251195 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
12261196 waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish")
12271197 require.NoError(t, leaderResult.err)
@@ -1246,7 +1216,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
12461216 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
12471217 _ = requireProxyHello(t, first)
12481218
1249- base, cancel := context.WithCancel(context.Background())
1219+ base, cancel := context.WithCancel(t.Context())
12501220 observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})}
12511221 waiter := openProxyForTest(d, observed, key)
12521222 select {
@@ -1261,9 +1231,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
12611231 require.Nil(t, waiterResult.proxy)
12621232 require.Equal(t, 1, factory.callCount())
12631233
1264- first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)}
1265- first.recv <- proxyRecv{frame: proxyMeta(key.Name)}
1266- first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()}
1234+ first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)}
12671235 leaderResult := awaitTestValue(t, leader, "leader construction did not finish")
12681236 require.NoError(t, leaderResult.err)
12691237 stopProxy(t, leaderResult.proxy)
@@ -1325,7 +1293,7 @@ func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) {
13251293
13261294 func setProxyLifecycleClient(p *proxySession, ac *attachedClient) {
13271295 p.sessionCore.mu.Lock()
1328- p.sessionCore.registerAttachmentLocked(ac)
1296+ p.registerAttachmentLocked(ac)
13291297 p.sessionCore.mu.Unlock()
13301298 }
13311299
@@ -1441,7 +1409,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
14411409 d, p, clock := newProxyLifecycleFixture(t)
14421410 transport := newProxyTestTransport()
14431411 t.Cleanup(func() { _ = transport.Close() })
1444- generation, _ := p.installTransport(transport, false)
1412+ generation, _ := p.installTransport(transport)
14451413 token, timer := armWarmTimer(t, d, p, clock)
14461414
14471415 require.True(t, d.markProxyReplaced(p, generation, transport))
@@ -1465,7 +1433,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
14651433 d, p, clock := newProxyLifecycleFixture(t)
14661434 transport := newProxyTestTransport()
14671435 t.Cleanup(func() { _ = transport.Close() })
1468- generation, _ := p.installTransport(transport, false)
1436+ generation, _ := p.installTransport(transport)
14691437
14701438 require.True(t, d.markProxyReplaced(p, generation, transport))
14711439 timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
@@ -1482,7 +1450,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
14821450 d, p, _ := newProxyLifecycleFixture(t)
14831451 transport := newProxyTestTransport()
14841452 t.Cleanup(func() { _ = transport.Close() })
1485- generation, _ := p.installTransport(transport, false)
1453+ generation, _ := p.installTransport(transport)
14861454
14871455 require.False(t, d.markProxyReplaced(p, generation-1, transport))
14881456 require.False(t, proxyExpired(p))
M internal/usecase/daemon/proxy_link.go +48 -150
@@ -7,7 +7,6 @@ import (
77 "io"
88 "time"
99
10- "github.com/bnema/vev/internal/domain"
1110 "github.com/bnema/vev/internal/ports"
1211 )
1312
@@ -54,9 +53,9 @@ const (
5453 proxyLinkReplaced
5554 )
5655
57-// dialProxyHandshake installs a candidate transport, sends the proxied Hello
58-// through the session's sole sender, and requires Welcome followed immediately
59-// by valid authoritative metadata. The candidate is never registry-visible.
56+// dialProxyHandshake installs a candidate transport, sends an ordinary Hello
57+// through the session's sole sender, and requires a matching Welcome. The
58+// candidate is never registry-visible.
6059 func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, intent uint8) (err error) {
6160 if parent == nil {
6261 //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
9897 resumeToken := p.resumeToken
9998 clientID := p.clientID
10099 size := p.contentSize
101- stateFloor := p.appliedState
102100 p.mu.Unlock()
103101 resume := intent == ports.IntentResume
104102 if !resume {
105103 resumeToken = 0
106- stateFloor = 0
107104 }
108- generation, previous := p.installTransport(transport, resume)
105+ generation, previous := p.installTransport(transport)
109106 if previous != nil && previous != transport {
110107 _ = previous.Close()
111108 }
@@ -121,7 +118,6 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int
121118 hello := ports.Hello{
122119 Version: ports.ProtocolVersion,
123120 Intent: intent,
124- Proxied: true,
125121 ClientID: clientID,
126122 ResumeToken: resumeToken,
127123 Name: p.key.Name,
@@ -132,23 +128,13 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int
132128 return fmt.Errorf("proxy session: send hello: %w", err)
133129 }
134130
135- welcome, meta, snapshot, err := recvProxyHandshake(ctx, transport, p.key.Name, size, stateFloor)
131+ welcome, err := recvProxyHandshake(ctx, transport, p.key.Name)
136132 if err != nil {
137133 return err
138134 }
139135 if err := ctx.Err(); err != nil {
140136 return err
141137 }
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- }
152138
153139 p.mu.Lock()
154140 if p.linkGeneration != generation || p.transport != transport {
@@ -156,7 +142,6 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int
156142 return errors.New("proxy session: link replaced during handshake")
157143 }
158144 p.resumeToken = welcome.ResumeToken
159- p.replaceSessionMetaLocked(meta, d.clock.Now())
160145 p.linkState = ports.LinkStateConnected
161146 p.expired = false
162147 p.mu.Unlock()
@@ -230,43 +215,16 @@ func proxyHandshakeContextError(parent context.Context, timedOut <-chan struct{}
230215 return fallback
231216 }
232217
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) {
234219 frame, err := recvProxyHandshakeFrame(ctx, transport)
235220 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)
237222 }
238223 welcome, err := validateProxyWelcome(frame, sessionName)
239224 if err != nil {
240- return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, 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")
225+ return ports.Welcome{}, err
265226 }
266- if snapshot.Size != size {
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
227+ return welcome, nil
270228 }
271229
272230 func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) {
@@ -284,29 +242,12 @@ func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome,
284242 if err != nil {
285243 return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err)
286244 }
287- if welcome.Capabilities&ports.CapabilityProxied == 0 {
288- return ports.Welcome{}, errors.New("proxy session: remote did not negotiate proxied capability")
289- }
290245 if welcome.SessionID == "" || welcome.SessionName != sessionName {
291246 return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName)
292247 }
293248 return welcome, nil
294249 }
295250
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-
310251 func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
311252 result := make(chan proxyRecvResult, 1)
312253 go func() {
@@ -326,7 +267,7 @@ func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (po
326267 }
327268 }
328269
329-func (p *proxySession) installTransport(transport ports.Transport, resume bool) (uint64, ports.Transport) {
270+func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
330271 // Link replacement participates in the same sender serialization as every
331272 // frame. A request can therefore only be published wholly before the
332273 // generation changes or wholly after it.
@@ -336,17 +277,12 @@ func (p *proxySession) installTransport(transport ports.Transport, resume bool)
336277 previous := p.transport
337278 p.linkGeneration++
338279 generation := p.linkGeneration
339- if resume {
340- // Resume keeps the applied remote state as the handshake floor, but the
341- // replacement must prove that state with a fresh base-zero snapshot.
342- p.screenReady = false
343- p.resetRequested = false
344- } else {
345- p.resetOutputStateLocked()
346- }
280+ // Each transport must establish its ordinary output dependency with a
281+ // dependency-free full frame before incremental bytes are forwarded.
282+ p.resetOutputStateLocked()
347283 p.transport = transport
348- // Authentication is not complete until the authoritative screen snapshot
349- // has been applied and acknowledged.
284+ // Authentication is complete after the remote Welcome; ordinary output
285+ // establishes the dependency chain asynchronously.
350286 p.linkState = ports.LinkStateProbing
351287 p.mu.Unlock()
352288 p.failPendingCommandsLocked(errProxyCommandGeneration)
@@ -367,17 +303,6 @@ func (p *proxySession) retireTransport(generation uint64, transport ports.Transp
367303 }
368304 }
369305
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-
381306 func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
382307 p.sendMu.Lock()
383308 defer p.sendMu.Unlock()
@@ -651,54 +576,48 @@ func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generat
651576 }
652577 }
653578
654-// handleProxySideEffect fans out a stateless remote effect to every attachment
655-// currently owning this proxy. Each attachment gets an independent capability,
656-// effect ticket, output snapshot, and exact transport-incarnation fence.
579+// handleProxySideEffect forwards a stateless remote effect to every live local
580+// attachment. Connection admission and transport incarnation checks make a stale
581+// handoff a harmless drop rather than a send to the client's next session.
657582 func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error {
658583 if p == nil || !p.currentLinkGeneration(generation) {
659584 return nil
660585 }
586+ attachments := snapshotAttachmentSession(p)
661587 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+ }
663604 if !p.currentLinkGeneration(generation) {
664- break
605+ ticket.End()
606+ return nil
665607 }
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 {
667616 sendErr = err
668617 }
669618 }
670- return sendErr
671-}
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)
619+ if sendErr != nil {
620+ return fmt.Errorf("proxy session: side-effect send: %w: %w", errProxyLinkSend, sendErr)
702621 }
703622 return nil
704623 }
@@ -712,28 +631,7 @@ func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports
712631 if err != nil {
713632 return proxyLinkStop, err
714633 }
715- if out.BaseStateNum != 0 || out.NewStateNum != 0 {
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 {
634+ if err := d.handleProxyOutput(p, generation, out); err != nil {
737635 return proxyLinkStop, err
738636 }
739637 return proxyLinkResume, nil
M internal/usecase/daemon/proxy_output.go +68 -72
@@ -6,104 +6,100 @@ import (
66 "github.com/bnema/vev/internal/ports"
77 )
88
9-// applyScreenUpdateForGeneration applies one semantic screen update while
10-// holding only the proxy leaf lock. A link generation fence prevents a stale
11-// receive goroutine from advancing a replacement link's screen.
12-func (p *proxySession) applyScreenUpdateForGeneration(generation uint64, update ports.ScreenUpdate) (ack uint64, requestReset, changed bool) {
13- if p == nil {
14- return 0, false, false
15- }
9+// applyOutputForGeneration validates the remote ordinary output dependency
10+// chain without retaining a second terminal or renderer state. Bytes are
11+// forwarded only after a full frame establishes the current remote stream.
12+func (p *proxySession) applyOutputForGeneration(generation uint64, out ports.Output) (ack uint64, requestReset, changed bool) {
1613 p.mu.Lock()
1714 defer p.mu.Unlock()
18- if p.linkGeneration != generation || p.screen == nil {
15+ if p.linkGeneration != generation {
1916 return 0, false, false
2017 }
21-
22- // The wire codec validates all semantic bounds. Geometry is still checked
23- // here because it is attachment state, not merely a wire property: a
24- // remote paint for the wrong content rectangle cannot be applied locally.
25- if update.Size != p.contentSize {
26- return 0, p.requestScreenResetLocked(), false
18+ invalid := func() (uint64, bool, bool) {
19+ if p.outputResetRequested {
20+ return 0, false, false
21+ }
22+ p.outputResetRequested = true
23+ return 0, true, false
2724 }
28- if update.Kind == ports.ScreenUpdateSnapshot {
29- // A base-zero paint may only move the consumer forward. This floor is
30- // retained across resume and resize, so resetRequested never authorizes
31- // replaying a stale snapshot.
32- if update.NewStateNum <= p.appliedState {
33- return 0, p.requestScreenResetLocked(), false
25+ if out.Epoch == 0 {
26+ return invalid()
27+ }
28+ if out.New == 0 {
29+ // New == 0 is an ordinary byte side effect, not a state transition.
30+ // 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()
3434 }
35- if err := p.screen.Apply(update); err != nil {
36- return 0, p.requestScreenResetLocked(), false
35+ return 0, false, len(out.Data) != 0
36+ }
37+ if !p.outputReady {
38+ if out.Base != 0 || !out.Full {
39+ return invalid()
3740 }
38- p.appliedState = update.NewStateNum
39- p.screenReady = true
40- p.resetRequested = false
41- return update.NewStateNum, false, true
41+ p.outputEpoch = out.Epoch
42+ p.outputState = out.New
43+ p.outputReady = true
44+ p.outputResetRequested = false
45+ return out.New, false, len(out.Data) != 0
4246 }
43-
44- if !p.screenReady || p.resetRequested || update.BaseStateNum != p.appliedState || update.NewStateNum != update.BaseStateNum+1 {
45- return 0, p.requestScreenResetLocked(), false
47+ if out.Epoch < p.outputEpoch {
48+ return invalid()
4649 }
47- before := p.screen.cursorOut
48- if err := p.screen.Apply(update); err != nil {
49- return 0, p.requestScreenResetLocked(), false
50+ if out.Epoch == p.outputEpoch {
51+ if p.outputResetRequested || out.Full || out.Base != p.outputState || out.New != out.Base+1 {
52+ return invalid()
53+ }
54+ p.outputState = out.New
55+ return out.New, false, len(out.Data) != 0
5056 }
51- p.appliedState = update.NewStateNum
52- return update.NewStateNum, false, update.Scroll != nil || len(update.Spans) != 0 || before != update.Cursor
53-}
54-
55-func (p *proxySession) requestScreenResetLocked() bool {
56- if p.resetRequested {
57- p.screenReady = false
58- return false
57+ if out.Base != 0 || !out.Full {
58+ return invalid()
5959 }
60- p.screenReady = false
61- p.resetRequested = true
62- return true
60+ p.outputEpoch = out.Epoch
61+ p.outputState = out.New
62+ p.outputResetRequested = false
63+ return out.New, false, len(out.Data) != 0
6364 }
6465
65-// handleProxyScreenUpdate completes screen handling after apply has released
66-// proxy.mu. Transport I/O and render invalidation never run under that lock.
67-func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, update ports.ScreenUpdate) error {
68- marks := d.newRuntimeMarkBatch()
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- }
66+// handleProxyOutput forwards ordinary remote terminal bytes to the local
67+// attachment and acknowledges a state-bearing frame only after forwarding.
68+func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error {
69+ ack, requestReset, changed := p.applyOutputForGeneration(generation, out)
7870 if requestReset {
7971 if err := p.sendGeneration(generation, ports.Frame{
8072 Type: ports.MsgOutputResetRequest,
8173 Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}),
8274 }); 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)
8476 }
85- marks.diagnostic(ports.RuntimeScreenResetRequested, 0, 0)
8677 }
87- // The reset send released proxy sendMu and apply released proxy.mu before
88- // observer I/O; a blocked sink cannot hold either protocol ownership lock.
89- marks.flush()
90- if !changed || !p.currentLinkGeneration(generation) {
91- return nil
78+ if changed {
79+ if err := d.handleProxySideEffect(p, generation, out); err != nil {
80+ return err
81+ }
9282 }
93-
94- for _, ac := range snapshotAttachmentSession(p) {
95- d.invalidateRender(p, ac, false, "proxy_output.go")
83+ if ack != 0 {
84+ payload, err := ports.MarshalAck(ports.Ack{Epoch: out.Epoch, State: ack})
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+ }
9694 }
9795 return nil
9896 }
9997
10098 func (p *proxySession) resetOutputStateLocked() {
101- p.appliedState = 0
102- p.screenReady = false
103- p.resetRequested = false
104- if p.screen != nil {
105- p.screen.stateNum = 0
106- }
99+ p.outputEpoch = 0
100+ p.outputState = 0
101+ p.outputReady = false
102+ p.outputResetRequested = false
107103 }
108104
109105 func (p *proxySession) currentLinkGeneration(generation uint64) bool {
M internal/usecase/daemon/proxy_output_test.go +113 -1069
@@ -1,1122 +1,166 @@
11 package daemon
22
33 import (
4- "context"
5- "io"
6- "log/slog"
7- "maps"
8- "sync"
9- "sync/atomic"
104 "testing"
11- "time"
125
136 "github.com/bnema/vev/internal/domain"
147 "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"
188 "github.com/stretchr/testify/require"
199 )
2010
21-var proxyHandshakeState atomic.Uint64
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 {
11+func newProxyOutputAttachment(t *testing.T, proxy *proxySession, id byte) (*attachedClient, *proxyTestTransport) {
19012 t.Helper()
191- factory := portsmocks.NewMockRemoteDialerFactory(t)
192- for _, transport := range transports {
193- dialer := portsmocks.NewMockDialer(t)
194- factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once()
195- dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once()
196- }
197- return factory
198-}
199-
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- }
13+ transport := newProxyTestTransport()
14+ ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: proxy.contentSize}
15+ ac.output.attachment = ac
16+ ac.clientID[0] = id
17+ ac.setSession(proxy)
18+ proxy.sessionCore.mu.Lock()
19+ require.True(t, proxy.registerAttachmentLocked(ac))
20+ proxy.sessionCore.mu.Unlock()
21+ return ac, transport
35622 }
35723
358-func TestProxyHandshakeRejectsInvalidWelcomeWithoutPublication(t *testing.T) {
24+func TestProxyOutputRejectsStaleStateAndRequestsOneReset(t *testing.T) {
35925 tests := []struct {
36026 name string
361- first ports.Frame
27+ state ports.Output
28+ stale ports.Output
36229 }{
363- {name: "missing proxied capability", first: proxyWelcome("work", 1, ports.CapabilityResume)},
364- {name: "malformed welcome", first: ports.Frame{Type: ports.MsgWelcome, Payload: []byte{0xff}}},
365- {name: "wrong first type", first: proxyMeta("work")},
30+ {
31+ name: "duplicate full",
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+ },
36645 }
36746 for _, tt := range tests {
36847 t.Run(tt.name, func(t *testing.T) {
369- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
370- tr := newProxyTestTransport()
371- tr.recv <- proxyRecv{frame: tt.first}
372- d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{})
373-
374- proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24})
375- require.Error(t, err)
376- require.Nil(t, proxy)
377- require.Empty(t, proxySessionSnapshot(d))
378- select {
379- case <-tr.closed:
380- default:
381- t.Fatal("failed handshake transport was not closed")
382- }
48+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
49+ require.NoError(t, err)
50+ proxy.linkGeneration = 1
51+ ack, reset, changed := proxy.applyOutputForGeneration(1, tt.state)
52+ require.Equal(t, uint64(4), ack)
53+ require.False(t, reset)
54+ require.Equal(t, len(tt.state.Data) != 0, changed)
55+
56+ ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale)
57+ require.Zero(t, ack)
58+ require.True(t, reset)
59+ require.False(t, changed)
60+ ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale)
61+ require.Zero(t, ack)
62+ require.False(t, reset)
63+ require.False(t, changed)
38364 })
38465 }
38566 }
38667
387-func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) {
388- firstKey := domain.RemoteSessionKey{Host: "build.example", Name: "work"}
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})
68+func TestProxyOutputFansOutToEveryLiveAttachment(t *testing.T) {
69+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
49170 require.NoError(t, err)
492- firstHello := requireProxyHello(t, first)
493- require.Equal(t, ports.IntentAttach, firstHello.Intent)
494- handshakeTimer := awaitTestValue(t, clock.timers, "proxy handshake timeout was not registered")
495- require.Equal(t, proxyHandshakeTimeout, handshakeTimer.duration)
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})}}
71+ remote := newProxyTestTransport()
72+ proxy.transport = remote
73+ proxy.linkGeneration = 1
74+ d := &Daemon{}
50975
510- select {
511- case <-proxy.done:
512- case <-time.After(time.Second):
513- t.Fatal("replacement did not terminate proxy link loop")
76+ attachments := make([]*proxyTestTransport, 0, 2)
77+ for id := byte(1); id <= 2; id++ {
78+ _, clientTransport := newProxyOutputAttachment(t, proxy, id)
79+ attachments = append(attachments, clientTransport)
51480 }
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{})}}
52881
529- result := d.runProxyTransport(context.Background(), proxy, generation, transport)
530- require.Equal(t, proxyLinkStop, result)
531- select {
532- case <-transport.closed:
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})
82+ result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
83+ Type: ports.MsgOutput,
84+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
85+ })
54986 require.NoError(t, err)
550- _ = requireProxyHello(t, tr)
551- var wg sync.WaitGroup
552- errs := make(chan error, 8)
553- for range 8 {
554- wg.Go(func() {
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 {
87+ require.Equal(t, proxyLinkResume, result)
88+ for _, transport := range attachments {
89+ frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive output")
90+ require.Equal(t, ports.MsgOutput, frame.Type)
91+ out, err := ports.UnmarshalOutput(frame.Payload)
56492 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)
56595 }
566- require.False(t, tr.concurrent.Load(), "Transport.Send calls overlapped")
567- stopProxy(t, proxy)
96+ ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive output acknowledgement")
97+ require.Equal(t, ports.MsgAck, ackFrame.Type)
56898 }
56999
570-func TestProxyHandshakeRejectsMissingInitialScreenSnapshotWithoutPublication(t *testing.T) {
571- key := domain.RemoteSessionKey{Host: "arch", Name: "work"}
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)
100+func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) {
101+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
621102 require.NoError(t, err)
622- require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot))
623- _ = awaitTestValue(t, first.sent, "initial screen was not acknowledged")
624- proxy.mu.Lock()
625- applied := proxy.appliedState
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)
103+ remote := newProxyTestTransport()
104+ proxy.transport = remote
105+ proxy.linkGeneration = 1
106+ d := &Daemon{}
649107
650- third := newProxyTestTransport()
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-}
108+ _, firstTransport := newProxyOutputAttachment(t, proxy, 1)
666109
667-func TestProxyScreenStaleSnapshotsAndApplyFailuresRequestOneResetWithoutMutation(t *testing.T) {
668- newProxy := func(t *testing.T) (*Daemon, *proxySession, uint64) {
110+ handle := func(output ports.Output) {
669111 t.Helper()
670- d, proxy, _, generation := newProxyOutputSession(t)
671- return d, proxy, generation
672- }
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- })
112+ result, err := d.handleLinkFrame(proxy, 1, ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)})
113+ require.NoError(t, err)
114+ require.Equal(t, proxyLinkResume, result)
836115 }
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")
837119
838- t.Run("awaited base-zero snapshot advances the floor", func(t *testing.T) {
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-}
120+ _, secondTransport := newProxyOutputAttachment(t, proxy, 2)
934121
935-// TestProxyLinkProtocolErrorStillStops fences malformed MsgOutput rejection
936-// separately from resumable structured screen reply failures.
937-func TestProxyLinkProtocolErrorStillStops(t *testing.T) {
938- d, proxy, transport, generation := newProxyOutputSession(t)
939- t.Cleanup(func() { _ = transport.Close() })
940- transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}
941-
942- require.Equal(t, proxyLinkStop, d.runProxyTransport(context.Background(), proxy, generation, transport))
943-}
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:
122+ handle(ports.Output{Epoch: 4, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("current full")})
123+ for _, transport := range []*proxyTestTransport{firstTransport, secondTransport} {
124+ frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive current output")
125+ require.Equal(t, ports.MsgOutput, frame.Type)
126+ out, err := ports.UnmarshalOutput(frame.Payload)
127+ require.NoError(t, err)
128+ require.Zero(t, out.New)
129+ require.False(t, out.Full, "local proxy side effects must remain stateless")
130+ require.Equal(t, []byte("current full"), out.Data)
987131 }
132+ ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive current acknowledgement")
133+ require.Equal(t, ports.MsgAck, ackFrame.Type)
988134 }
989135
990-func TestProxySideEffectFansOutToEveryActiveAttachment(t *testing.T) {
991- d := newTestDaemon(t, nil, stubClock{})
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)
136+func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) {
137+ proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24})
1006138 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{})
1027139 remote := newProxyTestTransport()
1028- staleTransport := newProxyTestTransport()
1029- proxy, stale := newAttachedProxyFixture(t, d, staleTransport, remote)
1030- liveTransport := newProxyTestTransport()
1031- live := &attachedClient{tr: liveTransport, output: newOutputStateStream()}
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)
140+ proxy.transport = remote
141+ proxy.linkGeneration = 1
142+ _, clientTransport := newProxyOutputAttachment(t, proxy, 1)
143+ d := &Daemon{}
1041144
1042- result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect")))
1043- require.Equal(t, proxyLinkResume, result)
145+ _, err = d.handleLinkFrame(proxy, 1, ports.Frame{
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+ })
1044149 require.NoError(t, err)
1045- awaitTestValue(t, liveTransport.sent, "live attachment did not receive side effect")
1046- select {
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"))
150+ _ = awaitTestValue(t, clientTransport.sent, "proxy attachment did not receive initial output")
151+ _ = awaitTestValue(t, remote.sent, "proxy remote did not receive initial acknowledgement")
1086152
1087- result, err := d.handleLinkFrame(proxy, 0, frame)
1088- require.Equal(t, proxyLinkResume, result)
153+ result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
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+ })
1089157 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)
1102158 require.Equal(t, proxyLinkResume, result)
1103- require.NoError(t, err)
1104159 select {
1105- case forwarded := <-thin.sent:
1106- t.Fatalf("stale attachment role forwarded side effect: %v", forwarded.Type)
160+ case frame := <-clientTransport.sent:
161+ t.Fatalf("stale proxy output forwarded as %v", frame.Type)
1107162 default:
1108163 }
1109-}
1110-
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))
164+ reset := awaitTestValue(t, remote.sent, "proxy remote did not receive reset request")
165+ require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
1122166 }
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 (
1313 "github.com/bnema/vev/internal/ports"
1414 )
1515
16-// proxySession is the local attachment identity for one structured remote
17-// session key. mu is a leaf lock: callers may take it below d.mu or sessionCore
16+// 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
1817 // locks, but must not acquire another architecture lock or perform transport I/O
1918 // while holding it. All Transport.Send calls are owned by sendMu.
2019 type proxySession struct {
2120 sessionCore
2221 key domain.RemoteSessionKey
2322
24- mu sync.Mutex
25- generation uint64
26- linkGeneration uint64
27- transport ports.Transport
28- resumeToken uint64
29- appliedState uint64
30- // screenReady is true only after a moving snapshot establishes the
31- // dependency required by subsequent deltas.
32- screenReady bool
33- resetRequested bool
34- screen *proxyScreenState
35- meta ports.SessionMeta
36- attentionAt []time.Time
37- linkState ports.LinkState
38- expired bool
39- warm *proxyWarmTimer
40- cancel context.CancelFunc
41- done chan struct{}
42- doneOnce sync.Once
43- clientID [16]byte
44- contentSize domain.Size
23+ mu sync.Mutex
24+ generation uint64
25+ linkGeneration uint64
26+ transport ports.Transport
27+ resumeToken uint64
28+ outputEpoch uint64
29+ outputState uint64
30+ outputReady bool
31+ outputResetRequested bool
32+ meta ports.SessionMeta
33+ attentionAt []time.Time
34+ linkState ports.LinkState
35+ expired bool
36+ warm *proxyWarmTimer
37+ cancel context.CancelFunc
38+ done chan struct{}
39+ doneOnce sync.Once
40+ clientID [16]byte
41+ contentSize domain.Size
4542
4643 sendMu sync.Mutex
4744 inputNext uint64
@@ -61,73 +58,44 @@ func (p *proxySession) core() *sessionCore {
6158
6259 func (p *proxySession) isProxy() bool { return true }
6360
64-func (p *proxySession) sessionMetaSnapshot() (ports.SessionMeta, bool) {
65- if p == nil {
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
61+func (p *proxySession) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) {
62+ return nil, false
9363 }
9464
95-// applyProxySessionMeta validates and atomically applies relayed metadata only
96-// for the exact current link generation. A remote session rename cannot mutate
97-// the immutable local identity, so it expires that exact proxy instead.
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")
65+func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
66+ if p == nil || !size.Valid() {
67+ return false, false
10168 }
102- if _, err := ports.MarshalSessionMeta(meta); err != nil {
103- return false, fmt.Errorf("proxy session: invalid metadata: %w", err)
69+ payload, err := ports.MarshalResize(ports.Resize{Size: size})
70+ if err != nil {
71+ return false, false
10472 }
105- meta = cloneSessionMeta(meta)
106- now := d.clock.Now()
107-
10873 p.mu.Lock()
109- if p.linkGeneration != generation {
110- p.mu.Unlock()
111- return false, nil
112- }
113- if meta.SessionName != p.key.Name {
114- transport := p.transport
74+ if p.contentSize == size {
75+ available := p.transport != nil
11576 p.mu.Unlock()
116- if transport != nil {
117- d.markProxyReplaced(p, generation, transport)
118- }
119- return false, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
77+ return false, available
12078 }
121- p.replaceSessionMetaLocked(meta, now)
79+ p.contentSize = size
80+ generation := p.linkGeneration
12281 p.mu.Unlock()
82+ return true, p.sendGeneration(generation, ports.Frame{
83+ Type: ports.MsgResize,
84+ Payload: payload,
85+ }) == nil
86+}
12387
124- d.pokeAttentionTicker()
125- for _, ac := range snapshotAttachmentSession(p) {
126- d.invalidateRender(p, ac, false, "proxy_session.go")
127- }
128- return true, nil
88+func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta {
89+ meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...)
90+ return meta
12991 }
13092
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.
13199 func (p *proxySession) snapshotView(opts viewOptions) sessionView {
132100 if p == nil {
133101 return sessionView{}
@@ -204,9 +172,8 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi
204172 if err := key.Validate(); err != nil {
205173 return nil, fmt.Errorf("proxy session key: %w", err)
206174 }
207- content := contentSize(size, false)
208- if size.Valid() && !validProxyScreenSize(content) {
209- return nil, fmt.Errorf("proxy session size: invalid content size %dx%d", content.Cols, content.Rows)
175+ if !size.Valid() {
176+ return nil, fmt.Errorf("proxy session size: invalid size %dx%d", size.Cols, size.Rows)
210177 }
211178 var clientID [16]byte
212179 if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil {
@@ -220,11 +187,10 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi
220187 },
221188 key: key,
222189 generation: 1,
223- screen: newProxyScreenState(content),
224190 linkState: ports.LinkStateConnected,
225191 done: make(chan struct{}),
226192 clientID: clientID,
227- contentSize: content,
193+ contentSize: size,
228194 commandPending: make(map[uint64]proxyCommandPending),
229195 }
230196 return p, nil
@@ -238,7 +204,7 @@ type proxyConstruction struct {
238204 var errProxySessionExpired = errors.New("proxy session: remote attachment has expired")
239205
240206 // openProxySession returns the exact already-published proxy for key, or elects
241-// one constructor for that structured key. Waiters never dial: they wait
207+// one constructor for that remote key. Waiters never dial: they wait
242208 // cancellably, then revalidate the exact registry winner under d.mu. The
243209 // reservation itself is held only under d.mu; dial and handshake I/O run after
244210 // 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)
281281 }
282282 }
283283
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-
295284 func (b *runtimeMarkBatch) flush() {
296285 if b == nil || b.observer == nil {
297286 return
@@ -479,15 +468,23 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
479468 }
480469
481470 func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
482- rows := min(r.Height, src.Height)
483- cols := min(r.Width, src.Width)
484- for y := range rows {
485- for x := range cols {
486- cell := src.At(x, y)
471+ startX, startY := max(r.X, 0), max(r.Y, 0)
472+ endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height)
473+ for y := startY; y < endY; y++ {
474+ sy := y - r.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)
487484 if dim {
488485 cell.Style = dimmer.Dim(cell.Style)
489486 }
490- dst.Set(r.X+x, r.Y+y, cell)
487+ dst.Set(x, y, cell)
491488 }
492489 }
493490 }
@@ -502,9 +499,12 @@ func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style
502499 if d.Dir == layout.Vertical {
503500 glyph = '─'
504501 }
505- y0 := d.Rect.Y + dy
506- for y := y0; y < y0+d.Rect.Height; y++ {
507- for x := d.Rect.X; x < d.Rect.X+d.Rect.Width; x++ {
502+ y0 := max(d.Rect.Y+dy, 0)
503+ y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height)
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++ {
508508 frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style})
509509 }
510510 }
M internal/usecase/daemon/render_capture.go +18 -6
@@ -35,17 +35,16 @@ type renderCaptureScratch struct {
3535 }
3636
3737 type damageReceipt struct {
38- pane *pane
39- proxy *proxySession
40- proxyScreen *proxyScreenState
41- generation uint64
38+ pane *pane
39+ generation uint64
4240 }
4341
4442 type capturedRenderState struct {
4543 attachment *attachedClient // identity only; never dereferenced by composition
4644 lease *attachmentLease
45+ view attachmentView
46+ window domain.Size
4747 reset bool
48- contentOnly bool
4948 layout capturedTabLayout
5049 panes []capturedPaneRenderState
5150 floating capturedFloatingRenderState
@@ -225,6 +224,13 @@ func captureLocalRenderState(
225224 return nil, false
226225 }
227226
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+ }
228234 scratch := &ac.renderScratch
229235 scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...)
230236 bars.status.tabs = scratch.statusTabs
@@ -266,8 +272,14 @@ func captureLocalRenderState(
266272 layoutSnap.dividers = scratch.dividers
267273 }
268274 state := &scratch.state
275+ view := ac.viewSnapshot()
276+ window := domain.Size{}
277+ if view.windowSet {
278+ window = ac.size
279+ }
269280 *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,
271283 styles: request.styles, styleGeneration: request.styleGeneration,
272284 overlays: overlays, preview: preview,
273285 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) {
11611161 },
11621162 {
11631163 file: "input.go",
1164- name: "proxied jump attention switches a local tab",
1164+ name: "jump attention switches a local tab",
11651165 tabs: 2,
1166- producer: "input.go",
1166+ producer: "attention.go",
11671167 run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) {
1168- ac.proxied = true
11691168 sess.tabs[1].attention = true
11701169 daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil)
11711170 require.Equal(t, 1, testAttachmentTabIndex(sess))
M internal/usecase/daemon/render_damage_receipt_test.go +27 -0
@@ -11,6 +11,33 @@ import (
1111 "github.com/stretchr/testify/require"
1212 )
1313
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+
1441 func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
1542 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
1643 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 ...
6868 scratch = scratchIn[0]
6969 }
7070 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+ }
7175 if width <= 0 || rows < 0 {
7276 return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset}
7377 }
@@ -75,10 +79,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
7579 // copied before incremental drawing so prepare/send failures cannot change it.
7680 contentY := 1
7781 frameHeight := rows + tabChromeRows
78- if state.contentOnly {
79- contentY = 0
80- frameHeight = rows
81- }
8282 canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight
8383 frame := scratch.frame
8484 if !canReuseFrame {
@@ -99,10 +99,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
9999 defaultDimmer := themeui.NewDimmer(state.theme)
100100 neutralBorder := styles.NeutralBorder
101101 inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
102- if !state.contentOnly {
103- drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
104- drawStatusBarState(frame.Row(rows+1), state.bars, styles)
105- }
102+ drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
103+ drawStatusBarState(frame.Row(rows+1), state.bars, styles)
106104 content := domain.Rect{Y: contentY, Width: width, Height: rows}
107105 if state.layout.valid {
108106 drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder))
@@ -166,7 +164,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
166164 frame, damage = composeCapturedCopyMode(state, frame, damage, content)
167165 frame, damage = composeCapturedOverlays(state, frame, damage)
168166 }
169- if !state.contentOnly && !full {
167+ if !full {
170168 if !sameCells(in.bars.top, baseFrame.Row(0)) {
171169 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
172170 }
@@ -188,23 +186,22 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
188186 cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive
189187 cursor := desiredCapturedCursor(cursorInputs, contentY)
190188 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 {
192- outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
193- } else {
194- outCache.bars.Reset()
195- }
189+ outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1))
196190 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
197191 }
198192
199193 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+ }
200197 style := styles.StatusBar
201198 if !focused {
202199 style = dimmer.Dim(neutralBorder)
203200 }
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++ {
205202 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
206203 }
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)
208205 }
209206
210207 func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut {
@@ -235,9 +232,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho
235232 styles = fallbackChromeStyles
236233 }
237234 rows := state.layout.area.Height
238- if !state.contentOnly {
239- rows += tabChromeRows
240- }
235+ rows += tabChromeRows
241236 size := domain.Size{Cols: state.layout.area.Width, Rows: rows}
242237 if snap.copySearchModel != nil {
243238 presentation := copySearchModal.Resolve(size)
@@ -428,13 +423,6 @@ func commitDamageReceipts(receipts []damageReceipt) {
428423 receipt.pane.mu.Unlock()
429424 continue
430425 }
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- }
438426 }
439427 }
440428
@@ -474,33 +462,19 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
474462 }
475463 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
476464 var (
477- preparedANSI *preparedOutput
478- preparedScreen *preparedStructuredOutput
479- err error
480- cursor cursorCandidate
481- data []byte
465+ preparedANSI *preparedOutput
466+ err error
467+ cursor cursorCandidate
468+ data []byte
482469 )
483- if ac.proxied {
484- screenOutput := ac.ensureScreenOutput()
485- if screenOutput == nil {
486- err = errors.New("proxied attachment has no structured output stream")
487- } else {
488- preparedScreen, err = screenOutput.prepare(composed.frame, composed.damage, composed.cursor, composed.reset, ac.echoAck.Load())
489- if err == nil {
490- data = preparedScreen.data
491- }
492- }
493- } else {
494- preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset)
495- if err == nil {
496- cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0)
497- data = append([]byte(nil), preparedANSI.data...)
498- data = append(data, cursor.data...)
499- }
470+ preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset)
471+ if err == nil {
472+ cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0)
473+ data = append([]byte(nil), preparedANSI.data...)
474+ data = append(data, cursor.data...)
500475 }
501476 endDiff(0, err == nil)
502477 if err != nil {
503- ac.discardProxyCapture()
504478 ac.sendMu.Unlock()
505479 d.log.Error("render draw failed", "err", err, "session", entry.core().name)
506480 // Without a coordinator reportError repaints synchronously. Suppress only
@@ -525,81 +499,54 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
525499 }
526500 var sendTr ports.Transport
527501 var sendErr error
528- // Metadata precedes any output bytes and is published on its own when the
529- // terminal frame is empty, so a proxied client never keeps a stale snapshot.
530- if len(data) > 0 || ac.proxied {
502+ if len(data) > 0 {
531503 sendTransport := ac.transportSnapshot()
532504 sendTr = sendTransport.transport
533- if ac.proxied {
534- if sess, ok := localSession(entry); ok {
535- sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.attachmentEffect)
536- } else if proxy, ok := entry.(*proxySession); ok {
537- meta, metaOK := proxy.sessionMetaSnapshot()
538- if !metaOK {
539- sendErr = errSessionMetaUnavailable
540- } else {
541- sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.attachmentEffect)
542- }
505+ endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
506+ if sendTr == nil {
507+ sendErr = errors.New("client transport is nil")
508+ } else {
509+ send := sendTr.Send
510+ if async, ok := sendTr.(ports.AsyncTransport); ok {
511+ send = async.SendAsync
543512 }
544- }
545- if len(data) > 0 {
546- endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data)))
547- if sendErr == nil && sendTr == nil {
548- sendErr = errors.New("client transport is nil")
513+ interruptible := false
514+ if marks.attachmentEffect != nil {
515+ interruptible = marks.attachmentEffect.beginTransportSend(sendTransport)
516+ if !interruptible {
517+ sendErr = errAttachmentTransition
518+ }
549519 }
550520 if sendErr == nil {
551- interruptible := false
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- }
521+ sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
575522 }
576- endEmit(uint64(len(data)), sendErr == nil)
577- if sendErr == nil && ac.proxied {
578- kind := ports.RuntimeScreenDelta
579- if preparedScreen.update.Kind == ports.ScreenUpdateSnapshot {
580- kind = ports.RuntimeScreenSnapshot
523+ if marks.attachmentEffect != nil && interruptible {
524+ if sendErr != nil {
525+ marks.attachmentEffect.reportTransportFailure(sendTransport)
581526 }
582- marks.diagnostic(kind, uint64(len(preparedScreen.data)), uint64(len(preparedScreen.update.Spans)))
527+ marks.attachmentEffect.endTransportSend()
583528 }
584529 }
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+ }
585536 }
586537 if sendErr == nil {
587538 if len(data) == 0 {
588- if ac.proxied {
589- preparedScreen.commitNoSend()
590- } else {
591- preparedANSI.commitNoSend()
539+ preparedANSI.commitNoSend()
540+ if !preparedANSI.sent {
541+ ac.sendMu.Unlock()
542+ return true
592543 }
593544 }
594545 // Publish only after output preparation and transport emission both
595546 // succeed. A cross-session transition may publish concurrently, but its
596547 // mandatory first-paint rebase waits for sendMu and therefore follows this
597548 // completed output transaction.
598- if ac.proxied {
599- ac.lastCursor = composed.cursor
600- } else {
601- ac.lastCursor = cursor.next
602- }
549+ ac.lastCursor = cursor.next
603550 ac.pipelineScratch = ac.pipelineCache
604551 ac.pipelineCache = composed.cache
605552
@@ -611,9 +558,6 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c
611558 ac.renderStages.emit()
612559 }
613560 }
614- if sendErr != nil {
615- ac.discardProxyCapture()
616- }
617561 ac.sendMu.Unlock()
618562 if sendErr != nil {
619563 // 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) {
5959 require.NoError(t, transport.Send(frame))
6060 output, err := ports.UnmarshalOutput(frame.Payload)
6161 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")
6363 terminal.Write(output.Data)
6464 }
6565 require.Equal(t, len(frames), transport.next)
@@ -128,6 +128,9 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
128128 t.Cleanup(release)
129129 d, sess, _, sends := newManualSessionWithPTYs(t, pty)
130130 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))
131134 ac.sendMu.Lock()
132135 ac.output.next = ac.output.maxOutstanding
133136 ac.output.acked = 0
@@ -252,8 +255,8 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
252255 require.Equal(t, uint64(1), ac.output.next)
253256 out, err := ports.UnmarshalOutput((<-sends).Payload)
254257 require.NoError(t, err)
255- require.Zero(t, out.BaseStateNum)
256- require.Equal(t, uint64(1), out.NewStateNum)
258+ require.Zero(t, out.Base)
259+ require.Equal(t, uint64(1), out.New)
257260 }
258261
259262 func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) {
M internal/usecase/daemon/render_test.go +1 -1
@@ -86,7 +86,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) {
8686 frame := awaitFrame(t, sends, ports.MsgOutput)
8787 output, err := ports.UnmarshalOutput(frame.Payload)
8888 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")
9090 select {
9191 case extra := <-sends:
9292 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
459459 oldSnap transportSnapshot
460460 credentialMatch bool
461461 clientMismatch bool
462- modeMismatch bool
463462 )
464463 if sess != nil {
465464 sess.mu.Lock()
@@ -470,8 +469,6 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
470469 ac = candidate
471470 if ac.clientID != h.ClientID {
472471 clientMismatch = true
473- } else if ac.proxied != h.Proxied {
474- modeMismatch = true
475472 } else {
476473 oldSnap = ac.transportSnapshot()
477474 credentialMatch = oldSnap.transport != nil
@@ -487,11 +484,8 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma
487484 }
488485 d.mu.Unlock()
489486
490- if clientMismatch || modeMismatch {
487+ if clientMismatch {
491488 reason := "client id mismatch"
492- if modeMismatch {
493- reason = "proxied mode mismatch"
494- }
495489 d.log.Warn("resume rejected", "session", sess.name, "err", reason)
496490 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
497491 }
@@ -603,11 +597,8 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
603597 if parked.claimed {
604598 return nil, nil, false, errResumeTokenLifecycleRace
605599 }
606- if ac.clientID != h.ClientID || ac.proxied != h.Proxied {
600+ if ac.clientID != h.ClientID {
607601 reason := "client id mismatch"
608- if ac.proxied != h.Proxied {
609- reason = "proxied mode mismatch"
610- }
611602 d.log.Warn("resume rejected", "session", sess.name, "err", reason)
612603 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
613604 }
M internal/usecase/daemon/resume_test.go +19 -18
@@ -280,7 +280,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) {
280280 output := awaitFrame(t, tr.sends, ports.MsgOutput)
281281 first, err := ports.UnmarshalOutput(output.Payload)
282282 require.NoError(t, err)
283- require.Zero(t, first.BaseStateNum)
283+ require.Zero(t, first.Base)
284284 tr.finish()
285285 <-done
286286 requireNoCoordinatorOutputFrame(t, tr.sends)
@@ -974,9 +974,10 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
974974 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
975975 ac := &attachedClient{output: newOutputStateStream()}
976976 ac.output.next = 3
977- ac.ackOutputState(3)
978- ac.ackOutputState(2)
979- ac.ackOutputState(4)
977+ epoch := ac.output.currentEpoch()
978+ ac.ackOutputState(epoch, 3)
979+ ac.ackOutputState(epoch, 2)
980+ ac.ackOutputState(epoch, 4)
980981 require.Equal(t, uint64(3), ac.output.acked, "stale or future ACKs must not move output state incorrectly")
981982
982983 ac.sendMu.Lock()
@@ -988,8 +989,8 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
988989 ac.sendMu.Unlock()
989990 out, err := ports.UnmarshalOutput(f.Payload)
990991 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(6), out.NewStateNum)
992+ require.Equal(t, uint64(5), out.Base, "output should remain incremental unless an explicit reset is requested")
993+ require.Equal(t, uint64(6), out.New)
993994
994995 ac.sendMu.Lock()
995996 reset = true
@@ -998,8 +999,8 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
998999 ac.sendMu.Unlock()
9991000 fullOut, err := ports.UnmarshalOutput(full.Payload)
10001001 require.NoError(t, err)
1001- require.Equal(t, uint64(0), fullOut.BaseStateNum)
1002- require.Equal(t, uint64(7), fullOut.NewStateNum)
1002+ require.Equal(t, uint64(0), fullOut.Base)
1003+ require.Equal(t, uint64(7), fullOut.New)
10031004 }
10041005
10051006 func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
@@ -1081,9 +1082,9 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
10811082 require.Len(t, sends, 1)
10821083 first, err := ports.UnmarshalOutput(sends[0].Payload)
10831084 require.NoError(t, err)
1084- require.Zero(t, first.BaseStateNum)
1085- require.Equal(t, uint64(maxUnackedOutputStates+1), first.NewStateNum)
1086- resumedAC.ackOutputState(first.NewStateNum)
1085+ require.Zero(t, first.Base)
1086+ require.Equal(t, uint64(1), first.New)
1087+ resumedAC.ackOutputState(first.Epoch, first.New)
10871088
10881089 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
10891090 d.paint(resumedSess, resumedAC, false, nil)
@@ -1091,7 +1092,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
10911092 require.Len(t, sends, 2)
10921093 second, err := ports.UnmarshalOutput(sends[1].Payload)
10931094 require.NoError(t, err)
1094- require.Equal(t, first.NewStateNum, second.BaseStateNum)
1095+ require.Equal(t, first.New, second.Base)
10951096 }
10961097
10971098 func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
@@ -1141,7 +1142,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) {
11411142 require.Len(t, sends, 1)
11421143 output, err := ports.UnmarshalOutput(sends[0].Payload)
11431144 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")
11451146 terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows)
11461147 terminal.Write(output.Data)
11471148 contents := strings.Join(frameRows(terminal.Frame), "\n")
@@ -1445,13 +1446,13 @@ func TestRawTerminalSideEffectsAreOutputStateNeutral(t *testing.T) {
14451446 require.NoError(t, err)
14461447 second, err := ports.UnmarshalOutput(sends[1].Payload)
14471448 require.NoError(t, err)
1448- require.Zero(t, first.BaseStateNum)
1449- require.Zero(t, first.NewStateNum)
1450- require.Zero(t, second.BaseStateNum)
1451- require.Zero(t, second.NewStateNum)
1449+ require.Zero(t, first.Base)
1450+ require.Zero(t, first.New)
1451+ require.Zero(t, second.Base)
1452+ require.Zero(t, second.New)
14521453 }
14531454
1454-func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) {
1455+func TestSequencedInputDoesNotPrematurelyEcho(t *testing.T) {
14551456 p, release := newBlockingPTY(t)
14561457 defer release()
14571458 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) {
184184 }
185185
186186 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...)
188188 }
189189
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) {
191191 env = copyEnvironment(env)
192192 if _, reserved := d.creating[name]; reserved {
193193 return nil, errSessionNameInUse
@@ -244,7 +244,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st
244244 if d.ptys == nil {
245245 return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable"))
246246 }
247- tbSize := contentSize(sz, proxied)
247+ tbSize := contentSize(sz)
248248 var names []string
249249 if len(restoredTabNames) > 0 {
250250 names = append([]string(nil), restoredTabNames[0]...)
@@ -425,7 +425,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name
425425 return errors.New("client detached")
426426 }
427427
428- newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied)
428+ newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env)
429429 d.mu.Unlock()
430430 if err != nil {
431431 return err
@@ -490,7 +490,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT
490490 cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env)
491491 source.mu.Unlock()
492492 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)
494494 return created, createErr
495495 },
496496 })
@@ -523,15 +523,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
523523 env := copyEnvironment(sess.env)
524524 attachments := sess.snapshotAttachmentsLocked()
525525 sess.mu.Unlock()
526- // A client-requested tab must use that client's negotiated viewport and
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)
526+ tbSize := contentSize(sz)
535527 tabStableID, paneStableID, err := d.newTabPaneStableIDs()
536528 if err != nil {
537529 return err
@@ -548,6 +540,10 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do
548540 return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err)
549541 }
550542 tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
543+ themeClient := ac
544+ if themeClient == nil && len(attachments) != 0 {
545+ themeClient = attachments[0]
546+ }
551547 if themeClient != nil {
552548 t := d.effectiveTheme(themeClient.getClientTheme())
553549 tb.mu.Lock()
@@ -653,19 +649,14 @@ func (tb *tab) closeAllPanes() {
653649 const tabChromeRows = 2
654650
655651 func tabSize(clientSize domain.Size) domain.Size {
656- return contentSize(clientSize, false)
652+ return contentSize(clientSize)
657653 }
658654
659-// contentSize maps the negotiated viewport to PTY geometry. A proxied client
660-// has already removed its local chrome rows, so the remote daemon must consume
661-// the received size verbatim instead of subtracting chrome a second time.
662-func contentSize(clientSize domain.Size, proxied bool) domain.Size {
655+// contentSize maps the negotiated viewport to PTY geometry.
656+func contentSize(clientSize domain.Size) domain.Size {
663657 if !clientSize.Valid() {
664658 clientSize = defaultSize
665659 }
666- if proxied {
667- return clientSize
668- }
669660 rows := max(clientSize.Rows-tabChromeRows, 1)
670661 return domain.Size{Cols: clientSize.Cols, Rows: rows}
671662 }
@@ -713,7 +704,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) {
713704 return
714705 }
715706 if owner.session != sess || owner.tab != tb {
716- d.log.Warn("pane owner mismatch", "pane", p.id)
717707 return
718708 }
719709 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
171171 Intent: ports.IntentAttach,
172172 Name: "work",
173173 ClientID: [16]byte{2},
174- Proxied: true,
175174 Size: domain.Size{Cols: 80, Rows: 24},
176175 }, secondTransport)
177176 require.NoError(t, err)
@@ -192,7 +191,7 @@ func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testi
192191 gotSize := tb.size
193192 pane := tb.focusedPane()
194193 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")
196195 assertPaneDefaultColors(t, pane, requesterTheme.Foreground, requesterTheme.Background)
197196
198197 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) {
15071507 frameInput([]byte("\x1b ")),
15081508 frameInput([]byte("CNT\r")),
15091509 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}})},
15111511 )
15121512
15131513 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) {
535535 // coordinator admits the epoch before any tab size, rectangle, VT screen,
536536 // snapshot dirtiness, or resize telemetry becomes visible.
537537 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) {
542538 if sess == nil {
543539 return nil, false
544540 }
545541 sess.layoutApplyMu.Lock()
546542 defer sess.layoutApplyMu.Unlock()
547543
548- target := contentSize(size, proxied)
544+ target := contentSize(size)
549545 for {
550546 if sess.ctx != nil && sess.ctx.Err() != nil {
551547 return nil, false
@@ -676,7 +672,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease *
676672 return false
677673 }
678674 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 {
680676 return rc.resizeCurrentForLease(epoch, ac, lease, true)
681677 })
682678 if !ok {
M internal/usecase/daemon/transactional_resize_test.go +1 -1
@@ -838,7 +838,7 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi
838838 require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame")
839839 output, err := ports.UnmarshalOutput(frame.Payload)
840840 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")
842842 select {
843843 case extra := <-frames:
844844 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) {
3636 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) {
3737 t.Fatal("second resize was not accepted")
3838 }
39- ac.ackOutputState(output.NewStateNum)
39+ ac.ackOutputState(output.Epoch, output.New)
4040 rc.notifyAck()
4141 second := awaitFrame(t, sends, ports.MsgOutput)
4242 if second.Type != ports.MsgOutput {
@@ -123,7 +123,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
123123 if err != nil {
124124 t.Fatalf("decode emitted output: %v", err)
125125 }
126- ac.ackOutputState(output.NewStateNum)
126+ ac.ackOutputState(output.Epoch, output.New)
127127
128128 pane := testAttachmentTab(sess).focusedPane()
129129 for _, lock := range []struct {
M internal/usecase/daemon/wire.go +11 -7
@@ -2,19 +2,16 @@
22 package daemon
33
44 import (
5+ "github.com/bnema/vev/internal/domain"
56 "github.com/bnema/vev/internal/ports"
67 )
78
89 func frameWelcome(s *session, ac *attachedClient) ports.Frame {
9- capabilities := ports.CapabilityResume
10- if ac.proxied {
11- capabilities |= ports.CapabilityProxied
12- }
1310 w := ports.Welcome{
1411 SessionID: string(s.id),
1512 SessionName: s.name,
1613 Ephemeral: s.ephemeral,
17- Capabilities: capabilities,
14+ Capabilities: ports.CapabilityResume,
1815 ResumeToken: ac.resumeToken,
1916 }
2017 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
@@ -24,8 +21,15 @@ func frameError(code uint16, text string) ports.Frame {
2421 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
2522 }
2623
27-func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) ports.Frame {
28- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: baseState, NewStateNum: state, EchoAck: echoAck, Data: b})}
24+func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) (ports.Frame, error) {
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
2933 }
3034
3135 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 12

Tags 11

↑↓ moveenter openesc close