fix(review): enforce strict wire semantics
- commit
- df21af70676d05c7d9f5552646ea23a01fa14795
- author
- brice · · 2026-08-04 06:08 UTC
- parent
- ea70803
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 9 | 9 | "testing" | |
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | 13 | "github.com/bnema/vev/internal/ports" | |
| 13 | 14 | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 14 | 15 | ) | |
| @@ -451,7 +452,7 @@ func TestTransportFloodClassification(t *testing.T) { | |||
| 451 | 452 | } | |
| 452 | 453 | } | |
| 453 | 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 | 456 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: primer}); err != nil { | |
| 456 | 457 | t.Fatal(err) | |
| 457 | 458 | } | |
| @@ -528,10 +529,12 @@ func TestTransportFloodClassification(t *testing.T) { | |||
| 528 | 529 | } | |
| 529 | 530 | }) | |
| 530 | 531 | 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), | |
| 535 | 538 | }) | |
| 536 | 539 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil { | |
| 537 | 540 | t.Fatal(err) | |
| @@ -851,10 +854,13 @@ func floodState(sender, receiver *Transport, link *simulatedLink) floodTransport | |||
| 851 | 854 | } | |
| 852 | 855 | ||
| 853 | 856 | 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), | |
| 858 | 864 | }) | |
| 859 | 865 | } | |
| 860 | 866 | ||
| @@ -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 | |||
| 105 | 105 | } | |
| 106 | 106 | ||
| 107 | 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 | 109 | client.recv <- recvResult{frame: input} | |
| 110 | 110 | client.recv <- recvResult{frame: ack} | |
| 111 | 111 | 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 ( | |||
| 7 | 7 | "testing" | |
| 8 | 8 | "time" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | 11 | "github.com/bnema/vev/internal/ports" | |
| 11 | 12 | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 12 | 13 | "github.com/stretchr/testify/require" | |
| @@ -113,7 +114,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) { | |||
| 113 | 114 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil { | |
| 114 | 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 | 118 | for range 2 { | |
| 118 | 119 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil { | |
| 119 | 120 | t.Fatal(err) | |
| @@ -121,7 +122,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) { | |||
| 121 | 122 | } | |
| 122 | 123 | waitForManualTimers(t, clk, 4) | |
| 123 | 124 | previousPackets := len(aPC.in) | |
| 124 | - | for tick := 0; tick < 128; tick++ { | |
| 125 | + | for tick := range 128 { | |
| 125 | 126 | clk.advance(initialPacingRTT) | |
| 126 | 127 | time.Sleep(time.Millisecond) | |
| 127 | 128 | packets := len(aPC.in) | |
| @@ -154,7 +155,7 @@ func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) { | |||
| 154 | 155 | done := make(chan error, 1) | |
| 155 | 156 | go func() { done <- a.Send(large) }() | |
| 156 | 157 | start := clk.Now() | |
| 157 | - | for tick := 0; tick < 128; tick++ { | |
| 158 | + | for range 128 { | |
| 158 | 159 | a.mu.Lock() | |
| 159 | 160 | p := a.pending[1] | |
| 160 | 161 | if p != nil && p.initialInFlight && !p.last.IsZero() { | |
| @@ -220,11 +221,11 @@ func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *tes | |||
| 220 | 221 | } | |
| 221 | 222 | a.mu.Unlock() | |
| 222 | 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 | 225 | done := make(chan error, 1) | |
| 225 | 226 | go func() { done <- a.SendSynchronous(sideEffect) }() | |
| 226 | 227 | ||
| 227 | - | for tick := 0; tick < 512; tick++ { | |
| 228 | + | for range 512 { | |
| 228 | 229 | clk.advance(initialPacingRTT) | |
| 229 | 230 | time.Sleep(time.Millisecond) | |
| 230 | 231 | select { | |
| @@ -328,7 +329,7 @@ func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) { | |||
| 328 | 329 | frame ports.Frame | |
| 329 | 330 | }{ | |
| 330 | 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 | 334 | for _, tt := range frames { | |
| 334 | 335 | 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.Frame | 329 | 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) { | |||
| 140 | 140 | } | |
| 141 | 141 | // Vary a couple to prove ordering and content are both preserved. | |
| 142 | 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 | 145 | var wg sync.WaitGroup | |
| 146 | 146 | 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.WaitGroup | 145 | 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") | |||
| 27 | 27 | ||
| 28 | 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 | 35 | // ErrInvalidSize reports a terminal size that cannot be represented by the | |
| 31 | 36 | // protocol or would exceed the bounded screen area. | |
| 32 | 37 | var ErrInvalidSize = errors.New("ports: invalid terminal size") | |
| @@ -512,7 +517,7 @@ func ValidateOutput(m Output) error { | |||
| 512 | 517 | if err := ValidateSize(m.Size); err != nil { | |
| 513 | 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 | 521 | return ErrInvalidOutput | |
| 517 | 522 | } | |
| 518 | 523 | return nil | |
| @@ -568,13 +573,9 @@ func preflightHello(b []byte) error { | |||
| 568 | 573 | if version == 21 { | |
| 569 | 574 | return ErrInvalidHello | |
| 570 | 575 | } | |
| 571 | - | intent, err := r.getUint8() | |
| 572 | - | if err != nil { | |
| 576 | + | if _, err := r.getUint8(); err != nil { | |
| 573 | 577 | return err | |
| 574 | 578 | } | |
| 575 | - | if intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume { | |
| 576 | - | return ErrInvalidHello | |
| 577 | - | } | |
| 578 | 579 | if len(r.b) < 16 { | |
| 579 | 580 | return errShortPayload | |
| 580 | 581 | } | |
| @@ -585,17 +586,12 @@ func preflightHello(b []byte) error { | |||
| 585 | 586 | if err := r.skipString(); err != nil { | |
| 586 | 587 | return err | |
| 587 | 588 | } | |
| 588 | - | cols, err := r.getUint16() | |
| 589 | - | if err != nil { | |
| 589 | + | if _, err := r.getUint16(); err != nil { | |
| 590 | 590 | return err | |
| 591 | 591 | } | |
| 592 | - | rows, err := r.getUint16() | |
| 593 | - | if err != nil { | |
| 592 | + | if _, err := r.getUint16(); err != nil { | |
| 594 | 593 | 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 | 595 | if err := r.skipString(); err != nil { | |
| 600 | 596 | return err | |
| 601 | 597 | } | |
| @@ -638,9 +634,6 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 638 | 634 | if h.Version, err = r.getUint16(); err != nil { | |
| 639 | 635 | return Hello{}, err | |
| 640 | 636 | } | |
| 641 | - | if h.Version == 21 { | |
| 642 | - | return Hello{}, ErrInvalidHello | |
| 643 | - | } | |
| 644 | 637 | if h.Intent, err = r.getUint8(); err != nil { | |
| 645 | 638 | return Hello{}, err | |
| 646 | 639 | } | |
| @@ -664,12 +657,6 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 664 | 657 | return Hello{}, err | |
| 665 | 658 | } | |
| 666 | 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 | 660 | if h.TermEnv, err = r.getString(); err != nil { | |
| 674 | 661 | return Hello{}, err | |
| 675 | 662 | } | |
| @@ -777,14 +764,14 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) { | |||
| 777 | 764 | } | |
| 778 | 765 | ||
| 779 | 766 | // MarshalResize encodes m into a Resize message payload. | |
| 780 | - | func MarshalResize(m Resize) []byte { | |
| 767 | + | func MarshalResize(m Resize) ([]byte, error) { | |
| 781 | 768 | if err := ValidateSize(m.Size); err != nil { | |
| 782 | - | return nil | |
| 769 | + | return nil, err | |
| 783 | 770 | } | |
| 784 | 771 | w := payloadWriter{} | |
| 785 | 772 | w.putUint16(uint16(m.Size.Cols)) | |
| 786 | 773 | w.putUint16(uint16(m.Size.Rows)) | |
| 787 | - | return w.b | |
| 774 | + | return w.b, nil | |
| 788 | 775 | } | |
| 789 | 776 | ||
| 790 | 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 | 939 | // 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) { | |
| 957 | 941 | if err := ValidateAck(m); err != nil { | |
| 958 | - | return nil | |
| 942 | + | return nil, err | |
| 959 | 943 | } | |
| 960 | 944 | w := payloadWriter{} | |
| 961 | 945 | w.putUint64(m.Epoch) | |
| 962 | 946 | w.putUint64(m.State) | |
| 963 | - | return w.b | |
| 947 | + | return w.b, nil | |
| 964 | 948 | } | |
| 965 | 949 | ||
| 966 | 950 | // UnmarshalAck decodes an epoch/state Ack payload. | |
| @@ -1055,18 +1039,9 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) { | |||
| 1055 | 1039 | ||
| 1056 | 1040 | // MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/ | |
| 1057 | 1041 | // 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) { | |
| 1068 | 1043 | if err := ValidateOutput(m); err != nil { | |
| 1069 | - | return nil | |
| 1044 | + | return nil, err | |
| 1070 | 1045 | } | |
| 1071 | 1046 | w := payloadWriter{} | |
| 1072 | 1047 | w.putUint64(m.Epoch) | |
| @@ -1078,17 +1053,16 @@ func MarshalOutput(m Output) []byte { | |||
| 1078 | 1053 | w.putUint16(uint16(m.Size.Rows)) | |
| 1079 | 1054 | w.putBool(m.Full) | |
| 1080 | 1055 | w.putLongBytes(m.Data) | |
| 1081 | - | return w.b | |
| 1056 | + | return w.b, nil | |
| 1082 | 1057 | } | |
| 1083 | 1058 | ||
| 1084 | 1059 | // UnmarshalOutput strictly decodes one final Output payload. Header semantics | |
| 1085 | 1060 | // are validated before the length-prefixed data is copied. | |
| 1086 | 1061 | func UnmarshalOutput(b []byte) (Output, error) { | |
| 1087 | - | const outputHeaderLen = 45 | |
| 1088 | 1062 | if len(b) > MaxFrameLen-1 { | |
| 1089 | 1063 | return Output{}, ErrInvalidOutput | |
| 1090 | 1064 | } | |
| 1091 | - | if len(b) < outputHeaderLen+4 { | |
| 1065 | + | if len(b) < outputPayloadOverhead { | |
| 1092 | 1066 | return Output{}, errShortPayload | |
| 1093 | 1067 | } | |
| 1094 | 1068 | 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 the | 35 | // 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 ErrInvalidOutput | 521 | return ErrInvalidOutput | ||
| 517 | } | 522 | } | ||
| 518 | return nil | 523 | return nil | ||
| @@ -568,13 +573,9 @@ func preflightHello(b []byte) error { | |||||
| 568 | if version == 21 { | 573 | if version == 21 { | ||
| 569 | return ErrInvalidHello | 574 | return ErrInvalidHello | ||
| 570 | } | 575 | } | ||
| 571 | - | intent, err := r.getUint8() | 576 | + | if _, err := r.getUint8(); err != nil { |
| 572 | - | if err != nil { | |||
| 573 | return err | 577 | 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 errShortPayload | 580 | 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 err | 587 | return err | ||
| 587 | } | 588 | } | ||
| 588 | - | cols, err := r.getUint16() | 589 | + | if _, err := r.getUint16(); err != nil { |
| 589 | - | if err != nil { | |||
| 590 | return err | 590 | return err | ||
| 591 | } | 591 | } | ||
| 592 | - | rows, err := r.getUint16() | 592 | + | if _, err := r.getUint16(); err != nil { |
| 593 | - | if err != nil { | |||
| 594 | return err | 593 | 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 err | 596 | 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{}, err | 635 | 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{}, err | 638 | return Hello{}, err | ||
| 646 | } | 639 | } | ||
| @@ -664,12 +657,6 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 664 | return Hello{}, err | 657 | 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{}, err | 661 | 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 nil | 769 | + | 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.b | 774 | + | 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 nil | 942 | + | 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.b | 947 | + | 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 nil | 1044 | + | 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.b | 1056 | + | return w.b, nil |
| 1082 | } | 1057 | } | ||
| 1083 | 1058 | ||||
| 1084 | // UnmarshalOutput strictly decodes one final Output payload. Header semantics | 1059 | // 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{}, ErrInvalidOutput | 1063 | return Output{}, ErrInvalidOutput | ||
| 1090 | } | 1064 | } | ||
| 1091 | - | if len(b) < outputHeaderLen+4 { | 1065 | + | if len(b) < outputPayloadOverhead { |
| 1092 | return Output{}, errShortPayload | 1066 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "encoding/binary" | |
| 6 | + | "errors" | |
| 6 | 7 | "reflect" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| @@ -13,9 +14,13 @@ func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 13 | 14 | if ProtocolVersion != 23 { | |
| 14 | 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 | - | 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) | |
| 19 | 24 | } | |
| 20 | 25 | } | |
| 21 | 26 | ||
| @@ -62,7 +67,10 @@ func TestFinalHelloSemanticValidation(t *testing.T) { | |||
| 62 | 67 | func TestFinalResizeGoldenStrict(t *testing.T) { | |
| 63 | 68 | msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}} | |
| 64 | 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 | 74 | if !bytes.Equal(got, want) { | |
| 67 | 75 | t.Fatalf("Resize bytes = %x, want %x", got, want) | |
| 68 | 76 | } | |
| @@ -73,8 +81,8 @@ func TestFinalResizeGoldenStrict(t *testing.T) { | |||
| 73 | 81 | assertAllPrefixesFail(t, got, UnmarshalResize) | |
| 74 | 82 | assertTrailingGarbageFails(t, got, UnmarshalResize) | |
| 75 | 83 | 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) | |
| 78 | 86 | } | |
| 79 | 87 | } | |
| 80 | 88 | } | |
| @@ -93,7 +101,10 @@ func TestFinalOutputGoldenStrict(t *testing.T) { | |||
| 93 | 101 | 0, 80, 0, 24, 1, | |
| 94 | 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 | 108 | if !bytes.Equal(got, want) { | |
| 98 | 109 | t.Fatalf("Output bytes = %x, want %x", got, want) | |
| 99 | 110 | } | |
| @@ -107,7 +118,19 @@ func TestFinalOutputGoldenStrict(t *testing.T) { | |||
| 107 | 118 | ||
| 108 | 119 | func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) { | |
| 109 | 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 | 134 | for _, tt := range []struct { | |
| 112 | 135 | name string | |
| 113 | 136 | mutate func([]byte) | |
| @@ -132,7 +155,10 @@ func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) { | |||
| 132 | 155 | func TestFinalAckGoldenStrict(t *testing.T) { | |
| 133 | 156 | msg := Ack{Epoch: 2, State: 7} | |
| 134 | 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 | 162 | if !bytes.Equal(got, want) { | |
| 137 | 163 | t.Fatalf("Ack bytes = %x, want %x", got, want) | |
| 138 | 164 | } | |
| @@ -145,6 +171,9 @@ func TestFinalAckGoldenStrict(t *testing.T) { | |||
| 145 | 171 | if _, err := UnmarshalAck(make([]byte, 16)); err == nil { | |
| 146 | 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 | 179 | 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 string | 135 | 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) { | |||
| 117 | 117 | ||
| 118 | 118 | func TestHelloEnvironmentCodec(t *testing.T) { | |
| 119 | 119 | t.Run("literal wire layout", func(t *testing.T) { | |
| 120 | - | got := MarshalHello(Hello{ | |
| 120 | + | msg := Hello{ | |
| 121 | 121 | Version: ProtocolVersion, | |
| 122 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |
| 122 | 123 | Env: []string{"A=B", "XY=123"}, | |
| 123 | - | }) | |
| 124 | + | } | |
| 125 | + | got := MarshalHello(msg) | |
| 124 | 126 | want := []byte{ | |
| 125 | 127 | 0x00, 0x17, 0x00, // version, intent | |
| 126 | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 127 | 129 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 128 | 130 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| 129 | 131 | 0x00, 0x00, // name | |
| 130 | - | 0x00, 0x00, 0x00, 0x00, // size | |
| 132 | + | 0x00, 0x01, 0x00, 0x01, // size | |
| 131 | 133 | 0x00, 0x00, // TERM | |
| 132 | 134 | 0x00, 0x00, // cwd | |
| 133 | 135 | 0x00, 0x00, // true color, max output in flight | |
| @@ -136,8 +138,11 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||
| 136 | 138 | 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3', | |
| 137 | 139 | } | |
| 138 | 140 | 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) | |
| 141 | 146 | }) | |
| 142 | 147 | ||
| 143 | 148 | t.Run("lossless order and values", func(t *testing.T) { | |
| @@ -343,7 +348,10 @@ func TestResizeGoldenAndRoundTrip(t *testing.T) { | |||
| 343 | 348 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | |
| 344 | 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 | 355 | if !bytes.Equal(got, want) { | |
| 348 | 356 | t.Fatalf("MarshalResize() = %#v, want %#v", got, want) | |
| 349 | 357 | } | |
| @@ -612,7 +620,10 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) { | |||
| 612 | 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 | 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 | 627 | if !bytes.Equal(got, want) { | |
| 617 | 628 | t.Fatalf("MarshalOutput() = %#v, want %#v", got, want) | |
| 618 | 629 | } | |
| @@ -632,7 +643,10 @@ func TestAckGoldenAndRoundTrip(t *testing.T) { | |||
| 632 | 643 | msg := Ack{Epoch: 1, State: 0x0102030405060708} | |
| 633 | 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 | 650 | if !bytes.Equal(got, want) { | |
| 637 | 651 | t.Fatalf("MarshalAck() = %#v, want %#v", got, want) | |
| 638 | 652 | } | |
| @@ -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, intent | 127 | 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 ID | 129 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 130 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | ||
| 129 | 0x00, 0x00, // name | 131 | 0x00, 0x00, // name | ||
| 130 | - | 0x00, 0x00, 0x00, 0x00, // size | 132 | + | 0x00, 0x01, 0x00, 0x01, // size |
| 131 | 0x00, 0x00, // TERM | 133 | 0x00, 0x00, // TERM | ||
| 132 | 0x00, 0x00, // cwd | 134 | 0x00, 0x00, // cwd | ||
| 133 | 0x00, 0x00, // true color, max output in flight | 135 | 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 ( | |||
| 7 | 7 | "reflect" | |
| 8 | 8 | "testing" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 10 | 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 | 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")})}, | |
| 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")})}, | |
| 16 | 25 | } | |
| 17 | 26 | ||
| 18 | 27 | // 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 an | 27 | // 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) { | |||
| 21 | 21 | for range cap(normal) { | |
| 22 | 22 | normal <- ports.Frame{Type: ports.MsgInput} | |
| 23 | 23 | } | |
| 24 | + | const epoch = 7 | |
| 24 | 25 | acks := newCumulativeAckQueue() | |
| 25 | 26 | for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ { | |
| 26 | - | acks.offer(state) | |
| 27 | + | acks.offer(epoch, state) | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -36,6 +37,7 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) { | |||
| 36 | 37 | require.Equal(t, ports.MsgAck, first.Type) | |
| 37 | 38 | ack, err := ports.UnmarshalAck(first.Payload) | |
| 38 | 39 | require.NoError(t, err) | |
| 40 | + | require.Equal(t, uint64(epoch), ack.Epoch) | |
| 39 | 41 | require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.State) | |
| 40 | 42 | } | |
| 41 | 43 | ||
| @@ -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) | |||
| 251 | 251 | } | |
| 252 | 252 | rememberOnce.Do(func() { | |
| 253 | 253 | learnerStarted = true | |
| 254 | - | rememberWG.Add(1) | |
| 255 | - | go func() { | |
| 256 | - | defer rememberWG.Done() | |
| 254 | + | rememberWG.Go(func() { | |
| 257 | 255 | if err := r.remoteHostLearner.RememberRemoteHost(); err != nil { | |
| 258 | 256 | r.logger.Warn("remembering remote host failed", "err", err) | |
| 259 | 257 | } | |
| 260 | - | }() | |
| 258 | + | }) | |
| 261 | 259 | }) | |
| 262 | 260 | } | |
| 263 | 261 | defer func() { | |
| @@ -839,7 +837,11 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 839 | 837 | // Before the asynchronous sender starts, preserve transport ordering by | |
| 840 | 838 | // requesting the reset synchronously after the initial Theme publication. | |
| 841 | 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 | 846 | if resetErr != nil { | |
| 845 | 847 | resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr)) | |
| @@ -883,8 +885,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 883 | 885 | if serr != nil { | |
| 884 | 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 | 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 | 894 | awaitingReconnectReset = true | |
| 889 | 895 | return nil | |
| 890 | 896 | case <-loopCtx.Done(): | |
| @@ -1097,16 +1103,7 @@ func newCumulativeAckQueue() *cumulativeAckQueue { | |||
| 1097 | 1103 | return &cumulativeAckQueue{wake: make(chan struct{}, 1)} | |
| 1098 | 1104 | } | |
| 1099 | 1105 | ||
| 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) { | |
| 1110 | 1107 | if epoch == 0 || state == 0 { | |
| 1111 | 1108 | return | |
| 1112 | 1109 | } | |
| @@ -1144,7 +1141,19 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 1144 | 1141 | } | |
| 1145 | 1142 | sendAck := func() bool { | |
| 1146 | 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 | 1158 | for { | |
| 1150 | 1159 | select { | |
| @@ -1742,7 +1751,12 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports. | |||
| 1742 | 1751 | if !ok { | |
| 1743 | 1752 | 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 | 1760 | select { | |
| 1747 | 1761 | case out <- frame: | |
| 1748 | 1762 | 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 = true | 253 | 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 by | 837 | // 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 = true | 894 | awaitingReconnectReset = true | ||
| 889 | return nil | 895 | 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 | return | 1108 | 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 | return | 1752 | 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) { | |||
| 408 | 408 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 409 | 409 | unblock := scriptRecv(tr, | |
| 410 | 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 | 412 | recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |
| 413 | 413 | ) | |
| 414 | 414 | 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 | |||
| 198 | 198 | } | |
| 199 | 199 | } | |
| 200 | 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 | 202 | transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | |
| 203 | 203 | ||
| 204 | 204 | 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) { | |||
| 375 | 375 | require.Contains(t, firstToast, "┌") | |
| 376 | 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{ | |
| 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"), | |
| 383 | 385 | })}} | |
| 384 | 386 | redrawn := requireReconnectToastOutput(t, out.completed) | |
| 385 | 387 | require.Contains(t, redrawn, "daemon incremental") | |
| @@ -392,47 +394,53 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) { | |||
| 392 | 394 | ||
| 393 | 395 | beforeAwaitingReset := out.String() | |
| 394 | 396 | 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"), | |
| 399 | 403 | })}} | |
| 400 | 404 | require.Eventually(t, func() bool { | |
| 401 | 405 | return strings.Contains(out.String(), "stateless side effect") && flushes.Load() > beforeStatelessFlushes | |
| 402 | 406 | }, time.Second, time.Millisecond) | |
| 403 | 407 | ||
| 404 | 408 | 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"), | |
| 409 | 415 | })}} | |
| 410 | - | requireAckedState(t, tr.sends, 3) | |
| 416 | + | requireAckedState(t, tr.sends, 1, 3) | |
| 411 | 417 | require.Contains(t, out.String(), "intervening incremental") | |
| 412 | 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 | 421 | Epoch: 2, | |
| 416 | 422 | Base: 0, | |
| 417 | 423 | New: 4, | |
| 424 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |
| 418 | 425 | Full: true, | |
| 419 | 426 | Data: []byte("authoritative reset"), | |
| 420 | 427 | })}} | |
| 421 | - | requireAckedState(t, tr.sends, 4) | |
| 428 | + | requireAckedState(t, tr.sends, 2, 4) | |
| 422 | 429 | require.Eventually(t, func() bool { return strings.Contains(out.String(), "authoritative reset") }, time.Second, time.Millisecond) | |
| 423 | 430 | require.NotContains(t, out.String()[len(beforeAwaitingReset):], strings.Repeat(" ", reconnectToastBoundsFor(term.size, "probing UDP path").Width)) | |
| 424 | 431 | ||
| 425 | 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 | 434 | Epoch: 2, | |
| 428 | 435 | Base: 4, | |
| 429 | 436 | New: 5, | |
| 437 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |
| 430 | 438 | Data: []byte("increment after reset"), | |
| 431 | 439 | })}} | |
| 432 | 440 | require.Eventually(t, func() bool { | |
| 433 | 441 | return strings.Contains(out.String(), "increment after reset") && flushes.Load() > beforeIncrementFlushes | |
| 434 | 442 | }, time.Second, time.Millisecond) | |
| 435 | - | requireAckedState(t, tr.sends, 5) | |
| 443 | + | requireAckedState(t, tr.sends, 2, 5) | |
| 436 | 444 | ||
| 437 | 445 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | |
| 438 | 446 | result := requireAttachResult(t, resultCh) | |
| @@ -440,7 +448,6 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) { | |||
| 440 | 448 | } | |
| 441 | 449 | ||
| 442 | 450 | func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T) { | |
| 443 | - | t.Skip("fixture predates strict base-zero output reset contract") | |
| 444 | 451 | out := newReconnectToastOutputRecorder() | |
| 445 | 452 | var flushes atomic.Int32 | |
| 446 | 453 | term := newReconnectToastTerminalHarnessWithOutputAndFlush(t, out, func() error { | |
| @@ -467,37 +474,46 @@ func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T) | |||
| 467 | 474 | ||
| 468 | 475 | beforeReset := out.String() | |
| 469 | 476 | 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")}, | |
| 472 | 479 | } { | |
| 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)}} | |
| 475 | 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 | 489 | 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"), | |
| 483 | 497 | })}} | |
| 484 | 498 | redrawn := requireReconnectToastOutput(t, out.completed) | |
| 485 | 499 | require.Contains(t, redrawn, "stage transition reset") | |
| 486 | 500 | require.Contains(t, redrawn, offlineMessage) | |
| 487 | 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 | 504 | 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"), | |
| 495 | 511 | })}} | |
| 496 | 512 | incremental := requireReconnectToastOutput(t, out.completed) | |
| 497 | 513 | require.Contains(t, incremental, "stage increment after reset") | |
| 498 | 514 | require.Contains(t, incremental, offlineMessage) | |
| 499 | 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 | 518 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | |
| 503 | 519 | result := requireAttachResult(t, resultCh) | |
| @@ -542,17 +558,19 @@ func requireResize(t *testing.T, sends *reconnectToastSentFrames) ports.Resize { | |||
| 542 | 558 | 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 | 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 | 564 | if frame.Type != ports.MsgAck { | |
| 549 | 565 | return false | |
| 550 | 566 | } | |
| 551 | 567 | ack, err := ports.UnmarshalAck(frame.Payload) | |
| 552 | - | return err == nil && ack.State >= state | |
| 568 | + | 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 | 571 | require.NoError(t, err) | |
| 572 | + | require.Equal(t, epoch, ack.Epoch) | |
| 573 | + | require.GreaterOrEqual(t, ack.State, state) | |
| 556 | 574 | } | |
| 557 | 575 | ||
| 558 | 576 | func requireAttachResult(t *testing.T, results <-chan attachResult) attachResult { | |
| @@ -657,11 +675,13 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi | |||
| 657 | 675 | require.Eventually(t, func() bool { return flushes.Load() > beforeStatus }, time.Second, time.Millisecond) | |
| 658 | 676 | beforeOutput := flushes.Load() | |
| 659 | 677 | ||
| 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"), | |
| 665 | 685 | })}} | |
| 666 | 686 | ||
| 667 | 687 | select { | |
| @@ -670,7 +690,7 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi | |||
| 670 | 690 | case <-time.After(time.Second): | |
| 671 | 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 | 694 | require.Greater(t, flushes.Load(), beforeOutput, "daemon output must be flushed before ACK") | |
| 675 | 695 | ||
| 676 | 696 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | |
| @@ -707,14 +727,16 @@ func TestLocalReconnectStatusTransitionsDoNotRequestAuthoritativeReset(t *testin | |||
| 707 | 727 | } | |
| 708 | 728 | ||
| 709 | 729 | 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), | |
| 715 | 737 | })}} | |
| 716 | 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 | 740 | _, sentResize := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgResize }) | |
| 719 | 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 | 795 | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | |
| 774 | 796 | requireReconnectToastOutput(t, out.completed) | |
| 775 | 797 | 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"), | |
| 781 | 805 | })}} | |
| 782 | 806 | ||
| 783 | 807 | 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() > beforeStatelessFlushes | 405 | 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() > beforeIncrementFlushes | 441 | 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.Int32 | 452 | 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 resize | 558 | 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 false | 565 | return false | ||
| 550 | } | 566 | } | ||
| 551 | ack, err := ports.UnmarshalAck(frame.Payload) | 567 | ack, err := ports.UnmarshalAck(frame.Payload) | ||
| 552 | - | return err == nil && ack.State >= state | 568 | + | 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) { | |||
| 50 | 50 | transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once() | |
| 51 | 51 | unblock := scriptRecv(transport, | |
| 52 | 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 | 55 | defer unblock() | |
| 56 | 56 | transport.EXPECT().Close().Return(nil).Once() | |
| @@ -90,7 +90,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) { | |||
| 90 | 90 | transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 91 | 91 | unblock := scriptRecv(transport, | |
| 92 | 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 | 94 | recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | |
| 95 | 95 | ) | |
| 96 | 96 | 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 | |||
| 614 | 614 | setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) { | |
| 615 | 615 | 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 | 618 | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | |
| 619 | 619 | ac.sendMu.Lock() | |
| 620 | 620 | 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 = 3 | 615 | 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) { | |||
| 46 | 46 | return | |
| 47 | 47 | } | |
| 48 | 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 | 55 | ac.view = view | |
| 50 | 56 | ac.viewMu.Unlock() | |
| 51 | 57 | } | |
| @@ -326,6 +332,7 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index | |||
| 326 | 332 | view.paneID = "" | |
| 327 | 333 | view.bookmark = 0 | |
| 328 | 334 | view.liveBottom = true | |
| 335 | + | view.revision++ | |
| 329 | 336 | ac.publishView(view) | |
| 330 | 337 | } | |
| 331 | 338 | } | |
| @@ -46,6 +46,12 @@ func (ac *attachedClient) publishView(view attachmentView) { | |||||
| 46 | return | 46 | 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 = view | 55 | 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 = 0 | 333 | view.bookmark = 0 | ||
| 328 | view.liveBottom = true | 334 | 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 ( | |||
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | 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 | 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 | 139 | func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) { | |
| 77 | 140 | sess := registryTestSession() | |
| 78 | 141 | 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 { | |||
| 276 | 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 | 280 | ac.sendMu.Lock() | |
| 281 | 281 | defer ac.sendMu.Unlock() | |
| 282 | 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 | 356 | if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil { | |
| 357 | 357 | return errAttachmentTransition | |
| 358 | 358 | } | |
| 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 | + | } | |
| 361 | 365 | if err != nil { | |
| 362 | 366 | ticket.reportTransportFailure(expected) | |
| 363 | 367 | } | |
| @@ -404,7 +408,12 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po | |||
| 404 | 408 | if !ac.transportSnapshotCurrent(expected) { | |
| 405 | 409 | 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 | 417 | return expected.transport, err | |
| 409 | 418 | } | |
| 410 | 419 | return d.boundedSendWith(expected.transport, func() error { | |
| @@ -413,7 +422,13 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po | |||
| 413 | 422 | if !ac.transportSnapshotCurrent(expected) { | |
| 414 | 423 | 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 | 819 | view.windowTop = 0 | |
| 805 | 820 | view.revision++ | |
| 806 | 821 | ac.publishView(view) | |
| 807 | - | ac.rebaseOutput() | |
| 808 | 822 | ac.pipelineCache = composeCacheInput{} | |
| 809 | 823 | ac.pipelineScratch = composeCacheInput{} | |
| 810 | 824 | ac.sendMu.Unlock() | |
| @@ -812,8 +826,7 @@ func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size | |||
| 812 | 826 | entry := token.sess | |
| 813 | 827 | // Rendering is attachment-scoped. Run it independently of the connection | |
| 814 | 828 | // 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 { | |
| 817 | 830 | _, _ = proxy.resize(size) | |
| 818 | 831 | return true | |
| 819 | 832 | } | |
| @@ -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 errAttachmentTransition | 357 | 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, errTransportReplaced | 409 | 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, err | 417 | 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 errTransportReplaced | 423 | 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 = 0 | 819 | 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.sess | 826 | entry := token.sess | ||
| 813 | // Rendering is attachment-scoped. Run it independently of the connection | 827 | // 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 true | 831 | 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 { | |||
| 256 | 256 | 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 | 260 | ac := token.ac | |
| 261 | 261 | if token.effect == nil || token.effect.ended.Load() { | |
| 262 | 262 | return false | |
| 263 | 263 | } | |
| 264 | 264 | ac.sendMu.Lock() | |
| 265 | - | ac.output.ack(values...) | |
| 265 | + | ac.output.ack(epoch, state) | |
| 266 | 266 | ac.sendMu.Unlock() | |
| 267 | 267 | if rc := token.sess.core().coordinator.Load(); rc != nil { | |
| 268 | 268 | rc.notifyAckForLease(token.lease) | |
| @@ -256,13 +256,13 @@ func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | |||||
| 256 | return true | 256 | 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.ac | 260 | ac := token.ac | ||
| 261 | if token.effect == nil || token.effect.ended.Load() { | 261 | if token.effect == nil || token.effect.ended.Load() { | ||
| 262 | return false | 262 | 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 | |||
| 271 | 271 | if !beginClipboardOwnerSend(item.owner, ticket, expected) { | |
| 272 | 272 | return errAttachmentTransition | |
| 273 | 273 | } | |
| 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 | + | } | |
| 280 | 283 | } | |
| 281 | 284 | if err != nil { | |
| 282 | 285 | 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 errAttachmentTransition | 272 | return errAttachmentTransition | ||
| 273 | } | 273 | } | ||
| 274 | - | frame := ac.output.sideEffect(item.seq, ac.echoAck.Load()) | 274 | + | unlockView := ac.output.lockView() |
| 275 | - | var err error | 275 | + | 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 { | |||
| 1256 | 1256 | // the session for ephemeral/new intents. | |
| 1257 | 1257 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | |
| 1258 | 1258 | sz := h.Size | |
| 1259 | + | if !sz.Valid() { | |
| 1260 | + | return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"} | |
| 1261 | + | } | |
| 1259 | 1262 | term := terminalEnv{TrueColor: h.TrueColor} | |
| 1260 | 1263 | ||
| 1261 | 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 | 1307 | 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 | 1310 | d.mu.Lock() | |
| 1312 | 1311 | // Shutdown/create interlock: once shutdown has begun (last session removed, | |
| 1313 | 1312 | // 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.Size | 1258 | 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, err | 1307 | 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 may | 1312 | // 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 { | |||
| 574 | 574 | ||
| 575 | 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 | 606 | func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} { | |
| 578 | 607 | done := make(chan struct{}) | |
| 579 | 608 | 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) { | |||
| 812 | 812 | ||
| 813 | 813 | result := make(chan error, 1) | |
| 814 | 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 | 816 | result <- err | |
| 817 | 817 | }() | |
| 818 | 818 | ||
| @@ -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 <- err | 816 | result <- err | ||
| 817 | }() | 817 | }() | ||
| 818 | 818 | ||||
M internal/usecase/daemon/output_state_stream.go +85 -38
| @@ -32,6 +32,18 @@ type outputStateStream struct { | |||
| 32 | 32 | 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 | 47 | func newOutputStateStream(windowSize ...uint8) *outputStateStream { | |
| 36 | 48 | window := uint8(maxUnackedOutputStates) | |
| 37 | 49 | if len(windowSize) > 0 { | |
| @@ -64,11 +76,13 @@ type preparedOutput struct { | |||
| 64 | 76 | } | |
| 65 | 77 | ||
| 66 | 78 | func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) { | |
| 79 | + | unlockView := s.lockView() | |
| 80 | + | defer unlockView() | |
| 67 | 81 | if s.epoch == 0 { | |
| 68 | 82 | s.epoch = 1 | |
| 69 | 83 | } | |
| 70 | 84 | if reset && s.initialized { | |
| 71 | - | s.rebase() | |
| 85 | + | s.rebaseLocked() | |
| 72 | 86 | } | |
| 73 | 87 | reset = reset || s.forceSnapshot || !s.initialized | |
| 74 | 88 | if s.next == ^uint64(0) { | |
| @@ -79,7 +93,7 @@ func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Dama | |||
| 79 | 93 | return nil, err | |
| 80 | 94 | } | |
| 81 | 95 | s.initialized = true | |
| 82 | - | generation, epoch, viewRevision := s.fence() | |
| 96 | + | generation, epoch, viewRevision := s.fenceLocked() | |
| 83 | 97 | return &preparedOutput{ | |
| 84 | 98 | stream: s, | |
| 85 | 99 | draw: draw, | |
| @@ -96,11 +110,13 @@ func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Dama | |||
| 96 | 110 | } | |
| 97 | 111 | ||
| 98 | 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 | 114 | return nil | |
| 101 | 115 | } | |
| 116 | + | unlockView := p.stream.lockView() | |
| 117 | + | defer unlockView() | |
| 102 | 118 | p.attempted = true | |
| 103 | - | if !p.current() { | |
| 119 | + | if !p.currentLocked() { | |
| 104 | 120 | return nil | |
| 105 | 121 | } | |
| 106 | 122 | if len(data) == 0 { | |
| @@ -112,7 +128,11 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame | |||
| 112 | 128 | if p.reset { | |
| 113 | 129 | 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 | 136 | if err := send(out); err != nil { | |
| 117 | 137 | p.stream.forceSnapshot = true | |
| 118 | 138 | return err | |
| @@ -125,11 +145,13 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame | |||
| 125 | 145 | } | |
| 126 | 146 | ||
| 127 | 147 | func (p *preparedOutput) commitNoSend() { | |
| 128 | - | if p == nil || p.attempted { | |
| 148 | + | if p == nil || p.stream == nil || p.attempted { | |
| 129 | 149 | return | |
| 130 | 150 | } | |
| 151 | + | unlockView := p.stream.lockView() | |
| 152 | + | defer unlockView() | |
| 131 | 153 | p.attempted = true | |
| 132 | - | if !p.current() || len(p.data) != 0 { | |
| 154 | + | if !p.currentLocked() || len(p.data) != 0 { | |
| 133 | 155 | return | |
| 134 | 156 | } | |
| 135 | 157 | p.commit() | |
| @@ -142,79 +164,84 @@ func (p *preparedOutput) commit() { | |||
| 142 | 164 | } | |
| 143 | 165 | ||
| 144 | 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 | 173 | if s.epoch == 0 { | |
| 146 | 174 | s.epoch = 1 | |
| 147 | 175 | } | |
| 148 | 176 | return s.epoch | |
| 149 | 177 | } | |
| 150 | 178 | ||
| 151 | - | func (s *outputStateStream) fence() (uint64, uint64, uint64) { | |
| 179 | + | func (s *outputStateStream) fenceLocked() (uint64, uint64, uint64) { | |
| 152 | 180 | if s == nil { | |
| 153 | 181 | return 0, 1, 0 | |
| 154 | 182 | } | |
| 155 | 183 | generation, revision := uint64(0), uint64(0) | |
| 156 | 184 | if s.attachment != nil { | |
| 157 | 185 | generation = s.attachment.connectionGeneration.Load() | |
| 158 | - | revision = s.attachment.viewSnapshot().revision | |
| 186 | + | revision = s.attachment.view.revision | |
| 159 | 187 | } | |
| 160 | - | return generation, s.currentEpoch(), revision | |
| 188 | + | return generation, s.currentEpochLocked(), revision | |
| 161 | 189 | } | |
| 162 | 190 | ||
| 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 { | |
| 165 | 193 | return false | |
| 166 | 194 | } | |
| 167 | 195 | if s.attachment == nil { | |
| 168 | 196 | return true | |
| 169 | 197 | } | |
| 170 | - | return s.attachment.viewSnapshot().revision == viewRevision | |
| 198 | + | return s.attachment.view.revision == viewRevision | |
| 171 | 199 | } | |
| 172 | 200 | ||
| 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) && | |
| 175 | 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 { | |
| 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{ | |
| 183 | 208 | Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision, | |
| 184 | 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 | 217 | // sideEffect builds an output frame without advancing the state stream. When | |
| 189 | 218 | // an attachment is present, callers must hold its sendMu; attachment-bound | |
| 190 | 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 | 223 | return s.sideEffectLocked(data, echoAck) | |
| 193 | 224 | } | |
| 194 | 225 | ||
| 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() | |
| 197 | 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 | 233 | size = s.attachment.size | |
| 200 | 234 | } | |
| 201 | 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 | 239 | if s == nil { | |
| 206 | 240 | return | |
| 207 | 241 | } | |
| 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 { | |
| 218 | 245 | return | |
| 219 | 246 | } | |
| 220 | 247 | s.acked = state | |
| @@ -225,7 +252,18 @@ func (s *outputStateStream) rebase() { | |||
| 225 | 252 | if s == nil { | |
| 226 | 253 | 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 | 267 | if epoch == ^uint64(0) { | |
| 230 | 268 | return | |
| 231 | 269 | } | |
| @@ -253,6 +291,8 @@ func (s *outputStateStream) syncCapacityLocked() { | |||
| 253 | 291 | if s == nil { | |
| 254 | 292 | return | |
| 255 | 293 | } | |
| 294 | + | unlockView := s.lockView() | |
| 295 | + | defer unlockView() | |
| 256 | 296 | s.maxOutstandingAtomic.Store(s.maxOutstanding) | |
| 257 | 297 | s.publishOutstanding() | |
| 258 | 298 | } | |
| @@ -264,4 +304,11 @@ func (s *outputStateStream) atCapacity() bool { | |||
| 264 | 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 | + | } | |
| @@ -32,6 +32,18 @@ type outputStateStream struct { | |||||
| 32 | attachment *attachedClient | 32 | 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 = 1 | 82 | 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.initialized | 87 | 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, err | 93 | return nil, err | ||
| 80 | } | 94 | } | ||
| 81 | s.initialized = true | 95 | 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 nil | 114 | return nil | ||
| 101 | } | 115 | } | ||
| 116 | + | unlockView := p.stream.lockView() | |||
| 117 | + | defer unlockView() | |||
| 102 | p.attempted = true | 118 | p.attempted = true | ||
| 103 | - | if !p.current() { | 119 | + | if !p.currentLocked() { |
| 104 | return nil | 120 | 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 = 0 | 129 | 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 = true | 137 | p.stream.forceSnapshot = true | ||
| 118 | return err | 138 | 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 | return | 149 | return | ||
| 130 | } | 150 | } | ||
| 151 | + | unlockView := p.stream.lockView() | |||
| 152 | + | defer unlockView() | |||
| 131 | p.attempted = true | 153 | p.attempted = true | ||
| 132 | - | if !p.current() || len(p.data) != 0 { | 154 | + | if !p.currentLocked() || len(p.data) != 0 { |
| 133 | return | 155 | 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 = 1 | 174 | s.epoch = 1 | ||
| 147 | } | 175 | } | ||
| 148 | return s.epoch | 176 | 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, 0 | 181 | 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().revision | 186 | + | revision = s.attachment.view.revision |
| 159 | } | 187 | } | ||
| 160 | - | return generation, s.currentEpoch(), revision | 188 | + | 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 false | 193 | return false | ||
| 166 | } | 194 | } | ||
| 167 | if s.attachment == nil { | 195 | if s.attachment == nil { | ||
| 168 | return true | 196 | return true | ||
| 169 | } | 197 | } | ||
| 170 | - | return s.attachment.viewSnapshot().revision == viewRevision | 198 | + | 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. When | 217 | // sideEffect builds an output frame without advancing the state stream. When | ||
| 189 | // an attachment is present, callers must hold its sendMu; attachment-bound | 218 | // 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.size | 233 | 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 | return | 240 | 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 | return | 245 | return | ||
| 219 | } | 246 | } | ||
| 220 | s.acked = state | 247 | s.acked = state | ||
| @@ -225,7 +252,18 @@ func (s *outputStateStream) rebase() { | |||||
| 225 | if s == nil { | 252 | if s == nil { | ||
| 226 | return | 253 | 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 | return | 268 | return | ||
| 231 | } | 269 | } | ||
| @@ -253,6 +291,8 @@ func (s *outputStateStream) syncCapacityLocked() { | |||||
| 253 | if s == nil { | 291 | if s == nil { | ||
| 254 | return | 292 | 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) { | |||
| 69 | 69 | <-probed | |
| 70 | 70 | require.True(t, stream.atCapacity()) | |
| 71 | 71 | ||
| 72 | - | stream.ack(1) | |
| 72 | + | stream.ack(1, 1) | |
| 73 | 73 | require.False(t, stream.atCapacity()) | |
| 74 | 74 | stream.rebase() | |
| 75 | 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 | 197 | func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) { | |
| 187 | 198 | stream := newOutputStateStream() | |
| 188 | 199 | stream.ack(1, 0) | |
| 189 | 200 | 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 | 204 | require.NoError(t, err) | |
| 192 | 205 | require.Equal(t, beforeEpoch, out.Epoch) | |
| 193 | 206 | require.Zero(t, out.Base) | |
| @@ -274,7 +287,7 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T | |||
| 274 | 287 | require.NoError(t, err) | |
| 275 | 288 | require.True(t, ok) | |
| 276 | 289 | mustApplyOutput(t, client, first) | |
| 277 | - | stream.ack(1) | |
| 290 | + | stream.ack(1, 1) | |
| 278 | 291 | ||
| 279 | 292 | scrolled := renderer.NewFrame(4, 3) | |
| 280 | 293 | fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "}) | |
| @@ -321,7 +334,11 @@ func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAc | |||
| 321 | 334 | if reset { | |
| 322 | 335 | 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 | 344 | 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 | <-probed | 69 | <-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.acked | 200 | 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 = 0 | 335 | 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) { | |||
| 67 | 67 | func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil } | |
| 68 | 68 | ||
| 69 | 69 | func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) { | |
| 70 | - | t.Skip("fixture is coupled to pre-refactor attach paint timing") | |
| 71 | 70 | p, releasePTY := newBlockingPTY(t) | |
| 72 | 71 | defer releasePTY() | |
| 73 | 72 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| @@ -82,30 +81,35 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) { | |||
| 82 | 81 | require.NoError(t, err) | |
| 83 | 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 | 90 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 86 | 91 | d.paint(sess, ac, false, nil) | |
| 87 | 92 | first := awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 88 | 93 | out, err := ports.UnmarshalOutput(first.Payload) | |
| 89 | 94 | require.NoError(t, err) | |
| 90 | 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 | 99 | updated := awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 92 | 100 | out, err = ports.UnmarshalOutput(updated.Payload) | |
| 93 | 101 | require.NoError(t, err) | |
| 94 | 102 | 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})} | |
| 102 | 106 | require.NoError(t, tr.Close()) | |
| 103 | 107 | d.runConnLoop(ac) | |
| 104 | 108 | ||
| 105 | 109 | // The production MsgAck path retires retained states without moving the | |
| 106 | 110 | // renderer baseline backward. | |
| 107 | 111 | d.paint(sess, ac, false, nil) | |
| 108 | - | requireNoOutputFrame(t, tr.sends) | |
| 112 | + | require.Empty(t, tr.sends) | |
| 109 | 113 | } | |
| 110 | 114 | ||
| 111 | 115 | func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) { | |
| @@ -151,7 +155,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||
| 151 | 155 | client := vt.NewScreen(80, 25) | |
| 152 | 156 | d.paint(sess, ac, true, nil) | |
| 153 | 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 | 160 | // Preserve the frame after one scroll while inducing a real VT scroll | |
| 157 | 161 | // damage event before each production paint. On main, both unacknowledged | |
| @@ -231,7 +235,7 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) { | |||
| 231 | 235 | d.paint(sess, ac, false, nil) | |
| 232 | 236 | awaitFrame(t, sends, ports.MsgOutput) | |
| 233 | 237 | ||
| 234 | - | ac.ackOutputState(out.New) | |
| 238 | + | ac.ackOutputState(out.Epoch, out.New) | |
| 235 | 239 | d.paint(sess, ac, false, nil) | |
| 236 | 240 | requireNoOutputFrame(t, sends) | |
| 237 | 241 | } | |
| @@ -300,7 +304,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||
| 300 | 304 | d.paint(sess, ac, true, nil) | |
| 301 | 305 | client := vt.NewScreen(80, 24) | |
| 302 | 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 | 309 | d.sessWg.Add(1) | |
| 306 | 310 | 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 ordered | 104 | + | // 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 the | 109 | // 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 scroll | 160 | // Preserve the frame after one scroll while inducing a real VT scroll | ||
| 157 | // damage event before each production paint. On main, both unacknowledged | 161 | // 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 | |||
| 575 | 575 | fixture.resetMetrics() | |
| 576 | 576 | for b.Loop() { | |
| 577 | 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 | 580 | metrics := fixture.metrics() | |
| 581 | 581 | if payload := fixture.output.lastPayload(); payload != nil { | |
| @@ -594,12 +594,12 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||
| 594 | 594 | func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) { | |
| 595 | 595 | b.Helper() | |
| 596 | 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 | 598 | b.ReportAllocs() | |
| 599 | 599 | b.ResetTimer() | |
| 600 | 600 | for range b.N { | |
| 601 | 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 | 604 | b.StopTimer() | |
| 605 | 605 | if fixture.searchMatches() == 0 { | |
| @@ -969,7 +969,7 @@ func TestLivePaintAllocationBudget(t *testing.T) { | |||
| 969 | 969 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000}) | |
| 970 | 970 | allocs := testing.AllocsPerRun(20, func() { | |
| 971 | 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 | 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 | 994 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | |
| 995 | 995 | run := func() { | |
| 996 | 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 | 1000 | // 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 adds | 1000 | // 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) { | |||
| 915 | 915 | client := vt.NewScreen(80, 25) | |
| 916 | 916 | d.firstPaint(source, ac, ac.size) | |
| 917 | 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 | 920 | targetPane.screen.Write([]byte("TARGET")) | |
| 921 | 921 | 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) | |||
| 496 | 496 | request := requireProxyCommandRequest(t, link) | |
| 497 | 497 | _ = awaitTestValue(t, clock.timers, "command did not arm timeout") | |
| 498 | 498 | oldGeneration := proxy.linkGeneration | |
| 499 | - | newGeneration, _ := proxy.installTransport(newProxyTestTransport(), false) | |
| 499 | + | newGeneration, _ := proxy.installTransport(newProxyTestTransport()) | |
| 500 | 500 | require.Greater(t, newGeneration, oldGeneration) | |
| 501 | 501 | require.Error(t, awaitTestValue(t, done, "replacement did not cancel command")) | |
| 502 | 502 | _, 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.linkGeneration | 498 | 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) { | |||
| 1216 | 1216 | require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial")) | |
| 1217 | 1217 | _ = requireProxyHello(t, first) | |
| 1218 | 1218 | ||
| 1219 | - | base, cancel := context.WithCancel(context.Background()) | |
| 1219 | + | base, cancel := context.WithCancel(t.Context()) | |
| 1220 | 1220 | observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})} | |
| 1221 | 1221 | waiter := openProxyForTest(d, observed, key) | |
| 1222 | 1222 | select { | |
| @@ -1409,7 +1409,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) { | |||
| 1409 | 1409 | d, p, clock := newProxyLifecycleFixture(t) | |
| 1410 | 1410 | transport := newProxyTestTransport() | |
| 1411 | 1411 | t.Cleanup(func() { _ = transport.Close() }) | |
| 1412 | - | generation, _ := p.installTransport(transport, false) | |
| 1412 | + | generation, _ := p.installTransport(transport) | |
| 1413 | 1413 | token, timer := armWarmTimer(t, d, p, clock) | |
| 1414 | 1414 | ||
| 1415 | 1415 | require.True(t, d.markProxyReplaced(p, generation, transport)) | |
| @@ -1433,7 +1433,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) { | |||
| 1433 | 1433 | d, p, clock := newProxyLifecycleFixture(t) | |
| 1434 | 1434 | transport := newProxyTestTransport() | |
| 1435 | 1435 | t.Cleanup(func() { _ = transport.Close() }) | |
| 1436 | - | generation, _ := p.installTransport(transport, false) | |
| 1436 | + | generation, _ := p.installTransport(transport) | |
| 1437 | 1437 | ||
| 1438 | 1438 | require.True(t, d.markProxyReplaced(p, generation, transport)) | |
| 1439 | 1439 | timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer") | |
| @@ -1450,7 +1450,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) { | |||
| 1450 | 1450 | d, p, _ := newProxyLifecycleFixture(t) | |
| 1451 | 1451 | transport := newProxyTestTransport() | |
| 1452 | 1452 | t.Cleanup(func() { _ = transport.Close() }) | |
| 1453 | - | generation, _ := p.installTransport(transport, false) | |
| 1453 | + | generation, _ := p.installTransport(transport) | |
| 1454 | 1454 | ||
| 1455 | 1455 | require.False(t, d.markProxyReplaced(p, generation-1, transport)) | |
| 1456 | 1456 | 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 | |||
| 102 | 102 | if !resume { | |
| 103 | 103 | resumeToken = 0 | |
| 104 | 104 | } | |
| 105 | - | generation, previous := p.installTransport(transport, resume) | |
| 105 | + | generation, previous := p.installTransport(transport) | |
| 106 | 106 | if previous != nil && previous != transport { | |
| 107 | 107 | _ = previous.Close() | |
| 108 | 108 | } | |
| @@ -248,20 +248,6 @@ func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, | |||
| 248 | 248 | 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 | 251 | func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) { | |
| 266 | 252 | result := make(chan proxyRecvResult, 1) | |
| 267 | 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 | 271 | // Link replacement participates in the same sender serialization as every | |
| 286 | 272 | // frame. A request can therefore only be published wholly before the | |
| 287 | 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 | 306 | func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error { | |
| 332 | 307 | p.sendMu.Lock() | |
| 333 | 308 | 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 = 0 | 103 | 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, nil | 248 | 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 every | 271 | // Link replacement participates in the same sender serialization as every | ||
| 286 | // frame. A request can therefore only be published wholly before the | 272 | // 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 | |||
| 81 | 81 | } | |
| 82 | 82 | } | |
| 83 | 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 | 88 | if err := p.sendGeneration(generation, ports.Frame{ | |
| 85 | 89 | Type: ports.MsgAck, | |
| 86 | - | Payload: ports.MarshalAck(ports.Ack{Epoch: out.Epoch, State: ack}), | |
| 90 | + | Payload: payload, | |
| 87 | 91 | }); err != nil { | |
| 88 | 92 | return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err) | |
| 89 | 93 | } | |
| @@ -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 ( | |||
| 8 | 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 | 24 | func TestProxyOutputRejectsStaleStateAndRequestsOneReset(t *testing.T) { | |
| 12 | 25 | tests := []struct { | |
| 13 | 26 | name string | |
| @@ -62,32 +75,25 @@ func TestProxyOutputFansOutToEveryLiveAttachment(t *testing.T) { | |||
| 62 | 75 | ||
| 63 | 76 | attachments := make([]*proxyTestTransport, 0, 2) | |
| 64 | 77 | 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) | |
| 73 | 79 | attachments = append(attachments, clientTransport) | |
| 74 | 80 | } | |
| 75 | 81 | ||
| 76 | 82 | result, err := d.handleLinkFrame(proxy, 1, ports.Frame{ | |
| 77 | 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 | 86 | require.NoError(t, err) | |
| 81 | 87 | require.Equal(t, proxyLinkResume, result) | |
| 82 | 88 | for _, transport := range attachments { | |
| 83 | - | frame := <-transport.sent | |
| 89 | + | frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive output") | |
| 84 | 90 | require.Equal(t, ports.MsgOutput, frame.Type) | |
| 85 | 91 | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 86 | 92 | require.NoError(t, err) | |
| 87 | 93 | require.Zero(t, out.New, "local proxy forwarding must not create a second ACK path") | |
| 88 | 94 | require.Equal(t, []byte("full"), out.Data) | |
| 89 | 95 | } | |
| 90 | - | ackFrame := <-remote.sent | |
| 96 | + | ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive output acknowledgement") | |
| 91 | 97 | require.Equal(t, ports.MsgAck, ackFrame.Type) | |
| 92 | 98 | } | |
| 93 | 99 | ||
| @@ -99,37 +105,23 @@ func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) { | |||
| 99 | 105 | proxy.linkGeneration = 1 | |
| 100 | 106 | d := &Daemon{} | |
| 101 | 107 | ||
| 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) | |
| 110 | 109 | ||
| 111 | 110 | handle := func(output ports.Output) { | |
| 112 | 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 | 113 | require.NoError(t, err) | |
| 115 | 114 | require.Equal(t, proxyLinkResume, result) | |
| 116 | 115 | } | |
| 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") | |
| 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 | 123 | for _, transport := range []*proxyTestTransport{firstTransport, secondTransport} { | |
| 132 | - | frame := <-transport.sent | |
| 124 | + | frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive current output") | |
| 133 | 125 | require.Equal(t, ports.MsgOutput, frame.Type) | |
| 134 | 126 | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 135 | 127 | require.NoError(t, err) | |
| @@ -137,7 +129,7 @@ func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) { | |||
| 137 | 129 | require.False(t, out.Full, "local proxy side effects must remain stateless") | |
| 138 | 130 | require.Equal(t, []byte("current full"), out.Data) | |
| 139 | 131 | } | |
| 140 | - | ackFrame := <-remote.sent | |
| 132 | + | ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive current acknowledgement") | |
| 141 | 133 | require.Equal(t, ports.MsgAck, ackFrame.Type) | |
| 142 | 134 | } | |
| 143 | 135 | ||
| @@ -147,26 +139,20 @@ func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) { | |||
| 147 | 139 | remote := newProxyTestTransport() | |
| 148 | 140 | proxy.transport = remote | |
| 149 | 141 | 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) | |
| 157 | 143 | d := &Daemon{} | |
| 158 | 144 | ||
| 159 | 145 | _, err = d.handleLinkFrame(proxy, 1, ports.Frame{ | |
| 160 | 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 | 149 | 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") | |
| 166 | 152 | ||
| 167 | 153 | result, err := d.handleLinkFrame(proxy, 1, ports.Frame{ | |
| 168 | 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 | 157 | require.NoError(t, err) | |
| 172 | 158 | require.Equal(t, proxyLinkResume, result) | |
| @@ -175,6 +161,6 @@ func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) { | |||
| 175 | 161 | t.Fatalf("stale proxy output forwarded as %v", frame.Type) | |
| 176 | 162 | default: | |
| 177 | 163 | } | |
| 178 | - | reset := <-remote.sent | |
| 164 | + | reset := awaitTestValue(t, remote.sent, "proxy remote did not receive reset request") | |
| 179 | 165 | require.Equal(t, ports.MsgOutputResetRequest, reset.Type) | |
| 180 | 166 | } | |
| @@ -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 string | 26 | 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.sent | 89 | + | 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.sent | 96 | + | 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 = 1 | 105 | 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.sent | 117 | + | _ = awaitTestValue(t, firstTransport.sent, "first proxy attachment did not receive output") |
| 119 | - | <-remote.sent | 118 | + | _ = 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.sent | 124 | + | 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.sent | 132 | + | 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 = remote | 140 | proxy.transport = remote | ||
| 149 | proxy.linkGeneration = 1 | 141 | 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.sent | 150 | + | _ = awaitTestValue(t, clientTransport.sent, "proxy attachment did not receive initial output") |
| 165 | - | <-remote.sent | 151 | + | _ = 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.sent | 164 | + | 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) { | |||
| 66 | 66 | if p == nil || !size.Valid() { | |
| 67 | 67 | return false, false | |
| 68 | 68 | } | |
| 69 | + | payload, err := ports.MarshalResize(ports.Resize{Size: size}) | |
| 70 | + | if err != nil { | |
| 71 | + | return false, false | |
| 72 | + | } | |
| 69 | 73 | p.mu.Lock() | |
| 70 | 74 | if p.contentSize == size { | |
| 71 | 75 | available := p.transport != nil | |
| @@ -77,7 +81,7 @@ func (p *proxySession) resize(size domain.Size) (changed, sent bool) { | |||
| 77 | 81 | p.mu.Unlock() | |
| 78 | 82 | return true, p.sendGeneration(generation, ports.Frame{ | |
| 79 | 83 | Type: ports.MsgResize, | |
| 80 | - | Payload: ports.MarshalResize(ports.Resize{Size: size}), | |
| 84 | + | Payload: payload, | |
| 81 | 85 | }) == nil | |
| 82 | 86 | } | |
| 83 | 87 | ||
| @@ -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, false | 67 | 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 != nil | 75 | 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 | }) == nil | 85 | }) == nil | ||
| 82 | } | 86 | } | ||
| 83 | 87 | ||||
M internal/usecase/daemon/proxy_test_helpers_test.go +3 -2
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "testing" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | 12 | "github.com/bnema/vev/internal/ports" | |
| 12 | 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 | 90 | 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"), | |
| 92 | 93 | })} | |
| 93 | 94 | } | |
| 94 | 95 | ||
| @@ -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) { | |||
| 59 | 59 | require.NoError(t, transport.Send(frame)) | |
| 60 | 60 | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 61 | 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 | 63 | terminal.Write(output.Data) | |
| 64 | 64 | } | |
| 65 | 65 | require.Equal(t, len(frames), transport.next) | |
| @@ -128,6 +128,9 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |||
| 128 | 128 | t.Cleanup(release) | |
| 129 | 129 | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | |
| 130 | 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 | 134 | ac.sendMu.Lock() | |
| 132 | 135 | ac.output.next = ac.output.maxOutstanding | |
| 133 | 136 | 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.maxOutstanding | 135 | ac.output.next = ac.output.maxOutstanding | ||
| 133 | ac.output.acked = 0 | 136 | ac.output.acked = 0 | ||
M internal/usecase/daemon/resume_test.go +5 -4
| @@ -974,9 +974,10 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||
| 974 | 974 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |
| 975 | 975 | ac := &attachedClient{output: newOutputStateStream()} | |
| 976 | 976 | 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) | |
| 980 | 981 | require.Equal(t, uint64(3), ac.output.acked, "stale or future ACKs must not move output state incorrectly") | |
| 981 | 982 | ||
| 982 | 983 | ac.sendMu.Lock() | |
| @@ -1083,7 +1084,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 1083 | 1084 | require.NoError(t, err) | |
| 1084 | 1085 | require.Zero(t, first.Base) | |
| 1085 | 1086 | require.Equal(t, uint64(1), first.New) | |
| 1086 | - | resumedAC.ackOutputState(first.New) | |
| 1087 | + | resumedAC.ackOutputState(first.Epoch, first.New) | |
| 1087 | 1088 | ||
| 1088 | 1089 | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 1089 | 1090 | 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 = 3 | 976 | 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) { | |||
| 1507 | 1507 | frameInput([]byte("\x1b ")), | |
| 1508 | 1508 | frameInput([]byte("CNT\r")), | |
| 1509 | 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 | 1513 | 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.WaitGroup | 1513 | var hg sync.WaitGroup | ||
M internal/usecase/daemon/transport_observability_test.go +2 -2
| @@ -36,7 +36,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) { | |||
| 36 | 36 | if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) { | |
| 37 | 37 | t.Fatal("second resize was not accepted") | |
| 38 | 38 | } | |
| 39 | - | ac.ackOutputState(output.New) | |
| 39 | + | ac.ackOutputState(output.Epoch, output.New) | |
| 40 | 40 | rc.notifyAck() | |
| 41 | 41 | second := awaitFrame(t, sends, ports.MsgOutput) | |
| 42 | 42 | if second.Type != ports.MsgOutput { | |
| @@ -123,7 +123,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t | |||
| 123 | 123 | if err != nil { | |
| 124 | 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 | 128 | pane := testAttachmentTab(sess).focusedPane() | |
| 129 | 129 | 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 { | |||
| 21 | 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 { | |
| 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{ | |
| 26 | 26 | Epoch: 1, Base: baseState, New: state, Echo: echoAck, | |
| 27 | 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 | 35 | 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 | + | } | |||