detached df21af7

fix(review): enforce strict wire semantics

commit
df21af70676d05c7d9f5552646ea23a01fa14795
author
brice · · 2026-08-04 06:08 UTC
parent
ea70803
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
45 files · +647 -335
M internal/adapters/dgram/network_sim_test.go +15 -9
@@ -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{Base: 0, New: 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- Base: uint64(state),
533- New: 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)
@@ -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- Base: uint64(baseState),
856- New: 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
@@ -9,6 +9,7 @@ import (
9 "testing"9 "testing"
10 "time"10 "time"
11 11
12+ "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"13 "github.com/bnema/vev/internal/ports"
13 pdgram "github.com/bnema/vev/pkg/dgram"14 pdgram "github.com/bnema/vev/pkg/dgram"
14 )15 )
@@ -451,7 +452,7 @@ func TestTransportFloodClassification(t *testing.T) {
451 }452 }
452 }453 }
453 b.mu.Unlock()454 b.mu.Unlock()
454- primer := ports.MarshalOutput(ports.Output{Base: 0, New: 1, Data: []byte("primer")})455+ primer := mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("primer")})
455 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: primer}); err != nil {456 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: primer}); err != nil {
456 t.Fatal(err)457 t.Fatal(err)
457 }458 }
@@ -528,10 +529,12 @@ func TestTransportFloodClassification(t *testing.T) {
528 }529 }
529 })530 })
530 for state := 1; state < outputCount; state++ {531 for state := 1; state < outputCount; state++ {
531- payload := ports.MarshalOutput(ports.Output{532+ payload := mustMarshalOutput(ports.Output{
532- Base: uint64(state),533+ Epoch: 1,
533- New: uint64(state + 1),534+ Base: uint64(state),
534- Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes),535+ New: uint64(state + 1),
536+ Size: domain.Size{Cols: 1, Rows: 1},
537+ Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes),
535 })538 })
536 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {539 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
537 t.Fatal(err)540 t.Fatal(err)
@@ -851,10 +854,13 @@ func floodState(sender, receiver *Transport, link *simulatedLink) floodTransport
851 }854 }
852 855
853 func floodOutputPayload(baseState, mtu int) []byte {856 func floodOutputPayload(baseState, mtu int) []byte {
854- return ports.MarshalOutput(ports.Output{857+ return mustMarshalOutput(ports.Output{
855- Base: uint64(baseState),858+ Epoch: 1,
856- New: uint64(baseState + 1),859+ Base: uint64(baseState),
857- Data: make([]byte, floodRecordMTUs*mtu),860+ New: uint64(baseState + 1),
861+ Size: domain.Size{Cols: 1, Rows: 1},
862+ Full: baseState == 0,
863+ Data: make([]byte, floodRecordMTUs*mtu),
858 })864 })
859 }865 }
860 866
M internal/adapters/dgram/proxy_test.go +1 -1
@@ -105,7 +105,7 @@ func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testi
105105 }
106106
107107 input := ports.Frame{Type: ports.MsgInput, Payload: []byte("still flowing")}
108- ack := ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{State: 7})}
108+ ack := ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: 1, State: 7})}
109109 client.recv <- recvResult{frame: input}
110110 client.recv <- recvResult{frame: ack}
111111 for _, want := range []ports.Frame{input, ack} {
@@ -105,7 +105,7 @@ func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testi
105 }105 }
106 106
107 input := ports.Frame{Type: ports.MsgInput, Payload: []byte("still flowing")}107 input := ports.Frame{Type: ports.MsgInput, Payload: []byte("still flowing")}
108- ack := ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{State: 7})}108+ ack := ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: 1, State: 7})}
109 client.recv <- recvResult{frame: input}109 client.recv <- recvResult{frame: input}
110 client.recv <- recvResult{frame: ack}110 client.recv <- recvResult{frame: ack}
111 for _, want := range []ports.Frame{input, ack} {111 for _, want := range []ports.Frame{input, ack} {
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{Base: 1, New: 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) {
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10+ "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 pdgram "github.com/bnema/vev/pkg/dgram"12 pdgram "github.com/bnema/vev/pkg/dgram"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
@@ -113,7 +114,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
113 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil {114 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil {
114 t.Fatal(err)115 t.Fatal(err)
115 }116 }
116- large := ports.MarshalOutput(ports.Output{Base: 1, New: 2, Data: make([]byte, 24*a.mtu)})117+ large := mustMarshalOutput(ports.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 24*a.mtu)})
117 for range 2 {118 for range 2 {
118 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil {119 if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil {
119 t.Fatal(err)120 t.Fatal(err)
@@ -121,7 +122,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
121 }122 }
122 waitForManualTimers(t, clk, 4)123 waitForManualTimers(t, clk, 4)
123 previousPackets := len(aPC.in)124 previousPackets := len(aPC.in)
124- for tick := 0; tick < 128; tick++ {125+ for tick := range 128 {
125 clk.advance(initialPacingRTT)126 clk.advance(initialPacingRTT)
126 time.Sleep(time.Millisecond)127 time.Sleep(time.Millisecond)
127 packets := len(aPC.in)128 packets := len(aPC.in)
@@ -154,7 +155,7 @@ func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) {
154 done := make(chan error, 1)155 done := make(chan error, 1)
155 go func() { done <- a.Send(large) }()156 go func() { done <- a.Send(large) }()
156 start := clk.Now()157 start := clk.Now()
157- for tick := 0; tick < 128; tick++ {158+ for range 128 {
158 a.mu.Lock()159 a.mu.Lock()
159 p := a.pending[1]160 p := a.pending[1]
160 if p != nil && p.initialInFlight && !p.last.IsZero() {161 if p != nil && p.initialInFlight && !p.last.IsZero() {
@@ -220,11 +221,11 @@ func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *tes
220 }221 }
221 a.mu.Unlock()222 a.mu.Unlock()
222 go a.resendPending()223 go a.resendPending()
223- sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: make([]byte, 100*1024)})}224+ sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 100*1024)})}
224 done := make(chan error, 1)225 done := make(chan error, 1)
225 go func() { done <- a.SendSynchronous(sideEffect) }()226 go func() { done <- a.SendSynchronous(sideEffect) }()
226 227
227- for tick := 0; tick < 512; tick++ {228+ for range 512 {
228 clk.advance(initialPacingRTT)229 clk.advance(initialPacingRTT)
229 time.Sleep(time.Millisecond)230 time.Sleep(time.Millisecond)
230 select {231 select {
@@ -328,7 +329,7 @@ func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) {
328 frame ports.Frame329 frame ports.Frame
329 }{330 }{
330 {name: "control", frame: ports.Frame{Type: ports.MsgPing}},331 {name: "control", frame: ports.Frame{Type: ports.MsgPing}},
331- {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: []byte("osc")})}},332+ {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("osc")})}},
332 }333 }
333 for _, tt := range frames {334 for _, tt := range frames {
334 t.Run(tt.name, func(t *testing.T) {335 t.Run(tt.name, func(t *testing.T) {
A internal/adapters/dgram/wire_test_helpers_test.go +19 -0
@@ -0,0 +1,19 @@
1+package dgram
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalOutput(m ports.Output) []byte {
6+ payload, err := ports.MarshalOutput(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
12+
13+func mustMarshalAck(m ports.Ack) []byte {
14+ payload, err := ports.MarshalAck(m)
15+ if err != nil {
16+ panic(err)
17+ }
18+ return payload
19+}
@@ -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() {
@@ -140,7 +140,7 @@ func TestTransportManySmallFramesBackToBack(t *testing.T) {
140 }140 }
141 // Vary a couple to prove ordering and content are both preserved.141 // Vary a couple to prove ordering and content are both preserved.
142 frames[7] = ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("hop")})}142 frames[7] = ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("hop")})}
143- frames[150] = ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 5, Rows: 6}})}143+ frames[150] = ports.Frame{Type: ports.MsgResize, Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 5, Rows: 6}})}
144 144
145 var wg sync.WaitGroup145 var wg sync.WaitGroup
146 wg.Go(func() {146 wg.Go(func() {
A internal/adapters/ipc/wire_test_helpers_test.go +11 -0
@@ -0,0 +1,11 @@
1+package ipc
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalResize(m ports.Resize) []byte {
6+ payload, err := ports.MarshalResize(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
@@ -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/ports/wire.go +19 -45
@@ -27,6 +27,11 @@ var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
2727
2828 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
2929
30+const (
31+ outputHeaderLen = 5*8 + 2*2 + 1
32+ outputPayloadOverhead = outputHeaderLen + 4
33+)
34+
3035 // ErrInvalidSize reports a terminal size that cannot be represented by the
3136 // protocol or would exceed the bounded screen area.
3237 var ErrInvalidSize = errors.New("ports: invalid terminal size")
@@ -512,7 +517,7 @@ func ValidateOutput(m Output) error {
512517 if err := ValidateSize(m.Size); err != nil {
513518 return fmt.Errorf("%w: size", ErrInvalidOutput)
514519 }
515- if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxFrameLen-1-45-4 {
520+ if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxFrameLen-1-outputPayloadOverhead {
516521 return ErrInvalidOutput
517522 }
518523 return nil
@@ -568,13 +573,9 @@ func preflightHello(b []byte) error {
568573 if version == 21 {
569574 return ErrInvalidHello
570575 }
571- intent, err := r.getUint8()
572- if err != nil {
576+ if _, err := r.getUint8(); err != nil {
573577 return err
574578 }
575- if intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume {
576- return ErrInvalidHello
577- }
578579 if len(r.b) < 16 {
579580 return errShortPayload
580581 }
@@ -585,17 +586,12 @@ func preflightHello(b []byte) error {
585586 if err := r.skipString(); err != nil {
586587 return err
587588 }
588- cols, err := r.getUint16()
589- if err != nil {
589+ if _, err := r.getUint16(); err != nil {
590590 return err
591591 }
592- rows, err := r.getUint16()
593- if err != nil {
592+ if _, err := r.getUint16(); err != nil {
594593 return err
595594 }
596- if err := ValidateSize(domain.Size{Cols: int(cols), Rows: int(rows)}); err != nil {
597- return fmt.Errorf("%w: size", ErrInvalidHello)
598- }
599595 if err := r.skipString(); err != nil {
600596 return err
601597 }
@@ -638,9 +634,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
638634 if h.Version, err = r.getUint16(); err != nil {
639635 return Hello{}, err
640636 }
641- if h.Version == 21 {
642- return Hello{}, ErrInvalidHello
643- }
644637 if h.Intent, err = r.getUint8(); err != nil {
645638 return Hello{}, err
646639 }
@@ -664,12 +657,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
664657 return Hello{}, err
665658 }
666659 h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
667- if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
668- return Hello{}, ErrInvalidHello
669- }
670- if err := ValidateSize(h.Size); err != nil {
671- return Hello{}, fmt.Errorf("%w: size", ErrInvalidHello)
672- }
673660 if h.TermEnv, err = r.getString(); err != nil {
674661 return Hello{}, err
675662 }
@@ -777,14 +764,14 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
777764 }
778765
779766 // MarshalResize encodes m into a Resize message payload.
780-func MarshalResize(m Resize) []byte {
767+func MarshalResize(m Resize) ([]byte, error) {
781768 if err := ValidateSize(m.Size); err != nil {
782- return nil
769+ return nil, err
783770 }
784771 w := payloadWriter{}
785772 w.putUint16(uint16(m.Size.Cols))
786773 w.putUint16(uint16(m.Size.Rows))
787- return w.b
774+ return w.b, nil
788775 }
789776
790777 // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.
@@ -950,17 +937,14 @@ func UnmarshalPong(b []byte) (Pong, error) {
950937 }
951938
952939 // MarshalAck encodes m into an epoch/state Ack payload.
953-func MarshalAck(m Ack) []byte {
954- if m.Epoch == 0 {
955- m.Epoch = 1
956- }
940+func MarshalAck(m Ack) ([]byte, error) {
957941 if err := ValidateAck(m); err != nil {
958- return nil
942+ return nil, err
959943 }
960944 w := payloadWriter{}
961945 w.putUint64(m.Epoch)
962946 w.putUint64(m.State)
963- return w.b
947+ return w.b, nil
964948 }
965949
966950 // UnmarshalAck decodes an epoch/state Ack payload.
@@ -1055,18 +1039,9 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
10551039
10561040 // MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/
10571041 // size/full/data message layout.
1058-func MarshalOutput(m Output) []byte {
1059- if m.Epoch == 0 {
1060- m.Epoch = 1
1061- }
1062- if m.Size == (domain.Size{}) {
1063- m.Size = domain.Size{Cols: 1, Rows: 1}
1064- }
1065- if m.New != 0 && m.Base == 0 {
1066- m.Full = true
1067- }
1042+func MarshalOutput(m Output) ([]byte, error) {
10681043 if err := ValidateOutput(m); err != nil {
1069- return nil
1044+ return nil, err
10701045 }
10711046 w := payloadWriter{}
10721047 w.putUint64(m.Epoch)
@@ -1078,17 +1053,16 @@ func MarshalOutput(m Output) []byte {
10781053 w.putUint16(uint16(m.Size.Rows))
10791054 w.putBool(m.Full)
10801055 w.putLongBytes(m.Data)
1081- return w.b
1056+ return w.b, nil
10821057 }
10831058
10841059 // UnmarshalOutput strictly decodes one final Output payload. Header semantics
10851060 // are validated before the length-prefixed data is copied.
10861061 func UnmarshalOutput(b []byte) (Output, error) {
1087- const outputHeaderLen = 45
10881062 if len(b) > MaxFrameLen-1 {
10891063 return Output{}, ErrInvalidOutput
10901064 }
1091- if len(b) < outputHeaderLen+4 {
1065+ if len(b) < outputPayloadOverhead {
10921066 return Output{}, errShortPayload
10931067 }
10941068 r := payloadReader{b: b}
@@ -27,6 +27,11 @@ var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload")
27 27
28 var errInvalidBoolean = errors.New("ports: invalid boolean flag")28 var errInvalidBoolean = errors.New("ports: invalid boolean flag")
29 29
30+const (
31+ outputHeaderLen = 5*8 + 2*2 + 1
32+ outputPayloadOverhead = outputHeaderLen + 4
33+)
34+
30 // ErrInvalidSize reports a terminal size that cannot be represented by the35 // ErrInvalidSize reports a terminal size that cannot be represented by the
31 // protocol or would exceed the bounded screen area.36 // protocol or would exceed the bounded screen area.
32 var ErrInvalidSize = errors.New("ports: invalid terminal size")37 var ErrInvalidSize = errors.New("ports: invalid terminal size")
@@ -512,7 +517,7 @@ func ValidateOutput(m Output) error {
512 if err := ValidateSize(m.Size); err != nil {517 if err := ValidateSize(m.Size); err != nil {
513 return fmt.Errorf("%w: size", ErrInvalidOutput)518 return fmt.Errorf("%w: size", ErrInvalidOutput)
514 }519 }
515- if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxFrameLen-1-45-4 {520+ if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxFrameLen-1-outputPayloadOverhead {
516 return ErrInvalidOutput521 return ErrInvalidOutput
517 }522 }
518 return nil523 return nil
@@ -568,13 +573,9 @@ func preflightHello(b []byte) error {
568 if version == 21 {573 if version == 21 {
569 return ErrInvalidHello574 return ErrInvalidHello
570 }575 }
571- intent, err := r.getUint8()576+ if _, err := r.getUint8(); err != nil {
572- if err != nil {
573 return err577 return err
574 }578 }
575- if intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume {
576- return ErrInvalidHello
577- }
578 if len(r.b) < 16 {579 if len(r.b) < 16 {
579 return errShortPayload580 return errShortPayload
580 }581 }
@@ -585,17 +586,12 @@ func preflightHello(b []byte) error {
585 if err := r.skipString(); err != nil {586 if err := r.skipString(); err != nil {
586 return err587 return err
587 }588 }
588- cols, err := r.getUint16()589+ if _, err := r.getUint16(); err != nil {
589- if err != nil {
590 return err590 return err
591 }591 }
592- rows, err := r.getUint16()592+ if _, err := r.getUint16(); err != nil {
593- if err != nil {
594 return err593 return err
595 }594 }
596- if err := ValidateSize(domain.Size{Cols: int(cols), Rows: int(rows)}); err != nil {
597- return fmt.Errorf("%w: size", ErrInvalidHello)
598- }
599 if err := r.skipString(); err != nil {595 if err := r.skipString(); err != nil {
600 return err596 return err
601 }597 }
@@ -638,9 +634,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
638 if h.Version, err = r.getUint16(); err != nil {634 if h.Version, err = r.getUint16(); err != nil {
639 return Hello{}, err635 return Hello{}, err
640 }636 }
641- if h.Version == 21 {
642- return Hello{}, ErrInvalidHello
643- }
644 if h.Intent, err = r.getUint8(); err != nil {637 if h.Intent, err = r.getUint8(); err != nil {
645 return Hello{}, err638 return Hello{}, err
646 }639 }
@@ -664,12 +657,6 @@ func UnmarshalHello(b []byte) (Hello, error) {
664 return Hello{}, err657 return Hello{}, err
665 }658 }
666 h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}659 h.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
667- if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume {
668- return Hello{}, ErrInvalidHello
669- }
670- if err := ValidateSize(h.Size); err != nil {
671- return Hello{}, fmt.Errorf("%w: size", ErrInvalidHello)
672- }
673 if h.TermEnv, err = r.getString(); err != nil {660 if h.TermEnv, err = r.getString(); err != nil {
674 return Hello{}, err661 return Hello{}, err
675 }662 }
@@ -777,14 +764,14 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) {
777 }764 }
778 765
779 // MarshalResize encodes m into a Resize message payload.766 // MarshalResize encodes m into a Resize message payload.
780-func MarshalResize(m Resize) []byte {767+func MarshalResize(m Resize) ([]byte, error) {
781 if err := ValidateSize(m.Size); err != nil {768 if err := ValidateSize(m.Size); err != nil {
782- return nil769+ return nil, err
783 }770 }
784 w := payloadWriter{}771 w := payloadWriter{}
785 w.putUint16(uint16(m.Size.Cols))772 w.putUint16(uint16(m.Size.Cols))
786 w.putUint16(uint16(m.Size.Rows))773 w.putUint16(uint16(m.Size.Rows))
787- return w.b774+ return w.b, nil
788 }775 }
789 776
790 // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.777 // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload.
@@ -950,17 +937,14 @@ func UnmarshalPong(b []byte) (Pong, error) {
950 }937 }
951 938
952 // MarshalAck encodes m into an epoch/state Ack payload.939 // MarshalAck encodes m into an epoch/state Ack payload.
953-func MarshalAck(m Ack) []byte {940+func MarshalAck(m Ack) ([]byte, error) {
954- if m.Epoch == 0 {
955- m.Epoch = 1
956- }
957 if err := ValidateAck(m); err != nil {941 if err := ValidateAck(m); err != nil {
958- return nil942+ return nil, err
959 }943 }
960 w := payloadWriter{}944 w := payloadWriter{}
961 w.putUint64(m.Epoch)945 w.putUint64(m.Epoch)
962 w.putUint64(m.State)946 w.putUint64(m.State)
963- return w.b947+ return w.b, nil
964 }948 }
965 949
966 // UnmarshalAck decodes an epoch/state Ack payload.950 // UnmarshalAck decodes an epoch/state Ack payload.
@@ -1055,18 +1039,9 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
1055 1039
1056 // MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/1040 // MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/
1057 // size/full/data message layout.1041 // size/full/data message layout.
1058-func MarshalOutput(m Output) []byte {1042+func MarshalOutput(m Output) ([]byte, error) {
1059- if m.Epoch == 0 {
1060- m.Epoch = 1
1061- }
1062- if m.Size == (domain.Size{}) {
1063- m.Size = domain.Size{Cols: 1, Rows: 1}
1064- }
1065- if m.New != 0 && m.Base == 0 {
1066- m.Full = true
1067- }
1068 if err := ValidateOutput(m); err != nil {1043 if err := ValidateOutput(m); err != nil {
1069- return nil1044+ return nil, err
1070 }1045 }
1071 w := payloadWriter{}1046 w := payloadWriter{}
1072 w.putUint64(m.Epoch)1047 w.putUint64(m.Epoch)
@@ -1078,17 +1053,16 @@ func MarshalOutput(m Output) []byte {
1078 w.putUint16(uint16(m.Size.Rows))1053 w.putUint16(uint16(m.Size.Rows))
1079 w.putBool(m.Full)1054 w.putBool(m.Full)
1080 w.putLongBytes(m.Data)1055 w.putLongBytes(m.Data)
1081- return w.b1056+ return w.b, nil
1082 }1057 }
1083 1058
1084 // UnmarshalOutput strictly decodes one final Output payload. Header semantics1059 // UnmarshalOutput strictly decodes one final Output payload. Header semantics
1085 // are validated before the length-prefixed data is copied.1060 // are validated before the length-prefixed data is copied.
1086 func UnmarshalOutput(b []byte) (Output, error) {1061 func UnmarshalOutput(b []byte) (Output, error) {
1087- const outputHeaderLen = 45
1088 if len(b) > MaxFrameLen-1 {1062 if len(b) > MaxFrameLen-1 {
1089 return Output{}, ErrInvalidOutput1063 return Output{}, ErrInvalidOutput
1090 }1064 }
1091- if len(b) < outputHeaderLen+4 {1065+ if len(b) < outputPayloadOverhead {
1092 return Output{}, errShortPayload1066 return Output{}, errShortPayload
1093 }1067 }
1094 r := payloadReader{b: b}1068 r := payloadReader{b: b}
M internal/ports/wire_final_test.go +38 -9
@@ -3,6 +3,7 @@ package ports
33 import (
44 "bytes"
55 "encoding/binary"
6+ "errors"
67 "reflect"
78 "testing"
89
@@ -13,9 +14,13 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
1314 if ProtocolVersion != 23 {
1415 t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion)
1516 }
16- payload := MarshalHello(Hello{Version: 21, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
17- if _, err := UnmarshalHello(payload); err == nil {
18- t.Fatal("UnmarshalHello accepted protocol v21")
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)
1924 }
2025 }
2126
@@ -62,7 +67,10 @@ func TestFinalHelloSemanticValidation(t *testing.T) {
6267 func TestFinalResizeGoldenStrict(t *testing.T) {
6368 msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}}
6469 want := []byte{0, 80, 0, 24}
65- got := MarshalResize(msg)
70+ got, err := MarshalResize(msg)
71+ if err != nil {
72+ t.Fatalf("MarshalResize() error = %v", err)
73+ }
6674 if !bytes.Equal(got, want) {
6775 t.Fatalf("Resize bytes = %x, want %x", got, want)
6876 }
@@ -73,8 +81,8 @@ func TestFinalResizeGoldenStrict(t *testing.T) {
7381 assertAllPrefixesFail(t, got, UnmarshalResize)
7482 assertTrailingGarbageFails(t, got, UnmarshalResize)
7583 for _, size := range []domain.Size{{}, {Cols: 513, Rows: 512}} {
76- if got := MarshalResize(Resize{Size: size}); got != nil {
77- t.Fatalf("MarshalResize(%+v) = %x, want nil", size, got)
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)
7886 }
7987 }
8088 }
@@ -93,7 +101,10 @@ func TestFinalOutputGoldenStrict(t *testing.T) {
93101 0, 80, 0, 24, 1,
94102 0, 0, 0, 2, 'o', 'k',
95103 }
96- got := MarshalOutput(msg)
104+ got, err := MarshalOutput(msg)
105+ if err != nil {
106+ t.Fatalf("MarshalOutput() error = %v", err)
107+ }
97108 if !bytes.Equal(got, want) {
98109 t.Fatalf("Output bytes = %x, want %x", got, want)
99110 }
@@ -107,7 +118,19 @@ func TestFinalOutputGoldenStrict(t *testing.T) {
107118
108119 func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
109120 valid := Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true}
110- payload := MarshalOutput(valid)
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+ }
111134 for _, tt := range []struct {
112135 name string
113136 mutate func([]byte)
@@ -132,7 +155,10 @@ func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
132155 func TestFinalAckGoldenStrict(t *testing.T) {
133156 msg := Ack{Epoch: 2, State: 7}
134157 want := []byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 7}
135- got := MarshalAck(msg)
158+ got, err := MarshalAck(msg)
159+ if err != nil {
160+ t.Fatalf("MarshalAck() error = %v", err)
161+ }
136162 if !bytes.Equal(got, want) {
137163 t.Fatalf("Ack bytes = %x, want %x", got, want)
138164 }
@@ -145,6 +171,9 @@ func TestFinalAckGoldenStrict(t *testing.T) {
145171 if _, err := UnmarshalAck(make([]byte, 16)); err == nil {
146172 t.Fatal("UnmarshalAck accepted zero epoch")
147173 }
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+ }
148177 }
149178
150179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
@@ -3,6 +3,7 @@ package ports
3 import (3 import (
4 "bytes"4 "bytes"
5 "encoding/binary"5 "encoding/binary"
6+ "errors"
6 "reflect"7 "reflect"
7 "testing"8 "testing"
8 9
@@ -13,9 +14,13 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) {
13 if ProtocolVersion != 23 {14 if ProtocolVersion != 23 {
14 t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion)15 t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion)
15 }16 }
16- payload := MarshalHello(Hello{Version: 21, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})17+ payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}})
17- if _, err := UnmarshalHello(payload); err == nil {18+ if len(payload) < 2 {
18- t.Fatal("UnmarshalHello accepted protocol v21")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)
19 }24 }
20 }25 }
21 26
@@ -62,7 +67,10 @@ func TestFinalHelloSemanticValidation(t *testing.T) {
62 func TestFinalResizeGoldenStrict(t *testing.T) {67 func TestFinalResizeGoldenStrict(t *testing.T) {
63 msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}}68 msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}}
64 want := []byte{0, 80, 0, 24}69 want := []byte{0, 80, 0, 24}
65- got := MarshalResize(msg)70+ got, err := MarshalResize(msg)
71+ if err != nil {
72+ t.Fatalf("MarshalResize() error = %v", err)
73+ }
66 if !bytes.Equal(got, want) {74 if !bytes.Equal(got, want) {
67 t.Fatalf("Resize bytes = %x, want %x", got, want)75 t.Fatalf("Resize bytes = %x, want %x", got, want)
68 }76 }
@@ -73,8 +81,8 @@ func TestFinalResizeGoldenStrict(t *testing.T) {
73 assertAllPrefixesFail(t, got, UnmarshalResize)81 assertAllPrefixesFail(t, got, UnmarshalResize)
74 assertTrailingGarbageFails(t, got, UnmarshalResize)82 assertTrailingGarbageFails(t, got, UnmarshalResize)
75 for _, size := range []domain.Size{{}, {Cols: 513, Rows: 512}} {83 for _, size := range []domain.Size{{}, {Cols: 513, Rows: 512}} {
76- if got := MarshalResize(Resize{Size: size}); got != nil {84+ if got, err := MarshalResize(Resize{Size: size}); err == nil || got != nil {
77- t.Fatalf("MarshalResize(%+v) = %x, want nil", size, got)85+ t.Fatalf("MarshalResize(%+v) = (%x, %v), want nil payload and error", size, got, err)
78 }86 }
79 }87 }
80 }88 }
@@ -93,7 +101,10 @@ func TestFinalOutputGoldenStrict(t *testing.T) {
93 0, 80, 0, 24, 1,101 0, 80, 0, 24, 1,
94 0, 0, 0, 2, 'o', 'k',102 0, 0, 0, 2, 'o', 'k',
95 }103 }
96- got := MarshalOutput(msg)104+ got, err := MarshalOutput(msg)
105+ if err != nil {
106+ t.Fatalf("MarshalOutput() error = %v", err)
107+ }
97 if !bytes.Equal(got, want) {108 if !bytes.Equal(got, want) {
98 t.Fatalf("Output bytes = %x, want %x", got, want)109 t.Fatalf("Output bytes = %x, want %x", got, want)
99 }110 }
@@ -107,7 +118,19 @@ func TestFinalOutputGoldenStrict(t *testing.T) {
107 118
108 func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {119 func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
109 valid := Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true}120 valid := Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true}
110- payload := MarshalOutput(valid)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+ }
111 for _, tt := range []struct {134 for _, tt := range []struct {
112 name string135 name string
113 mutate func([]byte)136 mutate func([]byte)
@@ -132,7 +155,10 @@ func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) {
132 func TestFinalAckGoldenStrict(t *testing.T) {155 func TestFinalAckGoldenStrict(t *testing.T) {
133 msg := Ack{Epoch: 2, State: 7}156 msg := Ack{Epoch: 2, State: 7}
134 want := []byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 7}157 want := []byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 7}
135- got := MarshalAck(msg)158+ got, err := MarshalAck(msg)
159+ if err != nil {
160+ t.Fatalf("MarshalAck() error = %v", err)
161+ }
136 if !bytes.Equal(got, want) {162 if !bytes.Equal(got, want) {
137 t.Fatalf("Ack bytes = %x, want %x", got, want)163 t.Fatalf("Ack bytes = %x, want %x", got, want)
138 }164 }
@@ -145,6 +171,9 @@ func TestFinalAckGoldenStrict(t *testing.T) {
145 if _, err := UnmarshalAck(make([]byte, 16)); err == nil {171 if _, err := UnmarshalAck(make([]byte, 16)); err == nil {
146 t.Fatal("UnmarshalAck accepted zero epoch")172 t.Fatal("UnmarshalAck accepted zero epoch")
147 }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+ }
148 }177 }
149 178
150 func TestFinalAttachTargetGoldenStrict(t *testing.T) {179 func TestFinalAttachTargetGoldenStrict(t *testing.T) {
M internal/ports/wire_test.go +22 -8
@@ -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{
125127 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,8 +138,11 @@ 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) {
@@ -343,7 +348,10 @@ 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 }
@@ -612,7 +620,10 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
612620 msg := Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n")}
613621 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 }
@@ -632,7 +643,10 @@ func TestAckGoldenAndRoundTrip(t *testing.T) {
632643 msg := Ack{Epoch: 1, State: 0x0102030405060708}
633644 want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
634645
635- got := MarshalAck(msg)
646+ got, err := MarshalAck(msg)
647+ if err != nil {
648+ t.Fatalf("MarshalAck() error = %v", err)
649+ }
636650 if !bytes.Equal(got, want) {
637651 t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
638652 }
@@ -117,17 +117,19 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
117 117
118 func TestHelloEnvironmentCodec(t *testing.T) {118 func TestHelloEnvironmentCodec(t *testing.T) {
119 t.Run("literal wire layout", func(t *testing.T) {119 t.Run("literal wire layout", func(t *testing.T) {
120- got := MarshalHello(Hello{120+ msg := Hello{
121 Version: ProtocolVersion,121 Version: ProtocolVersion,
122+ Size: domain.Size{Cols: 1, Rows: 1},
122 Env: []string{"A=B", "XY=123"},123 Env: []string{"A=B", "XY=123"},
123- })124+ }
125+ got := MarshalHello(msg)
124 want := []byte{126 want := []byte{
125 0x00, 0x17, 0x00, // version, intent127 0x00, 0x17, 0x00, // version, intent
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID129 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token130 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
129 0x00, 0x00, // name131 0x00, 0x00, // name
130- 0x00, 0x00, 0x00, 0x00, // size132+ 0x00, 0x01, 0x00, 0x01, // size
131 0x00, 0x00, // TERM133 0x00, 0x00, // TERM
132 0x00, 0x00, // cwd134 0x00, 0x00, // cwd
133 0x00, 0x00, // true color, max output in flight135 0x00, 0x00, // true color, max output in flight
@@ -136,8 +138,11 @@ func TestHelloEnvironmentCodec(t *testing.T) {
136 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',138 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3',
137 }139 }
138 require.Equal(t, want, got)140 require.Equal(t, want, got)
139- assertAllPrefixesFail(t, got, UnmarshalHello)141+ decoded, err := UnmarshalHello(want)
140- assertTrailingGarbageFails(t, got, UnmarshalHello)142+ require.NoError(t, err)
143+ require.Equal(t, msg, decoded)
144+ assertAllPrefixesFail(t, want, UnmarshalHello)
145+ assertTrailingGarbageFails(t, want, UnmarshalHello)
141 })146 })
142 147
143 t.Run("lossless order and values", func(t *testing.T) {148 t.Run("lossless order and values", func(t *testing.T) {
@@ -343,7 +348,10 @@ func TestResizeGoldenAndRoundTrip(t *testing.T) {
343 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}348 msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}}
344 want := []byte{0x00, 0x64, 0x00, 0x28}349 want := []byte{0x00, 0x64, 0x00, 0x28}
345 350
346- got := MarshalResize(msg)351+ got, err := MarshalResize(msg)
352+ if err != nil {
353+ t.Fatalf("MarshalResize() error = %v", err)
354+ }
347 if !bytes.Equal(got, want) {355 if !bytes.Equal(got, want) {
348 t.Fatalf("MarshalResize() = %#v, want %#v", got, want)356 t.Fatalf("MarshalResize() = %#v, want %#v", got, want)
349 }357 }
@@ -612,7 +620,10 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
612 msg := Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n")}620 msg := Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n")}
613 want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 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}621 want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x06, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
614 622
615- got := MarshalOutput(msg)623+ got, err := MarshalOutput(msg)
624+ if err != nil {
625+ t.Fatalf("MarshalOutput() error = %v", err)
626+ }
616 if !bytes.Equal(got, want) {627 if !bytes.Equal(got, want) {
617 t.Fatalf("MarshalOutput() = %#v, want %#v", got, want)628 t.Fatalf("MarshalOutput() = %#v, want %#v", got, want)
618 }629 }
@@ -632,7 +643,10 @@ func TestAckGoldenAndRoundTrip(t *testing.T) {
632 msg := Ack{Epoch: 1, State: 0x0102030405060708}643 msg := Ack{Epoch: 1, State: 0x0102030405060708}
633 want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}644 want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
634 645
635- got := MarshalAck(msg)646+ got, err := MarshalAck(msg)
647+ if err != nil {
648+ t.Fatalf("MarshalAck() error = %v", err)
649+ }
636 if !bytes.Equal(got, want) {650 if !bytes.Equal(got, want) {
637 t.Fatalf("MarshalAck() = %#v, want %#v", got, want)651 t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
638 }652 }
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{Base: 0, New: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
15- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Base: 1, New: 2, Echo: 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
@@ -7,12 +7,21 @@ import (
7 "reflect"7 "reflect"
8 "testing"8 "testing"
9 9
10+ "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 )12 )
12 13
14+func mustMarshalOutput(m ports.Output) []byte {
15+ payload, err := ports.MarshalOutput(m)
16+ if err != nil {
17+ panic(err)
18+ }
19+ return payload
20+}
21+
13 var transcript = []ports.Frame{22 var transcript = []ports.Frame{
14- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Base: 0, New: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},23+ {Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})},
15- {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Base: 1, New: 2, Echo: 7, Data: []byte("\x1b[2;1HTWO")})},24+ {Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Base: 1, New: 2, Echo: 7, Size: domain.Size{Cols: 80, Rows: 24}, Data: []byte("\x1b[2;1HTWO")})},
16 }25 }
17 26
18 // Transcript returns a deep copy of the canonical replay transcript so an27 // Transcript returns a deep copy of the canonical replay transcript so an
M internal/usecase/client/ack_queue_test.go +3 -1
@@ -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,6 +37,7 @@ 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)
40+ require.Equal(t, uint64(epoch), ack.Epoch)
3941 require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.State)
4042 }
4143
@@ -21,9 +21,10 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) {
21 for range cap(normal) {21 for range cap(normal) {
22 normal <- ports.Frame{Type: ports.MsgInput}22 normal <- ports.Frame{Type: ports.MsgInput}
23 }23 }
24+ const epoch = 7
24 acks := newCumulativeAckQueue()25 acks := newCumulativeAckQueue()
25 for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ {26 for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ {
26- acks.offer(state)27+ acks.offer(epoch, state)
27 }28 }
28 29
29 ctx, cancel := context.WithCancel(context.Background())30 ctx, cancel := context.WithCancel(context.Background())
@@ -36,6 +37,7 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) {
36 require.Equal(t, ports.MsgAck, first.Type)37 require.Equal(t, ports.MsgAck, first.Type)
37 ack, err := ports.UnmarshalAck(first.Payload)38 ack, err := ports.UnmarshalAck(first.Payload)
38 require.NoError(t, err)39 require.NoError(t, err)
40+ require.Equal(t, uint64(epoch), ack.Epoch)
39 require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.State)41 require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.State)
40 }42 }
41 43
M internal/usecase/client/client.go +32 -18
@@ -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() {
@@ -839,7 +837,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
839837 // Before the asynchronous sender starts, preserve transport ordering by
840838 // requesting the reset synchronously after the initial Theme publication.
841839 closed, resetErr := boundedPreWelcome(ctx, clk, transport, func() error {
842- 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})
843845 })
844846 if resetErr != nil {
845847 resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr))
@@ -883,8 +885,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
883885 if serr != nil {
884886 return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr)
885887 }
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+ }
886892 select {
887- case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})}:
893+ case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: payload}:
888894 awaitingReconnectReset = true
889895 return nil
890896 case <-loopCtx.Done():
@@ -1097,16 +1103,7 @@ func newCumulativeAckQueue() *cumulativeAckQueue {
10971103 return &cumulativeAckQueue{wake: make(chan struct{}, 1)}
10981104 }
10991105
1100-func (q *cumulativeAckQueue) offer(values ...uint64) {
1101- epoch, state := uint64(1), uint64(0)
1102- switch len(values) {
1103- case 1:
1104- state = values[0]
1105- case 2:
1106- epoch, state = values[0], values[1]
1107- default:
1108- return
1109- }
1106+func (q *cumulativeAckQueue) offer(epoch, state uint64) {
11101107 if epoch == 0 || state == 0 {
11111108 return
11121109 }
@@ -1144,7 +1141,19 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
11441141 }
11451142 sendAck := func() bool {
11461143 epoch, state := acks.take()
1147- return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})})
1144+ if state == 0 {
1145+ return true
1146+ }
1147+ payload, err := ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})
1148+ if err != nil {
1149+ select {
1150+ case errCh <- fmt.Errorf("encoding output ACK: %w", err):
1151+ default:
1152+ }
1153+ cancel()
1154+ return false
1155+ }
1156+ return send(ports.Frame{Type: ports.MsgAck, Payload: payload})
11481157 }
11491158 for {
11501159 select {
@@ -1742,7 +1751,12 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.
17421751 if !ok {
17431752 return
17441753 }
1745- 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}
17461760 select {
17471761 case out <- frame:
17481762 case <-ctx.Done():
@@ -251,13 +251,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error)
251 }251 }
252 rememberOnce.Do(func() {252 rememberOnce.Do(func() {
253 learnerStarted = true253 learnerStarted = true
254- rememberWG.Add(1)254+ rememberWG.Go(func() {
255- go func() {
256- defer rememberWG.Done()
257 if err := r.remoteHostLearner.RememberRemoteHost(); err != nil {255 if err := r.remoteHostLearner.RememberRemoteHost(); err != nil {
258 r.logger.Warn("remembering remote host failed", "err", err)256 r.logger.Warn("remembering remote host failed", "err", err)
259 }257 }
260- }()258+ })
261 })259 })
262 }260 }
263 defer func() {261 defer func() {
@@ -839,7 +837,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
839 // Before the asynchronous sender starts, preserve transport ordering by837 // Before the asynchronous sender starts, preserve transport ordering by
840 // requesting the reset synchronously after the initial Theme publication.838 // requesting the reset synchronously after the initial Theme publication.
841 closed, resetErr := boundedPreWelcome(ctx, clk, transport, func() error {839 closed, resetErr := boundedPreWelcome(ctx, clk, transport, func() error {
842- 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})
843 })845 })
844 if resetErr != nil {846 if resetErr != nil {
845 resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr))847 resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr))
@@ -883,8 +885,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
883 if serr != nil {885 if serr != nil {
884 return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr)886 return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr)
885 }887 }
888+ payload, err := ports.MarshalResize(ports.Resize{Size: size})
889+ if err != nil {
890+ return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err)
891+ }
886 select {892 select {
887- case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})}:893+ case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: payload}:
888 awaitingReconnectReset = true894 awaitingReconnectReset = true
889 return nil895 return nil
890 case <-loopCtx.Done():896 case <-loopCtx.Done():
@@ -1097,16 +1103,7 @@ func newCumulativeAckQueue() *cumulativeAckQueue {
1097 return &cumulativeAckQueue{wake: make(chan struct{}, 1)}1103 return &cumulativeAckQueue{wake: make(chan struct{}, 1)}
1098 }1104 }
1099 1105
1100-func (q *cumulativeAckQueue) offer(values ...uint64) {1106+func (q *cumulativeAckQueue) offer(epoch, state uint64) {
1101- epoch, state := uint64(1), uint64(0)
1102- switch len(values) {
1103- case 1:
1104- state = values[0]
1105- case 2:
1106- epoch, state = values[0], values[1]
1107- default:
1108- return
1109- }
1110 if epoch == 0 || state == 0 {1107 if epoch == 0 || state == 0 {
1111 return1108 return
1112 }1109 }
@@ -1144,7 +1141,19 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
1144 }1141 }
1145 sendAck := func() bool {1142 sendAck := func() bool {
1146 epoch, state := acks.take()1143 epoch, state := acks.take()
1147- return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})})1144+ if state == 0 {
1145+ return true
1146+ }
1147+ payload, err := ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})
1148+ if err != nil {
1149+ select {
1150+ case errCh <- fmt.Errorf("encoding output ACK: %w", err):
1151+ default:
1152+ }
1153+ cancel()
1154+ return false
1155+ }
1156+ return send(ports.Frame{Type: ports.MsgAck, Payload: payload})
1148 }1157 }
1149 for {1158 for {
1150 select {1159 select {
@@ -1742,7 +1751,12 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports.
1742 if !ok {1751 if !ok {
1743 return1752 return
1744 }1753 }
1745- 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}
1746 select {1760 select {
1747 case out <- frame:1761 case out <- frame:
1748 case <-ctx.Done():1762 case <-ctx.Done():
M internal/usecase/client/client_test.go +1 -1
@@ -408,7 +408,7 @@ func TestAttachHappyPath(t *testing.T) {
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()
@@ -408,7 +408,7 @@ func TestAttachHappyPath(t *testing.T) {
408 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()408 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
409 unblock := scriptRecv(tr,409 unblock := scriptRecv(tr,
410 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))},410 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))},
411- recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("hello world")}))},411+ recvItem{f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("hello world")}))},
412 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},412 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
413 )413 )
414 defer unblock()414 defer unblock()
M internal/usecase/client/clipboard_intercept_test.go +1 -1
@@ -198,7 +198,7 @@ func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testi
198198 }
199199 }
200200 beforeOutput := out.String()
201- transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Base: 0, New: 1, Full: true, 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 {
@@ -198,7 +198,7 @@ func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testi
198 }198 }
199 }199 }
200 beforeOutput := out.String()200 beforeOutput := out.String()
201- transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Base: 0, New: 1, Full: true, Data: []byte("incremental")}))201+ transport.recv <- frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("incremental")}))
202 transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))202 transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
203 203
204 select {204 select {
M internal/usecase/client/reconnect_toast_test.go +78 -54
@@ -375,11 +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- Base: 0,
380- New: 2,
381- Full: true,
382- 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"),
383385 })}}
384386 redrawn := requireReconnectToastOutput(t, out.completed)
385387 require.Contains(t, redrawn, "daemon incremental")
@@ -392,47 +394,53 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
392394
393395 beforeAwaitingReset := out.String()
394396 beforeStatelessFlushes := flushes.Load()
395- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
396- Base: 0,
397- New: 0,
398- 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"),
399403 })}}
400404 require.Eventually(t, func() bool {
401405 return strings.Contains(out.String(), "stateless side effect") && flushes.Load() > beforeStatelessFlushes
402406 }, time.Second, time.Millisecond)
403407
404408 afterStateless := out.String()
405- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
406- Base: 2,
407- New: 3,
408- 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"),
409415 })}}
410- requireAckedState(t, tr.sends, 3)
416+ requireAckedState(t, tr.sends, 1, 3)
411417 require.Contains(t, out.String(), "intervening incremental")
412418 require.Contains(t, afterStateless[len(beforeAwaitingReset):], "stateless side effect")
413419
414- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
420+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
415421 Epoch: 2,
416422 Base: 0,
417423 New: 4,
424+ Size: domain.Size{Cols: 1, Rows: 1},
418425 Full: true,
419426 Data: []byte("authoritative reset"),
420427 })}}
421- requireAckedState(t, tr.sends, 4)
428+ requireAckedState(t, tr.sends, 2, 4)
422429 require.Eventually(t, func() bool { return strings.Contains(out.String(), "authoritative reset") }, time.Second, time.Millisecond)
423430 require.NotContains(t, out.String()[len(beforeAwaitingReset):], strings.Repeat(" ", reconnectToastBoundsFor(term.size, "probing UDP path").Width))
424431
425432 beforeIncrementFlushes := flushes.Load()
426- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
433+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
427434 Epoch: 2,
428435 Base: 4,
429436 New: 5,
437+ Size: domain.Size{Cols: 1, Rows: 1},
430438 Data: []byte("increment after reset"),
431439 })}}
432440 require.Eventually(t, func() bool {
433441 return strings.Contains(out.String(), "increment after reset") && flushes.Load() > beforeIncrementFlushes
434442 }, time.Second, time.Millisecond)
435- requireAckedState(t, tr.sends, 5)
443+ requireAckedState(t, tr.sends, 2, 5)
436444
437445 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
438446 result := requireAttachResult(t, resultCh)
@@ -440,7 +448,6 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
440448 }
441449
442450 func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T) {
443- t.Skip("fixture predates strict base-zero output reset contract")
444451 out := newReconnectToastOutputRecorder()
445452 var flushes atomic.Int32
446453 term := newReconnectToastTerminalHarnessWithOutputAndFlush(t, out, func() error {
@@ -467,37 +474,46 @@ func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T)
467474
468475 beforeReset := out.String()
469476 for _, output := range []ports.Output{
470- {Base: 0, New: 2, Full: true, Data: []byte("first skipped increment")},
471- {Base: 2, New: 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")},
472479 } {
473- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(output)}}
474- requireAckedState(t, tr.sends, output.New)
480+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)}}
475481 require.Equal(t, beforeReset, out.String())
476482 }
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")
477488
478489 beforeResetFlushes := flushes.Load()
479- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
480- Base: 0,
481- New: 4,
482- 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"),
483497 })}}
484498 redrawn := requireReconnectToastOutput(t, out.completed)
485499 require.Contains(t, redrawn, "stage transition reset")
486500 require.Contains(t, redrawn, offlineMessage)
487501 require.Eventually(t, func() bool { return flushes.Load() > beforeResetFlushes }, time.Second, time.Millisecond)
488- requireAckedState(t, tr.sends, 4)
502+ requireAckedState(t, tr.sends, 2, 4)
489503
490504 beforeIncrementFlushes := flushes.Load()
491- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
492- Base: 4,
493- New: 5,
494- 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"),
495511 })}}
496512 incremental := requireReconnectToastOutput(t, out.completed)
497513 require.Contains(t, incremental, "stage increment after reset")
498514 require.Contains(t, incremental, offlineMessage)
499515 require.Eventually(t, func() bool { return flushes.Load() > beforeIncrementFlushes }, time.Second, time.Millisecond)
500- requireAckedState(t, tr.sends, 5)
516+ requireAckedState(t, tr.sends, 2, 5)
501517
502518 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
503519 result := requireAttachResult(t, resultCh)
@@ -542,17 +558,19 @@ func requireResize(t *testing.T, sends *reconnectToastSentFrames) ports.Resize {
542558 return resize
543559 }
544560
545-func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, state uint64) {
561+func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, epoch, state uint64) {
546562 t.Helper()
547- 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 {
548564 if frame.Type != ports.MsgAck {
549565 return false
550566 }
551567 ack, err := ports.UnmarshalAck(frame.Payload)
552- return err == nil && ack.State >= state
568+ return err == nil && ack.Epoch == epoch && ack.State >= state
553569 })
554- _, err := ports.UnmarshalAck(frame.Payload)
570+ ack, err := ports.UnmarshalAck(frame.Payload)
555571 require.NoError(t, err)
572+ require.Equal(t, epoch, ack.Epoch)
573+ require.GreaterOrEqual(t, ack.State, state)
556574 }
557575
558576 func requireAttachResult(t *testing.T, results <-chan attachResult) attachResult {
@@ -657,11 +675,13 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi
657675 require.Eventually(t, func() bool { return flushes.Load() > beforeStatus }, time.Second, time.Millisecond)
658676 beforeOutput := flushes.Load()
659677
660- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
661- Base: 0,
662- New: 2,
663- Full: true,
664- 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"),
665685 })}}
666686
667687 select {
@@ -670,7 +690,7 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi
670690 case <-time.After(time.Second):
671691 t.Fatal("timed out waiting for terminal-flushed observation")
672692 }
673- requireAckedState(t, tr.sends, 2)
693+ requireAckedState(t, tr.sends, 1, 2)
674694 require.Greater(t, flushes.Load(), beforeOutput, "daemon output must be flushed before ACK")
675695
676696 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
@@ -707,14 +727,16 @@ func TestLocalReconnectStatusTransitionsDoNotRequestAuthoritativeReset(t *testin
707727 }
708728
709729 const output = "local status output remains visible"
710- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
711- Base: 0,
712- New: 2,
713- Full: true,
714- 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),
715737 })}}
716738 require.Eventually(t, func() bool { return strings.Contains(out.String(), output) }, time.Second, time.Millisecond)
717- requireAckedState(t, tr.sends, 2)
739+ requireAckedState(t, tr.sends, 1, 2)
718740 _, sentResize := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgResize })
719741 require.False(t, sentResize, "local reconnect status must not request a daemon reset")
720742
@@ -773,11 +795,13 @@ func TestReconnectOverlayRedrawFailureDoesNotObserveOrAckOutput(t *testing.T) {
773795 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
774796 requireReconnectToastOutput(t, out.completed)
775797 arm()
776- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
777- Base: 0,
778- New: 2,
779- Full: true,
780- 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"),
781805 })}}
782806
783807 result := requireAttachResult(t, resultCh)
@@ -375,11 +375,13 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
375 require.Contains(t, firstToast, "┌")375 require.Contains(t, firstToast, "┌")
376 require.Contains(t, firstToast, "probing UDP path")376 require.Contains(t, firstToast, "probing UDP path")
377 377
378- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{378+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
379- Base: 0,379+ Epoch: 1,
380- New: 2,380+ Base: 0,
381- Full: true,381+ New: 2,
382- Data: []byte("daemon incremental"),382+ Size: domain.Size{Cols: 1, Rows: 1},
383+ Full: true,
384+ Data: []byte("daemon incremental"),
383 })}}385 })}}
384 redrawn := requireReconnectToastOutput(t, out.completed)386 redrawn := requireReconnectToastOutput(t, out.completed)
385 require.Contains(t, redrawn, "daemon incremental")387 require.Contains(t, redrawn, "daemon incremental")
@@ -392,47 +394,53 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
392 394
393 beforeAwaitingReset := out.String()395 beforeAwaitingReset := out.String()
394 beforeStatelessFlushes := flushes.Load()396 beforeStatelessFlushes := flushes.Load()
395- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{397+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
396- Base: 0,398+ Epoch: 1,
397- New: 0,399+ Base: 0,
398- Data: []byte("stateless side effect"),400+ New: 0,
401+ Size: domain.Size{Cols: 1, Rows: 1},
402+ Data: []byte("stateless side effect"),
399 })}}403 })}}
400 require.Eventually(t, func() bool {404 require.Eventually(t, func() bool {
401 return strings.Contains(out.String(), "stateless side effect") && flushes.Load() > beforeStatelessFlushes405 return strings.Contains(out.String(), "stateless side effect") && flushes.Load() > beforeStatelessFlushes
402 }, time.Second, time.Millisecond)406 }, time.Second, time.Millisecond)
403 407
404 afterStateless := out.String()408 afterStateless := out.String()
405- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{409+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
406- Base: 2,410+ Epoch: 1,
407- New: 3,411+ Base: 2,
408- Data: []byte("intervening incremental"),412+ New: 3,
413+ Size: domain.Size{Cols: 1, Rows: 1},
414+ Data: []byte("intervening incremental"),
409 })}}415 })}}
410- requireAckedState(t, tr.sends, 3)416+ requireAckedState(t, tr.sends, 1, 3)
411 require.Contains(t, out.String(), "intervening incremental")417 require.Contains(t, out.String(), "intervening incremental")
412 require.Contains(t, afterStateless[len(beforeAwaitingReset):], "stateless side effect")418 require.Contains(t, afterStateless[len(beforeAwaitingReset):], "stateless side effect")
413 419
414- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{420+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
415 Epoch: 2,421 Epoch: 2,
416 Base: 0,422 Base: 0,
417 New: 4,423 New: 4,
424+ Size: domain.Size{Cols: 1, Rows: 1},
418 Full: true,425 Full: true,
419 Data: []byte("authoritative reset"),426 Data: []byte("authoritative reset"),
420 })}}427 })}}
421- requireAckedState(t, tr.sends, 4)428+ requireAckedState(t, tr.sends, 2, 4)
422 require.Eventually(t, func() bool { return strings.Contains(out.String(), "authoritative reset") }, time.Second, time.Millisecond)429 require.Eventually(t, func() bool { return strings.Contains(out.String(), "authoritative reset") }, time.Second, time.Millisecond)
423 require.NotContains(t, out.String()[len(beforeAwaitingReset):], strings.Repeat(" ", reconnectToastBoundsFor(term.size, "probing UDP path").Width))430 require.NotContains(t, out.String()[len(beforeAwaitingReset):], strings.Repeat(" ", reconnectToastBoundsFor(term.size, "probing UDP path").Width))
424 431
425 beforeIncrementFlushes := flushes.Load()432 beforeIncrementFlushes := flushes.Load()
426- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{433+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
427 Epoch: 2,434 Epoch: 2,
428 Base: 4,435 Base: 4,
429 New: 5,436 New: 5,
437+ Size: domain.Size{Cols: 1, Rows: 1},
430 Data: []byte("increment after reset"),438 Data: []byte("increment after reset"),
431 })}}439 })}}
432 require.Eventually(t, func() bool {440 require.Eventually(t, func() bool {
433 return strings.Contains(out.String(), "increment after reset") && flushes.Load() > beforeIncrementFlushes441 return strings.Contains(out.String(), "increment after reset") && flushes.Load() > beforeIncrementFlushes
434 }, time.Second, time.Millisecond)442 }, time.Second, time.Millisecond)
435- requireAckedState(t, tr.sends, 5)443+ requireAckedState(t, tr.sends, 2, 5)
436 444
437 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}445 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
438 result := requireAttachResult(t, resultCh)446 result := requireAttachResult(t, resultCh)
@@ -440,7 +448,6 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) {
440 }448 }
441 449
442 func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T) {450 func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T) {
443- t.Skip("fixture predates strict base-zero output reset contract")
444 out := newReconnectToastOutputRecorder()451 out := newReconnectToastOutputRecorder()
445 var flushes atomic.Int32452 var flushes atomic.Int32
446 term := newReconnectToastTerminalHarnessWithOutputAndFlush(t, out, func() error {453 term := newReconnectToastTerminalHarnessWithOutputAndFlush(t, out, func() error {
@@ -467,37 +474,46 @@ func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T)
467 474
468 beforeReset := out.String()475 beforeReset := out.String()
469 for _, output := range []ports.Output{476 for _, output := range []ports.Output{
470- {Base: 0, New: 2, Full: true, Data: []byte("first skipped increment")},477+ {Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("first skipped increment")},
471- {Base: 2, New: 3, Data: []byte("second skipped increment")},478+ {Epoch: 1, Base: 2, New: 3, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("second skipped increment")},
472 } {479 } {
473- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(output)}}480+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)}}
474- requireAckedState(t, tr.sends, output.New)
475 require.Equal(t, beforeReset, out.String())481 require.Equal(t, beforeReset, out.String())
476 }482 }
483+ requireSentFrame(t, tr.sends, "output reset request", func(frame ports.Frame) bool {
484+ return frame.Type == ports.MsgOutputResetRequest
485+ })
486+ _, sentAck := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgAck })
487+ require.False(t, sentAck, "discarded output must not be ACKed")
477 488
478 beforeResetFlushes := flushes.Load()489 beforeResetFlushes := flushes.Load()
479- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{490+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
480- Base: 0,491+ Epoch: 2,
481- New: 4,492+ Base: 0,
482- Data: []byte("stage transition reset"),493+ New: 4,
494+ Size: domain.Size{Cols: 1, Rows: 1},
495+ Full: true,
496+ Data: []byte("stage transition reset"),
483 })}}497 })}}
484 redrawn := requireReconnectToastOutput(t, out.completed)498 redrawn := requireReconnectToastOutput(t, out.completed)
485 require.Contains(t, redrawn, "stage transition reset")499 require.Contains(t, redrawn, "stage transition reset")
486 require.Contains(t, redrawn, offlineMessage)500 require.Contains(t, redrawn, offlineMessage)
487 require.Eventually(t, func() bool { return flushes.Load() > beforeResetFlushes }, time.Second, time.Millisecond)501 require.Eventually(t, func() bool { return flushes.Load() > beforeResetFlushes }, time.Second, time.Millisecond)
488- requireAckedState(t, tr.sends, 4)502+ requireAckedState(t, tr.sends, 2, 4)
489 503
490 beforeIncrementFlushes := flushes.Load()504 beforeIncrementFlushes := flushes.Load()
491- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{505+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
492- Base: 4,506+ Epoch: 2,
493- New: 5,507+ Base: 4,
494- Data: []byte("stage increment after reset"),508+ New: 5,
509+ Size: domain.Size{Cols: 1, Rows: 1},
510+ Data: []byte("stage increment after reset"),
495 })}}511 })}}
496 incremental := requireReconnectToastOutput(t, out.completed)512 incremental := requireReconnectToastOutput(t, out.completed)
497 require.Contains(t, incremental, "stage increment after reset")513 require.Contains(t, incremental, "stage increment after reset")
498 require.Contains(t, incremental, offlineMessage)514 require.Contains(t, incremental, offlineMessage)
499 require.Eventually(t, func() bool { return flushes.Load() > beforeIncrementFlushes }, time.Second, time.Millisecond)515 require.Eventually(t, func() bool { return flushes.Load() > beforeIncrementFlushes }, time.Second, time.Millisecond)
500- requireAckedState(t, tr.sends, 5)516+ requireAckedState(t, tr.sends, 2, 5)
501 517
502 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}518 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
503 result := requireAttachResult(t, resultCh)519 result := requireAttachResult(t, resultCh)
@@ -542,17 +558,19 @@ func requireResize(t *testing.T, sends *reconnectToastSentFrames) ports.Resize {
542 return resize558 return resize
543 }559 }
544 560
545-func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, state uint64) {561+func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, epoch, state uint64) {
546 t.Helper()562 t.Helper()
547- 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 {
548 if frame.Type != ports.MsgAck {564 if frame.Type != ports.MsgAck {
549 return false565 return false
550 }566 }
551 ack, err := ports.UnmarshalAck(frame.Payload)567 ack, err := ports.UnmarshalAck(frame.Payload)
552- return err == nil && ack.State >= state568+ return err == nil && ack.Epoch == epoch && ack.State >= state
553 })569 })
554- _, err := ports.UnmarshalAck(frame.Payload)570+ ack, err := ports.UnmarshalAck(frame.Payload)
555 require.NoError(t, err)571 require.NoError(t, err)
572+ require.Equal(t, epoch, ack.Epoch)
573+ require.GreaterOrEqual(t, ack.State, state)
556 }574 }
557 575
558 func requireAttachResult(t *testing.T, results <-chan attachResult) attachResult {576 func requireAttachResult(t *testing.T, results <-chan attachResult) attachResult {
@@ -657,11 +675,13 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi
657 require.Eventually(t, func() bool { return flushes.Load() > beforeStatus }, time.Second, time.Millisecond)675 require.Eventually(t, func() bool { return flushes.Load() > beforeStatus }, time.Second, time.Millisecond)
658 beforeOutput := flushes.Load()676 beforeOutput := flushes.Load()
659 677
660- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{678+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
661- Base: 0,679+ Epoch: 1,
662- New: 2,680+ Base: 0,
663- Full: true,681+ New: 2,
664- Data: []byte("daemon output behind local reconnect status"),682+ Size: domain.Size{Cols: 1, Rows: 1},
683+ Full: true,
684+ Data: []byte("daemon output behind local reconnect status"),
665 })}}685 })}}
666 686
667 select {687 select {
@@ -670,7 +690,7 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi
670 case <-time.After(time.Second):690 case <-time.After(time.Second):
671 t.Fatal("timed out waiting for terminal-flushed observation")691 t.Fatal("timed out waiting for terminal-flushed observation")
672 }692 }
673- requireAckedState(t, tr.sends, 2)693+ requireAckedState(t, tr.sends, 1, 2)
674 require.Greater(t, flushes.Load(), beforeOutput, "daemon output must be flushed before ACK")694 require.Greater(t, flushes.Load(), beforeOutput, "daemon output must be flushed before ACK")
675 695
676 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}696 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
@@ -707,14 +727,16 @@ func TestLocalReconnectStatusTransitionsDoNotRequestAuthoritativeReset(t *testin
707 }727 }
708 728
709 const output = "local status output remains visible"729 const output = "local status output remains visible"
710- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{730+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
711- Base: 0,731+ Epoch: 1,
712- New: 2,732+ Base: 0,
713- Full: true,733+ New: 2,
714- Data: []byte(output),734+ Size: domain.Size{Cols: 1, Rows: 1},
735+ Full: true,
736+ Data: []byte(output),
715 })}}737 })}}
716 require.Eventually(t, func() bool { return strings.Contains(out.String(), output) }, time.Second, time.Millisecond)738 require.Eventually(t, func() bool { return strings.Contains(out.String(), output) }, time.Second, time.Millisecond)
717- requireAckedState(t, tr.sends, 2)739+ requireAckedState(t, tr.sends, 1, 2)
718 _, sentResize := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgResize })740 _, sentResize := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgResize })
719 require.False(t, sentResize, "local reconnect status must not request a daemon reset")741 require.False(t, sentResize, "local reconnect status must not request a daemon reset")
720 742
@@ -773,11 +795,13 @@ func TestReconnectOverlayRedrawFailureDoesNotObserveOrAckOutput(t *testing.T) {
773 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}795 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
774 requireReconnectToastOutput(t, out.completed)796 requireReconnectToastOutput(t, out.completed)
775 arm()797 arm()
776- tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{798+ tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
777- Base: 0,799+ Epoch: 1,
778- New: 2,800+ Base: 0,
779- Full: true,801+ New: 2,
780- Data: []byte("daemon output before failed redraw"),802+ Size: domain.Size{Cols: 1, Rows: 1},
803+ Full: true,
804+ Data: []byte("daemon output before failed redraw"),
781 })}}805 })}}
782 806
783 result := requireAttachResult(t, resultCh)807 result := requireAttachResult(t, resultCh)
M internal/usecase/client/transport_observability_test.go +2 -2
@@ -50,7 +50,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
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"), New: 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"), New: 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()
@@ -50,7 +50,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
50 transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()50 transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
51 unblock := scriptRecv(transport,51 unblock := scriptRecv(transport,
52 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},52 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
53- recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("flush-before-observe"), New: 3}))},53+ recvItem{f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("flush-before-observe"), New: 3}))},
54 )54 )
55 defer unblock()55 defer unblock()
56 transport.EXPECT().Close().Return(nil).Once()56 transport.EXPECT().Close().Return(nil).Once()
@@ -90,7 +90,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
90 transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()90 transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
91 unblock := scriptRecv(transport,91 unblock := scriptRecv(transport,
92 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},92 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
93- recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), New: 3}))},93+ recvItem{f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("unchanged-by-observer"), New: 3}))},
94 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},94 recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))},
95 )95 )
96 defer unblock()96 defer unblock()
A internal/usecase/client/wire_external_test_helpers_test.go +11 -0
@@ -0,0 +1,11 @@
1+package client_test
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalOutput(m ports.Output) []byte {
6+ payload, err := ports.MarshalOutput(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
@@ -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+}
@@ -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{State: 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()
@@ -614,7 +614,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes
614 setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) {614 setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) {
615 ac.output.next = 3615 ac.output.next = 3
616 },616 },
617- frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{State: 1})},617+ frame: ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: 1, State: 1})},
618 assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {618 assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) {
619 ac.sendMu.Lock()619 ac.sendMu.Lock()
620 defer ac.sendMu.Unlock()620 defer ac.sendMu.Unlock()
M internal/usecase/daemon/attachment_registry.go +7 -0
@@ -46,6 +46,12 @@ func (ac *attachedClient) publishView(view attachmentView) {
4646 return
4747 }
4848 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.
52+ if ac.output != nil && ac.view.revision != view.revision {
53+ ac.output.rebaseLocked()
54+ }
4955 ac.view = view
5056 ac.viewMu.Unlock()
5157 }
@@ -326,6 +332,7 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index
326332 view.paneID = ""
327333 view.bookmark = 0
328334 view.liveBottom = true
335+ view.revision++
329336 ac.publishView(view)
330337 }
331338 }
@@ -46,6 +46,12 @@ func (ac *attachedClient) publishView(view attachmentView) {
46 return46 return
47 }47 }
48 ac.viewMu.Lock()48 ac.viewMu.Lock()
49+ // Rebase while viewMu is held, before the new revision becomes visible.
50+ // Output fences take the same lock, so no side effect can pair the new
51+ // ViewRevision with the previous output epoch.
52+ if ac.output != nil && ac.view.revision != view.revision {
53+ ac.output.rebaseLocked()
54+ }
49 ac.view = view55 ac.view = view
50 ac.viewMu.Unlock()56 ac.viewMu.Unlock()
51 }57 }
@@ -326,6 +332,7 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index
326 view.paneID = ""332 view.paneID = ""
327 view.bookmark = 0333 view.bookmark = 0
328 view.liveBottom = true334 view.liveBottom = true
335+ view.revision++
329 ac.publishView(view)336 ac.publishView(view)
330 }337 }
331 }338 }
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)
@@ -5,6 +5,8 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/stretchr/testify/require"
8 )10 )
9 11
10 func registryTestSession() *session {12 func registryTestSession() *session {
@@ -73,6 +75,67 @@ func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) {
73 }75 }
74 }76 }
75 77
78+func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing.T) {
79+ sess := registryTestSession()
80+ ac := registryTestAttachment(1)
81+ ac.size = domain.Size{Cols: 80, Rows: 23}
82+ ac.output = newOutputStateStream()
83+ ac.output.attachment = ac
84+ require.True(t, sess.registerAttachment(ac))
85+ require.True(t, sess.selectAttachmentTab(ac, "tab-b"))
86+
87+ before := ac.viewSnapshot()
88+ beforeEpoch := ac.output.currentEpoch()
89+ require.NoError(t, sess.runMutation(func() error {
90+ sess.mu.Lock()
91+ sess.tabs = sess.tabs[:1]
92+ sess.mu.Unlock()
93+ return nil
94+ }))
95+
96+ require.Same(t, sess.tabs[0], sess.tabForAttachment(ac))
97+ after := ac.viewSnapshot()
98+ require.Greater(t, after.revision, before.revision)
99+ require.Greater(t, ac.output.currentEpoch(), beforeEpoch)
100+ frame, err := ac.output.sideEffect([]byte("x"), 0)
101+ require.NoError(t, err)
102+ output, err := ports.UnmarshalOutput(frame.Payload)
103+ require.NoError(t, err)
104+ require.Equal(t, after.revision, output.ViewRevision)
105+ require.Equal(t, ac.output.currentEpoch(), output.Epoch)
106+}
107+
108+func TestPrepareRemovedTabViewRebasesOutputBeforeSideEffect(t *testing.T) {
109+ sess := registryTestSession()
110+ ac := registryTestAttachment(1)
111+ ac.size = domain.Size{Cols: 80, Rows: 23}
112+ ac.output = newOutputStateStream()
113+ ac.output.attachment = ac
114+ require.True(t, sess.registerAttachment(ac))
115+ require.True(t, sess.selectAttachmentTab(ac, "tab-b"))
116+
117+ before := ac.viewSnapshot()
118+ beforeEpoch := ac.output.currentEpoch()
119+ sess.mu.Lock()
120+ sess.prepareAttachmentViewsForRemovedTabLocked(sess.tabs[1], 1)
121+ sess.tabs = sess.tabs[:1]
122+ sess.mu.Unlock()
123+
124+ after := ac.viewSnapshot()
125+ require.Equal(t, domain.TabStableID("tab-a"), after.tabID)
126+ require.Equal(t, domain.PaneStableID(""), after.paneID)
127+ require.Equal(t, before.revision+1, after.revision)
128+ require.Greater(t, ac.output.currentEpoch(), beforeEpoch)
129+
130+ frame, err := ac.output.sideEffect([]byte("replacement"), 0)
131+ require.NoError(t, err)
132+ output, err := ports.UnmarshalOutput(frame.Payload)
133+ require.NoError(t, err)
134+ require.Equal(t, after.revision, output.ViewRevision)
135+ require.NotEqual(t, beforeEpoch, output.Epoch)
136+ require.Equal(t, ac.output.currentEpoch(), output.Epoch)
137+}
138+
76 func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) {139 func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) {
77 sess := registryTestSession()140 sess := registryTestSession()
78 ac := registryTestAttachment(1)141 ac := registryTestAttachment(1)
M internal/usecase/daemon/client.go +22 -9
@@ -276,11 +276,11 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
276276 return tr != nil && ac.transportIs(tr)
277277 }
278278
279-func (ac *attachedClient) ackOutputState(values ...uint64) {
279+func (ac *attachedClient) ackOutputState(epoch, state uint64) {
280280 ac.sendMu.Lock()
281281 defer ac.sendMu.Unlock()
282282 if ac.output != nil {
283- ac.output.ack(values...)
283+ ac.output.ack(epoch, state)
284284 }
285285 }
286286
@@ -356,8 +356,12 @@ func (ac *attachedClient) sendExpectedSideEffectForAttachment(expected transport
356356 if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
357357 return errAttachmentTransition
358358 }
359- frame := ac.output.sideEffectLocked(data, echoAck)
360- err := expected.transport.Send(frame)
359+ unlockView := ac.output.lockView()
360+ defer unlockView()
361+ frame, err := ac.output.sideEffectLocked(data, echoAck)
362+ if err == nil {
363+ err = expected.transport.Send(frame)
364+ }
361365 if err != nil {
362366 ticket.reportTransportFailure(expected)
363367 }
@@ -404,7 +408,12 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
404408 if !ac.transportSnapshotCurrent(expected) {
405409 return expected.transport, errTransportReplaced
406410 }
407- err := owned.SendSynchronous(ac.output.sideEffectLocked(b, ac.echoAck.Load()))
411+ unlockView := ac.output.lockView()
412+ defer unlockView()
413+ frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
414+ if err == nil {
415+ err = owned.SendSynchronous(frame)
416+ }
408417 return expected.transport, err
409418 }
410419 return d.boundedSendWith(expected.transport, func() error {
@@ -413,7 +422,13 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
413422 if !ac.transportSnapshotCurrent(expected) {
414423 return errTransportReplaced
415424 }
416- return expected.transport.Send(ac.output.sideEffectLocked(b, ac.echoAck.Load()))
425+ unlockView := ac.output.lockView()
426+ defer 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)
417432 })
418433 }
419434
@@ -804,7 +819,6 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
804819 view.windowTop = 0
805820 view.revision++
806821 ac.publishView(view)
807- ac.rebaseOutput()
808822 ac.pipelineCache = composeCacheInput{}
809823 ac.pipelineScratch = composeCacheInput{}
810824 ac.sendMu.Unlock()
@@ -812,8 +826,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
812826 entry := token.sess
813827 // Rendering is attachment-scoped. Run it independently of the connection
814828 // reader so a blocked transport cannot hold session dispatch or peers.
815- if _, ok := entry.(*proxySession); ok {
816- proxy := entry.(*proxySession)
829+ if proxy, ok := entry.(*proxySession); ok {
817830 _, _ = proxy.resize(size)
818831 return true
819832 }
@@ -276,11 +276,11 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
276 return tr != nil && ac.transportIs(tr)276 return tr != nil && ac.transportIs(tr)
277 }277 }
278 278
279-func (ac *attachedClient) ackOutputState(values ...uint64) {279+func (ac *attachedClient) ackOutputState(epoch, state uint64) {
280 ac.sendMu.Lock()280 ac.sendMu.Lock()
281 defer ac.sendMu.Unlock()281 defer ac.sendMu.Unlock()
282 if ac.output != nil {282 if ac.output != nil {
283- ac.output.ack(values...)283+ ac.output.ack(epoch, state)
284 }284 }
285 }285 }
286 286
@@ -356,8 +356,12 @@ func (ac *attachedClient) sendExpectedSideEffectForAttachment(expected transport
356 if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {356 if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
357 return errAttachmentTransition357 return errAttachmentTransition
358 }358 }
359- frame := ac.output.sideEffectLocked(data, echoAck)359+ unlockView := ac.output.lockView()
360- err := expected.transport.Send(frame)360+ defer unlockView()
361+ frame, err := ac.output.sideEffectLocked(data, echoAck)
362+ if err == nil {
363+ err = expected.transport.Send(frame)
364+ }
361 if err != nil {365 if err != nil {
362 ticket.reportTransportFailure(expected)366 ticket.reportTransportFailure(expected)
363 }367 }
@@ -404,7 +408,12 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
404 if !ac.transportSnapshotCurrent(expected) {408 if !ac.transportSnapshotCurrent(expected) {
405 return expected.transport, errTransportReplaced409 return expected.transport, errTransportReplaced
406 }410 }
407- err := owned.SendSynchronous(ac.output.sideEffectLocked(b, ac.echoAck.Load()))411+ unlockView := ac.output.lockView()
412+ defer unlockView()
413+ frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
414+ if err == nil {
415+ err = owned.SendSynchronous(frame)
416+ }
408 return expected.transport, err417 return expected.transport, err
409 }418 }
410 return d.boundedSendWith(expected.transport, func() error {419 return d.boundedSendWith(expected.transport, func() error {
@@ -413,7 +422,13 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
413 if !ac.transportSnapshotCurrent(expected) {422 if !ac.transportSnapshotCurrent(expected) {
414 return errTransportReplaced423 return errTransportReplaced
415 }424 }
416- return expected.transport.Send(ac.output.sideEffectLocked(b, ac.echoAck.Load()))425+ unlockView := ac.output.lockView()
426+ defer 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)
417 })432 })
418 }433 }
419 434
@@ -804,7 +819,6 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
804 view.windowTop = 0819 view.windowTop = 0
805 view.revision++820 view.revision++
806 ac.publishView(view)821 ac.publishView(view)
807- ac.rebaseOutput()
808 ac.pipelineCache = composeCacheInput{}822 ac.pipelineCache = composeCacheInput{}
809 ac.pipelineScratch = composeCacheInput{}823 ac.pipelineScratch = composeCacheInput{}
810 ac.sendMu.Unlock()824 ac.sendMu.Unlock()
@@ -812,8 +826,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size
812 entry := token.sess826 entry := token.sess
813 // Rendering is attachment-scoped. Run it independently of the connection827 // Rendering is attachment-scoped. Run it independently of the connection
814 // reader so a blocked transport cannot hold session dispatch or peers.828 // reader so a blocked transport cannot hold session dispatch or peers.
815- if _, ok := entry.(*proxySession); ok {829+ if proxy, ok := entry.(*proxySession); ok {
816- proxy := entry.(*proxySession)
817 _, _ = proxy.resize(size)830 _, _ = proxy.resize(size)
818 return true831 return true
819 }832 }
M internal/usecase/daemon/client_frame_routing.go +2 -2
@@ -256,13 +256,13 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
256256 return true
257257 }
258258
259-func (d *Daemon) ackOutput(token attachmentConnectionToken, values ...uint64) bool {
259+func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) bool {
260260 ac := token.ac
261261 if token.effect == nil || token.effect.ended.Load() {
262262 return false
263263 }
264264 ac.sendMu.Lock()
265- ac.output.ack(values...)
265+ ac.output.ack(epoch, state)
266266 ac.sendMu.Unlock()
267267 if rc := token.sess.core().coordinator.Load(); rc != nil {
268268 rc.notifyAckForLease(token.lease)
@@ -256,13 +256,13 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool {
256 return true256 return true
257 }257 }
258 258
259-func (d *Daemon) ackOutput(token attachmentConnectionToken, values ...uint64) bool {259+func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) bool {
260 ac := token.ac260 ac := token.ac
261 if token.effect == nil || token.effect.ended.Load() {261 if token.effect == nil || token.effect.ended.Load() {
262 return false262 return false
263 }263 }
264 ac.sendMu.Lock()264 ac.sendMu.Lock()
265- ac.output.ack(values...)265+ ac.output.ack(epoch, state)
266 ac.sendMu.Unlock()266 ac.sendMu.Unlock()
267 if rc := token.sess.core().coordinator.Load(); rc != nil {267 if rc := token.sess.core().coordinator.Load(); rc != nil {
268 rc.notifyAckForLease(token.lease)268 rc.notifyAckForLease(token.lease)
M internal/usecase/daemon/clipboard.go +9 -6
@@ -271,12 +271,15 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
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+ unlockView := ac.output.lockView()
275+ defer 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)
@@ -271,12 +271,15 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta
271 if !beginClipboardOwnerSend(item.owner, ticket, expected) {271 if !beginClipboardOwnerSend(item.owner, ticket, expected) {
272 return errAttachmentTransition272 return errAttachmentTransition
273 }273 }
274- frame := ac.output.sideEffect(item.seq, ac.echoAck.Load())274+ unlockView := ac.output.lockView()
275- var err error275+ defer unlockView()
276- if owned {276+ frame, err := ac.output.sideEffectLocked(item.seq, ac.echoAck.Load())
277- err = ownedTransport.SendSynchronous(frame)277+ if err == nil {
278- } else {278+ if owned {
279- err = expected.transport.Send(frame)279+ err = ownedTransport.SendSynchronous(frame)
280+ } else {
281+ err = expected.transport.Send(frame)
282+ }
280 }283 }
281 if err != nil {284 if err != nil {
282 ticket.reportTransportFailure(expected)285 ticket.reportTransportFailure(expected)
M internal/usecase/daemon/daemon.go +3 -4
@@ -1256,6 +1256,9 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
12561256 // the session for ephemeral/new intents.
12571257 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
12581258 sz := h.Size
1259+ if !sz.Valid() {
1260+ return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1261+ }
12591262 term := terminalEnv{TrueColor: h.TrueColor}
12601263
12611264 // A non-zero token is an authoritative resume credential. If it is unknown,
@@ -1304,10 +1307,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
13041307 return nil, nil, err
13051308 }
13061309 }
1307- if !sz.Valid() {
1308- return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1309- }
1310-
13111310 d.mu.Lock()
13121311 // Shutdown/create interlock: once shutdown has begun (last session removed,
13131312 // or shutdownAll started) no new session may be created and no attach may
@@ -1256,6 +1256,9 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error {
1256 // the session for ephemeral/new intents.1256 // the session for ephemeral/new intents.
1257 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {1257 func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) {
1258 sz := h.Size1258 sz := h.Size
1259+ if !sz.Valid() {
1260+ return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1261+ }
1259 term := terminalEnv{TrueColor: h.TrueColor}1262 term := terminalEnv{TrueColor: h.TrueColor}
1260 1263
1261 // A non-zero token is an authoritative resume credential. If it is unknown,1264 // A non-zero token is an authoritative resume credential. If it is unknown,
@@ -1304,10 +1307,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
1304 return nil, nil, err1307 return nil, nil, err
1305 }1308 }
1306 }1309 }
1307- if !sz.Valid() {
1308- return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"}
1309- }
1310-
1311 d.mu.Lock()1310 d.mu.Lock()
1312 // Shutdown/create interlock: once shutdown has begun (last session removed,1311 // Shutdown/create interlock: once shutdown has begun (last session removed,
1313 // or shutdownAll started) no new session may be created and no attach may1312 // or shutdownAll started) no new session may be created and no attach may
M internal/usecase/daemon/daemon_test.go +29 -0
@@ -574,6 +574,35 @@ func tabCount(sess *session) int {
574574
575575 // --- handshake --------------------------------------------------------------
576576
577+func TestRouteRejectsInvalidTerminalSizeBeforeResumeMutation(t *testing.T) {
578+ pty, release := newBlockingPTY(t)
579+ defer release()
580+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
581+
582+ transport := &closeTrackingTransport{}
583+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport)
584+ require.NoError(t, err)
585+ token := ac.resumeToken
586+ d.clientGone(sess, ac, transport, false)
587+
588+ d.mu.Lock()
589+ parked := d.parked[token]
590+ d.mu.Unlock()
591+ require.NotNil(t, parked)
592+
593+ invalid := helloResumeCapable(ports.IntentResume, "work", token)
594+ invalid.Size = domain.Size{Cols: 0, Rows: 24}
595+ _, _, err = d.route(invalid, &closeTrackingTransport{})
596+ var protocolErr *protoErr
597+ require.ErrorAs(t, err, &protocolErr)
598+ require.Equal(t, ports.ErrInternal, protocolErr.code)
599+
600+ d.mu.Lock()
601+ require.Same(t, parked, d.parked[token], "invalid resume must not consume the parked attachment")
602+ d.mu.Unlock()
603+ require.Same(t, transport, ac.transport(), "invalid resume must not bind a replacement transport")
604+}
605+
577606 func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
578607 done := make(chan struct{})
579608 go func() {
@@ -574,6 +574,35 @@ func tabCount(sess *session) int {
574 574
575 // --- handshake --------------------------------------------------------------575 // --- handshake --------------------------------------------------------------
576 576
577+func TestRouteRejectsInvalidTerminalSizeBeforeResumeMutation(t *testing.T) {
578+ pty, release := newBlockingPTY(t)
579+ defer release()
580+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
581+
582+ transport := &closeTrackingTransport{}
583+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport)
584+ require.NoError(t, err)
585+ token := ac.resumeToken
586+ d.clientGone(sess, ac, transport, false)
587+
588+ d.mu.Lock()
589+ parked := d.parked[token]
590+ d.mu.Unlock()
591+ require.NotNil(t, parked)
592+
593+ invalid := helloResumeCapable(ports.IntentResume, "work", token)
594+ invalid.Size = domain.Size{Cols: 0, Rows: 24}
595+ _, _, err = d.route(invalid, &closeTrackingTransport{})
596+ var protocolErr *protoErr
597+ require.ErrorAs(t, err, &protocolErr)
598+ require.Equal(t, ports.ErrInternal, protocolErr.code)
599+
600+ d.mu.Lock()
601+ require.Same(t, parked, d.parked[token], "invalid resume must not consume the parked attachment")
602+ d.mu.Unlock()
603+ require.Same(t, transport, ac.transport(), "invalid resume must not bind a replacement transport")
604+}
605+
577 func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {606 func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} {
578 done := make(chan struct{})607 done := make(chan struct{})
579 go func() {608 go func() {
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
@@ -812,7 +812,7 @@ func TestAttachWaitsForRestore(t *testing.T) {
812 812
813 result := make(chan error, 1)813 result := make(chan error, 1)
814 go func() {814 go func() {
815- _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil)815+ _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name, Size: domain.Size{Cols: 80, Rows: 24}}, nil)
816 result <- err816 result <- err
817 }()817 }()
818 818
M internal/usecase/daemon/output_state_stream.go +85 -38
@@ -32,6 +32,18 @@ type outputStateStream struct {
3232 attachment *attachedClient
3333 }
3434
35+// lockView serializes an attachment's view publication with every output
36+// state transition. It deliberately does not take sendMu: callers that hold
37+// architecture locks can publish a view without reversing the paint path's
38+// sendMu -> session lock order.
39+func (s *outputStateStream) lockView() func() {
40+ if s == nil || s.attachment == nil {
41+ return func() {}
42+ }
43+ s.attachment.viewMu.Lock()
44+ return s.attachment.viewMu.Unlock
45+}
46+
3547 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
3648 window := uint8(maxUnackedOutputStates)
3749 if len(windowSize) > 0 {
@@ -64,11 +76,13 @@ type preparedOutput struct {
6476 }
6577
6678 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
79+ unlockView := s.lockView()
80+ defer unlockView()
6781 if s.epoch == 0 {
6882 s.epoch = 1
6983 }
7084 if reset && s.initialized {
71- s.rebase()
85+ s.rebaseLocked()
7286 }
7387 reset = reset || s.forceSnapshot || !s.initialized
7488 if s.next == ^uint64(0) {
@@ -79,7 +93,7 @@ func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Dama
7993 return nil, err
8094 }
8195 s.initialized = true
82- generation, epoch, viewRevision := s.fence()
96+ generation, epoch, viewRevision := s.fenceLocked()
8397 return &preparedOutput{
8498 stream: s,
8599 draw: draw,
@@ -96,11 +110,13 @@ func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Dama
96110 }
97111
98112 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {
99- if p == nil || p.attempted {
113+ if p == nil || p.stream == nil || p.attempted {
100114 return nil
101115 }
116+ unlockView := p.stream.lockView()
117+ defer unlockView()
102118 p.attempted = true
103- if !p.current() {
119+ if !p.currentLocked() {
104120 return nil
105121 }
106122 if len(data) == 0 {
@@ -112,7 +128,11 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
112128 if p.reset {
113129 base = 0
114130 }
115- out := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset)
131+ out, err := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset)
132+ if err != nil {
133+ p.stream.forceSnapshot = true
134+ return err
135+ }
116136 if err := send(out); err != nil {
117137 p.stream.forceSnapshot = true
118138 return err
@@ -125,11 +145,13 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
125145 }
126146
127147 func (p *preparedOutput) commitNoSend() {
128- if p == nil || p.attempted {
148+ if p == nil || p.stream == nil || p.attempted {
129149 return
130150 }
151+ unlockView := p.stream.lockView()
152+ defer unlockView()
131153 p.attempted = true
132- if !p.current() || len(p.data) != 0 {
154+ if !p.currentLocked() || len(p.data) != 0 {
133155 return
134156 }
135157 p.commit()
@@ -142,79 +164,84 @@ func (p *preparedOutput) commit() {
142164 }
143165
144166 func (s *outputStateStream) currentEpoch() uint64 {
167+ unlockView := s.lockView()
168+ defer unlockView()
169+ return s.currentEpochLocked()
170+}
171+
172+func (s *outputStateStream) currentEpochLocked() uint64 {
145173 if s.epoch == 0 {
146174 s.epoch = 1
147175 }
148176 return s.epoch
149177 }
150178
151-func (s *outputStateStream) fence() (uint64, uint64, uint64) {
179+func (s *outputStateStream) fenceLocked() (uint64, uint64, uint64) {
152180 if s == nil {
153181 return 0, 1, 0
154182 }
155183 generation, revision := uint64(0), uint64(0)
156184 if s.attachment != nil {
157185 generation = s.attachment.connectionGeneration.Load()
158- revision = s.attachment.viewSnapshot().revision
186+ revision = s.attachment.view.revision
159187 }
160- return generation, s.currentEpoch(), revision
188+ return generation, s.currentEpochLocked(), revision
161189 }
162190
163-func (s *outputStateStream) preparedCurrent(generation, epoch, viewRevision, base uint64) bool {
164- if s == nil || s.currentEpoch() != epoch || s.generation != generation || s.next != base {
191+func (s *outputStateStream) preparedCurrentLocked(generation, epoch, viewRevision, base uint64) bool {
192+ if s == nil || s.currentEpochLocked() != epoch || s.generation != generation || s.next != base {
165193 return false
166194 }
167195 if s.attachment == nil {
168196 return true
169197 }
170- return s.attachment.viewSnapshot().revision == viewRevision
198+ return s.attachment.view.revision == viewRevision
171199 }
172200
173-func (p *preparedOutput) current() bool {
174- return p != nil && p.stream != nil && p.stream.preparedCurrent(p.generation, p.epoch, p.viewRevision, p.base) &&
201+func (p *preparedOutput) currentLocked() bool {
202+ return p != nil && p.stream != nil && p.stream.preparedCurrentLocked(p.generation, p.epoch, p.viewRevision, p.base) &&
175203 (p.stream.attachment == nil || p.stream.attachment.connectionGeneration.Load() == p.connectionGeneration)
176204 }
177205
178-func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) ports.Frame {
179- if !size.Valid() {
180- size = domain.Size{Cols: 1, Rows: 1}
181- }
182- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
206+func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) (ports.Frame, error) {
207+ payload, err := ports.MarshalOutput(ports.Output{
183208 Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision,
184209 Size: size, Full: full, Data: data,
185- })}
210+ })
211+ if err != nil {
212+ return ports.Frame{}, err
213+ }
214+ return ports.Frame{Type: ports.MsgOutput, Payload: payload}, nil
186215 }
187216
188217 // sideEffect builds an output frame without advancing the state stream. When
189218 // an attachment is present, callers must hold its sendMu; attachment-bound
190219 // sends use sideEffectLocked after validating their transport incarnation.
191-func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {
220+func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {
221+ unlockView := s.lockView()
222+ defer unlockView()
192223 return s.sideEffectLocked(data, echoAck)
193224 }
194225
195-func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) ports.Frame {
196- _, epoch, viewRevision := s.fence()
226+// sideEffectLocked requires the attachment view lock for both frame creation
227+// and transport emission. Keeping that lock across the send prevents a view
228+// publication from overtaking a frame that was fenced just before it.
229+func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports.Frame, error) {
230+ _, epoch, viewRevision := s.fenceLocked()
197231 size := domain.Size{Cols: 1, Rows: 1}
198- if s != nil && s.attachment != nil && s.attachment.size.Valid() {
232+ if s != nil && s.attachment != nil {
199233 size = s.attachment.size
200234 }
201235 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
202236 }
203237
204-func (s *outputStateStream) ack(values ...uint64) {
238+func (s *outputStateStream) ack(epoch, state uint64) {
205239 if s == nil {
206240 return
207241 }
208- epoch, state := s.currentEpoch(), uint64(0)
209- switch len(values) {
210- case 1:
211- state = values[0]
212- case 2:
213- epoch, state = values[0], values[1]
214- default:
215- return
216- }
217- if epoch != s.currentEpoch() || state > s.next || state <= s.acked {
242+ unlockView := s.lockView()
243+ defer unlockView()
244+ if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
218245 return
219246 }
220247 s.acked = state
@@ -225,7 +252,18 @@ func (s *outputStateStream) rebase() {
225252 if s == nil {
226253 return
227254 }
228- epoch := s.currentEpoch()
255+ unlockView := s.lockView()
256+ defer unlockView()
257+ s.rebaseLocked()
258+}
259+
260+// rebaseLocked is called while the attachment view lock is held. Publishing
261+// the next view revision takes this path before making that revision visible.
262+func (s *outputStateStream) rebaseLocked() {
263+ if s == nil {
264+ return
265+ }
266+ epoch := s.currentEpochLocked()
229267 if epoch == ^uint64(0) {
230268 return
231269 }
@@ -253,6 +291,8 @@ func (s *outputStateStream) syncCapacityLocked() {
253291 if s == nil {
254292 return
255293 }
294+ unlockView := s.lockView()
295+ defer unlockView()
256296 s.maxOutstandingAtomic.Store(s.maxOutstanding)
257297 s.publishOutstanding()
258298 }
@@ -264,4 +304,11 @@ func (s *outputStateStream) atCapacity() bool {
264304 return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()
265305 }
266306
267-func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }
307+func (s *outputStateStream) outstanding() uint64 {
308+ if s == nil {
309+ return 0
310+ }
311+ unlockView := s.lockView()
312+ defer unlockView()
313+ return s.next - s.acked
314+}
@@ -32,6 +32,18 @@ type outputStateStream struct {
32 attachment *attachedClient32 attachment *attachedClient
33 }33 }
34 34
35+// lockView serializes an attachment's view publication with every output
36+// state transition. It deliberately does not take sendMu: callers that hold
37+// architecture locks can publish a view without reversing the paint path's
38+// sendMu -> session lock order.
39+func (s *outputStateStream) lockView() func() {
40+ if s == nil || s.attachment == nil {
41+ return func() {}
42+ }
43+ s.attachment.viewMu.Lock()
44+ return s.attachment.viewMu.Unlock
45+}
46+
35 func newOutputStateStream(windowSize ...uint8) *outputStateStream {47 func newOutputStateStream(windowSize ...uint8) *outputStateStream {
36 window := uint8(maxUnackedOutputStates)48 window := uint8(maxUnackedOutputStates)
37 if len(windowSize) > 0 {49 if len(windowSize) > 0 {
@@ -64,11 +76,13 @@ type preparedOutput struct {
64 }76 }
65 77
66 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {78 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
79+ unlockView := s.lockView()
80+ defer unlockView()
67 if s.epoch == 0 {81 if s.epoch == 0 {
68 s.epoch = 182 s.epoch = 1
69 }83 }
70 if reset && s.initialized {84 if reset && s.initialized {
71- s.rebase()85+ s.rebaseLocked()
72 }86 }
73 reset = reset || s.forceSnapshot || !s.initialized87 reset = reset || s.forceSnapshot || !s.initialized
74 if s.next == ^uint64(0) {88 if s.next == ^uint64(0) {
@@ -79,7 +93,7 @@ func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Dama
79 return nil, err93 return nil, err
80 }94 }
81 s.initialized = true95 s.initialized = true
82- generation, epoch, viewRevision := s.fence()96+ generation, epoch, viewRevision := s.fenceLocked()
83 return &preparedOutput{97 return &preparedOutput{
84 stream: s,98 stream: s,
85 draw: draw,99 draw: draw,
@@ -96,11 +110,13 @@ func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Dama
96 }110 }
97 111
98 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {112 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {
99- if p == nil || p.attempted {113+ if p == nil || p.stream == nil || p.attempted {
100 return nil114 return nil
101 }115 }
116+ unlockView := p.stream.lockView()
117+ defer unlockView()
102 p.attempted = true118 p.attempted = true
103- if !p.current() {119+ if !p.currentLocked() {
104 return nil120 return nil
105 }121 }
106 if len(data) == 0 {122 if len(data) == 0 {
@@ -112,7 +128,11 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
112 if p.reset {128 if p.reset {
113 base = 0129 base = 0
114 }130 }
115- out := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset)131+ out, err := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset)
132+ if err != nil {
133+ p.stream.forceSnapshot = true
134+ return err
135+ }
116 if err := send(out); err != nil {136 if err := send(out); err != nil {
117 p.stream.forceSnapshot = true137 p.stream.forceSnapshot = true
118 return err138 return err
@@ -125,11 +145,13 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
125 }145 }
126 146
127 func (p *preparedOutput) commitNoSend() {147 func (p *preparedOutput) commitNoSend() {
128- if p == nil || p.attempted {148+ if p == nil || p.stream == nil || p.attempted {
129 return149 return
130 }150 }
151+ unlockView := p.stream.lockView()
152+ defer unlockView()
131 p.attempted = true153 p.attempted = true
132- if !p.current() || len(p.data) != 0 {154+ if !p.currentLocked() || len(p.data) != 0 {
133 return155 return
134 }156 }
135 p.commit()157 p.commit()
@@ -142,79 +164,84 @@ func (p *preparedOutput) commit() {
142 }164 }
143 165
144 func (s *outputStateStream) currentEpoch() uint64 {166 func (s *outputStateStream) currentEpoch() uint64 {
167+ unlockView := s.lockView()
168+ defer unlockView()
169+ return s.currentEpochLocked()
170+}
171+
172+func (s *outputStateStream) currentEpochLocked() uint64 {
145 if s.epoch == 0 {173 if s.epoch == 0 {
146 s.epoch = 1174 s.epoch = 1
147 }175 }
148 return s.epoch176 return s.epoch
149 }177 }
150 178
151-func (s *outputStateStream) fence() (uint64, uint64, uint64) {179+func (s *outputStateStream) fenceLocked() (uint64, uint64, uint64) {
152 if s == nil {180 if s == nil {
153 return 0, 1, 0181 return 0, 1, 0
154 }182 }
155 generation, revision := uint64(0), uint64(0)183 generation, revision := uint64(0), uint64(0)
156 if s.attachment != nil {184 if s.attachment != nil {
157 generation = s.attachment.connectionGeneration.Load()185 generation = s.attachment.connectionGeneration.Load()
158- revision = s.attachment.viewSnapshot().revision186+ revision = s.attachment.view.revision
159 }187 }
160- return generation, s.currentEpoch(), revision188+ return generation, s.currentEpochLocked(), revision
161 }189 }
162 190
163-func (s *outputStateStream) preparedCurrent(generation, epoch, viewRevision, base uint64) bool {191+func (s *outputStateStream) preparedCurrentLocked(generation, epoch, viewRevision, base uint64) bool {
164- if s == nil || s.currentEpoch() != epoch || s.generation != generation || s.next != base {192+ if s == nil || s.currentEpochLocked() != epoch || s.generation != generation || s.next != base {
165 return false193 return false
166 }194 }
167 if s.attachment == nil {195 if s.attachment == nil {
168 return true196 return true
169 }197 }
170- return s.attachment.viewSnapshot().revision == viewRevision198+ return s.attachment.view.revision == viewRevision
171 }199 }
172 200
173-func (p *preparedOutput) current() bool {201+func (p *preparedOutput) currentLocked() bool {
174- return p != nil && p.stream != nil && p.stream.preparedCurrent(p.generation, p.epoch, p.viewRevision, p.base) &&202+ return p != nil && p.stream != nil && p.stream.preparedCurrentLocked(p.generation, p.epoch, p.viewRevision, p.base) &&
175 (p.stream.attachment == nil || p.stream.attachment.connectionGeneration.Load() == p.connectionGeneration)203 (p.stream.attachment == nil || p.stream.attachment.connectionGeneration.Load() == p.connectionGeneration)
176 }204 }
177 205
178-func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) ports.Frame {206+func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) (ports.Frame, error) {
179- if !size.Valid() {207+ payload, err := ports.MarshalOutput(ports.Output{
180- size = domain.Size{Cols: 1, Rows: 1}
181- }
182- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
183 Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision,208 Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision,
184 Size: size, Full: full, Data: data,209 Size: size, Full: full, Data: data,
185- })}210+ })
211+ if err != nil {
212+ return ports.Frame{}, err
213+ }
214+ return ports.Frame{Type: ports.MsgOutput, Payload: payload}, nil
186 }215 }
187 216
188 // sideEffect builds an output frame without advancing the state stream. When217 // sideEffect builds an output frame without advancing the state stream. When
189 // an attachment is present, callers must hold its sendMu; attachment-bound218 // an attachment is present, callers must hold its sendMu; attachment-bound
190 // sends use sideEffectLocked after validating their transport incarnation.219 // sends use sideEffectLocked after validating their transport incarnation.
191-func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {220+func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {
221+ unlockView := s.lockView()
222+ defer unlockView()
192 return s.sideEffectLocked(data, echoAck)223 return s.sideEffectLocked(data, echoAck)
193 }224 }
194 225
195-func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) ports.Frame {226+// sideEffectLocked requires the attachment view lock for both frame creation
196- _, epoch, viewRevision := s.fence()227+// and transport emission. Keeping that lock across the send prevents a view
228+// publication from overtaking a frame that was fenced just before it.
229+func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports.Frame, error) {
230+ _, epoch, viewRevision := s.fenceLocked()
197 size := domain.Size{Cols: 1, Rows: 1}231 size := domain.Size{Cols: 1, Rows: 1}
198- if s != nil && s.attachment != nil && s.attachment.size.Valid() {232+ if s != nil && s.attachment != nil {
199 size = s.attachment.size233 size = s.attachment.size
200 }234 }
201 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)235 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
202 }236 }
203 237
204-func (s *outputStateStream) ack(values ...uint64) {238+func (s *outputStateStream) ack(epoch, state uint64) {
205 if s == nil {239 if s == nil {
206 return240 return
207 }241 }
208- epoch, state := s.currentEpoch(), uint64(0)242+ unlockView := s.lockView()
209- switch len(values) {243+ defer unlockView()
210- case 1:244+ if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
211- state = values[0]
212- case 2:
213- epoch, state = values[0], values[1]
214- default:
215- return
216- }
217- if epoch != s.currentEpoch() || state > s.next || state <= s.acked {
218 return245 return
219 }246 }
220 s.acked = state247 s.acked = state
@@ -225,7 +252,18 @@ func (s *outputStateStream) rebase() {
225 if s == nil {252 if s == nil {
226 return253 return
227 }254 }
228- epoch := s.currentEpoch()255+ unlockView := s.lockView()
256+ defer unlockView()
257+ s.rebaseLocked()
258+}
259+
260+// rebaseLocked is called while the attachment view lock is held. Publishing
261+// the next view revision takes this path before making that revision visible.
262+func (s *outputStateStream) rebaseLocked() {
263+ if s == nil {
264+ return
265+ }
266+ epoch := s.currentEpochLocked()
229 if epoch == ^uint64(0) {267 if epoch == ^uint64(0) {
230 return268 return
231 }269 }
@@ -253,6 +291,8 @@ func (s *outputStateStream) syncCapacityLocked() {
253 if s == nil {291 if s == nil {
254 return292 return
255 }293 }
294+ unlockView := s.lockView()
295+ defer unlockView()
256 s.maxOutstandingAtomic.Store(s.maxOutstanding)296 s.maxOutstandingAtomic.Store(s.maxOutstanding)
257 s.publishOutstanding()297 s.publishOutstanding()
258 }298 }
@@ -264,4 +304,11 @@ func (s *outputStateStream) atCapacity() bool {
264 return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()304 return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load()
265 }305 }
266 306
267-func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked }307+func (s *outputStateStream) outstanding() uint64 {
308+ if s == nil {
309+ return 0
310+ }
311+ unlockView := s.lockView()
312+ defer unlockView()
313+ return s.next - s.acked
314+}
M internal/usecase/daemon/output_state_stream_test.go +21 -4
@@ -69,7 +69,7 @@ func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {
6969 <-probed
7070 require.True(t, stream.atCapacity())
7171
72- stream.ack(1)
72+ stream.ack(1, 1)
7373 require.False(t, stream.atCapacity())
7474 stream.rebase()
7575 require.False(t, stream.atCapacity())
@@ -183,11 +183,24 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
183183 }
184184 }
185185
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+
186197 func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) {
187198 stream := newOutputStateStream()
188199 stream.ack(1, 0)
189200 beforeEpoch, beforeNext, beforeAck := stream.epoch, stream.next, stream.acked
190- out, err := ports.UnmarshalOutput(stream.sideEffect([]byte("pty"), 9).Payload)
201+ frame, err := stream.sideEffect([]byte("pty"), 9)
202+ require.NoError(t, err)
203+ out, err := ports.UnmarshalOutput(frame.Payload)
191204 require.NoError(t, err)
192205 require.Equal(t, beforeEpoch, out.Epoch)
193206 require.Zero(t, out.Base)
@@ -274,7 +287,7 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
274287 require.NoError(t, err)
275288 require.True(t, ok)
276289 mustApplyOutput(t, client, first)
277- stream.ack(1)
290+ stream.ack(1, 1)
278291
279292 scrolled := renderer.NewFrame(4, 3)
280293 fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "})
@@ -321,7 +334,11 @@ func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAc
321334 if reset {
322335 base = 0
323336 }
324- 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
325342 }
326343
327344 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
@@ -69,7 +69,7 @@ func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {
69 <-probed69 <-probed
70 require.True(t, stream.atCapacity())70 require.True(t, stream.atCapacity())
71 71
72- stream.ack(1)72+ stream.ack(1, 1)
73 require.False(t, stream.atCapacity())73 require.False(t, stream.atCapacity())
74 stream.rebase()74 stream.rebase()
75 require.False(t, stream.atCapacity())75 require.False(t, stream.atCapacity())
@@ -183,11 +183,24 @@ func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) {
183 }183 }
184 }184 }
185 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+
186 func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) {197 func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) {
187 stream := newOutputStateStream()198 stream := newOutputStateStream()
188 stream.ack(1, 0)199 stream.ack(1, 0)
189 beforeEpoch, beforeNext, beforeAck := stream.epoch, stream.next, stream.acked200 beforeEpoch, beforeNext, beforeAck := stream.epoch, stream.next, stream.acked
190- out, err := ports.UnmarshalOutput(stream.sideEffect([]byte("pty"), 9).Payload)201+ frame, err := stream.sideEffect([]byte("pty"), 9)
202+ require.NoError(t, err)
203+ out, err := ports.UnmarshalOutput(frame.Payload)
191 require.NoError(t, err)204 require.NoError(t, err)
192 require.Equal(t, beforeEpoch, out.Epoch)205 require.Equal(t, beforeEpoch, out.Epoch)
193 require.Zero(t, out.Base)206 require.Zero(t, out.Base)
@@ -274,7 +287,7 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
274 require.NoError(t, err)287 require.NoError(t, err)
275 require.True(t, ok)288 require.True(t, ok)
276 mustApplyOutput(t, client, first)289 mustApplyOutput(t, client, first)
277- stream.ack(1)290+ stream.ack(1, 1)
278 291
279 scrolled := renderer.NewFrame(4, 3)292 scrolled := renderer.NewFrame(4, 3)
280 fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "})293 fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "})
@@ -321,7 +334,11 @@ func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAc
321 if reset {334 if reset {
322 base = 0335 base = 0
323 }336 }
324- 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
325 }342 }
326 343
327 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {344 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
M internal/usecase/daemon/output_state_sync_test.go +16 -12
@@ -67,7 +67,6 @@ func (t *datagramTestTransport) Recv() (ports.Frame, error) {
6767 func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }
6868
6969 func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
70- t.Skip("fixture is coupled to pre-refactor attach paint timing")
7170 p, releasePTY := newBlockingPTY(t)
7271 defer releasePTY()
7372 d, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -82,30 +81,35 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
8281 require.NoError(t, err)
8382 require.Same(t, sess, routed)
8483
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+
8590 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
8691 d.paint(sess, ac, false, nil)
8792 first := awaitFrame(t, tr.sends, ports.MsgOutput)
8893 out, err := ports.UnmarshalOutput(first.Payload)
8994 require.NoError(t, err)
9095 require.Equal(t, uint64(1), out.New)
96+
97+ sess.tabs[0].focusedPane().screen.Write([]byte("\rB"))
98+ d.paint(sess, ac, false, nil)
9199 updated := awaitFrame(t, tr.sends, ports.MsgOutput)
92100 out, err = ports.UnmarshalOutput(updated.Payload)
93101 require.NoError(t, err)
94102 require.Equal(t, uint64(2), out.New)
95-
96- // Before the MsgAck, the renderer has already advanced along the ordered
97- // output dependency chain, so an unchanged repaint is a no-op.
98- d.paint(sess, ac, false, nil)
99- requireNoOutputFrame(t, tr.sends)
100-
101- tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{State: 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})}
102106 require.NoError(t, tr.Close())
103107 d.runConnLoop(ac)
104108
105109 // The production MsgAck path retires retained states without moving the
106110 // renderer baseline backward.
107111 d.paint(sess, ac, false, nil)
108- requireNoOutputFrame(t, tr.sends)
112+ require.Empty(t, tr.sends)
109113 }
110114
111115 func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {
@@ -151,7 +155,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
151155 client := vt.NewScreen(80, 25)
152156 d.paint(sess, ac, true, nil)
153157 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
154- ac.ackOutputState(first.New)
158+ ac.ackOutputState(first.Epoch, first.New)
155159
156160 // Preserve the frame after one scroll while inducing a real VT scroll
157161 // damage event before each production paint. On main, both unacknowledged
@@ -231,7 +235,7 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
231235 d.paint(sess, ac, false, nil)
232236 awaitFrame(t, sends, ports.MsgOutput)
233237
234- ac.ackOutputState(out.New)
238+ ac.ackOutputState(out.Epoch, out.New)
235239 d.paint(sess, ac, false, nil)
236240 requireNoOutputFrame(t, sends)
237241 }
@@ -300,7 +304,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
300304 d.paint(sess, ac, true, nil)
301305 client := vt.NewScreen(80, 24)
302306 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303- ac.ackOutputState(initial.New)
307+ ac.ackOutputState(initial.Epoch, initial.New)
304308
305309 d.sessWg.Add(1)
306310 pane.onExit = func() {}
@@ -67,7 +67,6 @@ func (t *datagramTestTransport) Recv() (ports.Frame, error) {
67 func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }67 func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }
68 68
69 func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {69 func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
70- t.Skip("fixture is coupled to pre-refactor attach paint timing")
71 p, releasePTY := newBlockingPTY(t)70 p, releasePTY := newBlockingPTY(t)
72 defer releasePTY()71 defer releasePTY()
73 d, sess, _, _ := newManualSessionWithPTYs(t, p)72 d, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -82,30 +81,35 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
82 require.NoError(t, err)81 require.NoError(t, err)
83 require.Same(t, sess, routed)82 require.Same(t, sess, routed)
84 83
84+ // route publishes membership only; mark the attachment ready before the
85+ // direct pipeline paints so coordinator admission is deterministic.
86+ rc := sess.renderCoordinator()
87+ require.NotNil(t, rc)
88+ require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac)))
89+
85 sess.tabs[0].focusedPane().screen.Write([]byte("A"))90 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
86 d.paint(sess, ac, false, nil)91 d.paint(sess, ac, false, nil)
87 first := awaitFrame(t, tr.sends, ports.MsgOutput)92 first := awaitFrame(t, tr.sends, ports.MsgOutput)
88 out, err := ports.UnmarshalOutput(first.Payload)93 out, err := ports.UnmarshalOutput(first.Payload)
89 require.NoError(t, err)94 require.NoError(t, err)
90 require.Equal(t, uint64(1), out.New)95 require.Equal(t, uint64(1), out.New)
96+
97+ sess.tabs[0].focusedPane().screen.Write([]byte("\rB"))
98+ d.paint(sess, ac, false, nil)
91 updated := awaitFrame(t, tr.sends, ports.MsgOutput)99 updated := awaitFrame(t, tr.sends, ports.MsgOutput)
92 out, err = ports.UnmarshalOutput(updated.Payload)100 out, err = ports.UnmarshalOutput(updated.Payload)
93 require.NoError(t, err)101 require.NoError(t, err)
94 require.Equal(t, uint64(2), out.New)102 require.Equal(t, uint64(2), out.New)
95-103+ // Both states were emitted before the MsgAck; the ordered dependency chain
96- // Before the MsgAck, the renderer has already advanced along the ordered104+ // must therefore remain valid without waiting for renderer acknowledgement.
97- // output dependency chain, so an unchanged repaint is a no-op.105+ tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: out.Epoch, State: out.New})}
98- d.paint(sess, ac, false, nil)
99- requireNoOutputFrame(t, tr.sends)
100-
101- tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{State: out.New})}
102 require.NoError(t, tr.Close())106 require.NoError(t, tr.Close())
103 d.runConnLoop(ac)107 d.runConnLoop(ac)
104 108
105 // The production MsgAck path retires retained states without moving the109 // The production MsgAck path retires retained states without moving the
106 // renderer baseline backward.110 // renderer baseline backward.
107 d.paint(sess, ac, false, nil)111 d.paint(sess, ac, false, nil)
108- requireNoOutputFrame(t, tr.sends)112+ require.Empty(t, tr.sends)
109 }113 }
110 114
111 func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {115 func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) {
@@ -151,7 +155,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) {
151 client := vt.NewScreen(80, 25)155 client := vt.NewScreen(80, 25)
152 d.paint(sess, ac, true, nil)156 d.paint(sess, ac, true, nil)
153 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))157 first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput))
154- ac.ackOutputState(first.New)158+ ac.ackOutputState(first.Epoch, first.New)
155 159
156 // Preserve the frame after one scroll while inducing a real VT scroll160 // Preserve the frame after one scroll while inducing a real VT scroll
157 // damage event before each production paint. On main, both unacknowledged161 // damage event before each production paint. On main, both unacknowledged
@@ -231,7 +235,7 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
231 d.paint(sess, ac, false, nil)235 d.paint(sess, ac, false, nil)
232 awaitFrame(t, sends, ports.MsgOutput)236 awaitFrame(t, sends, ports.MsgOutput)
233 237
234- ac.ackOutputState(out.New)238+ ac.ackOutputState(out.Epoch, out.New)
235 d.paint(sess, ac, false, nil)239 d.paint(sess, ac, false, nil)
236 requireNoOutputFrame(t, sends)240 requireNoOutputFrame(t, sends)
237 }241 }
@@ -300,7 +304,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) {
300 d.paint(sess, ac, true, nil)304 d.paint(sess, ac, true, nil)
301 client := vt.NewScreen(80, 24)305 client := vt.NewScreen(80, 24)
302 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))306 initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
303- ac.ackOutputState(initial.New)307+ ac.ackOutputState(initial.Epoch, initial.New)
304 308
305 d.sessWg.Add(1)309 d.sessWg.Add(1)
306 pane.onExit = func() {}310 pane.onExit = func() {}
M internal/usecase/daemon/performance_benchmark_test.go +5 -5
@@ -575,7 +575,7 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
575575 fixture.resetMetrics()
576576 for b.Loop() {
577577 run(fixture)
578- fixture.ac.ackOutputState(fixture.ac.output.next)
578+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
579579 }
580580 metrics := fixture.metrics()
581581 if payload := fixture.output.lastPayload(); payload != nil {
@@ -594,12 +594,12 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
594594 func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) {
595595 b.Helper()
596596 fixture.d.enterCopyMode(fixture.sess, fixture.ac)
597- fixture.ac.ackOutputState(fixture.ac.output.next)
597+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
598598 b.ReportAllocs()
599599 b.ResetTimer()
600600 for range b.N {
601601 run(fixture)
602- fixture.ac.ackOutputState(fixture.ac.output.next)
602+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
603603 }
604604 b.StopTimer()
605605 if fixture.searchMatches() == 0 {
@@ -969,7 +969,7 @@ func TestLivePaintAllocationBudget(t *testing.T) {
969969 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
970970 allocs := testing.AllocsPerRun(20, func() {
971971 fixture.paintLive()
972- fixture.ac.ackOutputState(fixture.ac.output.next)
972+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
973973 })
974974 require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")
975975 })
@@ -994,7 +994,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) {
994994 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
995995 run := func() {
996996 fixture.d.enterCopyMode(fixture.sess, fixture.ac)
997- fixture.ac.ackOutputState(fixture.ac.output.next)
997+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
998998 }
999999
10001000 // Always exercise and validate copy entry. The race detector adds
@@ -575,7 +575,7 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
575 fixture.resetMetrics()575 fixture.resetMetrics()
576 for b.Loop() {576 for b.Loop() {
577 run(fixture)577 run(fixture)
578- fixture.ac.ackOutputState(fixture.ac.output.next)578+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
579 }579 }
580 metrics := fixture.metrics()580 metrics := fixture.metrics()
581 if payload := fixture.output.lastPayload(); payload != nil {581 if payload := fixture.output.lastPayload(); payload != nil {
@@ -594,12 +594,12 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor
594 func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) {594 func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) {
595 b.Helper()595 b.Helper()
596 fixture.d.enterCopyMode(fixture.sess, fixture.ac)596 fixture.d.enterCopyMode(fixture.sess, fixture.ac)
597- fixture.ac.ackOutputState(fixture.ac.output.next)597+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
598 b.ReportAllocs()598 b.ReportAllocs()
599 b.ResetTimer()599 b.ResetTimer()
600 for range b.N {600 for range b.N {
601 run(fixture)601 run(fixture)
602- fixture.ac.ackOutputState(fixture.ac.output.next)602+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
603 }603 }
604 b.StopTimer()604 b.StopTimer()
605 if fixture.searchMatches() == 0 {605 if fixture.searchMatches() == 0 {
@@ -969,7 +969,7 @@ func TestLivePaintAllocationBudget(t *testing.T) {
969 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})969 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000})
970 allocs := testing.AllocsPerRun(20, func() {970 allocs := testing.AllocsPerRun(20, func() {
971 fixture.paintLive()971 fixture.paintLive()
972- fixture.ac.ackOutputState(fixture.ac.output.next)972+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
973 })973 })
974 require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")974 require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints")
975 })975 })
@@ -994,7 +994,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) {
994 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})994 fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000})
995 run := func() {995 run := func() {
996 fixture.d.enterCopyMode(fixture.sess, fixture.ac)996 fixture.d.enterCopyMode(fixture.sess, fixture.ac)
997- fixture.ac.ackOutputState(fixture.ac.output.next)997+ fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next)
998 }998 }
999 999
1000 // Always exercise and validate copy entry. The race detector adds1000 // Always exercise and validate copy entry. The race detector adds
M internal/usecase/daemon/picker_test.go +1 -1
@@ -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.New)
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.
@@ -915,7 +915,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) {
915 client := vt.NewScreen(80, 25)915 client := vt.NewScreen(80, 25)
916 d.firstPaint(source, ac, ac.size)916 d.firstPaint(source, ac, ac.size)
917 sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))917 sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
918- ac.ackOutputState(sourceOutput.New)918+ ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New)
919 919
920 targetPane.screen.Write([]byte("TARGET"))920 targetPane.screen.Write([]byte("TARGET"))
921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.921 targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage.
M internal/usecase/daemon/proxy_input_test.go +1 -1
@@ -496,7 +496,7 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T)
496496 request := requireProxyCommandRequest(t, link)
497497 _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
498498 oldGeneration := proxy.linkGeneration
499- newGeneration, _ := proxy.installTransport(newProxyTestTransport(), false)
499+ newGeneration, _ := proxy.installTransport(newProxyTestTransport())
500500 require.Greater(t, newGeneration, oldGeneration)
501501 require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))
502502 _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
@@ -496,7 +496,7 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T)
496 request := requireProxyCommandRequest(t, link)496 request := requireProxyCommandRequest(t, link)
497 _ = awaitTestValue(t, clock.timers, "command did not arm timeout")497 _ = awaitTestValue(t, clock.timers, "command did not arm timeout")
498 oldGeneration := proxy.linkGeneration498 oldGeneration := proxy.linkGeneration
499- newGeneration, _ := proxy.installTransport(newProxyTestTransport(), false)499+ newGeneration, _ := proxy.installTransport(newProxyTestTransport())
500 require.Greater(t, newGeneration, oldGeneration)500 require.Greater(t, newGeneration, oldGeneration)
501 require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))501 require.Error(t, awaitTestValue(t, done, "replacement did not cancel command"))
502 _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))502 _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true}))
M internal/usecase/daemon/proxy_lifecycle_test.go +4 -4
@@ -1216,7 +1216,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
12161216 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
12171217 _ = requireProxyHello(t, first)
12181218
1219- base, cancel := context.WithCancel(context.Background())
1219+ base, cancel := context.WithCancel(t.Context())
12201220 observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})}
12211221 waiter := openProxyForTest(d, observed, key)
12221222 select {
@@ -1409,7 +1409,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
14091409 d, p, clock := newProxyLifecycleFixture(t)
14101410 transport := newProxyTestTransport()
14111411 t.Cleanup(func() { _ = transport.Close() })
1412- generation, _ := p.installTransport(transport, false)
1412+ generation, _ := p.installTransport(transport)
14131413 token, timer := armWarmTimer(t, d, p, clock)
14141414
14151415 require.True(t, d.markProxyReplaced(p, generation, transport))
@@ -1433,7 +1433,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
14331433 d, p, clock := newProxyLifecycleFixture(t)
14341434 transport := newProxyTestTransport()
14351435 t.Cleanup(func() { _ = transport.Close() })
1436- generation, _ := p.installTransport(transport, false)
1436+ generation, _ := p.installTransport(transport)
14371437
14381438 require.True(t, d.markProxyReplaced(p, generation, transport))
14391439 timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
@@ -1450,7 +1450,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
14501450 d, p, _ := newProxyLifecycleFixture(t)
14511451 transport := newProxyTestTransport()
14521452 t.Cleanup(func() { _ = transport.Close() })
1453- generation, _ := p.installTransport(transport, false)
1453+ generation, _ := p.installTransport(transport)
14541454
14551455 require.False(t, d.markProxyReplaced(p, generation-1, transport))
14561456 require.False(t, proxyExpired(p))
@@ -1216,7 +1216,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) {
1216 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))1216 require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial"))
1217 _ = requireProxyHello(t, first)1217 _ = requireProxyHello(t, first)
1218 1218
1219- base, cancel := context.WithCancel(context.Background())1219+ base, cancel := context.WithCancel(t.Context())
1220 observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})}1220 observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})}
1221 waiter := openProxyForTest(d, observed, key)1221 waiter := openProxyForTest(d, observed, key)
1222 select {1222 select {
@@ -1409,7 +1409,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1409 d, p, clock := newProxyLifecycleFixture(t)1409 d, p, clock := newProxyLifecycleFixture(t)
1410 transport := newProxyTestTransport()1410 transport := newProxyTestTransport()
1411 t.Cleanup(func() { _ = transport.Close() })1411 t.Cleanup(func() { _ = transport.Close() })
1412- generation, _ := p.installTransport(transport, false)1412+ generation, _ := p.installTransport(transport)
1413 token, timer := armWarmTimer(t, d, p, clock)1413 token, timer := armWarmTimer(t, d, p, clock)
1414 1414
1415 require.True(t, d.markProxyReplaced(p, generation, transport))1415 require.True(t, d.markProxyReplaced(p, generation, transport))
@@ -1433,7 +1433,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1433 d, p, clock := newProxyLifecycleFixture(t)1433 d, p, clock := newProxyLifecycleFixture(t)
1434 transport := newProxyTestTransport()1434 transport := newProxyTestTransport()
1435 t.Cleanup(func() { _ = transport.Close() })1435 t.Cleanup(func() { _ = transport.Close() })
1436- generation, _ := p.installTransport(transport, false)1436+ generation, _ := p.installTransport(transport)
1437 1437
1438 require.True(t, d.markProxyReplaced(p, generation, transport))1438 require.True(t, d.markProxyReplaced(p, generation, transport))
1439 timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")1439 timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer")
@@ -1450,7 +1450,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) {
1450 d, p, _ := newProxyLifecycleFixture(t)1450 d, p, _ := newProxyLifecycleFixture(t)
1451 transport := newProxyTestTransport()1451 transport := newProxyTestTransport()
1452 t.Cleanup(func() { _ = transport.Close() })1452 t.Cleanup(func() { _ = transport.Close() })
1453- generation, _ := p.installTransport(transport, false)1453+ generation, _ := p.installTransport(transport)
1454 1454
1455 require.False(t, d.markProxyReplaced(p, generation-1, transport))1455 require.False(t, d.markProxyReplaced(p, generation-1, transport))
1456 require.False(t, proxyExpired(p))1456 require.False(t, proxyExpired(p))
M internal/usecase/daemon/proxy_link.go +2 -27
@@ -102,7 +102,7 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int
102102 if !resume {
103103 resumeToken = 0
104104 }
105- generation, previous := p.installTransport(transport, resume)
105+ generation, previous := p.installTransport(transport)
106106 if previous != nil && previous != transport {
107107 _ = previous.Close()
108108 }
@@ -248,20 +248,6 @@ func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome,
248248 return welcome, nil
249249 }
250250
251-func validateProxyInitialMeta(frame ports.Frame, sessionName string) (ports.SessionMeta, error) {
252- if frame.Type != ports.MsgSessionMeta {
253- return ports.SessionMeta{}, fmt.Errorf("proxy session: second server frame is %d, want SessionMeta", frame.Type)
254- }
255- meta, err := ports.UnmarshalSessionMeta(frame.Payload)
256- if err != nil {
257- return ports.SessionMeta{}, fmt.Errorf("proxy session: decode initial metadata: %w", err)
258- }
259- if meta.SessionName != sessionName {
260- return ports.SessionMeta{}, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
261- }
262- return meta, nil
263-}
264-
265251 func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
266252 result := make(chan proxyRecvResult, 1)
267253 go func() {
@@ -281,7 +267,7 @@ func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (po
281267 }
282268 }
283269
284-func (p *proxySession) installTransport(transport ports.Transport, _ bool) (uint64, ports.Transport) {
270+func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
285271 // Link replacement participates in the same sender serialization as every
286272 // frame. A request can therefore only be published wholly before the
287273 // generation changes or wholly after it.
@@ -317,17 +303,6 @@ func (p *proxySession) retireTransport(generation uint64, transport ports.Transp
317303 }
318304 }
319305
320-// Every proxy Transport.Send path is serialized by sendMu: Hello, input,
321-// resize, ACK/reset, ping/pong, and command traffic. The leaf proxy lock is
322-// held only long enough to revalidate
323-// the immutable link snapshot; I/O happens after releasing it.
324-func (p *proxySession) sendCurrent(frame ports.Frame) error {
325- p.mu.Lock()
326- generation := p.linkGeneration
327- p.mu.Unlock()
328- return p.sendGeneration(generation, frame)
329-}
330-
331306 func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
332307 p.sendMu.Lock()
333308 defer p.sendMu.Unlock()
@@ -102,7 +102,7 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int
102 if !resume {102 if !resume {
103 resumeToken = 0103 resumeToken = 0
104 }104 }
105- generation, previous := p.installTransport(transport, resume)105+ generation, previous := p.installTransport(transport)
106 if previous != nil && previous != transport {106 if previous != nil && previous != transport {
107 _ = previous.Close()107 _ = previous.Close()
108 }108 }
@@ -248,20 +248,6 @@ func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome,
248 return welcome, nil248 return welcome, nil
249 }249 }
250 250
251-func validateProxyInitialMeta(frame ports.Frame, sessionName string) (ports.SessionMeta, error) {
252- if frame.Type != ports.MsgSessionMeta {
253- return ports.SessionMeta{}, fmt.Errorf("proxy session: second server frame is %d, want SessionMeta", frame.Type)
254- }
255- meta, err := ports.UnmarshalSessionMeta(frame.Payload)
256- if err != nil {
257- return ports.SessionMeta{}, fmt.Errorf("proxy session: decode initial metadata: %w", err)
258- }
259- if meta.SessionName != sessionName {
260- return ports.SessionMeta{}, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName)
261- }
262- return meta, nil
263-}
264-
265 func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {251 func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) {
266 result := make(chan proxyRecvResult, 1)252 result := make(chan proxyRecvResult, 1)
267 go func() {253 go func() {
@@ -281,7 +267,7 @@ func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (po
281 }267 }
282 }268 }
283 269
284-func (p *proxySession) installTransport(transport ports.Transport, _ bool) (uint64, ports.Transport) {270+func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) {
285 // Link replacement participates in the same sender serialization as every271 // Link replacement participates in the same sender serialization as every
286 // frame. A request can therefore only be published wholly before the272 // frame. A request can therefore only be published wholly before the
287 // generation changes or wholly after it.273 // generation changes or wholly after it.
@@ -317,17 +303,6 @@ func (p *proxySession) retireTransport(generation uint64, transport ports.Transp
317 }303 }
318 }304 }
319 305
320-// Every proxy Transport.Send path is serialized by sendMu: Hello, input,
321-// resize, ACK/reset, ping/pong, and command traffic. The leaf proxy lock is
322-// held only long enough to revalidate
323-// the immutable link snapshot; I/O happens after releasing it.
324-func (p *proxySession) sendCurrent(frame ports.Frame) error {
325- p.mu.Lock()
326- generation := p.linkGeneration
327- p.mu.Unlock()
328- return p.sendGeneration(generation, frame)
329-}
330-
331 func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {306 func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error {
332 p.sendMu.Lock()307 p.sendMu.Lock()
333 defer p.sendMu.Unlock()308 defer p.sendMu.Unlock()
M internal/usecase/daemon/proxy_output.go +5 -1
@@ -81,9 +81,13 @@ func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports
8181 }
8282 }
8383 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+ }
8488 if err := p.sendGeneration(generation, ports.Frame{
8589 Type: ports.MsgAck,
86- Payload: ports.MarshalAck(ports.Ack{Epoch: out.Epoch, State: ack}),
90+ Payload: payload,
8791 }); err != nil {
8892 return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err)
8993 }
@@ -81,9 +81,13 @@ func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports
81 }81 }
82 }82 }
83 if ack != 0 {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+ }
84 if err := p.sendGeneration(generation, ports.Frame{88 if err := p.sendGeneration(generation, ports.Frame{
85 Type: ports.MsgAck,89 Type: ports.MsgAck,
86- Payload: ports.MarshalAck(ports.Ack{Epoch: out.Epoch, State: ack}),90+ Payload: payload,
87 }); err != nil {91 }); err != nil {
88 return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err)92 return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err)
89 }93 }
M internal/usecase/daemon/proxy_output_test.go +33 -47
@@ -8,6 +8,19 @@ import (
88 "github.com/stretchr/testify/require"
99 )
1010
11+func newProxyOutputAttachment(t *testing.T, proxy *proxySession, id byte) (*attachedClient, *proxyTestTransport) {
12+ t.Helper()
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
22+}
23+
1124 func TestProxyOutputRejectsStaleStateAndRequestsOneReset(t *testing.T) {
1225 tests := []struct {
1326 name string
@@ -62,32 +75,25 @@ func TestProxyOutputFansOutToEveryLiveAttachment(t *testing.T) {
6275
6376 attachments := make([]*proxyTestTransport, 0, 2)
6477 for id := byte(1); id <= 2; id++ {
65- clientTransport := newProxyTestTransport()
66- ac := &attachedClient{tr: clientTransport, output: newOutputStateStream(), size: proxy.contentSize}
67- ac.output.attachment = ac
68- ac.clientID[0] = id
69- ac.setSession(proxy)
70- proxy.sessionCore.mu.Lock()
71- require.True(t, proxy.registerAttachmentLocked(ac))
72- proxy.sessionCore.mu.Unlock()
78+ _, clientTransport := newProxyOutputAttachment(t, proxy, id)
7379 attachments = append(attachments, clientTransport)
7480 }
7581
7682 result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
7783 Type: ports.MsgOutput,
78- Payload: ports.MarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Full: true, Data: []byte("full")}),
84+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
7985 })
8086 require.NoError(t, err)
8187 require.Equal(t, proxyLinkResume, result)
8288 for _, transport := range attachments {
83- frame := <-transport.sent
89+ frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive output")
8490 require.Equal(t, ports.MsgOutput, frame.Type)
8591 out, err := ports.UnmarshalOutput(frame.Payload)
8692 require.NoError(t, err)
8793 require.Zero(t, out.New, "local proxy forwarding must not create a second ACK path")
8894 require.Equal(t, []byte("full"), out.Data)
8995 }
90- ackFrame := <-remote.sent
96+ ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive output acknowledgement")
9197 require.Equal(t, ports.MsgAck, ackFrame.Type)
9298 }
9399
@@ -99,37 +105,23 @@ func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) {
99105 proxy.linkGeneration = 1
100106 d := &Daemon{}
101107
102- firstTransport := newProxyTestTransport()
103- first := &attachedClient{tr: firstTransport, output: newOutputStateStream(), size: proxy.contentSize}
104- first.output.attachment = first
105- first.clientID[0] = 1
106- first.setSession(proxy)
107- proxy.sessionCore.mu.Lock()
108- require.True(t, proxy.registerAttachmentLocked(first))
109- proxy.sessionCore.mu.Unlock()
108+ _, firstTransport := newProxyOutputAttachment(t, proxy, 1)
110109
111110 handle := func(output ports.Output) {
112111 t.Helper()
113- result, err := d.handleLinkFrame(proxy, 1, ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(output)})
112+ result, err := d.handleLinkFrame(proxy, 1, ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)})
114113 require.NoError(t, err)
115114 require.Equal(t, proxyLinkResume, result)
116115 }
117- handle(ports.Output{Epoch: 3, Base: 0, New: 1, Full: true, Data: []byte("before attach")})
118- <-firstTransport.sent
119- <-remote.sent
120-
121- secondTransport := newProxyTestTransport()
122- second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: proxy.contentSize}
123- second.output.attachment = second
124- second.clientID[0] = 2
125- second.setSession(proxy)
126- proxy.sessionCore.mu.Lock()
127- require.True(t, proxy.registerAttachmentLocked(second))
128- proxy.sessionCore.mu.Unlock()
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")
129119
130- handle(ports.Output{Epoch: 4, Base: 0, New: 1, Full: true, Data: []byte("current full")})
120+ _, secondTransport := newProxyOutputAttachment(t, proxy, 2)
121+
122+ handle(ports.Output{Epoch: 4, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("current full")})
131123 for _, transport := range []*proxyTestTransport{firstTransport, secondTransport} {
132- frame := <-transport.sent
124+ frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive current output")
133125 require.Equal(t, ports.MsgOutput, frame.Type)
134126 out, err := ports.UnmarshalOutput(frame.Payload)
135127 require.NoError(t, err)
@@ -137,7 +129,7 @@ func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) {
137129 require.False(t, out.Full, "local proxy side effects must remain stateless")
138130 require.Equal(t, []byte("current full"), out.Data)
139131 }
140- ackFrame := <-remote.sent
132+ ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive current acknowledgement")
141133 require.Equal(t, ports.MsgAck, ackFrame.Type)
142134 }
143135
@@ -147,26 +139,20 @@ func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) {
147139 remote := newProxyTestTransport()
148140 proxy.transport = remote
149141 proxy.linkGeneration = 1
150- clientTransport := newProxyTestTransport()
151- ac := &attachedClient{tr: clientTransport, output: newOutputStateStream(), size: proxy.contentSize}
152- ac.output.attachment = ac
153- ac.setSession(proxy)
154- proxy.sessionCore.mu.Lock()
155- require.True(t, proxy.registerAttachmentLocked(ac))
156- proxy.sessionCore.mu.Unlock()
142+ _, clientTransport := newProxyOutputAttachment(t, proxy, 1)
157143 d := &Daemon{}
158144
159145 _, err = d.handleLinkFrame(proxy, 1, ports.Frame{
160146 Type: ports.MsgOutput,
161- Payload: ports.MarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Full: true, Data: []byte("full")}),
147+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
162148 })
163149 require.NoError(t, err)
164- <-clientTransport.sent
165- <-remote.sent
150+ _ = awaitTestValue(t, clientTransport.sent, "proxy attachment did not receive initial output")
151+ _ = awaitTestValue(t, remote.sent, "proxy remote did not receive initial acknowledgement")
166152
167153 result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
168154 Type: ports.MsgOutput,
169- Payload: ports.MarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Full: true, Data: []byte("stale")}),
155+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("stale")}),
170156 })
171157 require.NoError(t, err)
172158 require.Equal(t, proxyLinkResume, result)
@@ -175,6 +161,6 @@ func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) {
175161 t.Fatalf("stale proxy output forwarded as %v", frame.Type)
176162 default:
177163 }
178- reset := <-remote.sent
164+ reset := awaitTestValue(t, remote.sent, "proxy remote did not receive reset request")
179165 require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
180166 }
@@ -8,6 +8,19 @@ import (
8 "github.com/stretchr/testify/require"8 "github.com/stretchr/testify/require"
9 )9 )
10 10
11+func newProxyOutputAttachment(t *testing.T, proxy *proxySession, id byte) (*attachedClient, *proxyTestTransport) {
12+ t.Helper()
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
22+}
23+
11 func TestProxyOutputRejectsStaleStateAndRequestsOneReset(t *testing.T) {24 func TestProxyOutputRejectsStaleStateAndRequestsOneReset(t *testing.T) {
12 tests := []struct {25 tests := []struct {
13 name string26 name string
@@ -62,32 +75,25 @@ func TestProxyOutputFansOutToEveryLiveAttachment(t *testing.T) {
62 75
63 attachments := make([]*proxyTestTransport, 0, 2)76 attachments := make([]*proxyTestTransport, 0, 2)
64 for id := byte(1); id <= 2; id++ {77 for id := byte(1); id <= 2; id++ {
65- clientTransport := newProxyTestTransport()78+ _, clientTransport := newProxyOutputAttachment(t, proxy, id)
66- ac := &attachedClient{tr: clientTransport, output: newOutputStateStream(), size: proxy.contentSize}
67- ac.output.attachment = ac
68- ac.clientID[0] = id
69- ac.setSession(proxy)
70- proxy.sessionCore.mu.Lock()
71- require.True(t, proxy.registerAttachmentLocked(ac))
72- proxy.sessionCore.mu.Unlock()
73 attachments = append(attachments, clientTransport)79 attachments = append(attachments, clientTransport)
74 }80 }
75 81
76 result, err := d.handleLinkFrame(proxy, 1, ports.Frame{82 result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
77 Type: ports.MsgOutput,83 Type: ports.MsgOutput,
78- Payload: ports.MarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Full: true, Data: []byte("full")}),84+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
79 })85 })
80 require.NoError(t, err)86 require.NoError(t, err)
81 require.Equal(t, proxyLinkResume, result)87 require.Equal(t, proxyLinkResume, result)
82 for _, transport := range attachments {88 for _, transport := range attachments {
83- frame := <-transport.sent89+ frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive output")
84 require.Equal(t, ports.MsgOutput, frame.Type)90 require.Equal(t, ports.MsgOutput, frame.Type)
85 out, err := ports.UnmarshalOutput(frame.Payload)91 out, err := ports.UnmarshalOutput(frame.Payload)
86 require.NoError(t, err)92 require.NoError(t, err)
87 require.Zero(t, out.New, "local proxy forwarding must not create a second ACK path")93 require.Zero(t, out.New, "local proxy forwarding must not create a second ACK path")
88 require.Equal(t, []byte("full"), out.Data)94 require.Equal(t, []byte("full"), out.Data)
89 }95 }
90- ackFrame := <-remote.sent96+ ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive output acknowledgement")
91 require.Equal(t, ports.MsgAck, ackFrame.Type)97 require.Equal(t, ports.MsgAck, ackFrame.Type)
92 }98 }
93 99
@@ -99,37 +105,23 @@ func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) {
99 proxy.linkGeneration = 1105 proxy.linkGeneration = 1
100 d := &Daemon{}106 d := &Daemon{}
101 107
102- firstTransport := newProxyTestTransport()108+ _, firstTransport := newProxyOutputAttachment(t, proxy, 1)
103- first := &attachedClient{tr: firstTransport, output: newOutputStateStream(), size: proxy.contentSize}
104- first.output.attachment = first
105- first.clientID[0] = 1
106- first.setSession(proxy)
107- proxy.sessionCore.mu.Lock()
108- require.True(t, proxy.registerAttachmentLocked(first))
109- proxy.sessionCore.mu.Unlock()
110 109
111 handle := func(output ports.Output) {110 handle := func(output ports.Output) {
112 t.Helper()111 t.Helper()
113- result, err := d.handleLinkFrame(proxy, 1, ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(output)})112+ result, err := d.handleLinkFrame(proxy, 1, ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)})
114 require.NoError(t, err)113 require.NoError(t, err)
115 require.Equal(t, proxyLinkResume, result)114 require.Equal(t, proxyLinkResume, result)
116 }115 }
117- handle(ports.Output{Epoch: 3, Base: 0, New: 1, Full: true, Data: []byte("before attach")})116+ handle(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("before attach")})
118- <-firstTransport.sent117+ _ = awaitTestValue(t, firstTransport.sent, "first proxy attachment did not receive output")
119- <-remote.sent118+ _ = awaitTestValue(t, remote.sent, "proxy remote did not receive first acknowledgement")
120-
121- secondTransport := newProxyTestTransport()
122- second := &attachedClient{tr: secondTransport, output: newOutputStateStream(), size: proxy.contentSize}
123- second.output.attachment = second
124- second.clientID[0] = 2
125- second.setSession(proxy)
126- proxy.sessionCore.mu.Lock()
127- require.True(t, proxy.registerAttachmentLocked(second))
128- proxy.sessionCore.mu.Unlock()
129 119
130- handle(ports.Output{Epoch: 4, Base: 0, New: 1, Full: true, Data: []byte("current full")})120+ _, secondTransport := newProxyOutputAttachment(t, proxy, 2)
121+
122+ handle(ports.Output{Epoch: 4, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("current full")})
131 for _, transport := range []*proxyTestTransport{firstTransport, secondTransport} {123 for _, transport := range []*proxyTestTransport{firstTransport, secondTransport} {
132- frame := <-transport.sent124+ frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive current output")
133 require.Equal(t, ports.MsgOutput, frame.Type)125 require.Equal(t, ports.MsgOutput, frame.Type)
134 out, err := ports.UnmarshalOutput(frame.Payload)126 out, err := ports.UnmarshalOutput(frame.Payload)
135 require.NoError(t, err)127 require.NoError(t, err)
@@ -137,7 +129,7 @@ func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) {
137 require.False(t, out.Full, "local proxy side effects must remain stateless")129 require.False(t, out.Full, "local proxy side effects must remain stateless")
138 require.Equal(t, []byte("current full"), out.Data)130 require.Equal(t, []byte("current full"), out.Data)
139 }131 }
140- ackFrame := <-remote.sent132+ ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive current acknowledgement")
141 require.Equal(t, ports.MsgAck, ackFrame.Type)133 require.Equal(t, ports.MsgAck, ackFrame.Type)
142 }134 }
143 135
@@ -147,26 +139,20 @@ func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) {
147 remote := newProxyTestTransport()139 remote := newProxyTestTransport()
148 proxy.transport = remote140 proxy.transport = remote
149 proxy.linkGeneration = 1141 proxy.linkGeneration = 1
150- clientTransport := newProxyTestTransport()142+ _, clientTransport := newProxyOutputAttachment(t, proxy, 1)
151- ac := &attachedClient{tr: clientTransport, output: newOutputStateStream(), size: proxy.contentSize}
152- ac.output.attachment = ac
153- ac.setSession(proxy)
154- proxy.sessionCore.mu.Lock()
155- require.True(t, proxy.registerAttachmentLocked(ac))
156- proxy.sessionCore.mu.Unlock()
157 d := &Daemon{}143 d := &Daemon{}
158 144
159 _, err = d.handleLinkFrame(proxy, 1, ports.Frame{145 _, err = d.handleLinkFrame(proxy, 1, ports.Frame{
160 Type: ports.MsgOutput,146 Type: ports.MsgOutput,
161- Payload: ports.MarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Full: true, Data: []byte("full")}),147+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}),
162 })148 })
163 require.NoError(t, err)149 require.NoError(t, err)
164- <-clientTransport.sent150+ _ = awaitTestValue(t, clientTransport.sent, "proxy attachment did not receive initial output")
165- <-remote.sent151+ _ = awaitTestValue(t, remote.sent, "proxy remote did not receive initial acknowledgement")
166 152
167 result, err := d.handleLinkFrame(proxy, 1, ports.Frame{153 result, err := d.handleLinkFrame(proxy, 1, ports.Frame{
168 Type: ports.MsgOutput,154 Type: ports.MsgOutput,
169- Payload: ports.MarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Full: true, Data: []byte("stale")}),155+ Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("stale")}),
170 })156 })
171 require.NoError(t, err)157 require.NoError(t, err)
172 require.Equal(t, proxyLinkResume, result)158 require.Equal(t, proxyLinkResume, result)
@@ -175,6 +161,6 @@ func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) {
175 t.Fatalf("stale proxy output forwarded as %v", frame.Type)161 t.Fatalf("stale proxy output forwarded as %v", frame.Type)
176 default:162 default:
177 }163 }
178- reset := <-remote.sent164+ reset := awaitTestValue(t, remote.sent, "proxy remote did not receive reset request")
179 require.Equal(t, ports.MsgOutputResetRequest, reset.Type)165 require.Equal(t, ports.MsgOutputResetRequest, reset.Type)
180 }166 }
M internal/usecase/daemon/proxy_session.go +5 -1
@@ -66,6 +66,10 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
6666 if p == nil || !size.Valid() {
6767 return false, false
6868 }
69+ payload, err := ports.MarshalResize(ports.Resize{Size: size})
70+ if err != nil {
71+ return false, false
72+ }
6973 p.mu.Lock()
7074 if p.contentSize == size {
7175 available := p.transport != nil
@@ -77,7 +81,7 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
7781 p.mu.Unlock()
7882 return true, p.sendGeneration(generation, ports.Frame{
7983 Type: ports.MsgResize,
80- Payload: ports.MarshalResize(ports.Resize{Size: size}),
84+ Payload: payload,
8185 }) == nil
8286 }
8387
@@ -66,6 +66,10 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
66 if p == nil || !size.Valid() {66 if p == nil || !size.Valid() {
67 return false, false67 return false, false
68 }68 }
69+ payload, err := ports.MarshalResize(ports.Resize{Size: size})
70+ if err != nil {
71+ return false, false
72+ }
69 p.mu.Lock()73 p.mu.Lock()
70 if p.contentSize == size {74 if p.contentSize == size {
71 available := p.transport != nil75 available := p.transport != nil
@@ -77,7 +81,7 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) {
77 p.mu.Unlock()81 p.mu.Unlock()
78 return true, p.sendGeneration(generation, ports.Frame{82 return true, p.sendGeneration(generation, ports.Frame{
79 Type: ports.MsgResize,83 Type: ports.MsgResize,
80- Payload: ports.MarshalResize(ports.Resize{Size: size}),84+ Payload: payload,
81 }) == nil85 }) == nil
82 }86 }
83 87
M internal/usecase/daemon/proxy_test_helpers_test.go +3 -2
@@ -8,6 +8,7 @@ import (
88 "testing"
99 "time"
1010
11+ "github.com/bnema/vev/internal/domain"
1112 "github.com/bnema/vev/internal/ports"
1213 "github.com/stretchr/testify/require"
1314 )
@@ -87,8 +88,8 @@ func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
8788 }
8889
8990 func proxyHandshakeSnapshot() ports.Frame {
90- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
91- Epoch: 1, Base: 0, New: 1, Full: true, Data: []byte("remote initial\n"),
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"),
9293 })}
9394 }
9495
@@ -8,6 +8,7 @@ import (
8 "testing"8 "testing"
9 "time"9 "time"
10 10
11+ "github.com/bnema/vev/internal/domain"
11 "github.com/bnema/vev/internal/ports"12 "github.com/bnema/vev/internal/ports"
12 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
13 )14 )
@@ -87,8 +88,8 @@ func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame {
87 }88 }
88 89
89 func proxyHandshakeSnapshot() ports.Frame {90 func proxyHandshakeSnapshot() ports.Frame {
90- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{91+ return ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{
91- Epoch: 1, Base: 0, New: 1, Full: true, Data: []byte("remote initial\n"),92+ Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("remote initial\n"),
92 })}93 })}
93 }94 }
94 95
M internal/usecase/daemon/render_pipeline_test.go +4 -1
@@ -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
@@ -59,7 +59,7 @@ func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) {
59 require.NoError(t, transport.Send(frame))59 require.NoError(t, transport.Send(frame))
60 output, err := ports.UnmarshalOutput(frame.Payload)60 output, err := ports.UnmarshalOutput(frame.Payload)
61 require.NoError(t, err)61 require.NoError(t, err)
62- require.Equal(t, frame.Payload, ports.MarshalOutput(output), "output payload must remain byte exact")62+ require.Equal(t, frame.Payload, mustMarshalOutput(output), "output payload must remain byte exact")
63 terminal.Write(output.Data)63 terminal.Write(output.Data)
64 }64 }
65 require.Equal(t, len(frames), transport.next)65 require.Equal(t, len(frames), transport.next)
@@ -128,6 +128,9 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) {
128 t.Cleanup(release)128 t.Cleanup(release)
129 d, sess, _, sends := newManualSessionWithPTYs(t, pty)129 d, sess, _, sends := newManualSessionWithPTYs(t, pty)
130 ac := sess.snapshotAttachments()[0]130 ac := sess.snapshotAttachments()[0]
131+ // Resolve the initial attachment view before filling the output window; a
132+ // first target repair is an epoch boundary by design.
133+ require.NotNil(t, sess.tabForAttachment(ac))
131 ac.sendMu.Lock()134 ac.sendMu.Lock()
132 ac.output.next = ac.output.maxOutstanding135 ac.output.next = ac.output.maxOutstanding
133 ac.output.acked = 0136 ac.output.acked = 0
M internal/usecase/daemon/resume_test.go +5 -4
@@ -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()
@@ -1083,7 +1084,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
10831084 require.NoError(t, err)
10841085 require.Zero(t, first.Base)
10851086 require.Equal(t, uint64(1), first.New)
1086- resumedAC.ackOutputState(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)
@@ -974,9 +974,10 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
974 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {974 func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
975 ac := &attachedClient{output: newOutputStateStream()}975 ac := &attachedClient{output: newOutputStateStream()}
976 ac.output.next = 3976 ac.output.next = 3
977- ac.ackOutputState(3)977+ epoch := ac.output.currentEpoch()
978- ac.ackOutputState(2)978+ ac.ackOutputState(epoch, 3)
979- ac.ackOutputState(4)979+ ac.ackOutputState(epoch, 2)
980+ ac.ackOutputState(epoch, 4)
980 require.Equal(t, uint64(3), ac.output.acked, "stale or future ACKs must not move output state incorrectly")981 require.Equal(t, uint64(3), ac.output.acked, "stale or future ACKs must not move output state incorrectly")
981 982
982 ac.sendMu.Lock()983 ac.sendMu.Lock()
@@ -1083,7 +1084,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) {
1083 require.NoError(t, err)1084 require.NoError(t, err)
1084 require.Zero(t, first.Base)1085 require.Zero(t, first.Base)
1085 require.Equal(t, uint64(1), first.New)1086 require.Equal(t, uint64(1), first.New)
1086- resumedAC.ackOutputState(first.New)1087+ resumedAC.ackOutputState(first.Epoch, first.New)
1087 1088
1088 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))1089 resumedSess.tabs[0].focusedPane().screen.Write([]byte("A"))
1089 d.paint(resumedSess, resumedAC, false, nil)1090 d.paint(resumedSess, resumedAC, false, nil)
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
@@ -1507,7 +1507,7 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) {
1507 frameInput([]byte("\x1b ")),1507 frameInput([]byte("\x1b ")),
1508 frameInput([]byte("CNT\r")),1508 frameInput([]byte("CNT\r")),
1509 frameInput([]byte("\x1b1")),1509 frameInput([]byte("\x1b1")),
1510- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},1510+ ports.Frame{Type: ports.MsgResize, Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})},
1511 )1511 )
1512 1512
1513 var hg sync.WaitGroup1513 var hg sync.WaitGroup
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.New)
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.New)
126+ ac.ackOutputState(output.Epoch, output.New)
127127
128128 pane := testAttachmentTab(sess).focusedPane()
129129 for _, lock := range []struct {
@@ -36,7 +36,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) {
36 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) {36 if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) {
37 t.Fatal("second resize was not accepted")37 t.Fatal("second resize was not accepted")
38 }38 }
39- ac.ackOutputState(output.New)39+ ac.ackOutputState(output.Epoch, output.New)
40 rc.notifyAck()40 rc.notifyAck()
41 second := awaitFrame(t, sends, ports.MsgOutput)41 second := awaitFrame(t, sends, ports.MsgOutput)
42 if second.Type != ports.MsgOutput {42 if second.Type != ports.MsgOutput {
@@ -123,7 +123,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t
123 if err != nil {123 if err != nil {
124 t.Fatalf("decode emitted output: %v", err)124 t.Fatalf("decode emitted output: %v", err)
125 }125 }
126- ac.ackOutputState(output.New)126+ ac.ackOutputState(output.Epoch, output.New)
127 127
128 pane := testAttachmentTab(sess).focusedPane()128 pane := testAttachmentTab(sess).focusedPane()
129 for _, lock := range []struct {129 for _, lock := range []struct {
M internal/usecase/daemon/wire.go +7 -3
@@ -21,11 +21,15 @@ func frameError(code uint16, text string) ports.Frame {
2121 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
2222 }
2323
24-func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) ports.Frame {
25- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{
24+func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) (ports.Frame, error) {
25+ payload, err := ports.MarshalOutput(ports.Output{
2626 Epoch: 1, Base: baseState, New: state, Echo: echoAck,
2727 Size: domain.Size{Cols: 1, Rows: 1}, Full: state != 0 && baseState == 0, Data: b,
28- })}
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 {
@@ -21,11 +21,15 @@ func frameError(code uint16, text string) ports.Frame {
21 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}21 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
22 }22 }
23 23
24-func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) ports.Frame {24+func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) (ports.Frame, error) {
25- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{25+ payload, err := ports.MarshalOutput(ports.Output{
26 Epoch: 1, Base: baseState, New: state, Echo: echoAck,26 Epoch: 1, Base: baseState, New: state, Echo: echoAck,
27 Size: domain.Size{Cols: 1, Rows: 1}, Full: state != 0 && baseState == 0, Data: b,27 Size: domain.Size{Cols: 1, Rows: 1}, Full: state != 0 && baseState == 0, Data: b,
28- })}28+ })
29+ if err != nil {
30+ return ports.Frame{}, err
31+ }
32+ return ports.Frame{Type: ports.MsgOutput, Payload: payload}, nil
29 }33 }
30 34
31 func frameDetached(reason uint8) ports.Frame {35 func frameDetached(reason uint8) ports.Frame {
A internal/usecase/daemon/wire_test_helpers_test.go +27 -0
@@ -0,0 +1,27 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalOutput(m ports.Output) []byte {
6+ payload, err := ports.MarshalOutput(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
12+
13+func mustMarshalAck(m ports.Ack) []byte {
14+ payload, err := ports.MarshalAck(m)
15+ if err != nil {
16+ panic(err)
17+ }
18+ return payload
19+}
20+
21+func mustMarshalResize(m ports.Resize) []byte {
22+ payload, err := ports.MarshalResize(m)
23+ if err != nil {
24+ panic(err)
25+ }
26+ return payload
27+}
@@ -0,0 +1,27 @@
1+package daemon
2+
3+import "github.com/bnema/vev/internal/ports"
4+
5+func mustMarshalOutput(m ports.Output) []byte {
6+ payload, err := ports.MarshalOutput(m)
7+ if err != nil {
8+ panic(err)
9+ }
10+ return payload
11+}
12+
13+func mustMarshalAck(m ports.Ack) []byte {
14+ payload, err := ports.MarshalAck(m)
15+ if err != nil {
16+ panic(err)
17+ }
18+ return payload
19+}
20+
21+func mustMarshalResize(m ports.Resize) []byte {
22+ payload, err := ports.MarshalResize(m)
23+ if err != nil {
24+ panic(err)
25+ }
26+ return payload
27+}

Keyboard shortcuts

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

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

Branches 2

Tags 13

↑↓ moveenter openesc close