Merge pull request #174 from bnema/feat/per-attachment-output-streams
feat(session): stream output per attachment
82 files · +2439 -6815
D .coderabbit.yaml +0 -11
| @@ -1,11 +0,0 @@ | |||||
| 1 | - | # Temporary PR #173 review scope; remove after the clean reviewed-head merge. | |||
| 2 | - | reviews: | |||
| 3 | - | path_filters: | |||
| 4 | - | - "!internal/usecase/daemon/move_atomicity_test.go" | |||
| 5 | - | - "!internal/usecase/daemon/move_final_source_attachment_race_test.go" | |||
| 6 | - | - "!internal/usecase/daemon/pane_sync_owner_test.go" | |||
| 7 | - | - "!internal/usecase/daemon/role_effect_gate_test.go" | |||
| 8 | - | - "!internal/usecase/daemon/snatched_client_test.go" | |||
| 9 | - | - "!internal/usecase/daemon/snatched_reclaim_test.go" | |||
| 10 | - | - "!internal/usecase/daemon/snatched_render_test.go" | |||
| 11 | - | - "!internal/usecase/daemon/stalled_attachment_test.go" | |||
M docs/performance.md +3 -65
| @@ -48,10 +48,9 @@ retransmits, pending, ACK RTT) remain separate from duration calculations. | |||||
| 48 | 48 | ||||
| 49 | ### Historical proxy ANSI baseline | 49 | ### Historical proxy ANSI baseline | ||
| 50 | 50 | ||||
| 51 | - | Before the structured path, proxied state-bearing screen updates transported ANSI | 51 | + | Before this refactor, proxied state-bearing screen updates transported ANSI |
| 52 | - | and were applied through the local proxy VT parser. In the candidate, proxied | 52 | + | and were applied through the local proxy VT parser. This parser benchmark remains |
| 53 | - | state-bearing frames are `MsgScreenUpdate`; this parser benchmark remains a | 53 | + | a historical diagnostic. Ordinary local output remains the ANSI `MsgOutput` |
| 54 | - | historical diagnostic. Ordinary local output remains the ANSI `MsgOutput` | |||
| 55 | guardrail. | 54 | guardrail. | ||
| 56 | 55 | ||||
| 57 | The binaries were built from clean, exact commits in temporary clones so their | 56 | The binaries were built from clean, exact commits in temporary clones so their | ||
| @@ -101,67 +100,6 @@ go test ./pkg/renderer -run '^$' -bench '^BenchmarkRenderer' -benchmem -count=10 | |||||
| 101 | The candidate guardrail completed with Go 1.26.5; its ten-repetition output is | 100 | The candidate guardrail completed with Go 1.26.5; its ten-repetition output is | ||
| 102 | retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`. | 101 | retained at `/tmp/vev-perf-results/candidate-2b48abf9-renderer.txt`. | ||
| 103 | 102 | ||||
| 104 | - | ### Comparable proxy pipeline benchmark | |||
| 105 | - | ||||
| 106 | - | `BenchmarkProxyPipeline` is a local comparison harness, not a network or | |||
| 107 | - | public-CLI result. Its four fixtures—one-cell, full-line, styled-line, and | |||
| 108 | - | full-width scroll—use semantically paired initial and mutated `renderer.Frame` | |||
| 109 | - | values. Snapshot/full-paint setup is completed before the timed steady-state | |||
| 110 | - | loop; the loop applies the same mutation and consecutive state transitions on | |||
| 111 | - | both sides. | |||
| 112 | - | ||||
| 113 | - | The `ansi-pipeline` slice measures `Renderer.Prepare`/bytes, | |||
| 114 | - | `MarshalOutput`, a real benchmark sender that performs the simulated transport, | |||
| 115 | - | `UnmarshalOutput`, `proxyANSIApplyState.apply`, and `Commit`. The | |||
| 116 | - | `structured-pipeline` slice measures `structuredOutputStream.prepare` (including | |||
| 117 | - | `MarshalScreenUpdate`), a real benchmark sender that performs the simulated | |||
| 118 | - | transport, `UnmarshalScreenUpdate`, `proxyScreenState.Apply`, damage | |||
| 119 | - | acknowledgement, and `prepared.send` commit. Both slices are local and exclude | |||
| 120 | - | network effects. `wirebytes/op` is the complete message payload size excluding | |||
| 121 | - | connection framing; unlike the historical table above, it includes the | |||
| 122 | - | `MsgOutput` or `MsgScreenUpdate` payload header. `B/op` is the benchmark | |||
| 123 | - | allocator metric; explicitly, `B/op != wirebytes/op`. `spans/op` is reported | |||
| 124 | - | separately for structured updates. Run it with: | |||
| 125 | - | ||||
| 126 | - | ```sh | |||
| 127 | - | cd /tmp/vev-perf-candidate-2b48abf9-clone | |||
| 128 | - | go test ./internal/usecase/daemon -run '^$' \ | |||
| 129 | - | -bench '^BenchmarkProxyPipeline$' -benchmem -count=10 \ | |||
| 130 | - | | tee /tmp/vev-perf-results/candidate-2b48abf9-proxy-pipeline.txt | |||
| 131 | - | ``` | |||
| 132 | - | ||||
| 133 | - | Measured candidate medians from that command (Go 1.26.5, Linux/amd64): | |||
| 134 | - | ||||
| 135 | - | | fixture | ANSI ns/op | structured ns/op | ANSI B/op | structured B/op | ANSI wirebytes/op | structured wirebytes/op | | |||
| 136 | - | | --- | ---: | ---: | ---: | ---: | ---: | ---: | | |||
| 137 | - | | one-cell | 530.55 | 1,016.5 | 192 | 888 | 37 | 71 | | |||
| 138 | - | | full-line | 8,637.5 | 22,187.5 | 674 | 19,928 | 154 | 190 | | |||
| 139 | - | | styled-line | 10,565.5 | 25,682 | 1,652 | 24,952 | 388 | 432 | | |||
| 140 | - | | full-width-scroll | 55,713.5 | 73,858 | 760 | 19,944 | 177 | 196 | | |||
| 141 | - | ||||
| 142 | - | The component diagnostics completed ten repetitions with separate | |||
| 143 | - | commands; their raw output is retained under `/tmp/vev-perf-results/`: | |||
| 144 | - | ||||
| 145 | - | ```sh | |||
| 146 | - | cd /tmp/vev-perf-candidate-2b48abf9-clone | |||
| 147 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkProxyANSIApply$' -benchmem -count=10 \ | |||
| 148 | - | | tee /tmp/vev-perf-results/candidate-2b48abf9-proxy-ansi.txt | |||
| 149 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkStructuredOutputPrepare$' -benchmem -count=10 \ | |||
| 150 | - | | tee /tmp/vev-perf-results/candidate-2b48abf9-structured-prepare.txt | |||
| 151 | - | go test ./internal/usecase/daemon -run '^$' -bench '^BenchmarkProxyScreenStateApply$' -benchmem -count=10 \ | |||
| 152 | - | | tee /tmp/vev-perf-results/candidate-2b48abf9-screen-apply.txt | |||
| 153 | - | ``` | |||
| 154 | - | ||||
| 155 | - | Their medians are respectively 107.45/5,107/6,561.5/251,126.5 ns/op for | |||
| 156 | - | ANSI one-cell/full-line/styled-line/scroll rows as reported in the raw file; | |||
| 157 | - | structured prepare medians are 673/16,928/23,220.5/61,424.5 ns/op and decoded | |||
| 158 | - | apply medians are 53.29/1,225/1,234/3,659.5 ns/op. These remain component | |||
| 159 | - | diagnostics, not head-to-head pipeline comparisons. | |||
| 160 | - | ||||
| 161 | - | The 35-scenario public-CLI harness A/B matrix remains the adoption gate. This | |||
| 162 | - | local pipeline benchmark does not replace that gate and this document records | |||
| 163 | - | no unmeasured result or conclusion. | |||
| 164 | - | ||||
| 165 | ### Public-CLI transport runs | 103 | ### Public-CLI transport runs | ||
| 166 | 104 | ||||
| 167 | The adoption gate requires a 10-second warmup, a 30-second measured interval, | 105 | The adoption gate requires a 10-second warmup, a 30-second measured interval, | ||
M internal/adapters/dgram/network_sim_test.go +18 -12
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "testing" | 9 | "testing" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 13 | pdgram "github.com/bnema/vev/pkg/dgram" | 14 | pdgram "github.com/bnema/vev/pkg/dgram" | ||
| 14 | ) | 15 | ) | ||
| @@ -451,7 +452,7 @@ func TestTransportFloodClassification(t *testing.T) { | |||||
| 451 | } | 452 | } | ||
| 452 | } | 453 | } | ||
| 453 | b.mu.Unlock() | 454 | b.mu.Unlock() | ||
| 454 | - | primer := ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("primer")}) | 455 | + | primer := mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("primer")}) |
| 455 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: primer}); err != nil { | 456 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: primer}); err != nil { | ||
| 456 | t.Fatal(err) | 457 | t.Fatal(err) | ||
| 457 | } | 458 | } | ||
| @@ -528,10 +529,12 @@ func TestTransportFloodClassification(t *testing.T) { | |||||
| 528 | } | 529 | } | ||
| 529 | }) | 530 | }) | ||
| 530 | for state := 1; state < outputCount; state++ { | 531 | for state := 1; state < outputCount; state++ { | ||
| 531 | - | payload := ports.MarshalOutput(ports.Output{ | 532 | + | payload := mustMarshalOutput(ports.Output{ |
| 532 | - | BaseStateNum: uint64(state), | 533 | + | Epoch: 1, |
| 533 | - | NewStateNum: uint64(state + 1), | 534 | + | Base: uint64(state), |
| 534 | - | Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes), | 535 | + | New: uint64(state + 1), |
| 536 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 537 | + | Data: bytes.Repeat([]byte{byte(state + 1)}, outputDataBytes), | |||
| 535 | }) | 538 | }) | ||
| 536 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil { | 539 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil { | ||
| 537 | t.Fatal(err) | 540 | t.Fatal(err) | ||
| @@ -631,7 +634,7 @@ func TestTransportFloodClassification(t *testing.T) { | |||||
| 631 | if err != nil { | 634 | if err != nil { | ||
| 632 | t.Fatal(err) | 635 | t.Fatal(err) | ||
| 633 | } | 636 | } | ||
| 634 | - | latestState = max(latestState, output.NewStateNum) | 637 | + | latestState = max(latestState, output.New) |
| 635 | default: | 638 | default: | ||
| 636 | goto drainedOutput | 639 | goto drainedOutput | ||
| 637 | } | 640 | } | ||
| @@ -851,10 +854,13 @@ func floodState(sender, receiver *Transport, link *simulatedLink) floodTransport | |||||
| 851 | } | 854 | } | ||
| 852 | 855 | ||||
| 853 | func floodOutputPayload(baseState, mtu int) []byte { | 856 | func floodOutputPayload(baseState, mtu int) []byte { | ||
| 854 | - | return ports.MarshalOutput(ports.Output{ | 857 | + | return mustMarshalOutput(ports.Output{ |
| 855 | - | BaseStateNum: uint64(baseState), | 858 | + | Epoch: 1, |
| 856 | - | NewStateNum: uint64(baseState + 1), | 859 | + | Base: uint64(baseState), |
| 857 | - | Data: make([]byte, floodRecordMTUs*mtu), | 860 | + | New: uint64(baseState + 1), |
| 861 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 862 | + | Full: baseState == 0, | |||
| 863 | + | Data: make([]byte, floodRecordMTUs*mtu), | |||
| 858 | }) | 864 | }) | ||
| 859 | } | 865 | } | ||
| 860 | 866 | ||||
| @@ -866,8 +872,8 @@ func TestFloodOutputPayloadIsIncrementalStateBearingOutput(t *testing.T) { | |||||
| 866 | if err != nil { | 872 | if err != nil { | ||
| 867 | t.Fatalf("state %d: %v", state, err) | 873 | t.Fatalf("state %d: %v", state, err) | ||
| 868 | } | 874 | } | ||
| 869 | - | if output.BaseStateNum != uint64(state) || output.NewStateNum != uint64(state+1) { | 875 | + | if output.Base != uint64(state) || output.New != uint64(state+1) { |
| 870 | - | t.Fatalf("output state = %d -> %d, want %d -> %d", output.BaseStateNum, output.NewStateNum, state, state+1) | 876 | + | t.Fatalf("output state = %d -> %d, want %d -> %d", output.Base, output.New, state, state+1) |
| 871 | } | 877 | } | ||
| 872 | minimumBytes := floodRecordMTUs * mtu | 878 | minimumBytes := floodRecordMTUs * mtu | ||
| 873 | if len(output.Data) < minimumBytes || len(payload) < minimumBytes { | 879 | if len(output.Data) < minimumBytes || len(payload) < minimumBytes { | ||
M internal/adapters/dgram/proxy_test.go +5 -4
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "testing" | 10 | "testing" | ||
| 11 | "time" | 11 | "time" | ||
| 12 | 12 | ||||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| @@ -87,7 +88,7 @@ func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testi | |||||
| 87 | daemon := newFakeTransport() | 88 | daemon := newFakeTransport() | ||
| 88 | errCh := make(chan error, 1) | 89 | errCh := make(chan error, 1) | ||
| 89 | go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }() | 90 | go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }() | ||
| 90 | - | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", MaxOutputInFlight: requested} | 91 | + | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: requested} |
| 91 | client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}} | 92 | client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}} | ||
| 92 | 93 | ||||
| 93 | select { | 94 | select { | ||
| @@ -104,7 +105,7 @@ func TestProxyRuntimeClampsDatagramHelloOutputWindowAndPreservesControl(t *testi | |||||
| 104 | } | 105 | } | ||
| 105 | 106 | ||||
| 106 | input := ports.Frame{Type: ports.MsgInput, Payload: []byte("still flowing")} | 107 | input := ports.Frame{Type: ports.MsgInput, Payload: []byte("still flowing")} | ||
| 107 | - | ack := ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 7})} | 108 | + | ack := ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: 1, State: 7})} |
| 108 | client.recv <- recvResult{frame: input} | 109 | client.recv <- recvResult{frame: input} | ||
| 109 | client.recv <- recvResult{frame: ack} | 110 | client.recv <- recvResult{frame: ack} | ||
| 110 | for _, want := range []ports.Frame{input, ack} { | 111 | for _, want := range []ports.Frame{input, ack} { | ||
| @@ -488,7 +489,7 @@ func TestProxyCopierStopsRetryingWhenContextCanceled(t *testing.T) { | |||||
| 488 | 489 | ||||
| 489 | func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T) { | 490 | func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T) { | ||
| 490 | t.Run("malformed payload remains malformed", func(t *testing.T) { | 491 | t.Run("malformed payload remains malformed", func(t *testing.T) { | ||
| 491 | - | payload := append(ports.MarshalHello(ports.Hello{Version: ports.ProtocolVersion, MaxOutputInFlight: 8}), 0xff) | 492 | + | payload := append(ports.MarshalHello(ports.Hello{Version: ports.ProtocolVersion, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 8}), 0xff) |
| 492 | frame := ports.Frame{Type: ports.MsgHello, Payload: payload} | 493 | frame := ports.Frame{Type: ports.MsgHello, Payload: payload} | ||
| 493 | got := clampDatagramHelloOutputWindow(frame) | 494 | got := clampDatagramHelloOutputWindow(frame) | ||
| 494 | if !reflect.DeepEqual(got, frame) { | 495 | if !reflect.DeepEqual(got, frame) { | ||
| @@ -502,7 +503,7 @@ func TestClampDatagramHelloOutputWindowPreservesProtocolValidation(t *testing.T) | |||||
| 502 | t.Run("mismatched version remains mismatched", func(t *testing.T) { | 503 | t.Run("mismatched version remains mismatched", func(t *testing.T) { | ||
| 503 | wantVersion := ports.ProtocolVersion + 1 | 504 | wantVersion := ports.ProtocolVersion + 1 | ||
| 504 | frame := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{ | 505 | frame := ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{ | ||
| 505 | - | Version: wantVersion, MaxOutputInFlight: 8, | 506 | + | Version: wantVersion, Size: domain.Size{Cols: 80, Rows: 24}, MaxOutputInFlight: 8, |
| 506 | })} | 507 | })} | ||
| 507 | got, err := ports.UnmarshalHello(clampDatagramHelloOutputWindow(frame).Payload) | 508 | got, err := ports.UnmarshalHello(clampDatagramHelloOutputWindow(frame).Payload) | ||
| 508 | if err != nil { | 509 | if err != nil { | ||
M internal/adapters/dgram/transport_send_test.go +7 -6
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | "testing" | 7 | "testing" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 11 | pdgram "github.com/bnema/vev/pkg/dgram" | 12 | pdgram "github.com/bnema/vev/pkg/dgram" | ||
| 12 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| @@ -113,7 +114,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) { | |||||
| 113 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil { | 114 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil { | ||
| 114 | t.Fatal(err) | 115 | t.Fatal(err) | ||
| 115 | } | 116 | } | ||
| 116 | - | large := ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: make([]byte, 24*a.mtu)}) | 117 | + | large := mustMarshalOutput(ports.Output{Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 24*a.mtu)}) |
| 117 | for range 2 { | 118 | for range 2 { | ||
| 118 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil { | 119 | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil { | ||
| 119 | t.Fatal(err) | 120 | t.Fatal(err) | ||
| @@ -121,7 +122,7 @@ func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) { | |||||
| 121 | } | 122 | } | ||
| 122 | waitForManualTimers(t, clk, 4) | 123 | waitForManualTimers(t, clk, 4) | ||
| 123 | previousPackets := len(aPC.in) | 124 | previousPackets := len(aPC.in) | ||
| 124 | - | for tick := 0; tick < 128; tick++ { | 125 | + | for tick := range 128 { |
| 125 | clk.advance(initialPacingRTT) | 126 | clk.advance(initialPacingRTT) | ||
| 126 | time.Sleep(time.Millisecond) | 127 | time.Sleep(time.Millisecond) | ||
| 127 | packets := len(aPC.in) | 128 | packets := len(aPC.in) | ||
| @@ -154,7 +155,7 @@ func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) { | |||||
| 154 | done := make(chan error, 1) | 155 | done := make(chan error, 1) | ||
| 155 | go func() { done <- a.Send(large) }() | 156 | go func() { done <- a.Send(large) }() | ||
| 156 | start := clk.Now() | 157 | start := clk.Now() | ||
| 157 | - | for tick := 0; tick < 128; tick++ { | 158 | + | for range 128 { |
| 158 | a.mu.Lock() | 159 | a.mu.Lock() | ||
| 159 | p := a.pending[1] | 160 | p := a.pending[1] | ||
| 160 | if p != nil && p.initialInFlight && !p.last.IsZero() { | 161 | if p != nil && p.initialInFlight && !p.last.IsZero() { | ||
| @@ -220,11 +221,11 @@ func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *tes | |||||
| 220 | } | 221 | } | ||
| 221 | a.mu.Unlock() | 222 | a.mu.Unlock() | ||
| 222 | go a.resendPending() | 223 | go a.resendPending() | ||
| 223 | - | sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: make([]byte, 100*1024)})} | 224 | + | sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: make([]byte, 100*1024)})} |
| 224 | done := make(chan error, 1) | 225 | done := make(chan error, 1) | ||
| 225 | go func() { done <- a.SendSynchronous(sideEffect) }() | 226 | go func() { done <- a.SendSynchronous(sideEffect) }() | ||
| 226 | 227 | ||||
| 227 | - | for tick := 0; tick < 512; tick++ { | 228 | + | for range 512 { |
| 228 | clk.advance(initialPacingRTT) | 229 | clk.advance(initialPacingRTT) | ||
| 229 | time.Sleep(time.Millisecond) | 230 | time.Sleep(time.Millisecond) | ||
| 230 | select { | 231 | select { | ||
| @@ -328,7 +329,7 @@ func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) { | |||||
| 328 | frame ports.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 | + | } | |||
M internal/adapters/ipc/transport_test.go +1 -1
| @@ -140,7 +140,7 @@ func TestTransportManySmallFramesBackToBack(t *testing.T) { | |||||
| 140 | } | 140 | } | ||
| 141 | // Vary a couple to prove ordering and content are both preserved. | 141 | // Vary a couple to prove ordering and content are both preserved. | ||
| 142 | frames[7] = ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("hop")})} | 142 | frames[7] = ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: []byte("hop")})} | ||
| 143 | - | frames[150] = ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 5, Rows: 6}})} | 143 | + | frames[150] = ports.Frame{Type: ports.MsgResize, Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 5, Rows: 6}})} |
| 144 | 144 | ||||
| 145 | var wg sync.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 | + | } | |||
M internal/adapters/sshstdio/transport_test.go +2 -1
| @@ -12,6 +12,7 @@ import ( | |||||
| 12 | "testing" | 12 | "testing" | ||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| @@ -120,7 +121,7 @@ func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) { | |||||
| 120 | defer func() { _ = client.Close() }() | 121 | defer func() { _ = client.Close() }() | ||
| 121 | defer func() { _ = server.Close() }() | 122 | defer func() { _ = server.Close() }() | ||
| 122 | 123 | ||||
| 123 | - | hello := ports.Hello{Version: ports.ProtocolVersion + 1, Intent: ports.IntentAttach, Name: "work"} | 124 | + | hello := ports.Hello{Version: ports.ProtocolVersion + 1, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}} |
| 124 | go func() { | 125 | go func() { | ||
| 125 | f, err := server.Recv() | 126 | f, err := server.Recv() | ||
| 126 | if err != nil { | 127 | if err != nil { | ||
M internal/ports/frame.go +2 -2
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 21 | 4 | + | const ProtocolVersion uint16 = 23 |
| 5 | 5 | ||||
| 6 | // MaxFrameLen is the largest permitted frame length, including the type byte | 6 | // MaxFrameLen is the largest permitted frame length, including the type byte | ||
| 7 | // and excluding the four-byte length prefix. | 7 | // and excluding the four-byte length prefix. | ||
| @@ -36,7 +36,7 @@ const ( | |||||
| 36 | MsgSessions MsgType = 21 | 36 | MsgSessions MsgType = 21 | ||
| 37 | MsgCommandResult MsgType = 22 | 37 | MsgCommandResult MsgType = 22 | ||
| 38 | MsgSessionMeta MsgType = 23 | 38 | MsgSessionMeta MsgType = 23 | ||
| 39 | - | MsgScreenUpdate MsgType = 24 | 39 | + | MsgAttachTarget MsgType = 25 |
| 40 | ) | 40 | ) | ||
| 41 | 41 | ||||
| 42 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 42 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | ||
M internal/ports/frame_test.go +11 -37
| @@ -3,17 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 21 { | 6 | + | if ProtocolVersion != 23 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 21", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion) |
| 8 | - | } | |||
| 9 | - | } | |||
| 10 | - | ||||
| 11 | - | func TestScreenUpdateMessageType(t *testing.T) { | |||
| 12 | - | if MsgScreenUpdate != 24 { | |||
| 13 | - | t.Fatalf("MsgScreenUpdate = %d, want 24", MsgScreenUpdate) | |||
| 14 | - | } | |||
| 15 | - | if MaxFrameLen != 16<<20 { | |||
| 16 | - | t.Fatalf("MaxFrameLen = %d, want %d", MaxFrameLen, 16<<20) | |||
| 17 | } | 8 | } | ||
| 18 | } | 9 | } | ||
| 19 | 10 | ||||
| @@ -30,9 +21,6 @@ func TestControlMsgTypes(t *testing.T) { | |||||
| 30 | if MsgSessionMeta != 23 { | 21 | if MsgSessionMeta != 23 { | ||
| 31 | t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta) | 22 | t.Fatalf("MsgSessionMeta = %d, want 23", MsgSessionMeta) | ||
| 32 | } | 23 | } | ||
| 33 | - | if CapabilityProxied != 1<<3 { | |||
| 34 | - | t.Fatalf("CapabilityProxied = %d, want 8", CapabilityProxied) | |||
| 35 | - | } | |||
| 36 | } | 24 | } | ||
| 37 | 25 | ||||
| 38 | func TestMsgTypeUnique(t *testing.T) { | 26 | func TestMsgTypeUnique(t *testing.T) { | ||
| @@ -40,28 +28,15 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 40 | name string | 28 | name string | ||
| 41 | typ MsgType | 29 | typ MsgType | ||
| 42 | }{ | 30 | }{ | ||
| 43 | - | {"MsgHello", MsgHello}, | 31 | + | {"MsgHello", MsgHello}, {"MsgInput", MsgInput}, {"MsgResize", MsgResize}, |
| 44 | - | {"MsgInput", MsgInput}, | 32 | + | {"MsgDetach", MsgDetach}, {"MsgPing", MsgPing}, {"MsgList", MsgList}, |
| 45 | - | {"MsgResize", MsgResize}, | 33 | + | {"MsgKill", MsgKill}, {"MsgTheme", MsgTheme}, {"MsgAck", MsgAck}, |
| 46 | - | {"MsgDetach", MsgDetach}, | 34 | + | {"MsgImagePush", MsgImagePush}, {"MsgClientNotice", MsgClientNotice}, |
| 47 | - | {"MsgPing", MsgPing}, | 35 | + | {"MsgCommand", MsgCommand}, {"MsgOutputResetRequest", MsgOutputResetRequest}, |
| 48 | - | {"MsgList", MsgList}, | 36 | + | {"MsgWelcome", MsgWelcome}, {"MsgError", MsgError}, {"MsgOutput", MsgOutput}, |
| 49 | - | {"MsgKill", MsgKill}, | 37 | + | {"MsgDetached", MsgDetached}, {"MsgPong", MsgPong}, {"MsgSessions", MsgSessions}, |
| 50 | - | {"MsgTheme", MsgTheme}, | 38 | + | {"MsgCommandResult", MsgCommandResult}, {"MsgSessionMeta", MsgSessionMeta}, |
| 51 | - | {"MsgAck", MsgAck}, | 39 | + | {"MsgAttachTarget", MsgAttachTarget}, |
| 52 | - | {"MsgImagePush", MsgImagePush}, | |||
| 53 | - | {"MsgClientNotice", MsgClientNotice}, | |||
| 54 | - | {"MsgCommand", MsgCommand}, | |||
| 55 | - | {"MsgOutputResetRequest", MsgOutputResetRequest}, | |||
| 56 | - | {"MsgWelcome", MsgWelcome}, | |||
| 57 | - | {"MsgError", MsgError}, | |||
| 58 | - | {"MsgOutput", MsgOutput}, | |||
| 59 | - | {"MsgDetached", MsgDetached}, | |||
| 60 | - | {"MsgPong", MsgPong}, | |||
| 61 | - | {"MsgSessions", MsgSessions}, | |||
| 62 | - | {"MsgCommandResult", MsgCommandResult}, | |||
| 63 | - | {"MsgSessionMeta", MsgSessionMeta}, | |||
| 64 | - | {"MsgScreenUpdate", MsgScreenUpdate}, | |||
| 65 | } | 40 | } | ||
| 66 | 41 | ||||
| 67 | seen := make(map[MsgType]string, len(tests)) | 42 | seen := make(map[MsgType]string, len(tests)) | ||
| @@ -71,7 +46,6 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 71 | } | 46 | } | ||
| 72 | seen[tt.typ] = tt.name | 47 | seen[tt.typ] = tt.name | ||
| 73 | } | 48 | } | ||
| 74 | - | ||||
| 75 | if len(seen) != len(tests) { | 49 | if len(seen) != len(tests) { | ||
| 76 | t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen)) | 50 | t.Fatalf("expected %d distinct MsgType values, got %d", len(tests), len(seen)) | ||
| 77 | } | 51 | } | ||
M internal/ports/wire.go +361 -43
| @@ -9,6 +9,7 @@ package ports | |||||
| 9 | import ( | 9 | import ( | ||
| 10 | "encoding/binary" | 10 | "encoding/binary" | ||
| 11 | "errors" | 11 | "errors" | ||
| 12 | + | "fmt" | |||
| 12 | "math" | 13 | "math" | ||
| 13 | 14 | ||||
| 14 | "github.com/bnema/vev/internal/domain" | 15 | "github.com/bnema/vev/internal/domain" | ||
| @@ -26,6 +27,27 @@ var errTrailingBytes = errors.New("ports: unexpected trailing bytes in payload") | |||||
| 26 | 27 | ||||
| 27 | var errInvalidBoolean = errors.New("ports: invalid boolean flag") | 28 | var errInvalidBoolean = errors.New("ports: invalid boolean flag") | ||
| 28 | 29 | ||||
| 30 | + | const ( | |||
| 31 | + | outputHeaderLen = 5*8 + 2*2 + 1 | |||
| 32 | + | outputPayloadOverhead = outputHeaderLen + 4 | |||
| 33 | + | ) | |||
| 34 | + | ||||
| 35 | + | // ErrInvalidSize reports a terminal size that cannot be represented by the | |||
| 36 | + | // protocol or would exceed the bounded screen area. | |||
| 37 | + | var ErrInvalidSize = errors.New("ports: invalid terminal size") | |||
| 38 | + | ||||
| 39 | + | // ErrInvalidHello reports a malformed Hello semantic value. | |||
| 40 | + | var ErrInvalidHello = errors.New("ports: invalid hello") | |||
| 41 | + | ||||
| 42 | + | // ErrInvalidOutput reports a malformed Output semantic value. | |||
| 43 | + | var ErrInvalidOutput = errors.New("ports: invalid output") | |||
| 44 | + | ||||
| 45 | + | // ErrInvalidAck reports a malformed Ack semantic value. | |||
| 46 | + | var ErrInvalidAck = errors.New("ports: invalid ack") | |||
| 47 | + | ||||
| 48 | + | // ErrInvalidAttachTarget reports a malformed attach target. | |||
| 49 | + | var ErrInvalidAttachTarget = errors.New("ports: invalid attach target") | |||
| 50 | + | ||||
| 29 | // ErrInvalidSessionMeta reports metadata that cannot be represented by the | 51 | // ErrInvalidSessionMeta reports metadata that cannot be represented by the | ||
| 30 | // protocol's ordered uint16-indexed tab layout. | 52 | // protocol's ordered uint16-indexed tab layout. | ||
| 31 | var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata") | 53 | var ErrInvalidSessionMeta = errors.New("ports: invalid session metadata") | ||
| @@ -47,7 +69,6 @@ const ( | |||||
| 47 | CapabilityResume uint32 = 1 << 0 | 69 | CapabilityResume uint32 = 1 << 0 | ||
| 48 | CapabilityUDP uint32 = 1 << 1 | 70 | CapabilityUDP uint32 = 1 << 1 | ||
| 49 | CapabilityPredict uint32 = 1 << 2 | 71 | CapabilityPredict uint32 = 1 << 2 | ||
| 50 | - | CapabilityProxied uint32 = 1 << 3 | |||
| 51 | ) | 72 | ) | ||
| 52 | 73 | ||||
| 53 | // ErrorMsg codes. | 74 | // ErrorMsg codes. | ||
| @@ -77,7 +98,6 @@ const ( | |||||
| 77 | type Hello struct { | 98 | type Hello struct { | ||
| 78 | Version uint16 | 99 | Version uint16 | ||
| 79 | Intent uint8 | 100 | Intent uint8 | ||
| 80 | - | Proxied bool | |||
| 81 | ClientID [16]byte | 101 | ClientID [16]byte | ||
| 82 | ResumeToken uint64 | 102 | ResumeToken uint64 | ||
| 83 | Name string | 103 | Name string | ||
| @@ -160,9 +180,10 @@ type Ping struct{} | |||||
| 160 | // Pong answers a Ping. | 180 | // Pong answers a Ping. | ||
| 161 | type Pong struct{} | 181 | type Pong struct{} | ||
| 162 | 182 | ||||
| 163 | - | // Ack acknowledges receipt/application of an output state number. | 183 | + | // Ack acknowledges receipt/application of an output state in one output epoch. |
| 164 | type Ack struct { | 184 | type Ack struct { | ||
| 165 | - | AckedStateNum uint64 | 185 | + | Epoch uint64 |
| 186 | + | State uint64 | |||
| 166 | } | 187 | } | ||
| 167 | 188 | ||||
| 168 | // Welcome is the daemon's reply to a successful Hello. | 189 | // Welcome is the daemon's reply to a successful Hello. | ||
| @@ -180,14 +201,26 @@ type ErrorMsg struct { | |||||
| 180 | Text string | 201 | Text string | ||
| 181 | } | 202 | } | ||
| 182 | 203 | ||||
| 183 | - | // Output carries raw PTY output, destined for the client's terminal. | 204 | + | // Output carries one ordered output publication for an attachment. |
| 184 | type Output struct { | 205 | type Output struct { | ||
| 185 | - | BaseStateNum uint64 | 206 | + | Epoch uint64 |
| 186 | - | NewStateNum uint64 | 207 | + | Base uint64 |
| 187 | - | EchoAck uint64 | 208 | + | New uint64 |
| 209 | + | Echo uint64 | |||
| 210 | + | ViewRevision uint64 | |||
| 211 | + | Size domain.Size | |||
| 212 | + | Full bool | |||
| 188 | Data []byte | 213 | Data []byte | ||
| 189 | } | 214 | } | ||
| 190 | 215 | ||||
| 216 | + | // AttachTarget identifies the endpoint and session selected for a new | |||
| 217 | + | // attachment. It deliberately carries no viewport, resume, or proxy state. | |||
| 218 | + | type AttachTarget struct { | |||
| 219 | + | Endpoint string | |||
| 220 | + | Session string | |||
| 221 | + | Intent uint8 | |||
| 222 | + | } | |||
| 223 | + | ||||
| 191 | // Detached tells a client it has been disconnected from its session and why. | 224 | // Detached tells a client it has been disconnected from its session and why. | ||
| 192 | type Detached struct { | 225 | type Detached struct { | ||
| 193 | Reason uint8 | 226 | Reason uint8 | ||
| @@ -227,18 +260,17 @@ type Sessions struct { | |||||
| 227 | Sessions []SessionInfo | 260 | Sessions []SessionInfo | ||
| 228 | } | 261 | } | ||
| 229 | 262 | ||||
| 230 | - | // OutputResetRequest asks a proxied daemon to rebase its output stream. | 263 | + | // OutputResetRequest asks a remote output stream to rebase. |
| 231 | type OutputResetRequest struct{} | 264 | type OutputResetRequest struct{} | ||
| 232 | 265 | ||||
| 233 | - | // SessionTabMeta describes one tab in a proxied session's authoritative | 266 | + | // SessionTabMeta describes one tab in a remote session's metadata snapshot. |
| 234 | - | // metadata snapshot. | |||
| 235 | type SessionTabMeta struct { | 267 | type SessionTabMeta struct { | ||
| 236 | Index uint16 | 268 | Index uint16 | ||
| 237 | Name string | 269 | Name string | ||
| 238 | Attention bool | 270 | Attention bool | ||
| 239 | } | 271 | } | ||
| 240 | 272 | ||||
| 241 | - | // SessionMeta is the authoritative tab metadata sent to a proxied client. | 273 | + | // SessionMeta is authoritative tab metadata for a remote session. |
| 242 | type SessionMeta struct { | 274 | type SessionMeta struct { | ||
| 243 | SessionName string | 275 | SessionName string | ||
| 244 | Active uint16 | 276 | Active uint16 | ||
| @@ -296,6 +328,11 @@ func (w *payloadWriter) putLongString(s string) { | |||||
| 296 | w.b = append(w.b, s...) | 328 | w.b = append(w.b, s...) | ||
| 297 | } | 329 | } | ||
| 298 | 330 | ||||
| 331 | + | func (w *payloadWriter) putLongBytes(b []byte) { | |||
| 332 | + | w.putUint32(uint32(len(b))) | |||
| 333 | + | w.b = append(w.b, b...) | |||
| 334 | + | } | |||
| 335 | + | ||||
| 299 | // payloadReader consumes a message payload field by field in wire order, | 336 | // payloadReader consumes a message payload field by field in wire order, | ||
| 300 | // erroring instead of panicking on any short read. | 337 | // erroring instead of panicking on any short read. | ||
| 301 | type payloadReader struct { | 338 | type payloadReader struct { | ||
| @@ -384,6 +421,43 @@ func (r *payloadReader) getLongString() (string, error) { | |||||
| 384 | return s, nil | 421 | return s, nil | ||
| 385 | } | 422 | } | ||
| 386 | 423 | ||||
| 424 | + | func (r *payloadReader) getLongBytes() ([]byte, error) { | |||
| 425 | + | n, err := r.getUint32() | |||
| 426 | + | if err != nil { | |||
| 427 | + | return nil, err | |||
| 428 | + | } | |||
| 429 | + | if uint64(n) > uint64(len(r.b)) { | |||
| 430 | + | return nil, errShortPayload | |||
| 431 | + | } | |||
| 432 | + | b := append([]byte(nil), r.b[:int(n)]...) | |||
| 433 | + | r.b = r.b[int(n):] | |||
| 434 | + | return b, nil | |||
| 435 | + | } | |||
| 436 | + | ||||
| 437 | + | func (r *payloadReader) skipString() error { | |||
| 438 | + | n, err := r.getUint16() | |||
| 439 | + | if err != nil { | |||
| 440 | + | return err | |||
| 441 | + | } | |||
| 442 | + | if int(n) > len(r.b) { | |||
| 443 | + | return errShortPayload | |||
| 444 | + | } | |||
| 445 | + | r.b = r.b[n:] | |||
| 446 | + | return nil | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func (r *payloadReader) skipLongString() error { | |||
| 450 | + | n, err := r.getUint32() | |||
| 451 | + | if err != nil { | |||
| 452 | + | return err | |||
| 453 | + | } | |||
| 454 | + | if uint64(n) > uint64(len(r.b)) { | |||
| 455 | + | return errShortPayload | |||
| 456 | + | } | |||
| 457 | + | r.b = r.b[n:] | |||
| 458 | + | return nil | |||
| 459 | + | } | |||
| 460 | + | ||||
| 387 | // rest consumes and returns all remaining bytes, copied so the result is | 461 | // rest consumes and returns all remaining bytes, copied so the result is | ||
| 388 | // independent of the reader's backing array. | 462 | // independent of the reader's backing array. | ||
| 389 | func (r *payloadReader) rest() []byte { | 463 | func (r *payloadReader) rest() []byte { | ||
| @@ -408,12 +482,72 @@ func PeekHelloVersion(b []byte) (uint16, bool) { | |||||
| 408 | return binary.BigEndian.Uint16(b), true | 482 | return binary.BigEndian.Uint16(b), true | ||
| 409 | } | 483 | } | ||
| 410 | 484 | ||||
| 485 | + | // ValidateHello validates Hello semantics without retaining or allocating any | |||
| 486 | + | // payload data. | |||
| 487 | + | func ValidateHello(h Hello) error { | |||
| 488 | + | if h.Intent != IntentEphemeral && h.Intent != IntentNew && h.Intent != IntentAttach && h.Intent != IntentResume { | |||
| 489 | + | return ErrInvalidHello | |||
| 490 | + | } | |||
| 491 | + | if err := ValidateSize(h.Size); err != nil { | |||
| 492 | + | return fmt.Errorf("%w: size", ErrInvalidHello) | |||
| 493 | + | } | |||
| 494 | + | if len(h.Name) > math.MaxUint16 || len(h.TermEnv) > math.MaxUint16 || len(h.Cwd) > math.MaxUint16 || len(h.Env) > math.MaxUint32 { | |||
| 495 | + | return ErrInvalidHello | |||
| 496 | + | } | |||
| 497 | + | for _, entry := range h.Env { | |||
| 498 | + | if uint64(len(entry)) > math.MaxUint32 { | |||
| 499 | + | return ErrInvalidHello | |||
| 500 | + | } | |||
| 501 | + | } | |||
| 502 | + | return nil | |||
| 503 | + | } | |||
| 504 | + | ||||
| 505 | + | // ValidateOutput validates the final output state before data allocation. | |||
| 506 | + | func ValidateOutput(m Output) error { | |||
| 507 | + | if m.Epoch == 0 { | |||
| 508 | + | return ErrInvalidOutput | |||
| 509 | + | } | |||
| 510 | + | if m.New == 0 { | |||
| 511 | + | if m.Base != 0 || m.Full { | |||
| 512 | + | return ErrInvalidOutput | |||
| 513 | + | } | |||
| 514 | + | } else if (m.Base == 0 && !m.Full) || (m.Base != 0 && (m.Full || m.New != m.Base+1)) { | |||
| 515 | + | return ErrInvalidOutput | |||
| 516 | + | } | |||
| 517 | + | if err := ValidateSize(m.Size); err != nil { | |||
| 518 | + | return fmt.Errorf("%w: size", ErrInvalidOutput) | |||
| 519 | + | } | |||
| 520 | + | if uint64(len(m.Data)) > math.MaxUint32 || len(m.Data) > MaxFrameLen-1-outputPayloadOverhead { | |||
| 521 | + | return ErrInvalidOutput | |||
| 522 | + | } | |||
| 523 | + | return nil | |||
| 524 | + | } | |||
| 525 | + | ||||
| 526 | + | // ValidateAck validates an epoch-scoped output acknowledgement. | |||
| 527 | + | func ValidateAck(m Ack) error { | |||
| 528 | + | if m.Epoch == 0 { | |||
| 529 | + | return ErrInvalidAck | |||
| 530 | + | } | |||
| 531 | + | return nil | |||
| 532 | + | } | |||
| 533 | + | ||||
| 534 | + | // ValidateAttachTarget validates only the endpoint, session, and intent | |||
| 535 | + | // fields carried by the server attach-target message. | |||
| 536 | + | func ValidateAttachTarget(m AttachTarget) error { | |||
| 537 | + | if m.Endpoint == "" || m.Session == "" || len(m.Endpoint) > math.MaxUint16 || len(m.Session) > math.MaxUint16 { | |||
| 538 | + | return ErrInvalidAttachTarget | |||
| 539 | + | } | |||
| 540 | + | if m.Intent != IntentEphemeral && m.Intent != IntentNew && m.Intent != IntentAttach && m.Intent != IntentResume { | |||
| 541 | + | return ErrInvalidAttachTarget | |||
| 542 | + | } | |||
| 543 | + | return nil | |||
| 544 | + | } | |||
| 545 | + | ||||
| 411 | // MarshalHello encodes h into a Hello message payload. | 546 | // MarshalHello encodes h into a Hello message payload. | ||
| 412 | func MarshalHello(h Hello) []byte { | 547 | func MarshalHello(h Hello) []byte { | ||
| 413 | w := payloadWriter{} | 548 | w := payloadWriter{} | ||
| 414 | w.putUint16(h.Version) | 549 | w.putUint16(h.Version) | ||
| 415 | w.putUint8(h.Intent) | 550 | w.putUint8(h.Intent) | ||
| 416 | - | w.putBool(h.Proxied) | |||
| 417 | w.putBytes(h.ClientID[:]) | 551 | w.putBytes(h.ClientID[:]) | ||
| 418 | w.putUint64(h.ResumeToken) | 552 | w.putUint64(h.ResumeToken) | ||
| 419 | w.putString(h.Name) | 553 | w.putString(h.Name) | ||
| @@ -430,8 +564,69 @@ func MarshalHello(h Hello) []byte { | |||||
| 430 | return w.b | 564 | return w.b | ||
| 431 | } | 565 | } | ||
| 432 | 566 | ||||
| 567 | + | func preflightHello(b []byte) error { | |||
| 568 | + | r := payloadReader{b: b} | |||
| 569 | + | version, err := r.getUint16() | |||
| 570 | + | if err != nil { | |||
| 571 | + | return err | |||
| 572 | + | } | |||
| 573 | + | if version == 21 { | |||
| 574 | + | return ErrInvalidHello | |||
| 575 | + | } | |||
| 576 | + | if _, err := r.getUint8(); err != nil { | |||
| 577 | + | return err | |||
| 578 | + | } | |||
| 579 | + | if len(r.b) < 16 { | |||
| 580 | + | return errShortPayload | |||
| 581 | + | } | |||
| 582 | + | r.b = r.b[16:] | |||
| 583 | + | if _, err := r.getUint64(); err != nil { | |||
| 584 | + | return err | |||
| 585 | + | } | |||
| 586 | + | if err := r.skipString(); err != nil { | |||
| 587 | + | return err | |||
| 588 | + | } | |||
| 589 | + | if _, err := r.getUint16(); err != nil { | |||
| 590 | + | return err | |||
| 591 | + | } | |||
| 592 | + | if _, err := r.getUint16(); err != nil { | |||
| 593 | + | return err | |||
| 594 | + | } | |||
| 595 | + | if err := r.skipString(); err != nil { | |||
| 596 | + | return err | |||
| 597 | + | } | |||
| 598 | + | if err := r.skipString(); err != nil { | |||
| 599 | + | return err | |||
| 600 | + | } | |||
| 601 | + | if _, err := r.getBool(); err != nil { | |||
| 602 | + | return err | |||
| 603 | + | } | |||
| 604 | + | if _, err := r.getUint8(); err != nil { | |||
| 605 | + | return err | |||
| 606 | + | } | |||
| 607 | + | envCount, err := r.getUint32() | |||
| 608 | + | if err != nil { | |||
| 609 | + | return err | |||
| 610 | + | } | |||
| 611 | + | if uint64(envCount) > uint64(len(r.b)/4) { | |||
| 612 | + | return errShortPayload | |||
| 613 | + | } | |||
| 614 | + | for range int(envCount) { | |||
| 615 | + | if err := r.skipLongString(); err != nil { | |||
| 616 | + | return err | |||
| 617 | + | } | |||
| 618 | + | } | |||
| 619 | + | return r.done() | |||
| 620 | + | } | |||
| 621 | + | ||||
| 433 | // UnmarshalHello decodes a Hello message payload. | 622 | // UnmarshalHello decodes a Hello message payload. | ||
| 434 | func UnmarshalHello(b []byte) (Hello, error) { | 623 | func UnmarshalHello(b []byte) (Hello, error) { | ||
| 624 | + | if len(b) > MaxFrameLen-1 { | |||
| 625 | + | return Hello{}, ErrInvalidHello | |||
| 626 | + | } | |||
| 627 | + | if err := preflightHello(b); err != nil { | |||
| 628 | + | return Hello{}, err | |||
| 629 | + | } | |||
| 435 | r := payloadReader{b: b} | 630 | r := payloadReader{b: b} | ||
| 436 | var h Hello | 631 | var h Hello | ||
| 437 | var err error | 632 | var err error | ||
| @@ -442,9 +637,6 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 442 | if h.Intent, err = r.getUint8(); err != nil { | 637 | if h.Intent, err = r.getUint8(); err != nil { | ||
| 443 | return Hello{}, err | 638 | return Hello{}, err | ||
| 444 | } | 639 | } | ||
| 445 | - | if h.Proxied, err = r.getBool(); err != nil { | |||
| 446 | - | return Hello{}, err | |||
| 447 | - | } | |||
| 448 | clientID, err := r.getBytes(len(h.ClientID)) | 640 | clientID, err := r.getBytes(len(h.ClientID)) | ||
| 449 | if err != nil { | 641 | if err != nil { | ||
| 450 | return Hello{}, err | 642 | return Hello{}, err | ||
| @@ -471,11 +663,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 471 | if h.Cwd, err = r.getString(); err != nil { | 663 | if h.Cwd, err = r.getString(); err != nil { | ||
| 472 | return Hello{}, err | 664 | return Hello{}, err | ||
| 473 | } | 665 | } | ||
| 474 | - | trueColor, err := r.getUint8() | 666 | + | if h.TrueColor, err = r.getBool(); err != nil { |
| 475 | - | if err != nil { | |||
| 476 | return Hello{}, err | 667 | return Hello{}, err | ||
| 477 | } | 668 | } | ||
| 478 | - | h.TrueColor = trueColor != 0 | |||
| 479 | if h.MaxOutputInFlight, err = r.getUint8(); err != nil { | 669 | if h.MaxOutputInFlight, err = r.getUint8(); err != nil { | ||
| 480 | return Hello{}, err | 670 | return Hello{}, err | ||
| 481 | } | 671 | } | ||
| @@ -501,6 +691,9 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 501 | if err := r.done(); err != nil { | 691 | if err := r.done(); err != nil { | ||
| 502 | return Hello{}, err | 692 | return Hello{}, err | ||
| 503 | } | 693 | } | ||
| 694 | + | if err := ValidateHello(h); err != nil { | |||
| 695 | + | return Hello{}, err | |||
| 696 | + | } | |||
| 504 | return h, nil | 697 | return h, nil | ||
| 505 | } | 698 | } | ||
| 506 | 699 | ||||
| @@ -571,11 +764,14 @@ func UnmarshalClientNotice(b []byte) (ClientNotice, error) { | |||||
| 571 | } | 764 | } | ||
| 572 | 765 | ||||
| 573 | // MarshalResize encodes m into a Resize message payload. | 766 | // MarshalResize encodes m into a Resize message payload. | ||
| 574 | - | func MarshalResize(m Resize) []byte { | 767 | + | func MarshalResize(m Resize) ([]byte, error) { |
| 768 | + | if err := ValidateSize(m.Size); err != nil { | |||
| 769 | + | return nil, err | |||
| 770 | + | } | |||
| 575 | w := payloadWriter{} | 771 | w := payloadWriter{} | ||
| 576 | w.putUint16(uint16(m.Size.Cols)) | 772 | w.putUint16(uint16(m.Size.Cols)) | ||
| 577 | w.putUint16(uint16(m.Size.Rows)) | 773 | w.putUint16(uint16(m.Size.Rows)) | ||
| 578 | - | return w.b | 774 | + | return w.b, nil |
| 579 | } | 775 | } | ||
| 580 | 776 | ||||
| 581 | // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload. | 777 | // MarshalTheme encodes m into a 57-byte fixed-width Theme message payload. | ||
| @@ -691,7 +887,11 @@ func UnmarshalResize(b []byte) (Resize, error) { | |||||
| 691 | if err := r.done(); err != nil { | 887 | if err := r.done(); err != nil { | ||
| 692 | return Resize{}, err | 888 | return Resize{}, err | ||
| 693 | } | 889 | } | ||
| 694 | - | return Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}}, nil | 890 | + | m := Resize{Size: domain.Size{Cols: int(cols), Rows: int(rows)}} |
| 891 | + | if err := ValidateSize(m.Size); err != nil { | |||
| 892 | + | return Resize{}, err | |||
| 893 | + | } | |||
| 894 | + | return m, nil | |||
| 695 | } | 895 | } | ||
| 696 | 896 | ||||
| 697 | // MarshalDetach encodes a Detach message payload (always empty). | 897 | // MarshalDetach encodes a Detach message payload (always empty). | ||
| @@ -736,24 +936,35 @@ func UnmarshalPong(b []byte) (Pong, error) { | |||||
| 736 | return Pong{}, nil | 936 | return Pong{}, nil | ||
| 737 | } | 937 | } | ||
| 738 | 938 | ||||
| 739 | - | // MarshalAck encodes m into an Ack message payload. | 939 | + | // MarshalAck encodes m into an epoch/state Ack payload. |
| 740 | - | func MarshalAck(m Ack) []byte { | 940 | + | func MarshalAck(m Ack) ([]byte, error) { |
| 941 | + | if err := ValidateAck(m); err != nil { | |||
| 942 | + | return nil, err | |||
| 943 | + | } | |||
| 741 | w := payloadWriter{} | 944 | w := payloadWriter{} | ||
| 742 | - | w.putUint64(m.AckedStateNum) | 945 | + | w.putUint64(m.Epoch) |
| 743 | - | return w.b | 946 | + | w.putUint64(m.State) |
| 947 | + | return w.b, nil | |||
| 744 | } | 948 | } | ||
| 745 | 949 | ||||
| 746 | - | // UnmarshalAck decodes an Ack message payload. | 950 | + | // UnmarshalAck decodes an epoch/state Ack payload. |
| 747 | func UnmarshalAck(b []byte) (Ack, error) { | 951 | func UnmarshalAck(b []byte) (Ack, error) { | ||
| 748 | r := payloadReader{b: b} | 952 | r := payloadReader{b: b} | ||
| 749 | - | acked, err := r.getUint64() | 953 | + | var m Ack |
| 750 | - | if err != nil { | 954 | + | var err error |
| 955 | + | if m.Epoch, err = r.getUint64(); err != nil { | |||
| 956 | + | return Ack{}, err | |||
| 957 | + | } | |||
| 958 | + | if m.State, err = r.getUint64(); err != nil { | |||
| 751 | return Ack{}, err | 959 | return Ack{}, err | ||
| 752 | } | 960 | } | ||
| 753 | if err := r.done(); err != nil { | 961 | if err := r.done(); err != nil { | ||
| 754 | return Ack{}, err | 962 | return Ack{}, err | ||
| 755 | } | 963 | } | ||
| 756 | - | return Ack{AckedStateNum: acked}, nil | 964 | + | if err := ValidateAck(m); err != nil { |
| 965 | + | return Ack{}, err | |||
| 966 | + | } | |||
| 967 | + | return m, nil | |||
| 757 | } | 968 | } | ||
| 758 | 969 | ||||
| 759 | // MarshalWelcome encodes m into a Welcome message payload. | 970 | // MarshalWelcome encodes m into a Welcome message payload. | ||
| @@ -826,33 +1037,140 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) { | |||||
| 826 | return m, nil | 1037 | return m, nil | ||
| 827 | } | 1038 | } | ||
| 828 | 1039 | ||||
| 829 | - | // MarshalOutput encodes m into an Output message payload. | 1040 | + | // MarshalOutput encodes m into the epoch/base/new/echo/viewRevision/ |
| 830 | - | func MarshalOutput(m Output) []byte { | 1041 | + | // size/full/data message layout. |
| 1042 | + | func MarshalOutput(m Output) ([]byte, error) { | |||
| 1043 | + | if err := ValidateOutput(m); err != nil { | |||
| 1044 | + | return nil, err | |||
| 1045 | + | } | |||
| 831 | w := payloadWriter{} | 1046 | w := payloadWriter{} | ||
| 832 | - | w.putUint64(m.BaseStateNum) | 1047 | + | w.putUint64(m.Epoch) |
| 833 | - | w.putUint64(m.NewStateNum) | 1048 | + | w.putUint64(m.Base) |
| 834 | - | w.putUint64(m.EchoAck) | 1049 | + | w.putUint64(m.New) |
| 835 | - | w.putBytes(m.Data) | 1050 | + | w.putUint64(m.Echo) |
| 836 | - | return w.b | 1051 | + | w.putUint64(m.ViewRevision) |
| 1052 | + | w.putUint16(uint16(m.Size.Cols)) | |||
| 1053 | + | w.putUint16(uint16(m.Size.Rows)) | |||
| 1054 | + | w.putBool(m.Full) | |||
| 1055 | + | w.putLongBytes(m.Data) | |||
| 1056 | + | return w.b, nil | |||
| 837 | } | 1057 | } | ||
| 838 | 1058 | ||||
| 839 | - | // UnmarshalOutput decodes an Output message payload. After fixed state | 1059 | + | // UnmarshalOutput strictly decodes one final Output payload. Header semantics |
| 840 | - | // numbers, the rest of the payload is data; there is no length prefix. | 1060 | + | // are validated before the length-prefixed data is copied. |
| 841 | func UnmarshalOutput(b []byte) (Output, error) { | 1061 | func UnmarshalOutput(b []byte) (Output, error) { | ||
| 1062 | + | if len(b) > MaxFrameLen-1 { | |||
| 1063 | + | return Output{}, ErrInvalidOutput | |||
| 1064 | + | } | |||
| 1065 | + | if len(b) < outputPayloadOverhead { | |||
| 1066 | + | return Output{}, errShortPayload | |||
| 1067 | + | } | |||
| 842 | r := payloadReader{b: b} | 1068 | r := payloadReader{b: b} | ||
| 843 | - | base, err := r.getUint64() | 1069 | + | var m Output |
| 1070 | + | var err error | |||
| 1071 | + | if m.Epoch, err = r.getUint64(); err != nil { | |||
| 1072 | + | return Output{}, err | |||
| 1073 | + | } | |||
| 1074 | + | if m.Base, err = r.getUint64(); err != nil { | |||
| 1075 | + | return Output{}, err | |||
| 1076 | + | } | |||
| 1077 | + | if m.New, err = r.getUint64(); err != nil { | |||
| 1078 | + | return Output{}, err | |||
| 1079 | + | } | |||
| 1080 | + | if m.Echo, err = r.getUint64(); err != nil { | |||
| 1081 | + | return Output{}, err | |||
| 1082 | + | } | |||
| 1083 | + | if m.ViewRevision, err = r.getUint64(); err != nil { | |||
| 1084 | + | return Output{}, err | |||
| 1085 | + | } | |||
| 1086 | + | cols, err := r.getUint16() | |||
| 844 | if err != nil { | 1087 | if err != nil { | ||
| 845 | return Output{}, err | 1088 | return Output{}, err | ||
| 846 | } | 1089 | } | ||
| 847 | - | newState, err := r.getUint64() | 1090 | + | rows, err := r.getUint16() |
| 848 | if err != nil { | 1091 | if err != nil { | ||
| 849 | return Output{}, err | 1092 | return Output{}, err | ||
| 850 | } | 1093 | } | ||
| 851 | - | echoAck, err := r.getUint64() | 1094 | + | m.Size = domain.Size{Cols: int(cols), Rows: int(rows)} |
| 1095 | + | if m.Full, err = r.getBool(); err != nil { | |||
| 1096 | + | return Output{}, err | |||
| 1097 | + | } | |||
| 1098 | + | if err := ValidateOutput(m); err != nil { | |||
| 1099 | + | return Output{}, err | |||
| 1100 | + | } | |||
| 1101 | + | if len(r.b) < 4 { | |||
| 1102 | + | return Output{}, errShortPayload | |||
| 1103 | + | } | |||
| 1104 | + | dataLen := binary.BigEndian.Uint32(r.b) | |||
| 1105 | + | if uint64(dataLen) > uint64(len(r.b)-4) { | |||
| 1106 | + | return Output{}, errShortPayload | |||
| 1107 | + | } | |||
| 1108 | + | if uint64(dataLen) < uint64(len(r.b)-4) { | |||
| 1109 | + | return Output{}, errTrailingBytes | |||
| 1110 | + | } | |||
| 1111 | + | data, err := r.getLongBytes() | |||
| 852 | if err != nil { | 1112 | if err != nil { | ||
| 853 | return Output{}, err | 1113 | return Output{}, err | ||
| 854 | } | 1114 | } | ||
| 855 | - | return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil | 1115 | + | if err := r.done(); err != nil { |
| 1116 | + | return Output{}, err | |||
| 1117 | + | } | |||
| 1118 | + | m.Data = data | |||
| 1119 | + | return m, nil | |||
| 1120 | + | } | |||
| 1121 | + | ||||
| 1122 | + | // MarshalAttachTarget encodes a strict server attach-target payload. | |||
| 1123 | + | func MarshalAttachTarget(m AttachTarget) []byte { | |||
| 1124 | + | if err := ValidateAttachTarget(m); err != nil { | |||
| 1125 | + | return nil | |||
| 1126 | + | } | |||
| 1127 | + | w := payloadWriter{} | |||
| 1128 | + | w.putString(m.Endpoint) | |||
| 1129 | + | w.putString(m.Session) | |||
| 1130 | + | w.putUint8(m.Intent) | |||
| 1131 | + | return w.b | |||
| 1132 | + | } | |||
| 1133 | + | ||||
| 1134 | + | // UnmarshalAttachTarget decodes a strict server attach-target payload. | |||
| 1135 | + | func UnmarshalAttachTarget(b []byte) (AttachTarget, error) { | |||
| 1136 | + | // Preflight lengths and intent before getString can allocate either value. | |||
| 1137 | + | probe := payloadReader{b: b} | |||
| 1138 | + | endpointLen, err := probe.getUint16() | |||
| 1139 | + | if err != nil || endpointLen == 0 || int(endpointLen) > len(probe.b) { | |||
| 1140 | + | return AttachTarget{}, ErrInvalidAttachTarget | |||
| 1141 | + | } | |||
| 1142 | + | probe.b = probe.b[endpointLen:] | |||
| 1143 | + | sessionLen, err := probe.getUint16() | |||
| 1144 | + | if err != nil || sessionLen == 0 || int(sessionLen) > len(probe.b) { | |||
| 1145 | + | return AttachTarget{}, ErrInvalidAttachTarget | |||
| 1146 | + | } | |||
| 1147 | + | probe.b = probe.b[sessionLen:] | |||
| 1148 | + | intent, err := probe.getUint8() | |||
| 1149 | + | if err != nil || (intent != IntentEphemeral && intent != IntentNew && intent != IntentAttach && intent != IntentResume) { | |||
| 1150 | + | return AttachTarget{}, ErrInvalidAttachTarget | |||
| 1151 | + | } | |||
| 1152 | + | if err := probe.done(); err != nil { | |||
| 1153 | + | return AttachTarget{}, ErrInvalidAttachTarget | |||
| 1154 | + | } | |||
| 1155 | + | ||||
| 1156 | + | r := payloadReader{b: b} | |||
| 1157 | + | var m AttachTarget | |||
| 1158 | + | if m.Endpoint, err = r.getString(); err != nil { | |||
| 1159 | + | return AttachTarget{}, err | |||
| 1160 | + | } | |||
| 1161 | + | if m.Session, err = r.getString(); err != nil { | |||
| 1162 | + | return AttachTarget{}, err | |||
| 1163 | + | } | |||
| 1164 | + | if m.Intent, err = r.getUint8(); err != nil { | |||
| 1165 | + | return AttachTarget{}, err | |||
| 1166 | + | } | |||
| 1167 | + | if err := r.done(); err != nil { | |||
| 1168 | + | return AttachTarget{}, err | |||
| 1169 | + | } | |||
| 1170 | + | if err := ValidateAttachTarget(m); err != nil { | |||
| 1171 | + | return AttachTarget{}, err | |||
| 1172 | + | } | |||
| 1173 | + | return m, nil | |||
| 856 | } | 1174 | } | ||
| 857 | 1175 | ||||
| 858 | // MarshalDetached encodes m into a Detached message payload. | 1176 | // MarshalDetached encodes m into a Detached message payload. | ||
A internal/ports/wire_final_test.go +197 -0
| @@ -0,0 +1,197 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | "errors" | |||
| 7 | + | "reflect" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestFinalProtocolVersionAndNoV21Hello(t *testing.T) { | |||
| 14 | + | if ProtocolVersion != 23 { | |||
| 15 | + | t.Fatalf("ProtocolVersion = %d, want 23", ProtocolVersion) | |||
| 16 | + | } | |||
| 17 | + | payload := MarshalHello(Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}}) | |||
| 18 | + | if len(payload) < 2 { | |||
| 19 | + | t.Fatal("MarshalHello produced a truncated valid payload") | |||
| 20 | + | } | |||
| 21 | + | binary.BigEndian.PutUint16(payload, 21) | |||
| 22 | + | if _, err := UnmarshalHello(payload); !errors.Is(err, ErrInvalidHello) { | |||
| 23 | + | t.Fatalf("UnmarshalHello(v21) error = %v, want ErrInvalidHello", err) | |||
| 24 | + | } | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestFinalHelloGoldenStrict(t *testing.T) { | |||
| 28 | + | msg := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |||
| 29 | + | want := append([]byte{0, 23, 2}, make([]byte, 16+8)...) | |||
| 30 | + | want = append(want, 0, 0, 0, 80, 0, 24, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) | |||
| 31 | + | got := MarshalHello(msg) | |||
| 32 | + | if !bytes.Equal(got, want) { | |||
| 33 | + | t.Fatalf("Hello bytes = %x, want %x", got, want) | |||
| 34 | + | } | |||
| 35 | + | back, err := UnmarshalHello(got) | |||
| 36 | + | if err != nil { | |||
| 37 | + | t.Fatalf("UnmarshalHello() error = %v", err) | |||
| 38 | + | } | |||
| 39 | + | if !reflect.DeepEqual(back, msg) { | |||
| 40 | + | t.Fatalf("Hello = %+v, want %+v", back, msg) | |||
| 41 | + | } | |||
| 42 | + | assertAllPrefixesFail(t, got, UnmarshalHello) | |||
| 43 | + | assertTrailingGarbageFails(t, got, UnmarshalHello) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestFinalHelloSemanticValidation(t *testing.T) { | |||
| 47 | + | valid := Hello{Version: ProtocolVersion, Intent: IntentAttach, Size: domain.Size{Cols: 80, Rows: 24}} | |||
| 48 | + | for _, tt := range []struct { | |||
| 49 | + | name string | |||
| 50 | + | mutate func(*Hello) | |||
| 51 | + | }{ | |||
| 52 | + | {name: "intent", mutate: func(h *Hello) { h.Intent = 99 }}, | |||
| 53 | + | {name: "zero columns", mutate: func(h *Hello) { h.Size.Cols = 0 }}, | |||
| 54 | + | {name: "zero rows", mutate: func(h *Hello) { h.Size.Rows = 0 }}, | |||
| 55 | + | {name: "area", mutate: func(h *Hello) { h.Size = domain.Size{Cols: 513, Rows: 512} }}, | |||
| 56 | + | } { | |||
| 57 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 58 | + | msg := valid | |||
| 59 | + | tt.mutate(&msg) | |||
| 60 | + | if err := ValidateHello(msg); err == nil { | |||
| 61 | + | t.Fatal("ValidateHello accepted malformed semantics") | |||
| 62 | + | } | |||
| 63 | + | }) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestFinalResizeGoldenStrict(t *testing.T) { | |||
| 68 | + | msg := Resize{Size: domain.Size{Cols: 80, Rows: 24}} | |||
| 69 | + | want := []byte{0, 80, 0, 24} | |||
| 70 | + | got, err := MarshalResize(msg) | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatalf("MarshalResize() error = %v", err) | |||
| 73 | + | } | |||
| 74 | + | if !bytes.Equal(got, want) { | |||
| 75 | + | t.Fatalf("Resize bytes = %x, want %x", got, want) | |||
| 76 | + | } | |||
| 77 | + | back, err := UnmarshalResize(got) | |||
| 78 | + | if err != nil || back != msg { | |||
| 79 | + | t.Fatalf("Resize = %+v, error %v, want %+v", back, err, msg) | |||
| 80 | + | } | |||
| 81 | + | assertAllPrefixesFail(t, got, UnmarshalResize) | |||
| 82 | + | assertTrailingGarbageFails(t, got, UnmarshalResize) | |||
| 83 | + | for _, size := range []domain.Size{{}, {Cols: 513, Rows: 512}} { | |||
| 84 | + | if got, err := MarshalResize(Resize{Size: size}); err == nil || got != nil { | |||
| 85 | + | t.Fatalf("MarshalResize(%+v) = (%x, %v), want nil payload and error", size, got, err) | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func TestFinalOutputGoldenStrict(t *testing.T) { | |||
| 91 | + | msg := Output{ | |||
| 92 | + | Epoch: 1, Base: 0, New: 7, Echo: 8, ViewRevision: 9, | |||
| 93 | + | Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("ok"), | |||
| 94 | + | } | |||
| 95 | + | want := []byte{ | |||
| 96 | + | 0, 0, 0, 0, 0, 0, 0, 1, | |||
| 97 | + | 0, 0, 0, 0, 0, 0, 0, 0, | |||
| 98 | + | 0, 0, 0, 0, 0, 0, 0, 7, | |||
| 99 | + | 0, 0, 0, 0, 0, 0, 0, 8, | |||
| 100 | + | 0, 0, 0, 0, 0, 0, 0, 9, | |||
| 101 | + | 0, 80, 0, 24, 1, | |||
| 102 | + | 0, 0, 0, 2, 'o', 'k', | |||
| 103 | + | } | |||
| 104 | + | got, err := MarshalOutput(msg) | |||
| 105 | + | if err != nil { | |||
| 106 | + | t.Fatalf("MarshalOutput() error = %v", err) | |||
| 107 | + | } | |||
| 108 | + | if !bytes.Equal(got, want) { | |||
| 109 | + | t.Fatalf("Output bytes = %x, want %x", got, want) | |||
| 110 | + | } | |||
| 111 | + | back, err := UnmarshalOutput(got) | |||
| 112 | + | if err != nil || back.Epoch != msg.Epoch || back.Base != msg.Base || back.New != msg.New || back.Echo != msg.Echo || back.ViewRevision != msg.ViewRevision || back.Size != msg.Size || back.Full != msg.Full || !bytes.Equal(back.Data, msg.Data) { | |||
| 113 | + | t.Fatalf("Output = %+v, error %v, want %+v", back, err, msg) | |||
| 114 | + | } | |||
| 115 | + | assertAllPrefixesFail(t, got, UnmarshalOutput) | |||
| 116 | + | assertTrailingGarbageFails(t, got, UnmarshalOutput) | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func TestFinalOutputSemanticValidationBeforeDataAllocation(t *testing.T) { | |||
| 120 | + | valid := Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true} | |||
| 121 | + | for _, bad := range []Output{ | |||
| 122 | + | {}, | |||
| 123 | + | {Epoch: 1, Base: 0, New: 1, Size: valid.Size}, | |||
| 124 | + | {Epoch: 1, Base: 0, New: 1, Full: true}, | |||
| 125 | + | } { | |||
| 126 | + | if got, err := MarshalOutput(bad); err == nil || got != nil { | |||
| 127 | + | t.Fatalf("MarshalOutput(%+v) = (%x, %v), want nil payload and error", bad, got, err) | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | payload, err := MarshalOutput(valid) | |||
| 131 | + | if err != nil { | |||
| 132 | + | t.Fatal(err) | |||
| 133 | + | } | |||
| 134 | + | for _, tt := range []struct { | |||
| 135 | + | name string | |||
| 136 | + | mutate func([]byte) | |||
| 137 | + | }{ | |||
| 138 | + | {name: "zero epoch", mutate: func(b []byte) { b[7] = 0 }}, | |||
| 139 | + | {name: "new does not advance", mutate: func(b []byte) { binary.BigEndian.PutUint64(b[16:24], 0) }}, | |||
| 140 | + | {name: "size zero", mutate: func(b []byte) { binary.BigEndian.PutUint16(b[40:42], 0) }}, | |||
| 141 | + | {name: "full flag false for reset", mutate: func(b []byte) { b[44] = 0 }}, | |||
| 142 | + | {name: "invalid bool", mutate: func(b []byte) { b[44] = 2 }}, | |||
| 143 | + | {name: "impossible data length", mutate: func(b []byte) { binary.BigEndian.PutUint32(b[45:49], ^uint32(0)) }}, | |||
| 144 | + | } { | |||
| 145 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 146 | + | bad := append([]byte(nil), payload...) | |||
| 147 | + | tt.mutate(bad) | |||
| 148 | + | if _, err := UnmarshalOutput(bad); err == nil { | |||
| 149 | + | t.Fatal("UnmarshalOutput accepted malformed semantics") | |||
| 150 | + | } | |||
| 151 | + | }) | |||
| 152 | + | } | |||
| 153 | + | } | |||
| 154 | + | ||||
| 155 | + | func TestFinalAckGoldenStrict(t *testing.T) { | |||
| 156 | + | msg := Ack{Epoch: 2, State: 7} | |||
| 157 | + | want := []byte{0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, 7} | |||
| 158 | + | got, err := MarshalAck(msg) | |||
| 159 | + | if err != nil { | |||
| 160 | + | t.Fatalf("MarshalAck() error = %v", err) | |||
| 161 | + | } | |||
| 162 | + | if !bytes.Equal(got, want) { | |||
| 163 | + | t.Fatalf("Ack bytes = %x, want %x", got, want) | |||
| 164 | + | } | |||
| 165 | + | back, err := UnmarshalAck(got) | |||
| 166 | + | if err != nil || back != (Ack{Epoch: 2, State: 7}) { | |||
| 167 | + | t.Fatalf("Ack = %+v, error %v", back, err) | |||
| 168 | + | } | |||
| 169 | + | assertAllPrefixesFail(t, got, UnmarshalAck) | |||
| 170 | + | assertTrailingGarbageFails(t, got, UnmarshalAck) | |||
| 171 | + | if _, err := UnmarshalAck(make([]byte, 16)); err == nil { | |||
| 172 | + | t.Fatal("UnmarshalAck accepted zero epoch") | |||
| 173 | + | } | |||
| 174 | + | if got, err := MarshalAck(Ack{State: 7}); err == nil || got != nil { | |||
| 175 | + | t.Fatalf("MarshalAck accepted zero epoch: payload=%x err=%v", got, err) | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func TestFinalAttachTargetGoldenStrict(t *testing.T) { | |||
| 180 | + | msg := AttachTarget{Endpoint: "host", Session: "work", Intent: IntentAttach} | |||
| 181 | + | want := []byte{0, 4, 'h', 'o', 's', 't', 0, 4, 'w', 'o', 'r', 'k', 2} | |||
| 182 | + | got := MarshalAttachTarget(msg) | |||
| 183 | + | if !bytes.Equal(got, want) { | |||
| 184 | + | t.Fatalf("AttachTarget bytes = %x, want %x", got, want) | |||
| 185 | + | } | |||
| 186 | + | back, err := UnmarshalAttachTarget(got) | |||
| 187 | + | if err != nil || back != msg { | |||
| 188 | + | t.Fatalf("AttachTarget = %+v, error %v, want %+v", back, err, msg) | |||
| 189 | + | } | |||
| 190 | + | assertAllPrefixesFail(t, got, UnmarshalAttachTarget) | |||
| 191 | + | assertTrailingGarbageFails(t, got, UnmarshalAttachTarget) | |||
| 192 | + | for _, bad := range []AttachTarget{{Session: "work", Intent: IntentAttach}, {Endpoint: "host", Intent: IntentAttach}, {Endpoint: "host", Session: "work", Intent: 99}} { | |||
| 193 | + | if got := MarshalAttachTarget(bad); got != nil { | |||
| 194 | + | t.Fatalf("MarshalAttachTarget(%+v) = %x, want nil", bad, got) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | } | |||
D internal/ports/wire_screen.go +0 -756
| @@ -1,756 +0,0 @@ | |||||
| 1 | - | package ports | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "encoding/binary" | |||
| 5 | - | "errors" | |||
| 6 | - | "math" | |||
| 7 | - | "unicode/utf8" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/vev/internal/domain" | |||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | // ScreenUpdateKind selects whether an update replaces the complete screen or | |||
| 14 | - | // applies a delta to the previously committed screen. | |||
| 15 | - | type ScreenUpdateKind uint8 | |||
| 16 | - | ||||
| 17 | - | const ( | |||
| 18 | - | ScreenUpdateSnapshot ScreenUpdateKind = 1 | |||
| 19 | - | ScreenUpdateDelta ScreenUpdateKind = 2 | |||
| 20 | - | ) | |||
| 21 | - | ||||
| 22 | - | // ScreenCursor is the absolute cursor state carried by each screen update. | |||
| 23 | - | type ScreenCursor struct { | |||
| 24 | - | Row, Col uint16 | |||
| 25 | - | Style uint8 | |||
| 26 | - | Visible bool | |||
| 27 | - | StyleSet bool | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | // ScreenScroll describes a full-width upward scroll applied before spans. | |||
| 31 | - | type ScreenScroll struct { | |||
| 32 | - | Top, Height, Count uint16 | |||
| 33 | - | } | |||
| 34 | - | ||||
| 35 | - | // ScreenSpan identifies owned cells beginning at a horizontal position. | |||
| 36 | - | type ScreenSpan struct { | |||
| 37 | - | Y, X uint16 | |||
| 38 | - | Cells []renderer.Cell | |||
| 39 | - | } | |||
| 40 | - | ||||
| 41 | - | // ScreenUpdate is the semantic structured screen message exchanged by proxy | |||
| 42 | - | // daemons. Wire style tables and run IDs remain private to its codec. | |||
| 43 | - | type ScreenUpdate struct { | |||
| 44 | - | BaseStateNum uint64 | |||
| 45 | - | NewStateNum uint64 | |||
| 46 | - | EchoAck uint64 | |||
| 47 | - | Kind ScreenUpdateKind | |||
| 48 | - | Size domain.Size | |||
| 49 | - | Cursor ScreenCursor | |||
| 50 | - | Scroll *ScreenScroll | |||
| 51 | - | Spans []ScreenSpan | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | var ( | |||
| 55 | - | // ErrInvalidScreenUpdate reports a malformed or non-canonical screen | |||
| 56 | - | // update. It is deliberately shared by marshal and unmarshal validation. | |||
| 57 | - | ErrInvalidScreenUpdate = errors.New("ports: invalid screen update") | |||
| 58 | - | // ErrScreenUpdateTooLarge reports a payload that cannot fit in a frame. | |||
| 59 | - | ErrScreenUpdateTooLarge = errors.New("ports: screen update too large") | |||
| 60 | - | ) | |||
| 61 | - | ||||
| 62 | - | const ( | |||
| 63 | - | maxScreenCells = 1 << 18 | |||
| 64 | - | ||||
| 65 | - | screenHeaderLen = 40 | |||
| 66 | - | screenStyleLen = 18 | |||
| 67 | - | screenSpanLimit = 4096 | |||
| 68 | - | ||||
| 69 | - | screenFlagCursorVisible = 1 << 0 | |||
| 70 | - | screenFlagCursorStyleSet = 1 << 1 | |||
| 71 | - | screenKnownCursorFlags = screenFlagCursorVisible | screenFlagCursorStyleSet | |||
| 72 | - | ||||
| 73 | - | styleBoldBit = 1 << 0 | |||
| 74 | - | styleItalicBit = 1 << 1 | |||
| 75 | - | styleInverseBit = 1 << 2 | |||
| 76 | - | styleDimBit = 1 << 3 | |||
| 77 | - | styleUnderlineBit = 1 << 4 | |||
| 78 | - | styleBlinkBit = 1 << 5 | |||
| 79 | - | styleStrikethroughBit = 1 << 6 | |||
| 80 | - | styleForegroundRGBBit = 1 << 7 | |||
| 81 | - | styleBackgroundRGBBit = 1 << 8 | |||
| 82 | - | styleUnderlineColorBit = 1 << 9 | |||
| 83 | - | styleUnderlineColorRGBBit = 1 << 10 | |||
| 84 | - | styleKnownBits = (1 << 11) - 1 | |||
| 85 | - | ) | |||
| 86 | - | ||||
| 87 | - | // The frame length includes MsgScreenUpdate's type byte. The codec takes | |||
| 88 | - | // only the payload, so one byte is reserved here. | |||
| 89 | - | var screenPayloadLimit = MaxFrameLen - 1 | |||
| 90 | - | ||||
| 91 | - | // MarshalScreenUpdate encodes a canonical structured screen update. | |||
| 92 | - | func MarshalScreenUpdate(m ScreenUpdate) ([]byte, error) { | |||
| 93 | - | if err := validateScreenUpdate(m); err != nil { | |||
| 94 | - | return nil, err | |||
| 95 | - | } | |||
| 96 | - | ||||
| 97 | - | styles := make([]renderer.Style, 0) | |||
| 98 | - | styleIDs := make(map[renderer.Style]uint16) | |||
| 99 | - | for _, span := range m.Spans { | |||
| 100 | - | for _, cell := range span.Cells { | |||
| 101 | - | style := canonicalScreenStyle(cell.Style) | |||
| 102 | - | if _, ok := styleIDs[style]; ok { | |||
| 103 | - | continue | |||
| 104 | - | } | |||
| 105 | - | if len(styles) == math.MaxUint16 { | |||
| 106 | - | return nil, ErrInvalidScreenUpdate | |||
| 107 | - | } | |||
| 108 | - | styleIDs[style] = uint16(len(styles)) | |||
| 109 | - | styles = append(styles, style) | |||
| 110 | - | } | |||
| 111 | - | } | |||
| 112 | - | w := screenWriter{b: make([]byte, 0, screenHeaderLen+len(styles)*screenStyleLen)} | |||
| 113 | - | w.u64(m.BaseStateNum) | |||
| 114 | - | w.u64(m.NewStateNum) | |||
| 115 | - | w.u64(m.EchoAck) | |||
| 116 | - | w.u8(uint8(m.Kind)) | |||
| 117 | - | w.u16(uint16(m.Size.Cols)) | |||
| 118 | - | w.u16(uint16(m.Size.Rows)) | |||
| 119 | - | w.u16(m.Cursor.Row) | |||
| 120 | - | w.u16(m.Cursor.Col) | |||
| 121 | - | w.u8(m.Cursor.Style) | |||
| 122 | - | var cursorFlags uint8 | |||
| 123 | - | if m.Cursor.Visible { | |||
| 124 | - | cursorFlags |= screenFlagCursorVisible | |||
| 125 | - | } | |||
| 126 | - | if m.Cursor.StyleSet { | |||
| 127 | - | cursorFlags |= screenFlagCursorStyleSet | |||
| 128 | - | } | |||
| 129 | - | w.u8(cursorFlags) | |||
| 130 | - | if m.Scroll != nil { | |||
| 131 | - | w.u8(1) | |||
| 132 | - | } else { | |||
| 133 | - | w.u8(0) | |||
| 134 | - | } | |||
| 135 | - | w.u16(uint16(len(styles))) | |||
| 136 | - | w.u16(uint16(len(m.Spans))) | |||
| 137 | - | if m.Scroll != nil { | |||
| 138 | - | w.u16(m.Scroll.Top) | |||
| 139 | - | w.u16(m.Scroll.Height) | |||
| 140 | - | w.u16(m.Scroll.Count) | |||
| 141 | - | } | |||
| 142 | - | for _, style := range styles { | |||
| 143 | - | appendScreenStyle(&w, style) | |||
| 144 | - | } | |||
| 145 | - | for _, span := range m.Spans { | |||
| 146 | - | w.u16(span.Y) | |||
| 147 | - | w.u16(span.X) | |||
| 148 | - | w.u16(uint16(len(span.Cells))) | |||
| 149 | - | runs := screenRunCount(span.Cells) | |||
| 150 | - | w.u16(uint16(runs)) | |||
| 151 | - | for start := 0; start < len(span.Cells); { | |||
| 152 | - | style := canonicalScreenStyle(span.Cells[start].Style) | |||
| 153 | - | id := styleIDs[style] | |||
| 154 | - | end := start + 1 | |||
| 155 | - | for end < len(span.Cells) && styleIDs[canonicalScreenStyle(span.Cells[end].Style)] == id { | |||
| 156 | - | end++ | |||
| 157 | - | } | |||
| 158 | - | w.u16(id) | |||
| 159 | - | w.u16(uint16(end - start)) | |||
| 160 | - | for _, cell := range span.Cells[start:end] { | |||
| 161 | - | if cell.Continuation { | |||
| 162 | - | w.uvarint(0) | |||
| 163 | - | } else { | |||
| 164 | - | w.uvarint(uint64(cell.Rune) + 1) | |||
| 165 | - | } | |||
| 166 | - | } | |||
| 167 | - | start = end | |||
| 168 | - | } | |||
| 169 | - | } | |||
| 170 | - | if w.tooLarge { | |||
| 171 | - | return nil, ErrScreenUpdateTooLarge | |||
| 172 | - | } | |||
| 173 | - | return w.b, nil | |||
| 174 | - | } | |||
| 175 | - | ||||
| 176 | - | // UnmarshalScreenUpdate strictly decodes one screen-update payload. All | |||
| 177 | - | // decoded slices own their data; no input-backed slice is retained. | |||
| 178 | - | func UnmarshalScreenUpdate(data []byte) (ScreenUpdate, error) { | |||
| 179 | - | if len(data) > screenPayloadLimit { | |||
| 180 | - | return ScreenUpdate{}, ErrScreenUpdateTooLarge | |||
| 181 | - | } | |||
| 182 | - | if len(data) < screenHeaderLen { | |||
| 183 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 184 | - | } | |||
| 185 | - | r := screenReader{b: data} | |||
| 186 | - | var m ScreenUpdate | |||
| 187 | - | var err error | |||
| 188 | - | if m.BaseStateNum, err = r.u64(); err != nil { | |||
| 189 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 190 | - | } | |||
| 191 | - | if m.NewStateNum, err = r.u64(); err != nil { | |||
| 192 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 193 | - | } | |||
| 194 | - | if m.EchoAck, err = r.u64(); err != nil { | |||
| 195 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 196 | - | } | |||
| 197 | - | kind, err := r.u8() | |||
| 198 | - | if err != nil || (ScreenUpdateKind(kind) != ScreenUpdateSnapshot && ScreenUpdateKind(kind) != ScreenUpdateDelta) { | |||
| 199 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 200 | - | } | |||
| 201 | - | m.Kind = ScreenUpdateKind(kind) | |||
| 202 | - | cols, err := r.u16() | |||
| 203 | - | if err != nil { | |||
| 204 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 205 | - | } | |||
| 206 | - | rows, err := r.u16() | |||
| 207 | - | if err != nil || cols == 0 || rows == 0 || !screenAreaWithinLimit(domain.Size{Cols: int(cols), Rows: int(rows)}) { | |||
| 208 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 209 | - | } | |||
| 210 | - | m.Size = domain.Size{Cols: int(cols), Rows: int(rows)} | |||
| 211 | - | if m.Cursor.Row, err = r.u16(); err != nil { | |||
| 212 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 213 | - | } | |||
| 214 | - | if m.Cursor.Col, err = r.u16(); err != nil { | |||
| 215 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 216 | - | } | |||
| 217 | - | if m.Cursor.Style, err = r.u8(); err != nil { | |||
| 218 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 219 | - | } | |||
| 220 | - | cursorFlags, err := r.u8() | |||
| 221 | - | if err != nil || cursorFlags&^uint8(screenKnownCursorFlags) != 0 || m.Cursor.Row >= rows || m.Cursor.Col >= cols { | |||
| 222 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 223 | - | } | |||
| 224 | - | m.Cursor.Visible = cursorFlags&screenFlagCursorVisible != 0 | |||
| 225 | - | m.Cursor.StyleSet = cursorFlags&screenFlagCursorStyleSet != 0 | |||
| 226 | - | if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) { | |||
| 227 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 228 | - | } | |||
| 229 | - | scrollPresent, err := r.u8() | |||
| 230 | - | if err != nil || scrollPresent > 1 { | |||
| 231 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 232 | - | } | |||
| 233 | - | styleCount, err := r.u16() | |||
| 234 | - | if err != nil || uint64(styleCount) > uint64(len(r.b))/screenStyleLen { | |||
| 235 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 236 | - | } | |||
| 237 | - | spanCount, err := r.u16() | |||
| 238 | - | if err != nil || spanCount > screenSpanLimit || uint64(spanCount) > uint64(len(r.b))/8 { | |||
| 239 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 240 | - | } | |||
| 241 | - | if scrollPresent != 0 { | |||
| 242 | - | if m.Kind != ScreenUpdateDelta || len(r.b) < 6 { | |||
| 243 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 244 | - | } | |||
| 245 | - | m.Scroll = new(ScreenScroll) | |||
| 246 | - | if m.Scroll.Top, err = r.u16(); err != nil { | |||
| 247 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 248 | - | } | |||
| 249 | - | if m.Scroll.Height, err = r.u16(); err != nil { | |||
| 250 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 251 | - | } | |||
| 252 | - | if m.Scroll.Count, err = r.u16(); err != nil { | |||
| 253 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 254 | - | } | |||
| 255 | - | if !validScreenScroll(*m.Scroll, rows) { | |||
| 256 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 257 | - | } | |||
| 258 | - | } else { | |||
| 259 | - | m.Scroll = nil | |||
| 260 | - | } | |||
| 261 | - | // styleCount was checked against the bytes still available before this | |||
| 262 | - | // allocation. Each style is copied field-by-field below. | |||
| 263 | - | styles := make([]renderer.Style, styleCount) | |||
| 264 | - | seenStyles := make(map[renderer.Style]struct{}, styleCount) | |||
| 265 | - | for i := range styles { | |||
| 266 | - | style, ok := readScreenStyle(&r) | |||
| 267 | - | if !ok { | |||
| 268 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 269 | - | } | |||
| 270 | - | if _, duplicate := seenStyles[style]; duplicate { | |||
| 271 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 272 | - | } | |||
| 273 | - | seenStyles[style] = struct{}{} | |||
| 274 | - | styles[i] = style | |||
| 275 | - | } | |||
| 276 | - | m.Spans = make([]ScreenSpan, spanCount) | |||
| 277 | - | usedStyles := make([]bool, styleCount) | |||
| 278 | - | nextStyleID := uint16(0) | |||
| 279 | - | var previousEnd uint32 | |||
| 280 | - | var previousY uint16 | |||
| 281 | - | for i := range m.Spans { | |||
| 282 | - | y, errY := r.u16() | |||
| 283 | - | x, errX := r.u16() | |||
| 284 | - | cellCount, errCells := r.u16() | |||
| 285 | - | runCount, errRuns := r.u16() | |||
| 286 | - | if errY != nil || errX != nil || errCells != nil || errRuns != nil || cellCount == 0 || runCount == 0 || runCount > cellCount || uint64(runCount) > uint64(len(r.b))/4 { | |||
| 287 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 288 | - | } | |||
| 289 | - | if y >= rows || x >= cols || uint64(x)+uint64(cellCount) > uint64(cols) { | |||
| 290 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 291 | - | } | |||
| 292 | - | if i > 0 { | |||
| 293 | - | if y < previousY || (y == previousY && uint32(x) < previousEnd) { | |||
| 294 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 295 | - | } | |||
| 296 | - | } | |||
| 297 | - | // Ordered non-overlapping spans have at most one cell per screen cell, | |||
| 298 | - | // so the area check above bounds aggregate decoded cells before each | |||
| 299 | - | // per-span allocation. Every cell has at least one token and every run | |||
| 300 | - | // has a four-byte descriptor; check both before allocating the slice. | |||
| 301 | - | minimum := uint64(runCount)*4 + uint64(cellCount) | |||
| 302 | - | if minimum > uint64(len(r.b)) { | |||
| 303 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 304 | - | } | |||
| 305 | - | cells := make([]renderer.Cell, cellCount) | |||
| 306 | - | filled := 0 | |||
| 307 | - | previousStyleID := uint16(math.MaxUint16) | |||
| 308 | - | for range runCount { | |||
| 309 | - | styleID, e1 := r.u16() | |||
| 310 | - | runCells, e2 := r.u16() | |||
| 311 | - | if e1 != nil || e2 != nil || runCells == 0 || styleID >= styleCount || styleID == previousStyleID || uint32(filled)+uint32(runCells) > uint32(cellCount) { | |||
| 312 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 313 | - | } | |||
| 314 | - | if !usedStyles[styleID] { | |||
| 315 | - | if styleID != nextStyleID { | |||
| 316 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 317 | - | } | |||
| 318 | - | nextStyleID++ | |||
| 319 | - | usedStyles[styleID] = true | |||
| 320 | - | } | |||
| 321 | - | previousStyleID = styleID | |||
| 322 | - | for range runCells { | |||
| 323 | - | token, ok := r.uvarint() | |||
| 324 | - | if !ok { | |||
| 325 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 326 | - | } | |||
| 327 | - | if token == 0 { | |||
| 328 | - | cells[filled] = renderer.Cell{Style: styles[styleID], Continuation: true} | |||
| 329 | - | } else { | |||
| 330 | - | runeValue, valid := screenRune(token) | |||
| 331 | - | if !valid { | |||
| 332 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 333 | - | } | |||
| 334 | - | cells[filled] = renderer.Cell{Rune: runeValue, Style: styles[styleID]} | |||
| 335 | - | } | |||
| 336 | - | filled++ | |||
| 337 | - | } | |||
| 338 | - | } | |||
| 339 | - | if filled != int(cellCount) { | |||
| 340 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 341 | - | } | |||
| 342 | - | m.Spans[i] = ScreenSpan{Y: y, X: x, Cells: cells} | |||
| 343 | - | previousY = y | |||
| 344 | - | previousEnd = uint32(x) + uint32(cellCount) | |||
| 345 | - | } | |||
| 346 | - | if len(r.b) != 0 || nextStyleID != styleCount { | |||
| 347 | - | return ScreenUpdate{}, ErrInvalidScreenUpdate | |||
| 348 | - | } | |||
| 349 | - | if err := validateScreenUpdate(m); err != nil { | |||
| 350 | - | return ScreenUpdate{}, err | |||
| 351 | - | } | |||
| 352 | - | return m, nil | |||
| 353 | - | } | |||
| 354 | - | ||||
| 355 | - | func validateScreenUpdate(m ScreenUpdate) error { | |||
| 356 | - | switch m.Kind { | |||
| 357 | - | case ScreenUpdateSnapshot: | |||
| 358 | - | if m.BaseStateNum != 0 || m.NewStateNum == 0 { | |||
| 359 | - | return ErrInvalidScreenUpdate | |||
| 360 | - | } | |||
| 361 | - | case ScreenUpdateDelta: | |||
| 362 | - | if m.BaseStateNum == 0 || m.BaseStateNum == math.MaxUint64 || m.NewStateNum != m.BaseStateNum+1 { | |||
| 363 | - | return ErrInvalidScreenUpdate | |||
| 364 | - | } | |||
| 365 | - | default: | |||
| 366 | - | return ErrInvalidScreenUpdate | |||
| 367 | - | } | |||
| 368 | - | if (m.Kind != ScreenUpdateSnapshot && m.Kind != ScreenUpdateDelta) || | |||
| 369 | - | m.Size.Cols <= 0 || m.Size.Rows <= 0 || m.Size.Cols > math.MaxUint16 || m.Size.Rows > math.MaxUint16 || | |||
| 370 | - | !screenAreaWithinLimit(m.Size) || m.Cursor.Row >= uint16(m.Size.Rows) || m.Cursor.Col >= uint16(m.Size.Cols) { | |||
| 371 | - | return ErrInvalidScreenUpdate | |||
| 372 | - | } | |||
| 373 | - | if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) { | |||
| 374 | - | return ErrInvalidScreenUpdate | |||
| 375 | - | } | |||
| 376 | - | if m.Kind == ScreenUpdateSnapshot && m.Scroll != nil { | |||
| 377 | - | return ErrInvalidScreenUpdate | |||
| 378 | - | } | |||
| 379 | - | if m.Scroll != nil && !validScreenScroll(*m.Scroll, uint16(m.Size.Rows)) { | |||
| 380 | - | return ErrInvalidScreenUpdate | |||
| 381 | - | } | |||
| 382 | - | if len(m.Spans) > screenSpanLimit || len(m.Spans) > math.MaxUint16 || (m.Kind == ScreenUpdateSnapshot && len(m.Spans) == 0) { | |||
| 383 | - | return ErrInvalidScreenUpdate | |||
| 384 | - | } | |||
| 385 | - | return validateScreenSpans(m) | |||
| 386 | - | } | |||
| 387 | - | ||||
| 388 | - | func screenAreaWithinLimit(size domain.Size) bool { | |||
| 389 | - | return size.Cols > 0 && size.Rows > 0 && size.Rows <= maxScreenCells/size.Cols | |||
| 390 | - | } | |||
| 391 | - | ||||
| 392 | - | func validScreenScroll(scroll ScreenScroll, rows uint16) bool { | |||
| 393 | - | return scroll.Height != 0 && scroll.Count != 0 && scroll.Count < scroll.Height && | |||
| 394 | - | scroll.Top < rows && uint64(scroll.Top)+uint64(scroll.Height) <= uint64(rows) | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | func validateScreenSpans(m ScreenUpdate) error { | |||
| 398 | - | cols, rows := uint32(m.Size.Cols), uint32(m.Size.Rows) | |||
| 399 | - | var previousY uint16 | |||
| 400 | - | var previousEnd uint32 | |||
| 401 | - | for i, span := range m.Spans { | |||
| 402 | - | if len(span.Cells) == 0 || len(span.Cells) > math.MaxUint16 || uint32(span.Y) >= rows || uint32(span.X) >= cols || uint64(span.X)+uint64(len(span.Cells)) > uint64(cols) { | |||
| 403 | - | return ErrInvalidScreenUpdate | |||
| 404 | - | } | |||
| 405 | - | if i > 0 && (span.Y < previousY || (span.Y == previousY && uint32(span.X) < previousEnd)) { | |||
| 406 | - | return ErrInvalidScreenUpdate | |||
| 407 | - | } | |||
| 408 | - | for _, cell := range span.Cells { | |||
| 409 | - | if (cell.Continuation && cell.Rune != 0) || (!cell.Continuation && !utf8.ValidRune(cell.Rune)) { | |||
| 410 | - | return ErrInvalidScreenUpdate | |||
| 411 | - | } | |||
| 412 | - | if err := validateScreenStyle(cell.Style); err != nil { | |||
| 413 | - | return err | |||
| 414 | - | } | |||
| 415 | - | } | |||
| 416 | - | previousY = span.Y | |||
| 417 | - | previousEnd = uint32(span.X) + uint32(len(span.Cells)) | |||
| 418 | - | } | |||
| 419 | - | if m.Kind == ScreenUpdateSnapshot { | |||
| 420 | - | if len(m.Spans) != m.Size.Rows { | |||
| 421 | - | return ErrInvalidScreenUpdate | |||
| 422 | - | } | |||
| 423 | - | for y, span := range m.Spans { | |||
| 424 | - | if int(span.Y) != y || span.X != 0 || len(span.Cells) != m.Size.Cols { | |||
| 425 | - | return ErrInvalidScreenUpdate | |||
| 426 | - | } | |||
| 427 | - | } | |||
| 428 | - | } | |||
| 429 | - | return nil | |||
| 430 | - | } | |||
| 431 | - | ||||
| 432 | - | func validateScreenStyle(style renderer.Style) error { | |||
| 433 | - | if style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 || style.UnderlineStyle > renderer.UnderlineDashed { | |||
| 434 | - | return ErrInvalidScreenUpdate | |||
| 435 | - | } | |||
| 436 | - | if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > math.MaxUint8 || style.ForegroundRGB != (renderer.RGB{})) { | |||
| 437 | - | return ErrInvalidScreenUpdate | |||
| 438 | - | } | |||
| 439 | - | if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > math.MaxUint8 || style.BackgroundRGB != (renderer.RGB{})) { | |||
| 440 | - | return ErrInvalidScreenUpdate | |||
| 441 | - | } | |||
| 442 | - | if style.HasUnderlineColorRGB && style.HasUnderlineColor { | |||
| 443 | - | return ErrInvalidScreenUpdate | |||
| 444 | - | } else if style.HasUnderlineColorRGB { | |||
| 445 | - | // The indexed value is inactive when RGB is selected. | |||
| 446 | - | } else if style.HasUnderlineColor { | |||
| 447 | - | if style.UnderlineColor < 0 || style.UnderlineColor > math.MaxUint8 || style.UnderlineColorRGB != (renderer.RGB{}) { | |||
| 448 | - | return ErrInvalidScreenUpdate | |||
| 449 | - | } | |||
| 450 | - | } else if style.UnderlineColorRGB != (renderer.RGB{}) { | |||
| 451 | - | return ErrInvalidScreenUpdate | |||
| 452 | - | } | |||
| 453 | - | return nil | |||
| 454 | - | } | |||
| 455 | - | ||||
| 456 | - | func canonicalScreenStyle(style renderer.Style) renderer.Style { | |||
| 457 | - | if style.HasForegroundRGB { | |||
| 458 | - | style.Foreground = -1 | |||
| 459 | - | } else { | |||
| 460 | - | style.ForegroundRGB = renderer.RGB{} | |||
| 461 | - | } | |||
| 462 | - | if style.HasBackgroundRGB { | |||
| 463 | - | style.Background = -1 | |||
| 464 | - | } else { | |||
| 465 | - | style.BackgroundRGB = renderer.RGB{} | |||
| 466 | - | } | |||
| 467 | - | if style.HasUnderlineColorRGB { | |||
| 468 | - | style.UnderlineColor = -1 | |||
| 469 | - | } else if style.HasUnderlineColor { | |||
| 470 | - | style.UnderlineColorRGB = renderer.RGB{} | |||
| 471 | - | } else { | |||
| 472 | - | style.UnderlineColor = -1 | |||
| 473 | - | style.UnderlineColorRGB = renderer.RGB{} | |||
| 474 | - | } | |||
| 475 | - | return style | |||
| 476 | - | } | |||
| 477 | - | ||||
| 478 | - | func screenStyleBits(style renderer.Style) uint16 { | |||
| 479 | - | var bits uint16 | |||
| 480 | - | if style.Bold { | |||
| 481 | - | bits |= styleBoldBit | |||
| 482 | - | } | |||
| 483 | - | if style.Italic { | |||
| 484 | - | bits |= styleItalicBit | |||
| 485 | - | } | |||
| 486 | - | if style.Inverse { | |||
| 487 | - | bits |= styleInverseBit | |||
| 488 | - | } | |||
| 489 | - | if style.Attrs&renderer.AttrDim != 0 { | |||
| 490 | - | bits |= styleDimBit | |||
| 491 | - | } | |||
| 492 | - | if style.Attrs&renderer.AttrUnderline != 0 { | |||
| 493 | - | bits |= styleUnderlineBit | |||
| 494 | - | } | |||
| 495 | - | if style.Attrs&renderer.AttrBlink != 0 { | |||
| 496 | - | bits |= styleBlinkBit | |||
| 497 | - | } | |||
| 498 | - | if style.Attrs&renderer.AttrStrikethrough != 0 { | |||
| 499 | - | bits |= styleStrikethroughBit | |||
| 500 | - | } | |||
| 501 | - | if style.HasForegroundRGB { | |||
| 502 | - | bits |= styleForegroundRGBBit | |||
| 503 | - | } | |||
| 504 | - | if style.HasBackgroundRGB { | |||
| 505 | - | bits |= styleBackgroundRGBBit | |||
| 506 | - | } | |||
| 507 | - | if style.HasUnderlineColor { | |||
| 508 | - | bits |= styleUnderlineColorBit | |||
| 509 | - | } | |||
| 510 | - | if style.HasUnderlineColorRGB { | |||
| 511 | - | bits |= styleUnderlineColorRGBBit | |||
| 512 | - | } | |||
| 513 | - | return bits | |||
| 514 | - | } | |||
| 515 | - | ||||
| 516 | - | func appendScreenStyle(w *screenWriter, style renderer.Style) { | |||
| 517 | - | style = canonicalScreenStyle(style) | |||
| 518 | - | w.u16(screenStyleBits(style)) | |||
| 519 | - | w.u16(screenColorIndex(style.Foreground)) | |||
| 520 | - | w.u16(screenColorIndex(style.Background)) | |||
| 521 | - | w.u16(screenColorIndex(style.UnderlineColor)) | |||
| 522 | - | w.u8(style.ForegroundRGB.R) | |||
| 523 | - | w.u8(style.ForegroundRGB.G) | |||
| 524 | - | w.u8(style.ForegroundRGB.B) | |||
| 525 | - | w.u8(style.BackgroundRGB.R) | |||
| 526 | - | w.u8(style.BackgroundRGB.G) | |||
| 527 | - | w.u8(style.BackgroundRGB.B) | |||
| 528 | - | w.u8(style.UnderlineColorRGB.R) | |||
| 529 | - | w.u8(style.UnderlineColorRGB.G) | |||
| 530 | - | w.u8(style.UnderlineColorRGB.B) | |||
| 531 | - | w.u8(uint8(style.UnderlineStyle)) | |||
| 532 | - | } | |||
| 533 | - | ||||
| 534 | - | func screenColorIndex(index int) uint16 { | |||
| 535 | - | if index < 0 { | |||
| 536 | - | return math.MaxUint16 | |||
| 537 | - | } | |||
| 538 | - | return uint16(index) | |||
| 539 | - | } | |||
| 540 | - | ||||
| 541 | - | func screenColorValue(index uint16) int { | |||
| 542 | - | if index == math.MaxUint16 { | |||
| 543 | - | return -1 | |||
| 544 | - | } | |||
| 545 | - | return int(index) | |||
| 546 | - | } | |||
| 547 | - | ||||
| 548 | - | func readScreenStyle(r *screenReader) (renderer.Style, bool) { | |||
| 549 | - | bits, ok := r.u16ok() | |||
| 550 | - | if !ok || bits&^uint16(styleKnownBits) != 0 { | |||
| 551 | - | return renderer.Style{}, false | |||
| 552 | - | } | |||
| 553 | - | foregroundIndex, ok := r.u16ok() | |||
| 554 | - | if !ok { | |||
| 555 | - | return renderer.Style{}, false | |||
| 556 | - | } | |||
| 557 | - | backgroundIndex, ok := r.u16ok() | |||
| 558 | - | if !ok { | |||
| 559 | - | return renderer.Style{}, false | |||
| 560 | - | } | |||
| 561 | - | underlineIndex, ok := r.u16ok() | |||
| 562 | - | if !ok { | |||
| 563 | - | return renderer.Style{}, false | |||
| 564 | - | } | |||
| 565 | - | fgRGB, ok := r.rgb() | |||
| 566 | - | if !ok { | |||
| 567 | - | return renderer.Style{}, false | |||
| 568 | - | } | |||
| 569 | - | bgRGB, ok := r.rgb() | |||
| 570 | - | if !ok { | |||
| 571 | - | return renderer.Style{}, false | |||
| 572 | - | } | |||
| 573 | - | underlineRGB, ok := r.rgb() | |||
| 574 | - | if !ok { | |||
| 575 | - | return renderer.Style{}, false | |||
| 576 | - | } | |||
| 577 | - | underlineStyle, ok := r.u8ok() | |||
| 578 | - | if !ok || underlineStyle > uint8(renderer.UnderlineDashed) { | |||
| 579 | - | return renderer.Style{}, false | |||
| 580 | - | } | |||
| 581 | - | style := renderer.Style{ | |||
| 582 | - | Bold: bits&styleBoldBit != 0, | |||
| 583 | - | Italic: bits&styleItalicBit != 0, | |||
| 584 | - | Inverse: bits&styleInverseBit != 0, | |||
| 585 | - | UnderlineStyle: renderer.UnderlineStyle(underlineStyle), | |||
| 586 | - | Foreground: screenColorValue(foregroundIndex), | |||
| 587 | - | Background: screenColorValue(backgroundIndex), | |||
| 588 | - | HasForegroundRGB: bits&styleForegroundRGBBit != 0, | |||
| 589 | - | ForegroundRGB: fgRGB, | |||
| 590 | - | HasBackgroundRGB: bits&styleBackgroundRGBBit != 0, | |||
| 591 | - | BackgroundRGB: bgRGB, | |||
| 592 | - | HasUnderlineColor: bits&styleUnderlineColorBit != 0, | |||
| 593 | - | UnderlineColor: screenColorValue(underlineIndex), | |||
| 594 | - | HasUnderlineColorRGB: bits&styleUnderlineColorRGBBit != 0, | |||
| 595 | - | UnderlineColorRGB: underlineRGB, | |||
| 596 | - | } | |||
| 597 | - | if style.HasUnderlineColor && style.HasUnderlineColorRGB { | |||
| 598 | - | return renderer.Style{}, false | |||
| 599 | - | } | |||
| 600 | - | style.Attrs = 0 | |||
| 601 | - | if bits&styleDimBit != 0 { | |||
| 602 | - | style.Attrs |= renderer.AttrDim | |||
| 603 | - | } | |||
| 604 | - | if bits&styleUnderlineBit != 0 { | |||
| 605 | - | style.Attrs |= renderer.AttrUnderline | |||
| 606 | - | } | |||
| 607 | - | if bits&styleBlinkBit != 0 { | |||
| 608 | - | style.Attrs |= renderer.AttrBlink | |||
| 609 | - | } | |||
| 610 | - | if bits&styleStrikethroughBit != 0 { | |||
| 611 | - | style.Attrs |= renderer.AttrStrikethrough | |||
| 612 | - | } | |||
| 613 | - | if style.HasForegroundRGB { | |||
| 614 | - | if foregroundIndex != math.MaxUint16 { | |||
| 615 | - | return renderer.Style{}, false | |||
| 616 | - | } | |||
| 617 | - | style.Foreground = -1 | |||
| 618 | - | } else if (foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8) || fgRGB != (renderer.RGB{}) { | |||
| 619 | - | return renderer.Style{}, false | |||
| 620 | - | } | |||
| 621 | - | if style.HasBackgroundRGB { | |||
| 622 | - | if backgroundIndex != math.MaxUint16 { | |||
| 623 | - | return renderer.Style{}, false | |||
| 624 | - | } | |||
| 625 | - | style.Background = -1 | |||
| 626 | - | } else if (backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8) || bgRGB != (renderer.RGB{}) { | |||
| 627 | - | return renderer.Style{}, false | |||
| 628 | - | } | |||
| 629 | - | if style.HasUnderlineColorRGB { | |||
| 630 | - | if underlineIndex != math.MaxUint16 { | |||
| 631 | - | return renderer.Style{}, false | |||
| 632 | - | } | |||
| 633 | - | style.UnderlineColor = -1 | |||
| 634 | - | } else if style.HasUnderlineColor { | |||
| 635 | - | if underlineIndex == math.MaxUint16 || underlineIndex > math.MaxUint8 || underlineRGB != (renderer.RGB{}) { | |||
| 636 | - | return renderer.Style{}, false | |||
| 637 | - | } | |||
| 638 | - | } else if underlineIndex != math.MaxUint16 || underlineRGB != (renderer.RGB{}) { | |||
| 639 | - | return renderer.Style{}, false | |||
| 640 | - | } | |||
| 641 | - | return style, true | |||
| 642 | - | } | |||
| 643 | - | ||||
| 644 | - | func screenRunCount(cells []renderer.Cell) int { | |||
| 645 | - | if len(cells) == 0 { | |||
| 646 | - | return 0 | |||
| 647 | - | } | |||
| 648 | - | n := 1 | |||
| 649 | - | previous := canonicalScreenStyle(cells[0].Style) | |||
| 650 | - | for _, cell := range cells[1:] { | |||
| 651 | - | style := canonicalScreenStyle(cell.Style) | |||
| 652 | - | if style != previous { | |||
| 653 | - | n++ | |||
| 654 | - | previous = style | |||
| 655 | - | } | |||
| 656 | - | } | |||
| 657 | - | return n | |||
| 658 | - | } | |||
| 659 | - | ||||
| 660 | - | func screenRune(token uint64) (rune, bool) { | |||
| 661 | - | if token == 0 || token > uint64(utf8.MaxRune)+1 { | |||
| 662 | - | return 0, false | |||
| 663 | - | } | |||
| 664 | - | r := rune(token - 1) | |||
| 665 | - | return r, utf8.ValidRune(r) | |||
| 666 | - | } | |||
| 667 | - | ||||
| 668 | - | type screenWriter struct { | |||
| 669 | - | b []byte | |||
| 670 | - | tooLarge bool | |||
| 671 | - | } | |||
| 672 | - | ||||
| 673 | - | func (w *screenWriter) append(p ...byte) { | |||
| 674 | - | if w.tooLarge || len(w.b) > screenPayloadLimit-len(p) { | |||
| 675 | - | w.tooLarge = true | |||
| 676 | - | return | |||
| 677 | - | } | |||
| 678 | - | w.b = append(w.b, p...) | |||
| 679 | - | } | |||
| 680 | - | func (w *screenWriter) u8(v uint8) { w.append(v) } | |||
| 681 | - | func (w *screenWriter) u16(v uint16) { w.append(byte(v>>8), byte(v)) } | |||
| 682 | - | func (w *screenWriter) u64(v uint64) { | |||
| 683 | - | var b [8]byte | |||
| 684 | - | binary.BigEndian.PutUint64(b[:], v) | |||
| 685 | - | w.append(b[:]...) | |||
| 686 | - | } | |||
| 687 | - | func (w *screenWriter) uvarint(v uint64) { | |||
| 688 | - | var b [10]byte | |||
| 689 | - | n := binary.PutUvarint(b[:], v) | |||
| 690 | - | w.append(b[:n]...) | |||
| 691 | - | } | |||
| 692 | - | ||||
| 693 | - | type screenReader struct{ b []byte } | |||
| 694 | - | ||||
| 695 | - | func (r *screenReader) take(n int) ([]byte, bool) { | |||
| 696 | - | if n < 0 || len(r.b) < n { | |||
| 697 | - | return nil, false | |||
| 698 | - | } | |||
| 699 | - | p := r.b[:n] | |||
| 700 | - | r.b = r.b[n:] | |||
| 701 | - | return p, true | |||
| 702 | - | } | |||
| 703 | - | func (r *screenReader) u8() (uint8, error) { | |||
| 704 | - | v, ok := r.u8ok() | |||
| 705 | - | if !ok { | |||
| 706 | - | return 0, ErrInvalidScreenUpdate | |||
| 707 | - | } | |||
| 708 | - | return v, nil | |||
| 709 | - | } | |||
| 710 | - | func (r *screenReader) u8ok() (uint8, bool) { | |||
| 711 | - | p, ok := r.take(1) | |||
| 712 | - | if !ok { | |||
| 713 | - | return 0, false | |||
| 714 | - | } | |||
| 715 | - | return p[0], true | |||
| 716 | - | } | |||
| 717 | - | func (r *screenReader) u16() (uint16, error) { | |||
| 718 | - | p, ok := r.take(2) | |||
| 719 | - | if !ok { | |||
| 720 | - | return 0, ErrInvalidScreenUpdate | |||
| 721 | - | } | |||
| 722 | - | return binary.BigEndian.Uint16(p), nil | |||
| 723 | - | } | |||
| 724 | - | func (r *screenReader) u16ok() (uint16, bool) { | |||
| 725 | - | p, ok := r.take(2) | |||
| 726 | - | if !ok { | |||
| 727 | - | return 0, false | |||
| 728 | - | } | |||
| 729 | - | return binary.BigEndian.Uint16(p), true | |||
| 730 | - | } | |||
| 731 | - | func (r *screenReader) u64() (uint64, error) { | |||
| 732 | - | p, ok := r.take(8) | |||
| 733 | - | if !ok { | |||
| 734 | - | return 0, ErrInvalidScreenUpdate | |||
| 735 | - | } | |||
| 736 | - | return binary.BigEndian.Uint64(p), nil | |||
| 737 | - | } | |||
| 738 | - | func (r *screenReader) rgb() (renderer.RGB, bool) { | |||
| 739 | - | p, ok := r.take(3) | |||
| 740 | - | if !ok { | |||
| 741 | - | return renderer.RGB{}, false | |||
| 742 | - | } | |||
| 743 | - | return renderer.RGB{R: p[0], G: p[1], B: p[2]}, true | |||
| 744 | - | } | |||
| 745 | - | func (r *screenReader) uvarint() (uint64, bool) { | |||
| 746 | - | v, n := binary.Uvarint(r.b) | |||
| 747 | - | if n <= 0 { | |||
| 748 | - | return 0, false | |||
| 749 | - | } | |||
| 750 | - | var canonical [binary.MaxVarintLen64]byte | |||
| 751 | - | if binary.PutUvarint(canonical[:], v) != n { | |||
| 752 | - | return 0, false | |||
| 753 | - | } | |||
| 754 | - | r.b = r.b[n:] | |||
| 755 | - | return v, true | |||
| 756 | - | } | |||
D internal/ports/wire_screen_test.go +0 -398
| @@ -1,398 +0,0 @@ | |||||
| 1 | - | package ports | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "bytes" | |||
| 5 | - | "encoding/binary" | |||
| 6 | - | "encoding/hex" | |||
| 7 | - | "errors" | |||
| 8 | - | "testing" | |||
| 9 | - | ||||
| 10 | - | "github.com/bnema/vev/internal/domain" | |||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | func screenGoldenMessages() []struct { | |||
| 15 | - | name string | |||
| 16 | - | msg ScreenUpdate | |||
| 17 | - | hex string | |||
| 18 | - | } { | |||
| 19 | - | defaultCell := func(r rune) renderer.Cell { | |||
| 20 | - | return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |||
| 21 | - | } | |||
| 22 | - | return []struct { | |||
| 23 | - | name string | |||
| 24 | - | msg ScreenUpdate | |||
| 25 | - | hex string | |||
| 26 | - | }{ | |||
| 27 | - | { | |||
| 28 | - | name: "initial 1x1 snapshot", | |||
| 29 | - | msg: ScreenUpdate{ | |||
| 30 | - | NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 31 | - | Cursor: ScreenCursor{Visible: true}, | |||
| 32 | - | Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}}, | |||
| 33 | - | }, | |||
| 34 | - | hex: "000000000000000000000000000000010000000000000000010001000100000000000100000100010000ffffffffffff0000000000000000000000000000000100010000000121", | |||
| 35 | - | }, | |||
| 36 | - | { | |||
| 37 | - | name: "explicit zero cursor style", | |||
| 38 | - | msg: ScreenUpdate{ | |||
| 39 | - | NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 40 | - | Cursor: ScreenCursor{Visible: true, StyleSet: true}, | |||
| 41 | - | Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}}, | |||
| 42 | - | }, | |||
| 43 | - | hex: "000000000000000000000000000000010000000000000000010001000100000000000300000100010000ffffffffffff0000000000000000000000000000000100010000000121", | |||
| 44 | - | }, | |||
| 45 | - | { | |||
| 46 | - | name: "later base-zero snapshot", | |||
| 47 | - | msg: ScreenUpdate{ | |||
| 48 | - | NewStateNum: 9, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 49 | - | Cursor: ScreenCursor{Visible: true}, | |||
| 50 | - | Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}}, | |||
| 51 | - | }, | |||
| 52 | - | hex: "000000000000000000000000000000090000000000000000010001000100000000000100000100010000ffffffffffff0000000000000000000000000000000100010000000121", | |||
| 53 | - | }, | |||
| 54 | - | { | |||
| 55 | - | name: "cursor-only delta", | |||
| 56 | - | msg: ScreenUpdate{ | |||
| 57 | - | BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Kind: ScreenUpdateDelta, | |||
| 58 | - | Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{Visible: true, StyleSet: true}, | |||
| 59 | - | }, | |||
| 60 | - | hex: "00000000000000010000000000000002000000000000000702000100010000000000030000000000", | |||
| 61 | - | }, | |||
| 62 | - | { | |||
| 63 | - | name: "upward scroll", | |||
| 64 | - | msg: ScreenUpdate{ | |||
| 65 | - | BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 66 | - | Cursor: ScreenCursor{Visible: true}, Scroll: &ScreenScroll{Top: 0, Height: 2, Count: 1}, | |||
| 67 | - | Spans: []ScreenSpan{{Y: 1, Cells: []renderer.Cell{defaultCell('a'), defaultCell('b')}}}, | |||
| 68 | - | }, | |||
| 69 | - | hex: "000000000000000100000000000000020000000000000000020002000200000000000101000100010000000200010000ffffffffffff000000000000000000000001000000020001000000026263", | |||
| 70 | - | }, | |||
| 71 | - | { | |||
| 72 | - | name: "unicode and wide continuation", | |||
| 73 | - | msg: ScreenUpdate{ | |||
| 74 | - | BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 1}, | |||
| 75 | - | Cursor: ScreenCursor{Visible: true}, | |||
| 76 | - | Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell('界'), {Continuation: true, Style: renderer.DefaultStyle()}, defaultCell('x')}}}, | |||
| 77 | - | }, | |||
| 78 | - | hex: "000000000000000100000000000000020000000000000000020003000100000000000100000100010000ffffffffffff00000000000000000000000000000003000100000003cdea010079", | |||
| 79 | - | }, | |||
| 80 | - | } | |||
| 81 | - | } | |||
| 82 | - | ||||
| 83 | - | func TestScreenUpdateStateNumbers(t *testing.T) { | |||
| 84 | - | snapshot := screenGoldenMessages()[0].msg | |||
| 85 | - | delta := screenGoldenMessages()[3].msg | |||
| 86 | - | valid := []ScreenUpdate{ | |||
| 87 | - | snapshot, | |||
| 88 | - | func() ScreenUpdate { | |||
| 89 | - | m := snapshot | |||
| 90 | - | m.NewStateNum = 9 | |||
| 91 | - | return m | |||
| 92 | - | }(), | |||
| 93 | - | delta, | |||
| 94 | - | } | |||
| 95 | - | for _, m := range valid { | |||
| 96 | - | wire, err := MarshalScreenUpdate(m) | |||
| 97 | - | if err != nil { | |||
| 98 | - | t.Fatalf("valid %d state numbers: %v", m.Kind, err) | |||
| 99 | - | } | |||
| 100 | - | if _, err := UnmarshalScreenUpdate(wire); err != nil { | |||
| 101 | - | t.Fatalf("valid %d state numbers decode: %v", m.Kind, err) | |||
| 102 | - | } | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | invalid := []struct { | |||
| 106 | - | name string | |||
| 107 | - | kind ScreenUpdateKind | |||
| 108 | - | base uint64 | |||
| 109 | - | new uint64 | |||
| 110 | - | }{ | |||
| 111 | - | {"snapshot nonzero base", ScreenUpdateSnapshot, 1, 1}, | |||
| 112 | - | {"snapshot zero new", ScreenUpdateSnapshot, 0, 0}, | |||
| 113 | - | {"delta zero base", ScreenUpdateDelta, 0, 1}, | |||
| 114 | - | {"delta zero new", ScreenUpdateDelta, 1, 0}, | |||
| 115 | - | {"delta equal", ScreenUpdateDelta, 1, 1}, | |||
| 116 | - | {"delta gap", ScreenUpdateDelta, 1, 3}, | |||
| 117 | - | {"delta overflow", ScreenUpdateDelta, ^uint64(0), 0}, | |||
| 118 | - | } | |||
| 119 | - | for _, tt := range invalid { | |||
| 120 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 121 | - | m := snapshot | |||
| 122 | - | if tt.kind == ScreenUpdateDelta { | |||
| 123 | - | m = delta | |||
| 124 | - | } | |||
| 125 | - | m.BaseStateNum, m.NewStateNum = tt.base, tt.new | |||
| 126 | - | if _, err := MarshalScreenUpdate(m); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 127 | - | t.Fatalf("marshal err = %v", err) | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | validWire, err := MarshalScreenUpdate(map[ScreenUpdateKind]ScreenUpdate{ | |||
| 131 | - | ScreenUpdateSnapshot: snapshot, | |||
| 132 | - | ScreenUpdateDelta: delta, | |||
| 133 | - | }[tt.kind]) | |||
| 134 | - | if err != nil { | |||
| 135 | - | t.Fatal(err) | |||
| 136 | - | } | |||
| 137 | - | binary.BigEndian.PutUint64(validWire[0:8], tt.base) | |||
| 138 | - | binary.BigEndian.PutUint64(validWire[8:16], tt.new) | |||
| 139 | - | if _, err := UnmarshalScreenUpdate(validWire); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 140 | - | t.Fatalf("unmarshal err = %v", err) | |||
| 141 | - | } | |||
| 142 | - | }) | |||
| 143 | - | } | |||
| 144 | - | } | |||
| 145 | - | ||||
| 146 | - | func TestScreenUpdateGoldens(t *testing.T) { | |||
| 147 | - | for _, tt := range screenGoldenMessages() { | |||
| 148 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 149 | - | want := mustDecodeHex(t, tt.hex) | |||
| 150 | - | got, err := MarshalScreenUpdate(tt.msg) | |||
| 151 | - | if err != nil { | |||
| 152 | - | t.Fatal(err) | |||
| 153 | - | } | |||
| 154 | - | if !bytes.Equal(got, want) { | |||
| 155 | - | t.Fatalf("wire = %x, want %x", got, want) | |||
| 156 | - | } | |||
| 157 | - | decoded, err := UnmarshalScreenUpdate(want) | |||
| 158 | - | if err != nil { | |||
| 159 | - | t.Fatal(err) | |||
| 160 | - | } | |||
| 161 | - | assertScreenEqual(t, tt.msg, decoded) | |||
| 162 | - | }) | |||
| 163 | - | } | |||
| 164 | - | } | |||
| 165 | - | ||||
| 166 | - | func assertScreenEqual(t *testing.T, want, got ScreenUpdate) { | |||
| 167 | - | t.Helper() | |||
| 168 | - | if want.BaseStateNum != got.BaseStateNum || want.NewStateNum != got.NewStateNum || want.EchoAck != got.EchoAck || want.Kind != got.Kind || want.Size != got.Size || want.Cursor != got.Cursor || len(want.Spans) != len(got.Spans) { | |||
| 169 | - | t.Fatalf("screen metadata = %+v, want %+v", got, want) | |||
| 170 | - | } | |||
| 171 | - | if (want.Scroll == nil) != (got.Scroll == nil) || want.Scroll != nil && *want.Scroll != *got.Scroll { | |||
| 172 | - | t.Fatalf("scroll = %+v, want %+v", got.Scroll, want.Scroll) | |||
| 173 | - | } | |||
| 174 | - | for i := range want.Spans { | |||
| 175 | - | if want.Spans[i].Y != got.Spans[i].Y || want.Spans[i].X != got.Spans[i].X || len(want.Spans[i].Cells) != len(got.Spans[i].Cells) { | |||
| 176 | - | t.Fatalf("span %d = %+v, want %+v", i, got.Spans[i], want.Spans[i]) | |||
| 177 | - | } | |||
| 178 | - | for j := range want.Spans[i].Cells { | |||
| 179 | - | if !want.Spans[i].Cells[j].Equal(got.Spans[i].Cells[j]) { | |||
| 180 | - | t.Fatalf("cell %d/%d = %+v, want %+v", i, j, got.Spans[i].Cells[j], want.Spans[i].Cells[j]) | |||
| 181 | - | } | |||
| 182 | - | } | |||
| 183 | - | } | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) { | |||
| 187 | - | for _, tt := range screenGoldenMessages() { | |||
| 188 | - | data := mustDecodeHex(t, tt.hex) | |||
| 189 | - | for n := 0; n < len(data); n++ { | |||
| 190 | - | if _, err := UnmarshalScreenUpdate(data[:n]); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 191 | - | t.Fatalf("%s prefix %d: err = %v", tt.name, n, err) | |||
| 192 | - | } | |||
| 193 | - | } | |||
| 194 | - | trailing := append(append([]byte(nil), data...), 0) | |||
| 195 | - | if _, err := UnmarshalScreenUpdate(trailing); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 196 | - | t.Fatalf("%s trailing: err = %v", tt.name, err) | |||
| 197 | - | } | |||
| 198 | - | } | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | func TestScreenUpdateHostileFields(t *testing.T) { | |||
| 202 | - | golden := mustDecodeHex(t, screenGoldenMessages()[0].hex) | |||
| 203 | - | tests := []struct { | |||
| 204 | - | name string | |||
| 205 | - | mut func([]byte) | |||
| 206 | - | }{ | |||
| 207 | - | {"zero columns", func(b []byte) { b[25], b[26] = 0, 0 }}, | |||
| 208 | - | {"unknown kind", func(b []byte) { b[24] = 3 }}, | |||
| 209 | - | {"unknown cursor flags", func(b []byte) { b[34] = 4 }}, | |||
| 210 | - | {"unknown style bits", func(b []byte) { b[40], b[41] = 0x08, 0x00 }}, | |||
| 211 | - | {"noncanonical foreground RGB", func(b []byte) { b[40], b[41] = 0, 0x80; b[42], b[43] = 0, 0 }}, | |||
| 212 | - | {"style ID mismatch", func(b []byte) { b[66], b[67] = 0, 1 }}, | |||
| 213 | - | {"run cell mismatch", func(b []byte) { b[68], b[69] = 0, 2 }}, | |||
| 214 | - | {"invalid Unicode", func(b []byte) { b[len(b)-1] = 0x81 }}, | |||
| 215 | - | {"overflow varint", func(b []byte) { b[len(b)-1] = 0x80 }}, | |||
| 216 | - | } | |||
| 217 | - | for _, tt := range tests { | |||
| 218 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 219 | - | data := append([]byte(nil), golden...) | |||
| 220 | - | tt.mut(data) | |||
| 221 | - | if _, err := UnmarshalScreenUpdate(data); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 222 | - | t.Fatalf("err = %v", err) | |||
| 223 | - | } | |||
| 224 | - | }) | |||
| 225 | - | } | |||
| 226 | - | for _, tt := range []struct { | |||
| 227 | - | name string | |||
| 228 | - | token []byte | |||
| 229 | - | }{ | |||
| 230 | - | {"noncanonical varint", []byte{0xa1, 0x00}}, | |||
| 231 | - | {"overflow varint", append(bytes.Repeat([]byte{0xff}, 10), 0x02)}, | |||
| 232 | - | } { | |||
| 233 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 234 | - | data := append(append([]byte(nil), golden[:len(golden)-1]...), tt.token...) | |||
| 235 | - | if _, err := UnmarshalScreenUpdate(data); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 236 | - | t.Fatalf("err = %v", err) | |||
| 237 | - | } | |||
| 238 | - | }) | |||
| 239 | - | } | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | func TestScreenUpdateSpanAndShapeValidation(t *testing.T) { | |||
| 243 | - | cell := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()} | |||
| 244 | - | base := ScreenUpdate{ | |||
| 245 | - | Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 2}, Cursor: ScreenCursor{Visible: true}, | |||
| 246 | - | Spans: []ScreenSpan{{Y: 0, X: 0, Cells: []renderer.Cell{cell}}, {Y: 1, X: 0, Cells: []renderer.Cell{cell}}}, | |||
| 247 | - | } | |||
| 248 | - | tests := []struct { | |||
| 249 | - | name string | |||
| 250 | - | edit func(*ScreenUpdate) | |||
| 251 | - | }{ | |||
| 252 | - | {"unordered", func(m *ScreenUpdate) { m.Spans[0].Y = 1; m.Spans[1].Y = 0 }}, | |||
| 253 | - | {"overlap", func(m *ScreenUpdate) { m.Spans[1].Y = 0; m.Spans[1].X = 0 }}, | |||
| 254 | - | {"out of bounds", func(m *ScreenUpdate) { m.Spans[1].X = 3 }}, | |||
| 255 | - | {"zero scroll", func(m *ScreenUpdate) { m.Scroll = &ScreenScroll{Top: 0, Height: 0, Count: 1} }}, | |||
| 256 | - | {"scroll count reaches height", func(m *ScreenUpdate) { m.Scroll = &ScreenScroll{Top: 0, Height: 1, Count: 1} }}, | |||
| 257 | - | } | |||
| 258 | - | for _, tt := range tests { | |||
| 259 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 260 | - | m := base | |||
| 261 | - | m.Spans = append([]ScreenSpan(nil), base.Spans...) | |||
| 262 | - | tt.edit(&m) | |||
| 263 | - | if _, err := MarshalScreenUpdate(m); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 264 | - | t.Fatalf("err = %v", err) | |||
| 265 | - | } | |||
| 266 | - | }) | |||
| 267 | - | } | |||
| 268 | - | tooWide := base | |||
| 269 | - | tooWide.Size = domain.Size{Cols: 1, Rows: 1} | |||
| 270 | - | tooWide.Spans = []ScreenSpan{{Cells: []renderer.Cell{cell, cell}}} | |||
| 271 | - | if _, err := MarshalScreenUpdate(tooWide); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 272 | - | t.Fatalf("out-of-bounds span err = %v", err) | |||
| 273 | - | } | |||
| 274 | - | if _, err := MarshalScreenUpdate(ScreenUpdate{Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{{Rune: rune(0xd800)}}}}}); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 275 | - | t.Fatalf("invalid rune err = %v", err) | |||
| 276 | - | } | |||
| 277 | - | if _, err := MarshalScreenUpdate(ScreenUpdate{Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 2, Rows: 2}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell, cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 278 | - | t.Fatalf("incomplete snapshot err = %v", err) | |||
| 279 | - | } | |||
| 280 | - | if _, err := MarshalScreenUpdate(ScreenUpdate{Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{StyleSet: true, Style: 7}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 281 | - | t.Fatalf("cursor style err = %v", err) | |||
| 282 | - | } | |||
| 283 | - | } | |||
| 284 | - | ||||
| 285 | - | func TestScreenUpdateOwnership(t *testing.T) { | |||
| 286 | - | golden := mustDecodeHex(t, screenGoldenMessages()[0].hex) | |||
| 287 | - | decoded, err := UnmarshalScreenUpdate(golden) | |||
| 288 | - | if err != nil { | |||
| 289 | - | t.Fatal(err) | |||
| 290 | - | } | |||
| 291 | - | golden[0] ^= 0xff | |||
| 292 | - | golden[len(golden)-1] ^= 0xff | |||
| 293 | - | want, err := MarshalScreenUpdate(decoded) | |||
| 294 | - | if err != nil { | |||
| 295 | - | t.Fatal(err) | |||
| 296 | - | } | |||
| 297 | - | if !bytes.Equal(want, mustDecodeHex(t, screenGoldenMessages()[0].hex)) { | |||
| 298 | - | t.Fatal("decoded screen retained transport payload") | |||
| 299 | - | } | |||
| 300 | - | } | |||
| 301 | - | ||||
| 302 | - | func TestScreenUpdateLimits(t *testing.T) { | |||
| 303 | - | if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) { | |||
| 304 | - | t.Fatalf("oversize err = %v", err) | |||
| 305 | - | } | |||
| 306 | - | previousLimit := screenPayloadLimit | |||
| 307 | - | t.Cleanup(func() { screenPayloadLimit = previousLimit }) | |||
| 308 | - | screenPayloadLimit = screenHeaderLen | |||
| 309 | - | if _, err := MarshalScreenUpdate(screenGoldenMessages()[0].msg); !errors.Is(err, ErrScreenUpdateTooLarge) { | |||
| 310 | - | t.Fatalf("marshal oversize err = %v", err) | |||
| 311 | - | } | |||
| 312 | - | tooMany := screenGoldenMessages()[0].msg | |||
| 313 | - | tooMany.Spans = make([]ScreenSpan, screenSpanLimit+1) | |||
| 314 | - | if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 315 | - | t.Fatalf("span limit err = %v", err) | |||
| 316 | - | } | |||
| 317 | - | } | |||
| 318 | - | ||||
| 319 | - | func TestScreenUpdateScreenAreaLimit(t *testing.T) { | |||
| 320 | - | boundary := ScreenUpdate{ | |||
| 321 | - | BaseStateNum: 1, | |||
| 322 | - | NewStateNum: 2, | |||
| 323 | - | Kind: ScreenUpdateDelta, | |||
| 324 | - | Size: domain.Size{Cols: 512, Rows: 512}, | |||
| 325 | - | Cursor: ScreenCursor{Visible: true}, | |||
| 326 | - | } | |||
| 327 | - | wire, err := MarshalScreenUpdate(boundary) | |||
| 328 | - | if err != nil { | |||
| 329 | - | t.Fatalf("boundary marshal: %v", err) | |||
| 330 | - | } | |||
| 331 | - | if _, err := UnmarshalScreenUpdate(wire); err != nil { | |||
| 332 | - | t.Fatalf("boundary unmarshal: %v", err) | |||
| 333 | - | } | |||
| 334 | - | ||||
| 335 | - | over := boundary | |||
| 336 | - | over.Size = domain.Size{Cols: 513, Rows: 512} | |||
| 337 | - | if _, err := MarshalScreenUpdate(over); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 338 | - | t.Fatalf("over-cap marshal err = %v", err) | |||
| 339 | - | } | |||
| 340 | - | ||||
| 341 | - | // Include a span descriptor and enough cell tokens that an unchecked | |||
| 342 | - | // decoder would reach its per-span cell allocation. | |||
| 343 | - | hostile := make([]byte, screenHeaderLen+8+4+513) | |||
| 344 | - | hostile[24] = byte(ScreenUpdateDelta) | |||
| 345 | - | binary.BigEndian.PutUint16(hostile[25:27], 513) | |||
| 346 | - | binary.BigEndian.PutUint16(hostile[27:29], 512) | |||
| 347 | - | binary.BigEndian.PutUint16(hostile[38:40], 1) | |||
| 348 | - | binary.BigEndian.PutUint16(hostile[40:42], 0) // y | |||
| 349 | - | binary.BigEndian.PutUint16(hostile[42:44], 0) // x | |||
| 350 | - | binary.BigEndian.PutUint16(hostile[44:46], 513) | |||
| 351 | - | binary.BigEndian.PutUint16(hostile[46:48], 1) | |||
| 352 | - | binary.BigEndian.PutUint16(hostile[48:50], 0) // style ID | |||
| 353 | - | binary.BigEndian.PutUint16(hostile[50:52], 513) | |||
| 354 | - | if _, err := UnmarshalScreenUpdate(hostile); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 355 | - | t.Fatalf("over-cap hostile header err = %v", err) | |||
| 356 | - | } | |||
| 357 | - | if allocs := testing.AllocsPerRun(100, func() { | |||
| 358 | - | if _, err := UnmarshalScreenUpdate(hostile); !errors.Is(err, ErrInvalidScreenUpdate) { | |||
| 359 | - | panic(err) | |||
| 360 | - | } | |||
| 361 | - | }); allocs != 0 { | |||
| 362 | - | t.Fatalf("over-cap hostile header allocations = %v, want 0", allocs) | |||
| 363 | - | } | |||
| 364 | - | } | |||
| 365 | - | ||||
| 366 | - | func mustDecodeHex(t testing.TB, s string) []byte { | |||
| 367 | - | t.Helper() | |||
| 368 | - | b, err := hex.DecodeString(s) | |||
| 369 | - | if err != nil { | |||
| 370 | - | t.Fatal(err) | |||
| 371 | - | } | |||
| 372 | - | return b | |||
| 373 | - | } | |||
| 374 | - | ||||
| 375 | - | func FuzzScreenUpdate(f *testing.F) { | |||
| 376 | - | for _, tt := range screenGoldenMessages() { | |||
| 377 | - | b := mustDecodeHex(f, tt.hex) | |||
| 378 | - | f.Add(b) | |||
| 379 | - | } | |||
| 380 | - | f.Fuzz(func(t *testing.T, data []byte) { | |||
| 381 | - | decoded, err := UnmarshalScreenUpdate(data) | |||
| 382 | - | if err != nil { | |||
| 383 | - | return | |||
| 384 | - | } | |||
| 385 | - | encoded, err := MarshalScreenUpdate(decoded) | |||
| 386 | - | if err != nil { | |||
| 387 | - | t.Fatal(err) | |||
| 388 | - | } | |||
| 389 | - | if !bytes.Equal(encoded, data) { | |||
| 390 | - | t.Fatalf("accepted data is not canonical: got %x, want %x", encoded, data) | |||
| 391 | - | } | |||
| 392 | - | roundtrip, err := UnmarshalScreenUpdate(encoded) | |||
| 393 | - | if err != nil { | |||
| 394 | - | t.Fatal(err) | |||
| 395 | - | } | |||
| 396 | - | assertScreenEqual(t, decoded, roundtrip) | |||
| 397 | - | }) | |||
| 398 | - | } | |||
A internal/ports/wire_size.go +23 -0
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | package ports | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "math" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // MaxWireScreenCells bounds every terminal size carried by the final wire | |||
| 10 | + | // protocol. The uint16 dimension limit is checked separately. | |||
| 11 | + | const MaxWireScreenCells = 1 << 18 | |||
| 12 | + | ||||
| 13 | + | // ValidateSize validates a terminal size before a codec allocates or mutates | |||
| 14 | + | // state. | |||
| 15 | + | func ValidateSize(size domain.Size) error { | |||
| 16 | + | if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 { | |||
| 17 | + | return ErrInvalidSize | |||
| 18 | + | } | |||
| 19 | + | if size.Rows > MaxWireScreenCells/size.Cols { | |||
| 20 | + | return ErrInvalidSize | |||
| 21 | + | } | |||
| 22 | + | return nil | |||
| 23 | + | } | |||
M internal/ports/wire_test.go +46 -80
| @@ -73,7 +73,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 73 | TrueColor: true, | 73 | TrueColor: true, | ||
| 74 | MaxOutputInFlight: 8, | 74 | MaxOutputInFlight: 8, | ||
| 75 | }, | 75 | }, | ||
| 76 | - | want: []byte{0x00, 0x01, 0x01, 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00}, | 76 | + | want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x08, 0x00, 0x00, 0x00, 0x00}, |
| 77 | }, | 77 | }, | ||
| 78 | { | 78 | { | ||
| 79 | name: "empty strings", | 79 | name: "empty strings", | ||
| @@ -81,12 +81,12 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 81 | Version: 1, | 81 | Version: 1, | ||
| 82 | Intent: IntentEphemeral, | 82 | Intent: IntentEphemeral, | ||
| 83 | Name: "", | 83 | Name: "", | ||
| 84 | - | Size: domain.Size{Cols: 0, Rows: 0}, | 84 | + | Size: domain.Size{Cols: 1, Rows: 1}, |
| 85 | TermEnv: "", | 85 | TermEnv: "", | ||
| 86 | Cwd: "", | 86 | Cwd: "", | ||
| 87 | TrueColor: false, | 87 | TrueColor: false, | ||
| 88 | }, | 88 | }, | ||
| 89 | - | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | 89 | + | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, |
| 90 | }, | 90 | }, | ||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| @@ -117,17 +117,19 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 117 | 117 | ||||
| 118 | func TestHelloEnvironmentCodec(t *testing.T) { | 118 | func TestHelloEnvironmentCodec(t *testing.T) { | ||
| 119 | t.Run("literal wire layout", func(t *testing.T) { | 119 | t.Run("literal wire layout", func(t *testing.T) { | ||
| 120 | - | got := MarshalHello(Hello{ | 120 | + | msg := Hello{ |
| 121 | Version: ProtocolVersion, | 121 | Version: ProtocolVersion, | ||
| 122 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 122 | Env: []string{"A=B", "XY=123"}, | 123 | Env: []string{"A=B", "XY=123"}, | ||
| 123 | - | }) | 124 | + | } |
| 125 | + | got := MarshalHello(msg) | |||
| 124 | want := []byte{ | 126 | want := []byte{ | ||
| 125 | - | 0x00, 0x15, 0x00, 0x00, // version, intent, proxied | 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,27 +138,30 @@ func TestHelloEnvironmentCodec(t *testing.T) { | |||||
| 136 | 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3', | 138 | 0x00, 0x00, 0x00, 0x06, 'X', 'Y', '=', '1', '2', '3', | ||
| 137 | } | 139 | } | ||
| 138 | require.Equal(t, want, got) | 140 | require.Equal(t, want, got) | ||
| 139 | - | assertAllPrefixesFail(t, got, UnmarshalHello) | 141 | + | decoded, err := UnmarshalHello(want) |
| 140 | - | assertTrailingGarbageFails(t, got, UnmarshalHello) | 142 | + | require.NoError(t, err) |
| 143 | + | require.Equal(t, msg, decoded) | |||
| 144 | + | assertAllPrefixesFail(t, want, UnmarshalHello) | |||
| 145 | + | assertTrailingGarbageFails(t, want, UnmarshalHello) | |||
| 141 | }) | 146 | }) | ||
| 142 | 147 | ||||
| 143 | t.Run("lossless order and values", func(t *testing.T) { | 148 | t.Run("lossless order and values", func(t *testing.T) { | ||
| 144 | entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536)) | 149 | entryOverUint16 := "LARGE=" + string(bytes.Repeat([]byte("x"), 65536)) | ||
| 145 | want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"} | 150 | want := []string{"A=first", "TOKEN=a=b=c", "EMPTY=", entryOverUint16, "A=second"} | ||
| 146 | - | payload := MarshalHello(Hello{Version: ProtocolVersion, Env: want}) | 151 | + | payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: want}) |
| 147 | got, err := UnmarshalHello(payload) | 152 | got, err := UnmarshalHello(payload) | ||
| 148 | require.NoError(t, err) | 153 | require.NoError(t, err) | ||
| 149 | require.Equal(t, want, got.Env) | 154 | require.Equal(t, want, got.Env) | ||
| 150 | }) | 155 | }) | ||
| 151 | 156 | ||||
| 152 | t.Run("empty", func(t *testing.T) { | 157 | t.Run("empty", func(t *testing.T) { | ||
| 153 | - | payload := MarshalHello(Hello{Version: ProtocolVersion, Env: []string{}}) | 158 | + | payload := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}, Env: []string{}}) |
| 154 | got, err := UnmarshalHello(payload) | 159 | got, err := UnmarshalHello(payload) | ||
| 155 | require.NoError(t, err) | 160 | require.NoError(t, err) | ||
| 156 | require.Empty(t, got.Env) | 161 | require.Empty(t, got.Env) | ||
| 157 | }) | 162 | }) | ||
| 158 | 163 | ||||
| 159 | - | base := MarshalHello(Hello{Version: ProtocolVersion}) | 164 | + | base := MarshalHello(Hello{Version: ProtocolVersion, Size: domain.Size{Cols: 1, Rows: 1}}) |
| 160 | withCount := func(count byte) []byte { | 165 | withCount := func(count byte) []byte { | ||
| 161 | payload := append([]byte(nil), base...) | 166 | payload := append([]byte(nil), base...) | ||
| 162 | payload[len(payload)-1] = count | 167 | payload[len(payload)-1] = count | ||
| @@ -247,7 +252,7 @@ func TestImagePushGoldenAndRoundTrip(t *testing.T) { | |||||
| 247 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | 252 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | ||
| 248 | palette := [16]renderer.RGB{ | 253 | palette := [16]renderer.RGB{ | ||
| 249 | {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15}, | 254 | {R: 0x10, G: 0x11, B: 0x12}, {R: 0x13, G: 0x14, B: 0x15}, | ||
| 250 | - | {R: 0x16, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b}, | 255 | + | {R: 0x17, G: 0x17, B: 0x18}, {R: 0x19, G: 0x1a, B: 0x1b}, |
| 251 | {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21}, | 256 | {R: 0x1c, G: 0x1d, B: 0x1e}, {R: 0x1f, G: 0x20, B: 0x21}, | ||
| 252 | {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27}, | 257 | {R: 0x22, G: 0x23, B: 0x24}, {R: 0x25, G: 0x26, B: 0x27}, | ||
| 253 | {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d}, | 258 | {R: 0x28, G: 0x29, B: 0x2a}, {R: 0x2b, G: 0x2c, B: 0x2d}, | ||
| @@ -296,7 +301,7 @@ func TestThemeGoldenAndRoundTrip(t *testing.T) { | |||||
| 296 | }, | 301 | }, | ||
| 297 | want: []byte{ | 302 | want: []byte{ | ||
| 298 | 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01, | 303 | 0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x80, 0x01, | ||
| 299 | - | 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, | 304 | + | 0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x17, 0x17, 0x18, |
| 300 | 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, | 305 | 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x21, | ||
| 301 | 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, | 306 | 0x22, 0x23, 0x24, 0x25, 0x26, 0x27, 0x28, 0x29, 0x2a, | ||
| 302 | 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, | 307 | 0x2b, 0x2c, 0x2d, 0x2e, 0x2f, 0x30, 0x31, 0x32, 0x33, | ||
| @@ -336,14 +341,17 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||||
| 336 | } | 341 | } | ||
| 337 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | 342 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | ||
| 338 | require.Equal(t, want, MarshalTheme(cleared)) | 343 | require.Equal(t, want, MarshalTheme(cleared)) | ||
| 339 | - | require.Equal(t, uint16(21), ProtocolVersion) | 344 | + | require.Equal(t, uint16(23), ProtocolVersion) |
| 340 | } | 345 | } | ||
| 341 | 346 | ||||
| 342 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | 347 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | ||
| 343 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | 348 | msg := Resize{Size: domain.Size{Cols: 100, Rows: 40}} | ||
| 344 | want := []byte{0x00, 0x64, 0x00, 0x28} | 349 | want := []byte{0x00, 0x64, 0x00, 0x28} | ||
| 345 | 350 | ||||
| 346 | - | got := MarshalResize(msg) | 351 | + | got, err := MarshalResize(msg) |
| 352 | + | if err != nil { | |||
| 353 | + | t.Fatalf("MarshalResize() error = %v", err) | |||
| 354 | + | } | |||
| 347 | if !bytes.Equal(got, want) { | 355 | if !bytes.Equal(got, want) { | ||
| 348 | t.Fatalf("MarshalResize() = %#v, want %#v", got, want) | 356 | t.Fatalf("MarshalResize() = %#v, want %#v", got, want) | ||
| 349 | } | 357 | } | ||
| @@ -461,7 +469,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 461 | name: "minimal", | 469 | name: "minimal", | ||
| 462 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | 470 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | ||
| 463 | want: []byte{ | 471 | want: []byte{ | ||
| 464 | - | 0x00, 0x15, | 472 | + | 0x00, 0x17, |
| 465 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 473 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 466 | 0x00, // attached | 474 | 0x00, // attached | ||
| 467 | 0x00, // self | 475 | 0x00, // self | ||
| @@ -484,7 +492,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||||
| 484 | JSON: true, | 492 | JSON: true, | ||
| 485 | }, | 493 | }, | ||
| 486 | want: []byte{ | 494 | want: []byte{ | ||
| 487 | - | 0x00, 0x15, | 495 | + | 0x00, 0x17, |
| 488 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | 496 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 489 | 0x00, // attached | 497 | 0x00, // attached | ||
| 490 | 0x01, // self | 498 | 0x01, // self | ||
| @@ -609,10 +617,13 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | |||||
| 609 | } | 617 | } | ||
| 610 | 618 | ||||
| 611 | func TestOutputGoldenAndRoundTrip(t *testing.T) { | 619 | func TestOutputGoldenAndRoundTrip(t *testing.T) { | ||
| 612 | - | msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")} | 620 | + | msg := Output{Epoch: 1, Base: 0, New: 2, Echo: 3, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("hello\n")} |
| 613 | - | want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a} | 621 | + | want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x06, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a} |
| 614 | 622 | ||||
| 615 | - | got := MarshalOutput(msg) | 623 | + | got, err := MarshalOutput(msg) |
| 624 | + | if err != nil { | |||
| 625 | + | t.Fatalf("MarshalOutput() error = %v", err) | |||
| 626 | + | } | |||
| 616 | if !bytes.Equal(got, want) { | 627 | if !bytes.Equal(got, want) { | ||
| 617 | t.Fatalf("MarshalOutput() = %#v, want %#v", got, want) | 628 | t.Fatalf("MarshalOutput() = %#v, want %#v", got, want) | ||
| 618 | } | 629 | } | ||
| @@ -624,25 +635,18 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) { | |||||
| 624 | t.Fatalf("round trip = %#v, want %#v", back, msg) | 635 | t.Fatalf("round trip = %#v, want %#v", back, msg) | ||
| 625 | } | 636 | } | ||
| 626 | 637 | ||||
| 627 | - | assertAllPrefixesFail(t, got[:24], UnmarshalOutput) | 638 | + | assertAllPrefixesFail(t, got, UnmarshalOutput) |
| 628 | - | 639 | + | assertTrailingGarbageFails(t, got, UnmarshalOutput) | |
| 629 | - | extra := []byte{0x00, 0xff, 0x7f} | |||
| 630 | - | withTrailing := append(append([]byte(nil), got...), extra...) | |||
| 631 | - | back, err = UnmarshalOutput(withTrailing) | |||
| 632 | - | if err != nil { | |||
| 633 | - | t.Fatalf("UnmarshalOutput() with trailing payload error = %v", err) | |||
| 634 | - | } | |||
| 635 | - | wantData := append(append([]byte(nil), msg.Data...), extra...) | |||
| 636 | - | if !bytes.Equal(back.Data, wantData) { | |||
| 637 | - | t.Fatalf("trailing payload data = %#v, want %#v", back.Data, wantData) | |||
| 638 | - | } | |||
| 639 | } | 640 | } | ||
| 640 | 641 | ||||
| 641 | func TestAckGoldenAndRoundTrip(t *testing.T) { | 642 | func TestAckGoldenAndRoundTrip(t *testing.T) { | ||
| 642 | - | msg := Ack{AckedStateNum: 0x0102030405060708} | 643 | + | msg := Ack{Epoch: 1, State: 0x0102030405060708} |
| 643 | - | want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} | 644 | + | want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} |
| 644 | 645 | ||||
| 645 | - | got := MarshalAck(msg) | 646 | + | got, err := MarshalAck(msg) |
| 647 | + | if err != nil { | |||
| 648 | + | t.Fatalf("MarshalAck() error = %v", err) | |||
| 649 | + | } | |||
| 646 | if !bytes.Equal(got, want) { | 650 | if !bytes.Equal(got, want) { | ||
| 647 | t.Fatalf("MarshalAck() = %#v, want %#v", got, want) | 651 | t.Fatalf("MarshalAck() = %#v, want %#v", got, want) | ||
| 648 | } | 652 | } | ||
| @@ -884,11 +888,11 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||||
| 884 | func TestHelloOutputWindowByteExactValues(t *testing.T) { | 888 | func TestHelloOutputWindowByteExactValues(t *testing.T) { | ||
| 885 | for _, window := range []uint8{0, 1, 8} { | 889 | for _, window := range []uint8{0, 1, 8} { | ||
| 886 | t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) { | 890 | t.Run(fmt.Sprintf("window_%d", window), func(t *testing.T) { | ||
| 887 | - | hello := Hello{Version: 14, MaxOutputInFlight: window} | 891 | + | hello := Hello{Version: 14, Size: domain.Size{Cols: 1, Rows: 1}, MaxOutputInFlight: window} |
| 888 | - | // The empty Hello has 39 zero bytes before the negotiated output window, | 892 | + | // The empty Hello has a fixed 1x1 size before the negotiated output window. |
| 889 | - | // followed by a uint32 zero environment-entry count. | 893 | + | want := append(make([]byte, 38), window, 0, 0, 0, 0) |
| 890 | - | want := append(make([]byte, 39), window, 0, 0, 0, 0) | |||
| 891 | want[1] = 14 | 894 | want[1] = 14 | ||
| 895 | + | want[30], want[32] = 1, 1 | |||
| 892 | got := MarshalHello(hello) | 896 | got := MarshalHello(hello) | ||
| 893 | requireBytesEqual(t, want, got) | 897 | requireBytesEqual(t, want, got) | ||
| 894 | back, err := UnmarshalHello(got) | 898 | back, err := UnmarshalHello(got) | ||
| @@ -906,44 +910,6 @@ func requireBytesEqual(t *testing.T, want, got []byte) { | |||||
| 906 | t.Fatalf("bytes = %#v, want %#v", got, want) | 910 | t.Fatalf("bytes = %#v, want %#v", got, want) | ||
| 907 | } | 911 | } | ||
| 908 | } | 912 | } | ||
| 909 | - | func TestProxiedHelloGoldenAndStrict(t *testing.T) { | |||
| 910 | - | msg := Hello{ | |||
| 911 | - | Version: ProtocolVersion, | |||
| 912 | - | Intent: IntentAttach, | |||
| 913 | - | Proxied: true, | |||
| 914 | - | ClientID: [16]byte{1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16}, | |||
| 915 | - | ResumeToken: 0x0102030405060708, | |||
| 916 | - | Name: "remote", | |||
| 917 | - | Size: domain.Size{Cols: 80, Rows: 22}, | |||
| 918 | - | TermEnv: "xterm", | |||
| 919 | - | Cwd: "/tmp", | |||
| 920 | - | TrueColor: true, | |||
| 921 | - | MaxOutputInFlight: 8, | |||
| 922 | - | } | |||
| 923 | - | want := []byte{ | |||
| 924 | - | 0x00, 0x15, 0x02, 0x01, | |||
| 925 | - | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |||
| 926 | - | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, | |||
| 927 | - | 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e', | |||
| 928 | - | 0x00, 0x50, 0x00, 0x16, | |||
| 929 | - | 0x00, 0x05, 'x', 't', 'e', 'r', 'm', | |||
| 930 | - | 0x00, 0x04, '/', 't', 'm', 'p', | |||
| 931 | - | 0x01, 0x08, 0x00, 0x00, 0x00, 0x00, | |||
| 932 | - | } | |||
| 933 | - | got := MarshalHello(msg) | |||
| 934 | - | require.Equal(t, want, got) | |||
| 935 | - | back, err := UnmarshalHello(got) | |||
| 936 | - | require.NoError(t, err) | |||
| 937 | - | require.Equal(t, msg, back) | |||
| 938 | - | assertAllPrefixesFail(t, got, UnmarshalHello) | |||
| 939 | - | assertTrailingGarbageFails(t, got, UnmarshalHello) | |||
| 940 | - | ||||
| 941 | - | malformed := append([]byte(nil), got...) | |||
| 942 | - | malformed[3] = 2 | |||
| 943 | - | _, err = UnmarshalHello(malformed) | |||
| 944 | - | require.Error(t, err) | |||
| 945 | - | } | |||
| 946 | - | ||||
| 947 | func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | 913 | func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | ||
| 948 | tests := []struct { | 914 | tests := []struct { | ||
| 949 | name string | 915 | name string | ||
| @@ -954,7 +920,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 954 | name: "attached", | 920 | name: "attached", | ||
| 955 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | 921 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | ||
| 956 | want: []byte{ | 922 | want: []byte{ | ||
| 957 | - | 0x00, 0x15, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, | 923 | + | 0x00, 0x17, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01, |
| 958 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | 924 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | ||
| 959 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | 925 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | ||
| 960 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 926 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| @@ -964,7 +930,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||||
| 964 | name: "control", | 930 | name: "control", | ||
| 965 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | 931 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | ||
| 966 | want: []byte{ | 932 | want: []byte{ | ||
| 967 | - | 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 933 | + | 0x00, 0x17, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, |
| 968 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | 934 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | ||
| 969 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | 935 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | ||
| 970 | }, | 936 | }, | ||
M internal/testutil/replaytest/replay.go +11 -2
| @@ -7,12 +7,21 @@ import ( | |||||
| 7 | "reflect" | 7 | "reflect" | ||
| 8 | "testing" | 8 | "testing" | ||
| 9 | 9 | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 10 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| 14 | + | func mustMarshalOutput(m ports.Output) []byte { | |||
| 15 | + | payload, err := ports.MarshalOutput(m) | |||
| 16 | + | if err != nil { | |||
| 17 | + | panic(err) | |||
| 18 | + | } | |||
| 19 | + | return payload | |||
| 20 | + | } | |||
| 21 | + | ||||
| 13 | var transcript = []ports.Frame{ | 22 | var transcript = []ports.Frame{ | ||
| 14 | - | {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})}, | 23 | + | {Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 80, Rows: 24}, Full: true, Data: []byte("\x1b[2J\x1b[Hone\r\ntwo")})}, |
| 15 | - | {Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Data: []byte("\x1b[2;1HTWO")})}, | 24 | + | {Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{Epoch: 1, Base: 1, New: 2, Echo: 7, Size: domain.Size{Cols: 80, Rows: 24}, Data: []byte("\x1b[2;1HTWO")})}, |
| 16 | } | 25 | } | ||
| 17 | 26 | ||||
| 18 | // Transcript returns a deep copy of the canonical replay transcript so an | 27 | // Transcript returns a deep copy of the canonical replay transcript so an | ||
M internal/usecase/client/ack_queue_test.go +4 -2
| @@ -21,9 +21,10 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) { | |||||
| 21 | for range cap(normal) { | 21 | for range cap(normal) { | ||
| 22 | normal <- ports.Frame{Type: ports.MsgInput} | 22 | normal <- ports.Frame{Type: ports.MsgInput} | ||
| 23 | } | 23 | } | ||
| 24 | + | const epoch = 7 | |||
| 24 | acks := newCumulativeAckQueue() | 25 | acks := newCumulativeAckQueue() | ||
| 25 | for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ { | 26 | for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ { | ||
| 26 | - | acks.offer(state) | 27 | + | acks.offer(epoch, state) |
| 27 | } | 28 | } | ||
| 28 | 29 | ||||
| 29 | ctx, cancel := context.WithCancel(context.Background()) | 30 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -36,7 +37,8 @@ func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) { | |||||
| 36 | require.Equal(t, ports.MsgAck, first.Type) | 37 | require.Equal(t, ports.MsgAck, first.Type) | ||
| 37 | ack, err := ports.UnmarshalAck(first.Payload) | 38 | ack, err := ports.UnmarshalAck(first.Payload) | ||
| 38 | require.NoError(t, err) | 39 | require.NoError(t, err) | ||
| 39 | - | require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.AckedStateNum) | 40 | + | require.Equal(t, uint64(epoch), ack.Epoch) |
| 41 | + | require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.State) | |||
| 40 | } | 42 | } | ||
| 41 | 43 | ||||
| 42 | const maxUnackedOutputStatesForTest = 8 | 44 | const maxUnackedOutputStatesForTest = 8 | ||
M internal/usecase/client/client.go +73 -35
| @@ -251,13 +251,11 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 251 | } | 251 | } | ||
| 252 | rememberOnce.Do(func() { | 252 | rememberOnce.Do(func() { | ||
| 253 | learnerStarted = 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() { | ||
| @@ -832,12 +830,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 832 | clip = clipboard | 830 | clip = clipboard | ||
| 833 | } | 831 | } | ||
| 834 | awaitingReconnectReset := false | 832 | awaitingReconnectReset := false | ||
| 833 | + | outputState := outputApplyState{} | |||
| 834 | + | outputResetRequested := false | |||
| 835 | if reconnect.showing { | 835 | if reconnect.showing { | ||
| 836 | if reconnect.remote { | 836 | if reconnect.remote { | ||
| 837 | // Before the asynchronous sender starts, preserve transport ordering by | 837 | // Before the asynchronous sender starts, preserve transport ordering by | ||
| 838 | // requesting the reset synchronously after the initial Theme publication. | 838 | // requesting the reset synchronously after the initial Theme publication. | ||
| 839 | closed, resetErr := boundedPreWelcome(ctx, clk, transport, func() error { | 839 | closed, resetErr := boundedPreWelcome(ctx, clk, transport, func() error { | ||
| 840 | - | return transport.Send(ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})}) | 840 | + | payload, err := ports.MarshalResize(ports.Resize{Size: size}) |
| 841 | + | if err != nil { | |||
| 842 | + | return err | |||
| 843 | + | } | |||
| 844 | + | return transport.Send(ports.Frame{Type: ports.MsgResize, Payload: payload}) | |||
| 841 | }) | 845 | }) | ||
| 842 | if resetErr != nil { | 846 | if resetErr != nil { | ||
| 843 | resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr)) | 847 | resetResult := welcomedResult(fmt.Errorf("requesting reconnect reconciliation: %w", resetErr)) | ||
| @@ -881,8 +885,12 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 881 | if serr != nil { | 885 | if serr != nil { | ||
| 882 | return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr) | 886 | return fmt.Errorf("reading terminal size for reconnect reconciliation: %w", serr) | ||
| 883 | } | 887 | } | ||
| 888 | + | payload, err := ports.MarshalResize(ports.Resize{Size: size}) | |||
| 889 | + | if err != nil { | |||
| 890 | + | return fmt.Errorf("encoding terminal size for reconnect reconciliation: %w", err) | |||
| 891 | + | } | |||
| 884 | select { | 892 | select { | ||
| 885 | - | case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: size})}: | 893 | + | case sendCh <- ports.Frame{Type: ports.MsgResize, Payload: payload}: |
| 886 | awaitingReconnectReset = true | 894 | awaitingReconnectReset = true | ||
| 887 | return nil | 895 | return nil | ||
| 888 | case <-loopCtx.Done(): | 896 | case <-loopCtx.Done(): | ||
| @@ -990,19 +998,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 990 | if derr != nil { | 998 | if derr != nil { | ||
| 991 | return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr)) | 999 | return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr)) | ||
| 992 | } | 1000 | } | ||
| 993 | - | // Dismissing or changing a local overlay requests a same-size resize, | 1001 | + | nextState, accepted := outputState.next(o) |
| 994 | - | // whose stateful base-zero Output is the daemon's authoritative reset. | 1002 | + | if !accepted { |
| 995 | - | // ACK intervening increments so the output window keeps moving, but do | 1003 | + | // A discarded frame was never written and must never be ACKed. |
| 996 | - | // not apply diffs based on cells hidden by the client-local overlay. | 1004 | + | // Ask the daemon for one authoritative full reset, coalescing |
| 997 | - | resetFrame := isReconnectResetFrame(o) | 1005 | + | // repeated gaps while that reset is in flight. |
| 998 | - | if awaitingReconnectReset && !resetFrame && o.NewStateNum != 0 { | 1006 | + | if !outputResetRequested { |
| 999 | - | ackQueue.offer(o.NewStateNum) | 1007 | + | select { |
| 1008 | + | case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}: | |||
| 1009 | + | outputResetRequested = true | |||
| 1010 | + | case <-loopCtx.Done(): | |||
| 1011 | + | return loopCanceledResult() | |||
| 1012 | + | } | |||
| 1013 | + | } | |||
| 1000 | continue | 1014 | continue | ||
| 1001 | } | 1015 | } | ||
| 1002 | if _, werr := term.Out().Write(o.Data); werr != nil { | 1016 | if _, werr := term.Out().Write(o.Data); werr != nil { | ||
| 1003 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | 1017 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | ||
| 1004 | } | 1018 | } | ||
| 1005 | - | if awaitingReconnectReset && resetFrame { | 1019 | + | if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 { |
| 1006 | awaitingReconnectReset = false | 1020 | awaitingReconnectReset = false | ||
| 1007 | } | 1021 | } | ||
| 1008 | if reconnect.showing && reconnect.remote { | 1022 | if reconnect.showing && reconnect.remote { | ||
| @@ -1016,13 +1030,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1016 | } else if ferr := term.Flush(); ferr != nil { | 1030 | } else if ferr := term.Flush(); ferr != nil { | ||
| 1017 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | 1031 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | ||
| 1018 | } | 1032 | } | ||
| 1033 | + | outputState = nextState | |||
| 1034 | + | if o.New != 0 { | |||
| 1035 | + | ackQueue.offer(o.Epoch, o.New) | |||
| 1036 | + | outputResetRequested = false | |||
| 1037 | + | } | |||
| 1019 | // The terminal boundary is after a successful flush and before ACK. | 1038 | // The terminal boundary is after a successful flush and before ACK. | ||
| 1020 | if observer != nil { | 1039 | if observer != nil { | ||
| 1021 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true)) | 1040 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true)) | ||
| 1022 | } | 1041 | } | ||
| 1023 | - | if o.NewStateNum != 0 { | |||
| 1024 | - | ackQueue.offer(o.NewStateNum) | |||
| 1025 | - | } | |||
| 1026 | if !ms.firstOutput { | 1042 | if !ms.firstOutput { | ||
| 1027 | ms.firstOutput = true | 1043 | ms.firstOutput = true | ||
| 1028 | log.Debug("received first output") | 1044 | log.Debug("received first output") | ||
| @@ -1049,13 +1065,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1049 | } | 1065 | } | ||
| 1050 | } | 1066 | } | ||
| 1051 | 1067 | ||||
| 1052 | - | // isReconnectResetFrame identifies the authoritative stateful reset emitted | |||
| 1053 | - | // by the daemon's existing resize path. Stateless side effects can also have a | |||
| 1054 | - | // zero base, so they must not dismiss or reconcile a client-local overlay. | |||
| 1055 | - | func isReconnectResetFrame(o ports.Output) bool { | |||
| 1056 | - | return o.BaseStateNum == 0 && o.NewStateNum != 0 | |||
| 1057 | - | } | |||
| 1058 | - | ||||
| 1059 | // recvResult carries one framed message (or a read error) from the receive | 1068 | // recvResult carries one framed message (or a read error) from the receive | ||
| 1060 | // pump to the main loop. | 1069 | // pump to the main loop. | ||
| 1061 | type recvResult struct { | 1070 | type recvResult struct { | ||
| @@ -1084,27 +1093,39 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu | |||||
| 1084 | // resize pumps through one goroutine keeps the transport's single-writer | 1093 | // resize pumps through one goroutine keeps the transport's single-writer | ||
| 1085 | // contract intact. A send failure cancels the loop and is surfaced once. | 1094 | // contract intact. A send failure cancels the loop and is surfaced once. | ||
| 1086 | type cumulativeAckQueue struct { | 1095 | type cumulativeAckQueue struct { | ||
| 1087 | - | latest atomic.Uint64 | 1096 | + | mu sync.Mutex |
| 1088 | - | wake chan struct{} | 1097 | + | latestEpoch uint64 |
| 1098 | + | latestState uint64 | |||
| 1099 | + | wake chan struct{} | |||
| 1089 | } | 1100 | } | ||
| 1090 | 1101 | ||||
| 1091 | func newCumulativeAckQueue() *cumulativeAckQueue { | 1102 | func newCumulativeAckQueue() *cumulativeAckQueue { | ||
| 1092 | return &cumulativeAckQueue{wake: make(chan struct{}, 1)} | 1103 | return &cumulativeAckQueue{wake: make(chan struct{}, 1)} | ||
| 1093 | } | 1104 | } | ||
| 1094 | 1105 | ||||
| 1095 | - | func (q *cumulativeAckQueue) offer(state uint64) { | 1106 | + | func (q *cumulativeAckQueue) offer(epoch, state uint64) { |
| 1096 | - | for { | 1107 | + | if epoch == 0 || state == 0 { |
| 1097 | - | current := q.latest.Load() | 1108 | + | return |
| 1098 | - | if state <= current || q.latest.CompareAndSwap(current, state) { | 1109 | + | } |
| 1099 | - | break | 1110 | + | q.mu.Lock() |
| 1100 | - | } | 1111 | + | if epoch > q.latestEpoch || (epoch == q.latestEpoch && state > q.latestState) { |
| 1112 | + | q.latestEpoch, q.latestState = epoch, state | |||
| 1101 | } | 1113 | } | ||
| 1114 | + | q.mu.Unlock() | |||
| 1102 | select { | 1115 | select { | ||
| 1103 | case q.wake <- struct{}{}: | 1116 | case q.wake <- struct{}{}: | ||
| 1104 | default: | 1117 | default: | ||
| 1105 | } | 1118 | } | ||
| 1106 | } | 1119 | } | ||
| 1107 | 1120 | ||||
| 1121 | + | func (q *cumulativeAckQueue) take() (uint64, uint64) { | |||
| 1122 | + | q.mu.Lock() | |||
| 1123 | + | defer q.mu.Unlock() | |||
| 1124 | + | epoch, state := q.latestEpoch, q.latestState | |||
| 1125 | + | q.latestEpoch, q.latestState = 0, 0 | |||
| 1126 | + | return epoch, state | |||
| 1127 | + | } | |||
| 1128 | + | ||||
| 1108 | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) { | 1129 | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) { | ||
| 1109 | defer log.Debug("sender pump exited") | 1130 | defer log.Debug("sender pump exited") | ||
| 1110 | send := func(f ports.Frame) bool { | 1131 | send := func(f ports.Frame) bool { | ||
| @@ -1119,8 +1140,20 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 1119 | return true | 1140 | return true | ||
| 1120 | } | 1141 | } | ||
| 1121 | sendAck := func() bool { | 1142 | sendAck := func() bool { | ||
| 1122 | - | state := acks.latest.Swap(0) | 1143 | + | epoch, state := acks.take() |
| 1123 | - | return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: state})}) | 1144 | + | if state == 0 { |
| 1145 | + | return true | |||
| 1146 | + | } | |||
| 1147 | + | payload, err := ports.MarshalAck(ports.Ack{Epoch: epoch, State: state}) | |||
| 1148 | + | if err != nil { | |||
| 1149 | + | select { | |||
| 1150 | + | case errCh <- fmt.Errorf("encoding output ACK: %w", err): | |||
| 1151 | + | default: | |||
| 1152 | + | } | |||
| 1153 | + | cancel() | |||
| 1154 | + | return false | |||
| 1155 | + | } | |||
| 1156 | + | return send(ports.Frame{Type: ports.MsgAck, Payload: payload}) | |||
| 1124 | } | 1157 | } | ||
| 1125 | for { | 1158 | for { | ||
| 1126 | select { | 1159 | select { | ||
| @@ -1718,7 +1751,12 @@ func runResize(ctx context.Context, events <-chan domain.Size, out chan<- ports. | |||||
| 1718 | if !ok { | 1751 | if !ok { | ||
| 1719 | return | 1752 | return | ||
| 1720 | } | 1753 | } | ||
| 1721 | - | frame := ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: sz})} | 1754 | + | payload, err := ports.MarshalResize(ports.Resize{Size: sz}) |
| 1755 | + | if err != nil { | |||
| 1756 | + | log.Error("encoding terminal resize", "error", err) | |||
| 1757 | + | continue | |||
| 1758 | + | } | |||
| 1759 | + | frame := ports.Frame{Type: ports.MsgResize, Payload: payload} | |||
| 1722 | select { | 1760 | select { | ||
| 1723 | case out <- frame: | 1761 | case out <- frame: | ||
| 1724 | case <-ctx.Done(): | 1762 | case <-ctx.Done(): | ||
M internal/usecase/client/client_test.go +1 -1
| @@ -408,7 +408,7 @@ func TestAttachHappyPath(t *testing.T) { | |||||
| 408 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | 408 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | ||
| 409 | unblock := scriptRecv(tr, | 409 | unblock := scriptRecv(tr, | ||
| 410 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))}, | 410 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))}, | ||
| 411 | - | recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("hello world")}))}, | 411 | + | recvItem{f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("hello world")}))}, |
| 412 | recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | 412 | recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | ||
| 413 | ) | 413 | ) | ||
| 414 | defer unblock() | 414 | defer unblock() | ||
M internal/usecase/client/clipboard_intercept_test.go +1 -1
| @@ -198,7 +198,7 @@ func TestRunRemoteClipboardFailureNotifiesDaemonAndWritesOutputVerbatim(t *testi | |||||
| 198 | } | 198 | } | ||
| 199 | } | 199 | } | ||
| 200 | beforeOutput := out.String() | 200 | beforeOutput := out.String() | ||
| 201 | - | transport.recv <- frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: []byte("incremental")})) | 201 | + | transport.recv <- frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("incremental")})) |
| 202 | transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | 202 | transport.recv <- frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})) | ||
| 203 | 203 | ||||
| 204 | select { | 204 | select { | ||
A internal/usecase/client/output_state.go +50 -0
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |||
| 4 | + | ||||
| 5 | + | // outputApplyState is the client-side dependency chain for one attachment. | |||
| 6 | + | // State-bearing frames are accepted only when their epoch, base, and view | |||
| 7 | + | // revision continue the last frame; a new epoch must carry a full reset. | |||
| 8 | + | type outputApplyState struct { | |||
| 9 | + | epoch uint64 | |||
| 10 | + | state uint64 | |||
| 11 | + | viewRevision uint64 | |||
| 12 | + | initialized bool | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func (s outputApplyState) next(output ports.Output) (outputApplyState, bool) { | |||
| 16 | + | if output.Epoch == 0 { | |||
| 17 | + | return outputApplyState{}, false | |||
| 18 | + | } | |||
| 19 | + | if !s.initialized { | |||
| 20 | + | if output.New == 0 { | |||
| 21 | + | return s, true | |||
| 22 | + | } | |||
| 23 | + | if output.Base != 0 || !output.Full { | |||
| 24 | + | return outputApplyState{}, false | |||
| 25 | + | } | |||
| 26 | + | return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true | |||
| 27 | + | } | |||
| 28 | + | if output.Epoch < s.epoch { | |||
| 29 | + | return outputApplyState{}, false | |||
| 30 | + | } | |||
| 31 | + | if output.Epoch == s.epoch { | |||
| 32 | + | if output.ViewRevision != s.viewRevision { | |||
| 33 | + | return outputApplyState{}, false | |||
| 34 | + | } | |||
| 35 | + | if output.New == 0 { | |||
| 36 | + | if output.Base != 0 || output.Full { | |||
| 37 | + | return outputApplyState{}, false | |||
| 38 | + | } | |||
| 39 | + | return s, true | |||
| 40 | + | } | |||
| 41 | + | if output.Full || output.Base != s.state || output.New != output.Base+1 { | |||
| 42 | + | return outputApplyState{}, false | |||
| 43 | + | } | |||
| 44 | + | return outputApplyState{epoch: s.epoch, state: output.New, viewRevision: s.viewRevision, initialized: true}, true | |||
| 45 | + | } | |||
| 46 | + | if !output.Full || output.Base != 0 { | |||
| 47 | + | return outputApplyState{}, false | |||
| 48 | + | } | |||
| 49 | + | return outputApplyState{epoch: output.Epoch, state: output.New, viewRevision: output.ViewRevision, initialized: true}, true | |||
| 50 | + | } | |||
A internal/usecase/client/output_state_test.go +37 -0
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | "github.com/bnema/vev/internal/ports" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestOutputApplyStateTransitions(t *testing.T) { | |||
| 11 | + | full := ports.Output{Epoch: 1, Base: 0, New: 1, Full: true, ViewRevision: 2, Size: domain.Size{Cols: 80, Rows: 24}} | |||
| 12 | + | tests := []struct { | |||
| 13 | + | name string | |||
| 14 | + | state outputApplyState | |||
| 15 | + | output ports.Output | |||
| 16 | + | accepted bool | |||
| 17 | + | want outputApplyState | |||
| 18 | + | }{ | |||
| 19 | + | {name: "first full", output: full, accepted: true, want: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}}, | |||
| 20 | + | {name: "first gap", output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 2}, accepted: false}, | |||
| 21 | + | {name: "increment", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 2}, accepted: true, want: outputApplyState{epoch: 1, state: 2, viewRevision: 2, initialized: true}}, | |||
| 22 | + | {name: "base gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 0, New: 2, Full: true, ViewRevision: 2}, accepted: false}, | |||
| 23 | + | {name: "revision gap", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 1, Base: 1, New: 2, ViewRevision: 3}, accepted: false}, | |||
| 24 | + | {name: "new epoch reset", state: outputApplyState{epoch: 1, state: 1, viewRevision: 2, initialized: true}, output: ports.Output{Epoch: 2, Base: 0, New: 1, Full: true, ViewRevision: 3}, accepted: true, want: outputApplyState{epoch: 2, state: 1, viewRevision: 3, initialized: true}}, | |||
| 25 | + | } | |||
| 26 | + | for _, tt := range tests { | |||
| 27 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 28 | + | got, accepted := tt.state.next(tt.output) | |||
| 29 | + | if accepted != tt.accepted { | |||
| 30 | + | t.Fatalf("accepted = %v, want %v", accepted, tt.accepted) | |||
| 31 | + | } | |||
| 32 | + | if accepted && got != tt.want { | |||
| 33 | + | t.Fatalf("state = %+v, want %+v", got, tt.want) | |||
| 34 | + | } | |||
| 35 | + | }) | |||
| 36 | + | } | |||
| 37 | + | } | |||
M internal/usecase/client/reconnect_toast_test.go +88 -56
| @@ -375,10 +375,13 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) { | |||||
| 375 | require.Contains(t, firstToast, "┌") | 375 | require.Contains(t, firstToast, "┌") | ||
| 376 | require.Contains(t, firstToast, "probing UDP path") | 376 | require.Contains(t, firstToast, "probing UDP path") | ||
| 377 | 377 | ||||
| 378 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 378 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 379 | - | BaseStateNum: 1, | 379 | + | Epoch: 1, |
| 380 | - | NewStateNum: 2, | 380 | + | Base: 0, |
| 381 | - | Data: []byte("daemon incremental"), | 381 | + | New: 2, |
| 382 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 383 | + | Full: true, | |||
| 384 | + | Data: []byte("daemon incremental"), | |||
| 382 | })}} | 385 | })}} | ||
| 383 | redrawn := requireReconnectToastOutput(t, out.completed) | 386 | redrawn := requireReconnectToastOutput(t, out.completed) | ||
| 384 | require.Contains(t, redrawn, "daemon incremental") | 387 | require.Contains(t, redrawn, "daemon incremental") | ||
| @@ -391,44 +394,53 @@ func TestProbingToastReconcilesDaemonOutputAndDismissal(t *testing.T) { | |||||
| 391 | 394 | ||||
| 392 | beforeAwaitingReset := out.String() | 395 | beforeAwaitingReset := out.String() | ||
| 393 | beforeStatelessFlushes := flushes.Load() | 396 | beforeStatelessFlushes := flushes.Load() | ||
| 394 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 397 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 395 | - | BaseStateNum: 0, | 398 | + | Epoch: 1, |
| 396 | - | NewStateNum: 0, | 399 | + | Base: 0, |
| 397 | - | Data: []byte("stateless side effect"), | 400 | + | New: 0, |
| 401 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 402 | + | Data: []byte("stateless side effect"), | |||
| 398 | })}} | 403 | })}} | ||
| 399 | require.Eventually(t, func() bool { | 404 | require.Eventually(t, func() bool { | ||
| 400 | return strings.Contains(out.String(), "stateless side effect") && flushes.Load() > beforeStatelessFlushes | 405 | return strings.Contains(out.String(), "stateless side effect") && flushes.Load() > beforeStatelessFlushes | ||
| 401 | }, time.Second, time.Millisecond) | 406 | }, time.Second, time.Millisecond) | ||
| 402 | 407 | ||||
| 403 | afterStateless := out.String() | 408 | afterStateless := out.String() | ||
| 404 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 409 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 405 | - | BaseStateNum: 2, | 410 | + | Epoch: 1, |
| 406 | - | NewStateNum: 3, | 411 | + | Base: 2, |
| 407 | - | Data: []byte("intervening incremental"), | 412 | + | New: 3, |
| 413 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 414 | + | Data: []byte("intervening incremental"), | |||
| 408 | })}} | 415 | })}} | ||
| 409 | - | requireAckedState(t, tr.sends, 3) | 416 | + | requireAckedState(t, tr.sends, 1, 3) |
| 410 | - | require.Equal(t, afterStateless, out.String(), "stateless output must not satisfy pending reset reconciliation") | 417 | + | require.Contains(t, out.String(), "intervening incremental") |
| 411 | require.Contains(t, afterStateless[len(beforeAwaitingReset):], "stateless side effect") | 418 | require.Contains(t, afterStateless[len(beforeAwaitingReset):], "stateless side effect") | ||
| 412 | 419 | ||||
| 413 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 420 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 414 | - | BaseStateNum: 0, | 421 | + | Epoch: 2, |
| 415 | - | NewStateNum: 4, | 422 | + | Base: 0, |
| 416 | - | Data: []byte("authoritative reset"), | 423 | + | New: 4, |
| 424 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 425 | + | Full: true, | |||
| 426 | + | Data: []byte("authoritative reset"), | |||
| 417 | })}} | 427 | })}} | ||
| 418 | - | requireAckedState(t, tr.sends, 4) | 428 | + | requireAckedState(t, tr.sends, 2, 4) |
| 419 | require.Eventually(t, func() bool { return strings.Contains(out.String(), "authoritative reset") }, time.Second, time.Millisecond) | 429 | require.Eventually(t, func() bool { return strings.Contains(out.String(), "authoritative reset") }, time.Second, time.Millisecond) | ||
| 420 | require.NotContains(t, out.String()[len(beforeAwaitingReset):], strings.Repeat(" ", reconnectToastBoundsFor(term.size, "probing UDP path").Width)) | 430 | require.NotContains(t, out.String()[len(beforeAwaitingReset):], strings.Repeat(" ", reconnectToastBoundsFor(term.size, "probing UDP path").Width)) | ||
| 421 | 431 | ||||
| 422 | beforeIncrementFlushes := flushes.Load() | 432 | beforeIncrementFlushes := flushes.Load() | ||
| 423 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 433 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 424 | - | BaseStateNum: 4, | 434 | + | Epoch: 2, |
| 425 | - | NewStateNum: 5, | 435 | + | Base: 4, |
| 426 | - | Data: []byte("increment after reset"), | 436 | + | New: 5, |
| 437 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 438 | + | Data: []byte("increment after reset"), | |||
| 427 | })}} | 439 | })}} | ||
| 428 | require.Eventually(t, func() bool { | 440 | require.Eventually(t, func() bool { | ||
| 429 | return strings.Contains(out.String(), "increment after reset") && flushes.Load() > beforeIncrementFlushes | 441 | return strings.Contains(out.String(), "increment after reset") && flushes.Load() > beforeIncrementFlushes | ||
| 430 | }, time.Second, time.Millisecond) | 442 | }, time.Second, time.Millisecond) | ||
| 431 | - | requireAckedState(t, tr.sends, 5) | 443 | + | requireAckedState(t, tr.sends, 2, 5) |
| 432 | 444 | ||||
| 433 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | 445 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | ||
| 434 | result := requireAttachResult(t, resultCh) | 446 | result := requireAttachResult(t, resultCh) | ||
| @@ -462,37 +474,46 @@ func TestActiveReconnectToastStageTransitionReconcilesBeforeRedraw(t *testing.T) | |||||
| 462 | 474 | ||||
| 463 | beforeReset := out.String() | 475 | beforeReset := out.String() | ||
| 464 | for _, output := range []ports.Output{ | 476 | for _, output := range []ports.Output{ | ||
| 465 | - | {BaseStateNum: 1, NewStateNum: 2, Data: []byte("first skipped increment")}, | 477 | + | {Epoch: 1, Base: 1, New: 2, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("first skipped increment")}, |
| 466 | - | {BaseStateNum: 2, NewStateNum: 3, Data: []byte("second skipped increment")}, | 478 | + | {Epoch: 1, Base: 2, New: 3, Size: domain.Size{Cols: 1, Rows: 1}, Data: []byte("second skipped increment")}, |
| 467 | } { | 479 | } { | ||
| 468 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(output)}} | 480 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)}} |
| 469 | - | requireAckedState(t, tr.sends, output.NewStateNum) | |||
| 470 | require.Equal(t, beforeReset, out.String()) | 481 | require.Equal(t, beforeReset, out.String()) | ||
| 471 | } | 482 | } | ||
| 483 | + | requireSentFrame(t, tr.sends, "output reset request", func(frame ports.Frame) bool { | |||
| 484 | + | return frame.Type == ports.MsgOutputResetRequest | |||
| 485 | + | }) | |||
| 486 | + | _, sentAck := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgAck }) | |||
| 487 | + | require.False(t, sentAck, "discarded output must not be ACKed") | |||
| 472 | 488 | ||||
| 473 | beforeResetFlushes := flushes.Load() | 489 | beforeResetFlushes := flushes.Load() | ||
| 474 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 490 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 475 | - | BaseStateNum: 0, | 491 | + | Epoch: 2, |
| 476 | - | NewStateNum: 4, | 492 | + | Base: 0, |
| 477 | - | Data: []byte("stage transition reset"), | 493 | + | New: 4, |
| 494 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 495 | + | Full: true, | |||
| 496 | + | Data: []byte("stage transition reset"), | |||
| 478 | })}} | 497 | })}} | ||
| 479 | redrawn := requireReconnectToastOutput(t, out.completed) | 498 | redrawn := requireReconnectToastOutput(t, out.completed) | ||
| 480 | require.Contains(t, redrawn, "stage transition reset") | 499 | require.Contains(t, redrawn, "stage transition reset") | ||
| 481 | require.Contains(t, redrawn, offlineMessage) | 500 | require.Contains(t, redrawn, offlineMessage) | ||
| 482 | require.Eventually(t, func() bool { return flushes.Load() > beforeResetFlushes }, time.Second, time.Millisecond) | 501 | require.Eventually(t, func() bool { return flushes.Load() > beforeResetFlushes }, time.Second, time.Millisecond) | ||
| 483 | - | requireAckedState(t, tr.sends, 4) | 502 | + | requireAckedState(t, tr.sends, 2, 4) |
| 484 | 503 | ||||
| 485 | beforeIncrementFlushes := flushes.Load() | 504 | beforeIncrementFlushes := flushes.Load() | ||
| 486 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 505 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 487 | - | BaseStateNum: 4, | 506 | + | Epoch: 2, |
| 488 | - | NewStateNum: 5, | 507 | + | Base: 4, |
| 489 | - | Data: []byte("stage increment after reset"), | 508 | + | New: 5, |
| 509 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 510 | + | Data: []byte("stage increment after reset"), | |||
| 490 | })}} | 511 | })}} | ||
| 491 | incremental := requireReconnectToastOutput(t, out.completed) | 512 | incremental := requireReconnectToastOutput(t, out.completed) | ||
| 492 | require.Contains(t, incremental, "stage increment after reset") | 513 | require.Contains(t, incremental, "stage increment after reset") | ||
| 493 | require.Contains(t, incremental, offlineMessage) | 514 | require.Contains(t, incremental, offlineMessage) | ||
| 494 | require.Eventually(t, func() bool { return flushes.Load() > beforeIncrementFlushes }, time.Second, time.Millisecond) | 515 | require.Eventually(t, func() bool { return flushes.Load() > beforeIncrementFlushes }, time.Second, time.Millisecond) | ||
| 495 | - | requireAckedState(t, tr.sends, 5) | 516 | + | requireAckedState(t, tr.sends, 2, 5) |
| 496 | 517 | ||||
| 497 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | 518 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | ||
| 498 | result := requireAttachResult(t, resultCh) | 519 | result := requireAttachResult(t, resultCh) | ||
| @@ -537,17 +558,19 @@ func requireResize(t *testing.T, sends *reconnectToastSentFrames) ports.Resize { | |||||
| 537 | return resize | 558 | return resize | ||
| 538 | } | 559 | } | ||
| 539 | 560 | ||||
| 540 | - | func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, state uint64) { | 561 | + | func requireAckedState(t *testing.T, sends *reconnectToastSentFrames, epoch, state uint64) { |
| 541 | t.Helper() | 562 | t.Helper() | ||
| 542 | - | frame := requireSentFrame(t, sends, fmt.Sprintf("ACK for state %d", state), func(frame ports.Frame) bool { | 563 | + | frame := requireSentFrame(t, sends, fmt.Sprintf("ACK for epoch %d state %d", epoch, state), func(frame ports.Frame) bool { |
| 543 | if frame.Type != ports.MsgAck { | 564 | if frame.Type != ports.MsgAck { | ||
| 544 | return false | 565 | return false | ||
| 545 | } | 566 | } | ||
| 546 | ack, err := ports.UnmarshalAck(frame.Payload) | 567 | ack, err := ports.UnmarshalAck(frame.Payload) | ||
| 547 | - | return err == nil && ack.AckedStateNum >= state | 568 | + | return err == nil && ack.Epoch == epoch && ack.State >= state |
| 548 | }) | 569 | }) | ||
| 549 | - | _, err := ports.UnmarshalAck(frame.Payload) | 570 | + | ack, err := ports.UnmarshalAck(frame.Payload) |
| 550 | require.NoError(t, err) | 571 | require.NoError(t, err) | ||
| 572 | + | require.Equal(t, epoch, ack.Epoch) | |||
| 573 | + | require.GreaterOrEqual(t, ack.State, state) | |||
| 551 | } | 574 | } | ||
| 552 | 575 | ||||
| 553 | func requireAttachResult(t *testing.T, results <-chan attachResult) attachResult { | 576 | func requireAttachResult(t *testing.T, results <-chan attachResult) attachResult { | ||
| @@ -652,10 +675,13 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi | |||||
| 652 | require.Eventually(t, func() bool { return flushes.Load() > beforeStatus }, time.Second, time.Millisecond) | 675 | require.Eventually(t, func() bool { return flushes.Load() > beforeStatus }, time.Second, time.Millisecond) | ||
| 653 | beforeOutput := flushes.Load() | 676 | beforeOutput := flushes.Load() | ||
| 654 | 677 | ||||
| 655 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 678 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 656 | - | BaseStateNum: 1, | 679 | + | Epoch: 1, |
| 657 | - | NewStateNum: 2, | 680 | + | Base: 0, |
| 658 | - | Data: []byte("daemon output behind local reconnect status"), | 681 | + | New: 2, |
| 682 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 683 | + | Full: true, | |||
| 684 | + | Data: []byte("daemon output behind local reconnect status"), | |||
| 659 | })}} | 685 | })}} | ||
| 660 | 686 | ||||
| 661 | select { | 687 | select { | ||
| @@ -664,7 +690,7 @@ func TestLocalReconnectStatusFlushesDaemonOutputBeforeObservationAndAck(t *testi | |||||
| 664 | case <-time.After(time.Second): | 690 | case <-time.After(time.Second): | ||
| 665 | t.Fatal("timed out waiting for terminal-flushed observation") | 691 | t.Fatal("timed out waiting for terminal-flushed observation") | ||
| 666 | } | 692 | } | ||
| 667 | - | requireAckedState(t, tr.sends, 2) | 693 | + | requireAckedState(t, tr.sends, 1, 2) |
| 668 | require.Greater(t, flushes.Load(), beforeOutput, "daemon output must be flushed before ACK") | 694 | require.Greater(t, flushes.Load(), beforeOutput, "daemon output must be flushed before ACK") | ||
| 669 | 695 | ||||
| 670 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | 696 | tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)} | ||
| @@ -701,13 +727,16 @@ func TestLocalReconnectStatusTransitionsDoNotRequestAuthoritativeReset(t *testin | |||||
| 701 | } | 727 | } | ||
| 702 | 728 | ||||
| 703 | const output = "local status output remains visible" | 729 | const output = "local status output remains visible" | ||
| 704 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 730 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 705 | - | BaseStateNum: 1, | 731 | + | Epoch: 1, |
| 706 | - | NewStateNum: 2, | 732 | + | Base: 0, |
| 707 | - | Data: []byte(output), | 733 | + | New: 2, |
| 734 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 735 | + | Full: true, | |||
| 736 | + | Data: []byte(output), | |||
| 708 | })}} | 737 | })}} | ||
| 709 | require.Eventually(t, func() bool { return strings.Contains(out.String(), output) }, time.Second, time.Millisecond) | 738 | require.Eventually(t, func() bool { return strings.Contains(out.String(), output) }, time.Second, time.Millisecond) | ||
| 710 | - | requireAckedState(t, tr.sends, 2) | 739 | + | requireAckedState(t, tr.sends, 1, 2) |
| 711 | _, sentResize := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgResize }) | 740 | _, sentResize := tr.sends.find(func(frame ports.Frame) bool { return frame.Type == ports.MsgResize }) | ||
| 712 | require.False(t, sentResize, "local reconnect status must not request a daemon reset") | 741 | require.False(t, sentResize, "local reconnect status must not request a daemon reset") | ||
| 713 | 742 | ||||
| @@ -766,10 +795,13 @@ func TestReconnectOverlayRedrawFailureDoesNotObserveOrAckOutput(t *testing.T) { | |||||
| 766 | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | 795 | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} | ||
| 767 | requireReconnectToastOutput(t, out.completed) | 796 | requireReconnectToastOutput(t, out.completed) | ||
| 768 | arm() | 797 | arm() | ||
| 769 | - | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | 798 | + | tr.recvCh <- reconnectToastRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ |
| 770 | - | BaseStateNum: 1, | 799 | + | Epoch: 1, |
| 771 | - | NewStateNum: 2, | 800 | + | Base: 0, |
| 772 | - | Data: []byte("daemon output before failed redraw"), | 801 | + | New: 2, |
| 802 | + | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 803 | + | Full: true, | |||
| 804 | + | Data: []byte("daemon output before failed redraw"), | |||
| 773 | })}} | 805 | })}} | ||
| 774 | 806 | ||||
| 775 | result := requireAttachResult(t, resultCh) | 807 | result := requireAttachResult(t, resultCh) | ||
M internal/usecase/client/transport_observability_test.go +2 -2
| @@ -50,7 +50,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) { | |||||
| 50 | transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once() | 50 | transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once() | ||
| 51 | unblock := scriptRecv(transport, | 51 | unblock := scriptRecv(transport, | ||
| 52 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | 52 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | ||
| 53 | - | recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("flush-before-observe"), NewStateNum: 3}))}, | 53 | + | recvItem{f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("flush-before-observe"), New: 3}))}, |
| 54 | ) | 54 | ) | ||
| 55 | defer unblock() | 55 | defer unblock() | ||
| 56 | transport.EXPECT().Close().Return(nil).Once() | 56 | transport.EXPECT().Close().Return(nil).Once() | ||
| @@ -90,7 +90,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) { | |||||
| 90 | transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | 90 | transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | ||
| 91 | unblock := scriptRecv(transport, | 91 | unblock := scriptRecv(transport, | ||
| 92 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | 92 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | ||
| 93 | - | recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))}, | 93 | + | recvItem{f: frameOf(ports.MsgOutput, mustMarshalOutput(ports.Output{Epoch: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("unchanged-by-observer"), New: 3}))}, |
| 94 | recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | 94 | recvItem{f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}, | ||
| 95 | ) | 95 | ) | ||
| 96 | defer unblock() | 96 | defer unblock() | ||
A internal/usecase/client/wire_external_test_helpers_test.go +11 -0
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package client_test | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |||
| 4 | + | ||||
| 5 | + | func mustMarshalOutput(m ports.Output) []byte { | |||
| 6 | + | payload, err := ports.MarshalOutput(m) | |||
| 7 | + | if err != nil { | |||
| 8 | + | panic(err) | |||
| 9 | + | } | |||
| 10 | + | return payload | |||
| 11 | + | } | |||
A internal/usecase/client/wire_test_helpers_test.go +11 -0
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |||
| 4 | + | ||||
| 5 | + | func mustMarshalOutput(m ports.Output) []byte { | |||
| 6 | + | payload, err := ports.MarshalOutput(m) | |||
| 7 | + | if err != nil { | |||
| 8 | + | panic(err) | |||
| 9 | + | } | |||
| 10 | + | return payload | |||
| 11 | + | } | |||
M internal/usecase/daemon/attachment_effect_gate_test.go +1 -1
| @@ -614,7 +614,7 @@ func TestAttachmentEffectGateAdmittedActiveEffectsFinishBeforeReplacement(t *tes | |||||
| 614 | setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) { | 614 | setup: func(_ *Daemon, _ *session, ac *attachedClient, _ *transactionalResizePTY) { | ||
| 615 | ac.output.next = 3 | 615 | ac.output.next = 3 | ||
| 616 | }, | 616 | }, | ||
| 617 | - | frame: ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: 1})}, | 617 | + | frame: ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: 1, State: 1})}, |
| 618 | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | 618 | assert: func(t *testing.T, ac *attachedClient, _ *transactionalResizePTY) { | ||
| 619 | ac.sendMu.Lock() | 619 | ac.sendMu.Lock() | ||
| 620 | defer ac.sendMu.Unlock() | 620 | defer ac.sendMu.Unlock() | ||
M internal/usecase/daemon/attachment_registry.go +19 -3
| @@ -17,6 +17,7 @@ type attachmentView struct { | |||||
| 17 | paneID domain.PaneStableID | 17 | paneID domain.PaneStableID | ||
| 18 | windowTop int | 18 | windowTop int | ||
| 19 | windowRows int | 19 | windowRows int | ||
| 20 | + | windowSet bool | |||
| 20 | bookmark vt.RowID | 21 | bookmark vt.RowID | ||
| 21 | liveBottom bool | 22 | liveBottom bool | ||
| 22 | revision uint64 | 23 | revision uint64 | ||
| @@ -45,6 +46,19 @@ func (ac *attachedClient) publishView(view attachmentView) { | |||||
| 45 | return | 46 | return | ||
| 46 | } | 47 | } | ||
| 47 | ac.viewMu.Lock() | 48 | ac.viewMu.Lock() | ||
| 49 | + | // Rebase while viewMu is held, before the new revision becomes visible. | |||
| 50 | + | // Output fences take the same lock, so no side effect can pair the new | |||
| 51 | + | // ViewRevision with the previous output epoch. Test/headless fixtures may | |||
| 52 | + | // leave output.attachment unset; use the stream-local lock for those. | |||
| 53 | + | if ac.output != nil && ac.view.revision != view.revision { | |||
| 54 | + | if ac.output.attachment == ac { | |||
| 55 | + | ac.output.rebaseLocked() | |||
| 56 | + | } else { | |||
| 57 | + | ac.output.stateMu.Lock() | |||
| 58 | + | ac.output.rebaseLocked() | |||
| 59 | + | ac.output.stateMu.Unlock() | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 48 | ac.view = view | 62 | ac.view = view | ||
| 49 | ac.viewMu.Unlock() | 63 | ac.viewMu.Unlock() | ||
| 50 | } | 64 | } | ||
| @@ -241,6 +255,7 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment | |||||
| 241 | view.paneID = "" | 255 | view.paneID = "" | ||
| 242 | view.windowTop = 0 | 256 | view.windowTop = 0 | ||
| 243 | view.windowRows = 0 | 257 | view.windowRows = 0 | ||
| 258 | + | view.windowSet = false | |||
| 244 | view.bookmark = 0 | 259 | view.bookmark = 0 | ||
| 245 | view.liveBottom = true | 260 | view.liveBottom = true | ||
| 246 | return view | 261 | return view | ||
| @@ -293,7 +308,7 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool { | |||||
| 293 | } | 308 | } | ||
| 294 | before := ac.viewSnapshot() | 309 | before := ac.viewSnapshot() | ||
| 295 | after := s.repairAttachmentViewLocked(ac, before) | 310 | after := s.repairAttachmentViewLocked(ac, before) | ||
| 296 | - | if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom { | 311 | + | if before.tabID == after.tabID && before.paneID == after.paneID && before.windowTop == after.windowTop && before.windowRows == after.windowRows && before.windowSet == after.windowSet && before.bookmark == after.bookmark && before.liveBottom == after.liveBottom { |
| 297 | return false | 312 | return false | ||
| 298 | } | 313 | } | ||
| 299 | after.revision++ | 314 | after.revision++ | ||
| @@ -324,6 +339,7 @@ func (s *session) prepareAttachmentViewsForRemovedTabLocked(removed *tab, index | |||||
| 324 | view.paneID = "" | 339 | view.paneID = "" | ||
| 325 | view.bookmark = 0 | 340 | view.bookmark = 0 | ||
| 326 | view.liveBottom = true | 341 | view.liveBottom = true | ||
| 342 | + | view.revision++ | |||
| 327 | ac.publishView(view) | 343 | ac.publishView(view) | ||
| 328 | } | 344 | } | ||
| 329 | } | 345 | } | ||
| @@ -369,7 +385,7 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab { | |||||
| 369 | } | 385 | } | ||
| 370 | view := ac.viewSnapshot() | 386 | view := ac.viewSnapshot() | ||
| 371 | repaired := s.repairAttachmentViewLocked(ac, view) | 387 | repaired := s.repairAttachmentViewLocked(ac, view) | ||
| 372 | - | if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom { | 388 | + | if repaired.tabID != view.tabID || repaired.paneID != view.paneID || repaired.windowTop != view.windowTop || repaired.windowRows != view.windowRows || repaired.windowSet != view.windowSet || repaired.bookmark != view.bookmark || repaired.liveBottom != view.liveBottom { |
| 373 | repaired.revision++ | 389 | repaired.revision++ | ||
| 374 | ac.publishView(repaired) | 390 | ac.publishView(repaired) | ||
| 375 | } | 391 | } | ||
| @@ -476,7 +492,7 @@ func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachme | |||||
| 476 | next = s.repairAttachmentViewLocked(ac, next) | 492 | next = s.repairAttachmentViewLocked(ac, next) | ||
| 477 | next.revision++ | 493 | next.revision++ | ||
| 478 | ac.publishView(next) | 494 | ac.publishView(next) | ||
| 479 | - | changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom | 495 | + | changed = next.tabID != before.tabID || next.paneID != before.paneID || next.windowTop != before.windowTop || next.windowRows != before.windowRows || next.windowSet != before.windowSet || next.bookmark != before.bookmark || next.liveBottom != before.liveBottom |
| 480 | return nil | 496 | return nil | ||
| 481 | }) | 497 | }) | ||
| 482 | return changed | 498 | return changed | ||
M internal/usecase/daemon/attachment_registry_test.go +63 -0
| @@ -5,6 +5,8 @@ import ( | |||||
| 5 | "testing" | 5 | "testing" | ||
| 6 | 6 | ||||
| 7 | "github.com/bnema/vev/internal/domain" | 7 | "github.com/bnema/vev/internal/domain" | ||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| 10 | func registryTestSession() *session { | 12 | func registryTestSession() *session { | ||
| @@ -73,6 +75,67 @@ func TestAttachmentRegistryRepairsRemovedStableTarget(t *testing.T) { | |||||
| 73 | } | 75 | } | ||
| 74 | } | 76 | } | ||
| 75 | 77 | ||||
| 78 | + | func TestTabForAttachmentRepairRebasesOutputBeforePublishingRevision(t *testing.T) { | |||
| 79 | + | sess := registryTestSession() | |||
| 80 | + | ac := registryTestAttachment(1) | |||
| 81 | + | ac.size = domain.Size{Cols: 80, Rows: 23} | |||
| 82 | + | ac.output = newOutputStateStream() | |||
| 83 | + | ac.output.attachment = ac | |||
| 84 | + | require.True(t, sess.registerAttachment(ac)) | |||
| 85 | + | require.True(t, sess.selectAttachmentTab(ac, "tab-b")) | |||
| 86 | + | ||||
| 87 | + | before := ac.viewSnapshot() | |||
| 88 | + | beforeEpoch := ac.output.currentEpoch() | |||
| 89 | + | require.NoError(t, sess.runMutation(func() error { | |||
| 90 | + | sess.mu.Lock() | |||
| 91 | + | sess.tabs = sess.tabs[:1] | |||
| 92 | + | sess.mu.Unlock() | |||
| 93 | + | return nil | |||
| 94 | + | })) | |||
| 95 | + | ||||
| 96 | + | require.Same(t, sess.tabs[0], sess.tabForAttachment(ac)) | |||
| 97 | + | after := ac.viewSnapshot() | |||
| 98 | + | require.Greater(t, after.revision, before.revision) | |||
| 99 | + | require.Greater(t, ac.output.currentEpoch(), beforeEpoch) | |||
| 100 | + | frame, err := ac.output.sideEffect([]byte("x"), 0) | |||
| 101 | + | require.NoError(t, err) | |||
| 102 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 103 | + | require.NoError(t, err) | |||
| 104 | + | require.Equal(t, after.revision, output.ViewRevision) | |||
| 105 | + | require.Equal(t, ac.output.currentEpoch(), output.Epoch) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestPrepareRemovedTabViewRebasesOutputBeforeSideEffect(t *testing.T) { | |||
| 109 | + | sess := registryTestSession() | |||
| 110 | + | ac := registryTestAttachment(1) | |||
| 111 | + | ac.size = domain.Size{Cols: 80, Rows: 23} | |||
| 112 | + | ac.output = newOutputStateStream() | |||
| 113 | + | ac.output.attachment = ac | |||
| 114 | + | require.True(t, sess.registerAttachment(ac)) | |||
| 115 | + | require.True(t, sess.selectAttachmentTab(ac, "tab-b")) | |||
| 116 | + | ||||
| 117 | + | before := ac.viewSnapshot() | |||
| 118 | + | beforeEpoch := ac.output.currentEpoch() | |||
| 119 | + | sess.mu.Lock() | |||
| 120 | + | sess.prepareAttachmentViewsForRemovedTabLocked(sess.tabs[1], 1) | |||
| 121 | + | sess.tabs = sess.tabs[:1] | |||
| 122 | + | sess.mu.Unlock() | |||
| 123 | + | ||||
| 124 | + | after := ac.viewSnapshot() | |||
| 125 | + | require.Equal(t, domain.TabStableID("tab-a"), after.tabID) | |||
| 126 | + | require.Equal(t, domain.PaneStableID(""), after.paneID) | |||
| 127 | + | require.Equal(t, before.revision+1, after.revision) | |||
| 128 | + | require.Greater(t, ac.output.currentEpoch(), beforeEpoch) | |||
| 129 | + | ||||
| 130 | + | frame, err := ac.output.sideEffect([]byte("replacement"), 0) | |||
| 131 | + | require.NoError(t, err) | |||
| 132 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 133 | + | require.NoError(t, err) | |||
| 134 | + | require.Equal(t, after.revision, output.ViewRevision) | |||
| 135 | + | require.NotEqual(t, beforeEpoch, output.Epoch) | |||
| 136 | + | require.Equal(t, ac.output.currentEpoch(), output.Epoch) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 76 | func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) { | 139 | func TestAttachmentRegistryViewMutationIsSerialized(t *testing.T) { | ||
| 77 | sess := registryTestSession() | 140 | sess := registryTestSession() | ||
| 78 | ac := registryTestAttachment(1) | 141 | ac := registryTestAttachment(1) | ||
A internal/usecase/daemon/attachment_render_test.go +113 -0
| @@ -0,0 +1,113 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "sync" | |||
| 6 | + | "sync/atomic" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type blockedAttachmentTransport struct { | |||
| 16 | + | entered chan struct{} | |||
| 17 | + | release chan struct{} | |||
| 18 | + | sends chan ports.Frame | |||
| 19 | + | blocked atomic.Bool | |||
| 20 | + | once sync.Once | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func (t *blockedAttachmentTransport) Send(frame ports.Frame) error { | |||
| 24 | + | if frame.Type == ports.MsgOutput && t.blocked.Load() { | |||
| 25 | + | t.once.Do(func() { close(t.entered) }) | |||
| 26 | + | <-t.release | |||
| 27 | + | } | |||
| 28 | + | t.sends <- frame | |||
| 29 | + | return nil | |||
| 30 | + | } | |||
| 31 | + | func (*blockedAttachmentTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 32 | + | func (*blockedAttachmentTransport) Close() error { return nil } | |||
| 33 | + | ||||
| 34 | + | func TestAttachmentResizeKeepsSessionContentAndPeersFixed(t *testing.T) { | |||
| 35 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 36 | + | defer releasePTY() | |||
| 37 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 38 | + | firstTransport, _ := newCapturingTransport(t) | |||
| 39 | + | sess, first, err := d.route(ports.Hello{ | |||
| 40 | + | Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", | |||
| 41 | + | Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}, | |||
| 42 | + | }, firstTransport) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | secondTransport, _ := newCapturingTransport(t) | |||
| 45 | + | _, second, err := d.route(ports.Hello{ | |||
| 46 | + | Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", | |||
| 47 | + | Size: domain.Size{Cols: 100, Rows: 40}, ClientID: [16]byte{2}, | |||
| 48 | + | }, secondTransport) | |||
| 49 | + | require.NoError(t, err) | |||
| 50 | + | ||||
| 51 | + | tb := sess.tabs[0] | |||
| 52 | + | tb.mu.Lock() | |||
| 53 | + | contentBefore := tb.size | |||
| 54 | + | tb.mu.Unlock() | |||
| 55 | + | firstSize := first.size | |||
| 56 | + | secondRevision := second.viewSnapshot().revision | |||
| 57 | + | secondEpoch := second.output.currentEpoch() | |||
| 58 | + | ||||
| 59 | + | rc := sess.renderCoordinator() | |||
| 60 | + | lease := rc.attachmentLease(second) | |||
| 61 | + | require.True(t, rc.markAttachmentReady(lease)) | |||
| 62 | + | token := sess.attachmentToken(second, secondTransport) | |||
| 63 | + | token.lease = lease | |||
| 64 | + | ticket, admitted := second.beginAttachmentEffect(token) | |||
| 65 | + | require.True(t, admitted) | |||
| 66 | + | token.effect = ticket | |||
| 67 | + | require.True(t, d.resizeAttachmentForLease(token, domain.Size{Cols: 120, Rows: 50})) | |||
| 68 | + | ticket.End() | |||
| 69 | + | ||||
| 70 | + | tb.mu.Lock() | |||
| 71 | + | require.Equal(t, contentBefore, tb.size) | |||
| 72 | + | tb.mu.Unlock() | |||
| 73 | + | require.Equal(t, firstSize, first.size) | |||
| 74 | + | require.Equal(t, domain.Size{Cols: 120, Rows: 50}, second.size) | |||
| 75 | + | require.Equal(t, secondRevision+1, second.viewSnapshot().revision) | |||
| 76 | + | require.Greater(t, second.output.currentEpoch(), secondEpoch) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestAttachmentPaintFanoutDoesNotWaitForBlockedPeer(t *testing.T) { | |||
| 80 | + | pty, releasePTY := newBlockingPTY(t) | |||
| 81 | + | defer releasePTY() | |||
| 82 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 83 | + | firstTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)} | |||
| 84 | + | sess, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{1}}, firstTransport) | |||
| 85 | + | require.NoError(t, err) | |||
| 86 | + | secondTransport := &blockedAttachmentTransport{entered: make(chan struct{}), release: make(chan struct{}), sends: make(chan ports.Frame, 8)} | |||
| 87 | + | _, _, err = d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24}, ClientID: [16]byte{2}}, secondTransport) | |||
| 88 | + | require.NoError(t, err) | |||
| 89 | + | rc := sess.renderCoordinator() | |||
| 90 | + | for _, ac := range sess.snapshotAttachments() { | |||
| 91 | + | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |||
| 92 | + | } | |||
| 93 | + | firstTransport.blocked.Store(true) | |||
| 94 | + | ||||
| 95 | + | sess.tabs[0].focusedPane().screen.Write([]byte("shared")) | |||
| 96 | + | rc.invalidate(renderInvalidation{class: invalidateUrgent, reset: true, producer: "test"}) | |||
| 97 | + | rc.fireCurrent(false) | |||
| 98 | + | select { | |||
| 99 | + | case <-firstTransport.entered: | |||
| 100 | + | case <-time.After(time.Second): | |||
| 101 | + | t.Fatal("first attachment did not reach blocked send") | |||
| 102 | + | } | |||
| 103 | + | select { | |||
| 104 | + | case frame := <-secondTransport.sends: | |||
| 105 | + | require.Equal(t, ports.MsgOutput, frame.Type) | |||
| 106 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |||
| 107 | + | require.NoError(t, err) | |||
| 108 | + | require.True(t, out.Full, "fan-out peers must send a fresh first frame even after another attachment acknowledges shared damage") | |||
| 109 | + | case <-time.After(time.Second): | |||
| 110 | + | t.Fatal("second attachment waited for first attachment transport") | |||
| 111 | + | } | |||
| 112 | + | close(firstTransport.release) | |||
| 113 | + | } | |||
M internal/usecase/daemon/client.go +113 -93
| @@ -36,12 +36,9 @@ type attachedClient struct { | |||||
| 36 | tr ports.Transport | 36 | tr ports.Transport | ||
| 37 | transportIncarnation uint64 | 37 | transportIncarnation uint64 | ||
| 38 | output *outputStateStream | 38 | output *outputStateStream | ||
| 39 | - | // screenOutput is allocated only for proxied attachments and shares output's | 39 | + | overlays *overlayRuntime |
| 40 | - | // state-number/ACK window. Both streams are serialized by sendMu. | 40 | + | overlayOnce sync.Once |
| 41 | - | screenOutput *structuredOutputStream | 41 | + | clientID [16]byte |
| 42 | - | overlays *overlayRuntime | |||
| 43 | - | overlayOnce sync.Once | |||
| 44 | - | clientID [16]byte | |||
| 45 | // connectionGeneration is the wire-facing capability generation. attachmentEffects is | 42 | // connectionGeneration is the wire-facing capability generation. attachmentEffects is | ||
| 46 | // the linearization gate for every attachment-bound observable operation. An attachment | 43 | // the linearization gate for every attachment-bound observable operation. An attachment | ||
| 47 | // transition freezes and drains this gate before changing either generation | 44 | // transition freezes and drains this gate before changing either generation | ||
| @@ -55,10 +52,7 @@ type attachedClient struct { | |||||
| 55 | // credential instead of consuming it. | 52 | // credential instead of consuming it. | ||
| 56 | resumeClaimToken uint64 | 53 | resumeClaimToken uint64 | ||
| 57 | parked bool | 54 | parked bool | ||
| 58 | - | // proxied is negotiated once by Hello and remains immutable for this | 55 | + | echoAck atomic.Uint64 |
| 59 | - | // attachment, including across transport resume. | |||
| 60 | - | proxied bool | |||
| 61 | - | echoAck atomic.Uint64 | |||
| 62 | // prepareFailureFallback prevents a direct fallback paint from recursively | 56 | // prepareFailureFallback prevents a direct fallback paint from recursively | ||
| 63 | // reporting the same failed prepare through its notice repaint. It is only | 57 | // reporting the same failed prepare through its notice repaint. It is only | ||
| 64 | // needed while no render coordinator is installed. | 58 | // needed while no render coordinator is installed. | ||
| @@ -69,13 +63,6 @@ type attachedClient struct { | |||||
| 69 | pipelineCache composeCacheInput | 63 | pipelineCache composeCacheInput | ||
| 70 | pipelineScratch composeCacheInput | 64 | pipelineScratch composeCacheInput | ||
| 71 | renderScratch renderCaptureScratch // only touched while sendMu is held | 65 | renderScratch renderCaptureScratch // only touched while sendMu is held | ||
| 72 | - | // proxyCapture retains the last captured proxy frame for damage-aware | |||
| 73 | - | // updates. It is attachment-owned and only touched while sendMu is held. | |||
| 74 | - | proxyCapture proxyCapture | |||
| 75 | - | // sessionMeta is the last authoritative metadata snapshot successfully sent | |||
| 76 | - | // on a proxied attachment. It is only read or written while sendMu is held. | |||
| 77 | - | sessionMeta ports.SessionMeta | |||
| 78 | - | sessionMetaSent bool | |||
| 79 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | 66 | // captureFrames is keyed by pane ownership, not the tab-local PaneID, so | ||
| 80 | // snapshots cannot leak when an attachment switches tabs or sessions. | 67 | // snapshots cannot leak when an attachment switches tabs or sessions. | ||
| 81 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | 68 | captureFrames map[*pane]capturedPaneRenderState // only touched while sendMu is held | ||
| @@ -289,50 +276,23 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool { | |||||
| 289 | return tr != nil && ac.transportIs(tr) | 276 | return tr != nil && ac.transportIs(tr) | ||
| 290 | } | 277 | } | ||
| 291 | 278 | ||||
| 292 | - | func (ac *attachedClient) ackOutputState(state uint64) { | 279 | + | func (ac *attachedClient) ackOutputState(epoch, state uint64) { |
| 293 | ac.sendMu.Lock() | 280 | ac.sendMu.Lock() | ||
| 294 | defer ac.sendMu.Unlock() | 281 | defer ac.sendMu.Unlock() | ||
| 295 | - | ac.output.ack(state) | 282 | + | if ac.output != nil { |
| 296 | - | } | 283 | + | ac.output.ack(epoch, state) |
| 297 | - | ||||
| 298 | - | // ensureScreenOutput is called while sendMu owns the attachment. Production | |||
| 299 | - | // constructors allocate this at attachment creation; the lazy path keeps | |||
| 300 | - | // hand-built test attachments faithful when they set proxied after creation. | |||
| 301 | - | func (ac *attachedClient) ensureScreenOutput() *structuredOutputStream { | |||
| 302 | - | if ac == nil || !ac.proxied || ac.output == nil { | |||
| 303 | - | return nil | |||
| 304 | - | } | |||
| 305 | - | if ac.screenOutput == nil { | |||
| 306 | - | ac.screenOutput = newStructuredOutputStream(ac.output) | |||
| 307 | - | } | |||
| 308 | - | return ac.screenOutput | |||
| 309 | - | } | |||
| 310 | - | ||||
| 311 | - | // discardProxyCapture abandons a candidate that was not emitted. The next | |||
| 312 | - | // capture clones the authoritative screen once, avoiding replaying pending | |||
| 313 | - | // scroll damage onto an already-captured failed candidate. | |||
| 314 | - | func (ac *attachedClient) discardProxyCapture() { | |||
| 315 | - | if ac != nil && ac.proxied { | |||
| 316 | - | ac.proxyCapture = proxyCapture{} | |||
| 317 | } | 284 | } | ||
| 318 | } | 285 | } | ||
| 319 | 286 | ||||
| 320 | - | // rebaseOutput resets both wire representations while retaining the shared | 287 | + | // rebaseOutput resets only this attachment's output representations. Callers |
| 321 | - | // state-number chain. Callers hold sendMu (or the activation barrier). | 288 | + | // hold sendMu (or the activation barrier). |
| 322 | func (ac *attachedClient) rebaseOutput() { | 289 | func (ac *attachedClient) rebaseOutput() { | ||
| 323 | if ac == nil { | 290 | if ac == nil { | ||
| 324 | return | 291 | return | ||
| 325 | } | 292 | } | ||
| 326 | - | if ac.proxied { | |||
| 327 | - | ac.ensureScreenOutput() | |||
| 328 | - | } | |||
| 329 | if ac.output != nil { | 293 | if ac.output != nil { | ||
| 330 | ac.output.rebase() | 294 | ac.output.rebase() | ||
| 331 | } | 295 | } | ||
| 332 | - | if ac.screenOutput != nil { | |||
| 333 | - | ac.screenOutput.rebase() | |||
| 334 | - | } | |||
| 335 | - | ac.proxyCapture = proxyCapture{} | |||
| 336 | } | 296 | } | ||
| 337 | 297 | ||||
| 338 | var errTransportReplaced = errors.New("client transport was replaced") | 298 | var errTransportReplaced = errors.New("client transport was replaced") | ||
| @@ -384,6 +344,31 @@ func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportS | |||||
| 384 | return err | 344 | return err | ||
| 385 | } | 345 | } | ||
| 386 | 346 | ||||
| 347 | + | // sendExpectedSideEffectForAttachment captures the output epoch, view revision, | |||
| 348 | + | // and window while sendMu is held. A resize or handoff therefore cannot put a | |||
| 349 | + | // side-effect frame from the old attachment state onto the new transport state. | |||
| 350 | + | func (ac *attachedClient) sendExpectedSideEffectForAttachment(expected transportSnapshot, data []byte, echoAck uint64, ticket *attachmentEffectTicket) error { | |||
| 351 | + | ac.sendMu.Lock() | |||
| 352 | + | defer ac.sendMu.Unlock() | |||
| 353 | + | if ticket == nil { | |||
| 354 | + | return errAttachmentTransition | |||
| 355 | + | } | |||
| 356 | + | if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil { | |||
| 357 | + | return errAttachmentTransition | |||
| 358 | + | } | |||
| 359 | + | ac.output.lockView() | |||
| 360 | + | defer ac.output.unlockView() | |||
| 361 | + | frame, err := ac.output.sideEffectLocked(data, echoAck) | |||
| 362 | + | if err == nil { | |||
| 363 | + | err = expected.transport.Send(frame) | |||
| 364 | + | } | |||
| 365 | + | if err != nil { | |||
| 366 | + | ticket.reportTransportFailure(expected) | |||
| 367 | + | } | |||
| 368 | + | ticket.endTransportSend() | |||
| 369 | + | return err | |||
| 370 | + | } | |||
| 371 | + | ||||
| 387 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | 372 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | ||
| 388 | // waiting on sendMu behind a wedged paint) does not complete within | 373 | // waiting on sendMu behind a wedged paint) does not complete within | ||
| 389 | // detachNotifyTimeout, the transport is force-closed, failing the in-flight | 374 | // detachNotifyTimeout, the transport is force-closed, failing the in-flight | ||
| @@ -413,24 +398,37 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error { | |||||
| 413 | } | 398 | } | ||
| 414 | 399 | ||||
| 415 | func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) { | 400 | func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) { | ||
| 416 | - | frame := ac.output.sideEffect(b, ac.echoAck.Load()) | |||
| 417 | expected := ac.transportSnapshot() | 401 | expected := ac.transportSnapshot() | ||
| 418 | if expected.transport == nil { | 402 | if expected.transport == nil { | ||
| 419 | return nil, errors.New("client transport is nil") | 403 | return nil, errors.New("client transport is nil") | ||
| 420 | } | 404 | } | ||
| 421 | - | ac.sendMu.Lock() | |||
| 422 | - | if !ac.transportSnapshotCurrent(expected) { | |||
| 423 | - | ac.sendMu.Unlock() | |||
| 424 | - | return expected.transport, errTransportReplaced | |||
| 425 | - | } | |||
| 426 | if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok { | 405 | if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok { | ||
| 427 | - | err := owned.SendSynchronous(frame) | 406 | + | ac.sendMu.Lock() |
| 428 | - | ac.sendMu.Unlock() | 407 | + | defer ac.sendMu.Unlock() |
| 408 | + | if !ac.transportSnapshotCurrent(expected) { | |||
| 409 | + | return expected.transport, errTransportReplaced | |||
| 410 | + | } | |||
| 411 | + | ac.output.lockView() | |||
| 412 | + | defer ac.output.unlockView() | |||
| 413 | + | frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load()) | |||
| 414 | + | if err == nil { | |||
| 415 | + | err = owned.SendSynchronous(frame) | |||
| 416 | + | } | |||
| 429 | return expected.transport, err | 417 | return expected.transport, err | ||
| 430 | } | 418 | } | ||
| 431 | - | ac.sendMu.Unlock() | |||
| 432 | return d.boundedSendWith(expected.transport, func() error { | 419 | return d.boundedSendWith(expected.transport, func() error { | ||
| 433 | - | return ac.sendExpectedTransport(expected, frame) | 420 | + | ac.sendMu.Lock() |
| 421 | + | defer ac.sendMu.Unlock() | |||
| 422 | + | if !ac.transportSnapshotCurrent(expected) { | |||
| 423 | + | return errTransportReplaced | |||
| 424 | + | } | |||
| 425 | + | ac.output.lockView() | |||
| 426 | + | defer ac.output.unlockView() | |||
| 427 | + | frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load()) | |||
| 428 | + | if err != nil { | |||
| 429 | + | return err | |||
| 430 | + | } | |||
| 431 | + | return expected.transport.Send(frame) | |||
| 434 | }) | 432 | }) | ||
| 435 | } | 433 | } | ||
| 436 | 434 | ||||
| @@ -523,7 +521,6 @@ type attachClientOptions struct { | |||||
| 523 | clientID [16]byte | 521 | clientID [16]byte | ||
| 524 | resumeCapable bool | 522 | resumeCapable bool | ||
| 525 | maxOutputInFlight uint8 | 523 | maxOutputInFlight uint8 | ||
| 526 | - | proxied bool | |||
| 527 | } | 524 | } | ||
| 528 | 525 | ||||
| 529 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | 526 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) { | ||
| @@ -567,14 +564,12 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||||
| 567 | tr: tr, | 564 | tr: tr, | ||
| 568 | output: output, | 565 | output: output, | ||
| 569 | size: sz, | 566 | size: sz, | ||
| 567 | + | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | |||
| 570 | clientID: opts.clientID, | 568 | clientID: opts.clientID, | ||
| 571 | resumeCapable: opts.resumeCapable, | 569 | resumeCapable: opts.resumeCapable, | ||
| 572 | resumeToken: resumeToken, | 570 | resumeToken: resumeToken, | ||
| 573 | - | proxied: opts.proxied, | |||
| 574 | - | } | |||
| 575 | - | if opts.proxied { | |||
| 576 | - | ac.screenOutput = newStructuredOutputStream(output) | |||
| 577 | } | 571 | } | ||
| 572 | + | output.attachment = ac | |||
| 578 | ac.initOverlays() | 573 | ac.initOverlays() | ||
| 579 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | 574 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings) | ||
| 580 | return ac | 575 | return ac | ||
| @@ -648,6 +643,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry attachmentSess | |||||
| 648 | d.paint(entry, attachment, fanoutReset, lease) | 643 | d.paint(entry, attachment, fanoutReset, lease) | ||
| 649 | } else if len(attachments) > 1 { | 644 | } else if len(attachments) > 1 { | ||
| 650 | go d.paint(entry, attachment, fanoutReset, lease) | 645 | go d.paint(entry, attachment, fanoutReset, lease) | ||
| 646 | + | ||||
| 651 | } else { | 647 | } else { | ||
| 652 | d.paint(entry, attachment, fanoutReset, lease) | 648 | d.paint(entry, attachment, fanoutReset, lease) | ||
| 653 | } | 649 | } | ||
| @@ -709,8 +705,8 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |||||
| 709 | token.ac.captureFrames = nil | 705 | token.ac.captureFrames = nil | ||
| 710 | token.ac.sendMu.Unlock() | 706 | token.ac.sendMu.Unlock() | ||
| 711 | } | 707 | } | ||
| 712 | - | if proxy, ok := token.sess.(*proxySession); ok { | 708 | + | if _, ok := token.sess.(*proxySession); ok { |
| 713 | - | d.firstProxyPaintWithLease(proxy, token.ac, token.lease) | 709 | + | // Remote ordinary output arrives independently after the proxy Welcome. |
| 714 | return true | 710 | return true | ||
| 715 | } | 711 | } | ||
| 716 | sess, ok := localSession(token.sess) | 712 | sess, ok := localSession(token.sess) | ||
| @@ -721,15 +717,14 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |||||
| 721 | return true | 717 | return true | ||
| 722 | } | 718 | } | ||
| 723 | 719 | ||||
| 724 | - | // firstPaint guarantees the freshly attached client sees the full screen: if | 720 | + | // firstPaint guarantees the freshly attached client sees the full screen. The |
| 725 | - | // the tab size differs from the client's it resizes first, then immediately | 721 | + | // session content geometry is fixed by the first valid Hello; an attachment's |
| 726 | - | // emits a full redraw. It is retained for direct test/headless setup; active | 722 | + | // window is rendered without resizing shared tabs or PTYs. |
| 727 | - | // attachment transitions use firstPaintForTransition with their exact lease. | |||
| 728 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | 723 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | ||
| 729 | d.firstPaintWithLease(sess, ac, clientSize, nil) | 724 | d.firstPaintWithLease(sess, ac, clientSize, nil) | ||
| 730 | } | 725 | } | ||
| 731 | 726 | ||||
| 732 | - | func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) { | 727 | + | func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain.Size, lease *attachmentLease) { |
| 733 | // Global notices raised while nothing was attached surface on this client. | 728 | // Global notices raised while nothing was attached surface on this client. | ||
| 734 | // Drained before the early return below so a session without an active tab | 729 | // Drained before the early return below so a session without an active tab | ||
| 735 | // cannot swallow the queue. | 730 | // cannot swallow the queue. | ||
| @@ -738,29 +733,14 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||||
| 738 | if tb == nil { | 733 | if tb == nil { | ||
| 739 | return | 734 | return | ||
| 740 | } | 735 | } | ||
| 741 | - | tb.mu.Lock() | |||
| 742 | - | wsz := tb.size | |||
| 743 | - | tb.mu.Unlock() | |||
| 744 | - | ||||
| 745 | - | outerResizeAccepted := false | |||
| 746 | - | if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) { | |||
| 747 | - | if lease == nil { | |||
| 748 | - | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |||
| 749 | - | } else { | |||
| 750 | - | outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true) | |||
| 751 | - | } | |||
| 752 | - | } | |||
| 753 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 736 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 754 | - | // Activation can synchronously resize a retained floating pane. An accepted | 737 | + | // Activation still performs attachment-local warmup, but it must not turn a |
| 755 | - | // synchronous outer request already includes that pane and emits the reset, | 738 | + | // new window into a shared session resize. |
| 756 | - | // but activation still performs its warmup work. | 739 | + | d.ensureFloatingWarm(sess, tb) |
| 757 | - | activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease) | 740 | + | if lease == nil { |
| 758 | - | if !outerResizeAccepted && !activationResized { | 741 | + | d.invalidateRenderNow(sess, ac, true, "client.go") |
| 759 | - | if lease == nil { | 742 | + | } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) { |
| 760 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | 743 | + | rc.fireCurrentForLease(lease) |
| 761 | - | } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) { | |||
| 762 | - | rc.fireCurrentForLease(lease) | |||
| 763 | - | } | |||
| 764 | } | 744 | } | ||
| 765 | } | 745 | } | ||
| 766 | 746 | ||||
| @@ -814,6 +794,46 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 814 | d.requestTransactionalResize(sess, ac, sz, false) | 794 | d.requestTransactionalResize(sess, ac, sz, false) | ||
| 815 | } | 795 | } | ||
| 816 | 796 | ||||
| 797 | + | // resizeAttachmentForLease changes only the sender's window/view. Session tab | |||
| 798 | + | // geometry and PTY sizes are deliberately outside this mutation boundary. | |||
| 799 | + | // Callers hold the attachment effect admission; output state is rebased before | |||
| 800 | + | // the targeted redraw so every subsequent frame starts a new epoch. | |||
| 801 | + | func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool { | |||
| 802 | + | if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() { | |||
| 803 | + | return false | |||
| 804 | + | } | |||
| 805 | + | ac := token.ac | |||
| 806 | + | ac.sendMu.Lock() | |||
| 807 | + | if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) { | |||
| 808 | + | ac.sendMu.Unlock() | |||
| 809 | + | return false | |||
| 810 | + | } | |||
| 811 | + | if ac.size == size { | |||
| 812 | + | ac.sendMu.Unlock() | |||
| 813 | + | return false | |||
| 814 | + | } | |||
| 815 | + | ac.size = size | |||
| 816 | + | view := ac.viewSnapshot() | |||
| 817 | + | view.windowRows = size.Rows | |||
| 818 | + | view.windowSet = true | |||
| 819 | + | view.windowTop = 0 | |||
| 820 | + | view.revision++ | |||
| 821 | + | ac.publishView(view) | |||
| 822 | + | ac.pipelineCache = composeCacheInput{} | |||
| 823 | + | ac.pipelineScratch = composeCacheInput{} | |||
| 824 | + | ac.sendMu.Unlock() | |||
| 825 | + | ||||
| 826 | + | entry := token.sess | |||
| 827 | + | // Rendering is attachment-scoped. Run it independently of the connection | |||
| 828 | + | // reader so a blocked transport cannot hold session dispatch or peers. | |||
| 829 | + | if proxy, ok := entry.(*proxySession); ok { | |||
| 830 | + | _, _ = proxy.resize(size) | |||
| 831 | + | return true | |||
| 832 | + | } | |||
| 833 | + | go d.paint(entry, ac, true, token.lease) | |||
| 834 | + | return true | |||
| 835 | + | } | |||
| 836 | + | ||||
| 817 | // handleClientNotice maps the closed client-event enum to daemon-owned notice | 837 | // handleClientNotice maps the closed client-event enum to daemon-owned notice | ||
| 818 | // content. routingMu makes ownership validation and toast mutation one atomic | 838 | // content. routingMu makes ownership validation and toast mutation one atomic | ||
| 819 | // attachment-routing operation: attachment publication also takes routingMu | 839 | // attachment-routing operation: attachment publication also takes routingMu | ||
M internal/usecase/daemon/client_frame_routing.go +19 -26
| @@ -102,11 +102,7 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||||
| 102 | } | 102 | } | ||
| 103 | case ports.MsgResize: | 103 | case ports.MsgResize: | ||
| 104 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() { | 104 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() { | ||
| 105 | - | if sess, ok := localSession(token.sess); ok { | 105 | + | d.resizeAttachmentForLease(token, rz.Size) |
| 106 | - | d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false) | |||
| 107 | - | } else if proxy, ok := token.sess.(*proxySession); ok { | |||
| 108 | - | d.resizeProxyForLease(proxy, token.ac, token.lease, rz.Size) | |||
| 109 | - | } | |||
| 110 | } | 106 | } | ||
| 111 | case ports.MsgTheme: | 107 | case ports.MsgTheme: | ||
| 112 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | 108 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | ||
| @@ -129,24 +125,22 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||||
| 129 | } | 125 | } | ||
| 130 | case ports.MsgAck: | 126 | case ports.MsgAck: | ||
| 131 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | 127 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | ||
| 132 | - | d.ackOutput(token, ack.AckedStateNum) | 128 | + | d.ackOutput(token, ack.Epoch, ack.State) |
| 133 | } | 129 | } | ||
| 134 | case ports.MsgOutputResetRequest: | 130 | case ports.MsgOutputResetRequest: | ||
| 135 | if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil { | 131 | if _, derr := ports.UnmarshalOutputResetRequest(f.Payload); derr == nil { | ||
| 136 | d.resetOutput(token) | 132 | d.resetOutput(token) | ||
| 137 | } | 133 | } | ||
| 138 | case ports.MsgCommand: | 134 | case ports.MsgCommand: | ||
| 139 | - | if token.ac.proxied { | 135 | + | request, derr := ports.UnmarshalCommandRequest(f.Payload) |
| 140 | - | request, derr := ports.UnmarshalCommandRequest(f.Payload) | 136 | + | if derr != nil { |
| 141 | - | if derr != nil { | 137 | + | return false |
| 142 | - | return false | 138 | + | } |
| 143 | - | } | 139 | + | result := d.executeAttachedCommand(token, request) |
| 144 | - | result := d.executeAttachedCommand(token, request) | 140 | + | if err := token.sendControl(frameCommandResult(result)); err != nil { |
| 145 | - | if err := token.sendControl(frameCommandResult(result)); err != nil { | 141 | + | token.endAttachmentEffect() |
| 146 | - | token.endAttachmentEffect() | 142 | + | d.detachOnAttachmentSendError(token, token.transport.transport) |
| 147 | - | d.detachOnAttachmentSendError(token, token.transport.transport) | 143 | + | return true |
| 148 | - | return true | |||
| 149 | - | } | |||
| 150 | } | 144 | } | ||
| 151 | case ports.MsgPing: | 145 | case ports.MsgPing: | ||
| 152 | if err := token.sendControl(framePong()); err != nil { | 146 | if err := token.sendControl(framePong()); err != nil { | ||
| @@ -246,30 +240,29 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool { | |||||
| 246 | // transport revalidation under that lock rejects a link replaced while waiting. | 240 | // transport revalidation under that lock rejects a link replaced while waiting. | ||
| 247 | func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | 241 | func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | ||
| 248 | ac := token.ac | 242 | ac := token.ac | ||
| 249 | - | if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() { | 243 | + | if ac == nil || token.effect == nil || token.effect.ended.Load() { |
| 250 | return false | 244 | return false | ||
| 251 | } | 245 | } | ||
| 252 | ac.sendMu.Lock() | 246 | ac.sendMu.Lock() | ||
| 253 | - | if token.effect.ended.Load() || !ac.proxied || !token.attachmentCurrent() || ac.output == nil { | 247 | + | if token.effect.ended.Load() || !token.attachmentCurrent() || ac.output == nil { |
| 254 | ac.sendMu.Unlock() | 248 | ac.sendMu.Unlock() | ||
| 255 | return false | 249 | return false | ||
| 256 | } | 250 | } | ||
| 257 | ac.rebaseOutput() | 251 | ac.rebaseOutput() | ||
| 252 | + | ac.pipelineCache = composeCacheInput{} | |||
| 253 | + | ac.pipelineScratch = composeCacheInput{} | |||
| 258 | ac.sendMu.Unlock() | 254 | ac.sendMu.Unlock() | ||
| 259 | - | 255 | + | go d.paint(token.sess, ac, true, token.lease) | |
| 260 | - | rc := token.sess.core().coordinator.Load() | 256 | + | return true |
| 261 | - | return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{ | |||
| 262 | - | class: invalidateUrgent, reset: true, producer: "output-reset-request", | |||
| 263 | - | }) | |||
| 264 | } | 257 | } | ||
| 265 | 258 | ||||
| 266 | - | func (d *Daemon) ackOutput(token attachmentConnectionToken, state uint64) bool { | 259 | + | func (d *Daemon) ackOutput(token attachmentConnectionToken, epoch, state uint64) bool { |
| 267 | ac := token.ac | 260 | ac := token.ac | ||
| 268 | if token.effect == nil || token.effect.ended.Load() { | 261 | if token.effect == nil || token.effect.ended.Load() { | ||
| 269 | return false | 262 | return false | ||
| 270 | } | 263 | } | ||
| 271 | ac.sendMu.Lock() | 264 | ac.sendMu.Lock() | ||
| 272 | - | ac.output.ack(state) | 265 | + | ac.output.ack(epoch, state) |
| 273 | ac.sendMu.Unlock() | 266 | ac.sendMu.Unlock() | ||
| 274 | if rc := token.sess.core().coordinator.Load(); rc != nil { | 267 | if rc := token.sess.core().coordinator.Load(); rc != nil { | ||
| 275 | rc.notifyAckForLease(token.lease) | 268 | rc.notifyAckForLease(token.lease) | ||
M internal/usecase/daemon/clipboard.go +9 -6
| @@ -271,12 +271,15 @@ func (d *Daemon) boundedSendClipboardForward(item clipboardForward, ticket *atta | |||||
| 271 | if !beginClipboardOwnerSend(item.owner, ticket, expected) { | 271 | if !beginClipboardOwnerSend(item.owner, ticket, expected) { | ||
| 272 | return errAttachmentTransition | 272 | return errAttachmentTransition | ||
| 273 | } | 273 | } | ||
| 274 | - | frame := ac.output.sideEffect(item.seq, ac.echoAck.Load()) | 274 | + | ac.output.lockView() |
| 275 | - | var err error | 275 | + | defer ac.output.unlockView() |
| 276 | - | if owned { | 276 | + | frame, err := ac.output.sideEffectLocked(item.seq, ac.echoAck.Load()) |
| 277 | - | err = ownedTransport.SendSynchronous(frame) | 277 | + | if err == nil { |
| 278 | - | } else { | 278 | + | if owned { |
| 279 | - | err = expected.transport.Send(frame) | 279 | + | err = ownedTransport.SendSynchronous(frame) |
| 280 | + | } else { | |||
| 281 | + | err = expected.transport.Send(frame) | |||
| 282 | + | } | |||
| 280 | } | 283 | } | ||
| 281 | if err != nil { | 284 | if err != nil { | ||
| 282 | ticket.reportTransportFailure(expected) | 285 | ticket.reportTransportFailure(expected) | ||
M internal/usecase/daemon/daemon.go +8 -19
| @@ -64,7 +64,8 @@ func normalizeOutputWindow(window uint8) uint8 { | |||||
| 64 | 64 | ||||
| 65 | const defaultResumeParkGrace = 15 * time.Minute | 65 | const defaultResumeParkGrace = 15 * time.Minute | ||
| 66 | 66 | ||||
| 67 | - | // defaultSize is used when a client's Hello carries no valid dimensions. | 67 | + | // defaultSize is retained for headless layout helpers that have no client |
| 68 | + | // window; Hello routing rejects invalid dimensions instead of using it. | |||
| 68 | var defaultSize = domain.Size{Cols: 80, Rows: 24} | 69 | var defaultSize = domain.Size{Cols: 80, Rows: 24} | ||
| 69 | 70 | ||||
| 70 | type Daemon struct { | 71 | type Daemon struct { | ||
| @@ -73,7 +74,7 @@ type Daemon struct { | |||||
| 73 | stopped map[string]stoppedSession | 74 | stopped map[string]stoppedSession | ||
| 74 | // creating reserves names while durable creation I/O runs without mu. | 75 | // creating reserves names while durable creation I/O runs without mu. | ||
| 75 | creating map[string]struct{} | 76 | creating map[string]struct{} | ||
| 76 | - | // proxyConstructions serializes remote IntentAttach handshakes by structured | 77 | + | // proxyConstructions serializes remote IntentAttach handshakes by remote |
| 77 | // key. Entries are guarded by mu and never retain it across dial or link I/O. | 78 | // key. Entries are guarded by mu and never retain it across dial or link I/O. | ||
| 78 | proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction | 79 | proxyConstructions map[domain.RemoteSessionKey]*proxyConstruction | ||
| 79 | nextID uint64 | 80 | nextID uint64 | ||
| @@ -1142,16 +1143,6 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||||
| 1142 | d.clientGone(sess, ac, tr, false) | 1143 | d.clientGone(sess, ac, tr, false) | ||
| 1143 | return | 1144 | return | ||
| 1144 | } | 1145 | } | ||
| 1145 | - | if ac.proxied { | |||
| 1146 | - | meta, ok := sess.sessionMetaSnapshotFor(ac) | |||
| 1147 | - | metaFrame, metaErr := frameSessionMeta(meta) | |||
| 1148 | - | if !ok || metaErr != nil || ac.sendExpectedTransportForAttachment(postWelcomeToken.transport, metaFrame, postWelcomeTicket) != nil { | |||
| 1149 | - | postWelcomeTicket.End() | |||
| 1150 | - | d.clientGone(sess, ac, tr, false) | |||
| 1151 | - | return | |||
| 1152 | - | } | |||
| 1153 | - | ac.markSessionMetaSent(meta) | |||
| 1154 | - | } | |||
| 1155 | if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) { | 1146 | if postWelcomeLease != nil && (rc == nil || !rc.markAttachmentReady(postWelcomeLease)) { | ||
| 1156 | postWelcomeTicket.End() | 1147 | postWelcomeTicket.End() | ||
| 1157 | // The attachment was detached while Welcome was in flight; never let | 1148 | // The attachment was detached while Welcome was in flight; never let | ||
| @@ -1193,7 +1184,6 @@ func (d *Daemon) finishAttach(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 1193 | clientID: h.ClientID, | 1184 | clientID: h.ClientID, | ||
| 1194 | resumeCapable: true, | 1185 | resumeCapable: true, | ||
| 1195 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | 1186 | maxOutputInFlight: normalizeOutputWindow(h.MaxOutputInFlight), | ||
| 1196 | - | proxied: h.Proxied, | |||
| 1197 | } | 1187 | } | ||
| 1198 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | 1188 | ac := d.prepareAttachedClientLocked(tr, sz, opts) | ||
| 1199 | d.mu.Unlock() | 1189 | d.mu.Unlock() | ||
| @@ -1267,7 +1257,7 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |||||
| 1267 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | 1257 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | ||
| 1268 | sz := h.Size | 1258 | sz := h.Size | ||
| 1269 | if !sz.Valid() { | 1259 | if !sz.Valid() { | ||
| 1270 | - | sz = defaultSize | 1260 | + | return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"} |
| 1271 | } | 1261 | } | ||
| 1272 | term := terminalEnv{TrueColor: h.TrueColor} | 1262 | term := terminalEnv{TrueColor: h.TrueColor} | ||
| 1273 | 1263 | ||||
| @@ -1317,7 +1307,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1317 | return nil, nil, err | 1307 | return nil, nil, err | ||
| 1318 | } | 1308 | } | ||
| 1319 | } | 1309 | } | ||
| 1320 | - | ||||
| 1321 | d.mu.Lock() | 1310 | d.mu.Lock() | ||
| 1322 | // Shutdown/create interlock: once shutdown has begun (last session removed, | 1311 | // Shutdown/create interlock: once shutdown has begun (last session removed, | ||
| 1323 | // or shutdownAll started) no new session may be created and no attach may | 1312 | // or shutdownAll started) no new session may be created and no attach may | ||
| @@ -1339,7 +1328,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1339 | } | 1328 | } | ||
| 1340 | cwd := d.dirOrHome(stopped.cwd) | 1329 | cwd := d.dirOrHome(stopped.cwd) | ||
| 1341 | var err error | 1330 | var err error | ||
| 1342 | - | sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames) | 1331 | + | sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames) |
| 1343 | if err != nil { | 1332 | if err != nil { | ||
| 1344 | d.mu.Unlock() | 1333 | d.mu.Unlock() | ||
| 1345 | return nil, nil, err | 1334 | return nil, nil, err | ||
| @@ -1350,7 +1339,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1350 | 1339 | ||||
| 1351 | case ports.IntentEphemeral: | 1340 | case ports.IntentEphemeral: | ||
| 1352 | name := d.allocEphemeralNameLocked() | 1341 | name := d.allocEphemeralNameLocked() | ||
| 1353 | - | sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env, h.Proxied) | 1342 | + | sess, err := d.createSessionLockedWithMode(name, true, h.Cwd, sz, term, h.Env) |
| 1354 | if err != nil { | 1343 | if err != nil { | ||
| 1355 | d.mu.Unlock() | 1344 | d.mu.Unlock() | ||
| 1356 | return nil, nil, err | 1345 | return nil, nil, err | ||
| @@ -1371,7 +1360,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1371 | d.mu.Unlock() | 1360 | d.mu.Unlock() | ||
| 1372 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | 1361 | return nil, nil, &protoErr{ports.ErrNameTaken, "session name already in use: " + h.Name} | ||
| 1373 | } | 1362 | } | ||
| 1374 | - | sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env, h.Proxied) | 1363 | + | sess, err := d.createSessionLockedWithMode(h.Name, false, h.Cwd, sz, term, h.Env) |
| 1375 | if err != nil { | 1364 | if err != nil { | ||
| 1376 | d.mu.Unlock() | 1365 | d.mu.Unlock() | ||
| 1377 | return nil, nil, err | 1366 | return nil, nil, err | ||
| @@ -1389,7 +1378,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1389 | } | 1378 | } | ||
| 1390 | cwd := d.dirOrHome(stopped.cwd) | 1379 | cwd := d.dirOrHome(stopped.cwd) | ||
| 1391 | var err error | 1380 | var err error | ||
| 1392 | - | sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, h.Proxied, stopped.tabNames) | 1381 | + | sess, err = d.createSessionLockedWithMode(h.Name, false, cwd, sz, term, h.Env, stopped.tabNames) |
| 1393 | if err != nil { | 1382 | if err != nil { | ||
| 1394 | d.mu.Unlock() | 1383 | d.mu.Unlock() | ||
| 1395 | return nil, nil, err | 1384 | return nil, nil, err | ||
M internal/usecase/daemon/daemon_test.go +41 -1
| @@ -574,6 +574,46 @@ func tabCount(sess *session) int { | |||||
| 574 | 574 | ||||
| 575 | // --- handshake -------------------------------------------------------------- | 575 | // --- handshake -------------------------------------------------------------- | ||
| 576 | 576 | ||||
| 577 | + | func TestRouteRejectsInvalidTerminalSizeBeforeResumeMutation(t *testing.T) { | |||
| 578 | + | tests := []struct { | |||
| 579 | + | name string | |||
| 580 | + | size domain.Size | |||
| 581 | + | }{ | |||
| 582 | + | {name: "zero columns", size: domain.Size{Cols: 0, Rows: 24}}, | |||
| 583 | + | {name: "zero rows", size: domain.Size{Cols: 80, Rows: 0}}, | |||
| 584 | + | } | |||
| 585 | + | for _, tt := range tests { | |||
| 586 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 587 | + | pty, release := newBlockingPTY(t) | |||
| 588 | + | defer release() | |||
| 589 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 590 | + | ||||
| 591 | + | transport := &closeTrackingTransport{} | |||
| 592 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), transport) | |||
| 593 | + | require.NoError(t, err) | |||
| 594 | + | token := ac.resumeToken | |||
| 595 | + | d.clientGone(sess, ac, transport, false) | |||
| 596 | + | ||||
| 597 | + | d.mu.Lock() | |||
| 598 | + | parked := d.parked[token] | |||
| 599 | + | d.mu.Unlock() | |||
| 600 | + | require.NotNil(t, parked) | |||
| 601 | + | ||||
| 602 | + | invalid := helloResumeCapable(ports.IntentResume, "work", token) | |||
| 603 | + | invalid.Size = tt.size | |||
| 604 | + | _, _, err = d.route(invalid, &closeTrackingTransport{}) | |||
| 605 | + | var protocolErr *protoErr | |||
| 606 | + | require.ErrorAs(t, err, &protocolErr) | |||
| 607 | + | require.Equal(t, ports.ErrInternal, protocolErr.code) | |||
| 608 | + | ||||
| 609 | + | d.mu.Lock() | |||
| 610 | + | require.Same(t, parked, d.parked[token], "invalid resume must not consume the parked attachment") | |||
| 611 | + | d.mu.Unlock() | |||
| 612 | + | require.Same(t, transport, ac.transport(), "invalid resume must not bind a replacement transport") | |||
| 613 | + | }) | |||
| 614 | + | } | |||
| 615 | + | } | |||
| 616 | + | ||||
| 577 | func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} { | 617 | func waitGroupDone(wg *sync.WaitGroup) <-chan struct{} { | ||
| 578 | done := make(chan struct{}) | 618 | done := make(chan struct{}) | ||
| 579 | go func() { | 619 | go func() { | ||
| @@ -651,7 +691,7 @@ func TestHandleHelloDefersFreshOutputUntilWelcome(t *testing.T) { | |||||
| 651 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | 691 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | ||
| 652 | first, err := ports.UnmarshalOutput(output.Payload) | 692 | first, err := ports.UnmarshalOutput(output.Payload) | ||
| 653 | require.NoError(t, err) | 693 | require.NoError(t, err) | ||
| 654 | - | require.Zero(t, first.BaseStateNum, "the first post-Welcome frame is full") | 694 | + | require.Zero(t, first.Base, "the first post-Welcome frame is full") |
| 655 | tr.finish() | 695 | tr.finish() | ||
| 656 | awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion") | 696 | awaitTestCompletion(t, done, "timed out waiting for Welcome handler completion") | ||
| 657 | requireNoCoordinatorOutputFrame(t, tr.sends) | 697 | requireNoCoordinatorOutputFrame(t, tr.sends) | ||
M internal/usecase/daemon/durable_recovery_test.go +1 -1
| @@ -812,7 +812,7 @@ func TestAttachWaitsForRestore(t *testing.T) { | |||||
| 812 | 812 | ||||
| 813 | result := make(chan error, 1) | 813 | result := make(chan error, 1) | ||
| 814 | go func() { | 814 | go func() { | ||
| 815 | - | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name}, nil) | 815 | + | _, _, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: record.Name, Size: domain.Size{Cols: 80, Rows: 24}}, nil) |
| 816 | result <- err | 816 | result <- err | ||
| 817 | }() | 817 | }() | ||
| 818 | 818 | ||||
M internal/usecase/daemon/floating_test.go +1 -1
| @@ -543,7 +543,7 @@ func TestFloatingEOFRepaintsVisibleSlotOnly(t *testing.T) { | |||||
| 543 | repaint := awaitFrame(t, sends, ports.MsgOutput) | 543 | repaint := awaitFrame(t, sends, ports.MsgOutput) | ||
| 544 | output, err := ports.UnmarshalOutput(repaint.Payload) | 544 | output, err := ports.UnmarshalOutput(repaint.Payload) | ||
| 545 | require.NoError(t, err) | 545 | require.NoError(t, err) | ||
| 546 | - | require.Zero(t, output.BaseStateNum, "visible EOF must force a dependency-free full repaint") | 546 | + | require.Zero(t, output.Base, "visible EOF must force a dependency-free full repaint") |
| 547 | require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup") | 547 | require.Contains(t, string(output.Data), "underlying-cell", "repaint must restore cells previously covered by the popup") | ||
| 548 | require.NotNil(t, floating) | 548 | require.NotNil(t, floating) | ||
| 549 | }) | 549 | }) | ||
M internal/usecase/daemon/input.go +2 -17
| @@ -16,7 +16,7 @@ func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, _ uint6 | |||||
| 16 | // Do not acknowledge client-side echo prediction here: input has only been | 16 | // Do not acknowledge client-side echo prediction here: input has only been | ||
| 17 | // accepted/routed, not necessarily echoed by the PTY and incorporated into a | 17 | // accepted/routed, not necessarily echoed by the PTY and incorporated into a | ||
| 18 | // rendered screen state. Until prediction is implemented against rendered | 18 | // rendered screen state. Until prediction is implemented against rendered | ||
| 19 | - | // output state, EchoAck must remain conservative. | 19 | + | // output state, Echo must remain conservative. |
| 20 | d.handleInput(sess, ac, data) | 20 | d.handleInput(sess, ac, data) | ||
| 21 | } | 21 | } | ||
| 22 | 22 | ||||
| @@ -486,16 +486,6 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||||
| 486 | case keys.ActionOpenPalette: | 486 | case keys.ActionOpenPalette: | ||
| 487 | h.d.enterPalette(sess, h.ac) | 487 | h.d.enterPalette(sess, h.ac) | ||
| 488 | case keys.ActionJumpAttention: | 488 | case keys.ActionJumpAttention: | ||
| 489 | - | if !proxiedJumpSearchesOtherSessions(h.ac.proxied) { | |||
| 490 | - | _ = sess.runMutation(func() error { | |||
| 491 | - | if idx, ok := oldestAttentionTab(sess); ok && sess.switchAttachmentTabForDispatch(h.ac, idx) { | |||
| 492 | - | h.d.activateTabAfterResizeForLease(sess, sess.tabForAttachment(h.ac), false, h.ac, nil) | |||
| 493 | - | h.d.invalidateRender(sess, h.ac, true, "input.go") | |||
| 494 | - | } | |||
| 495 | - | return nil | |||
| 496 | - | }) | |||
| 497 | - | return | |||
| 498 | - | } | |||
| 499 | if effect == nil { | 489 | if effect == nil { | ||
| 500 | if err := h.d.jumpAttention(sess, h.ac); err != nil { | 490 | if err := h.d.jumpAttention(sess, h.ac); err != nil { | ||
| 501 | h.d.reportError(sess, err) | 491 | h.d.reportError(sess, err) | ||
| @@ -550,12 +540,7 @@ func (h daemonKeyHandler) Action(action keys.Action, _ []byte) { | |||||
| 550 | } | 540 | } | ||
| 551 | 541 | ||||
| 552 | func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) { | 542 | func (h daemonKeyHandler) focus(sess *session, dir layout.Direction, effect *attachmentEffectTicket) { | ||
| 553 | - | var err error | 543 | + | err := h.d.focusDir(sess, h.ac, dir, effect) |
| 554 | - | if h.ac.proxied { | |||
| 555 | - | err = h.d.focusDirProxied(sess, h.ac, dir) | |||
| 556 | - | } else { | |||
| 557 | - | err = h.d.focusDir(sess, h.ac, dir, effect) | |||
| 558 | - | } | |||
| 559 | if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) { | 544 | if err != nil && !errors.Is(err, errAttachmentTransition) && !errors.Is(err, errNoNeighbor) { | ||
| 560 | h.d.reportError(sess, err) | 545 | h.d.reportError(sess, err) | ||
| 561 | } | 546 | } | ||
M internal/usecase/daemon/input_test.go +1 -1
| @@ -214,7 +214,7 @@ func TestSwitchTabFirstFrameDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||||
| 214 | second := awaitFrame(t, sends, ports.MsgOutput) | 214 | second := awaitFrame(t, sends, ports.MsgOutput) | ||
| 215 | secondOutput, err := ports.UnmarshalOutput(second.Payload) | 215 | secondOutput, err := ports.UnmarshalOutput(second.Payload) | ||
| 216 | require.NoError(t, err) | 216 | require.NoError(t, err) | ||
| 217 | - | require.Zero(t, secondOutput.BaseStateNum, "tab switch must emit the complete target frame first") | 217 | + | require.Zero(t, secondOutput.Base, "tab switch must emit the complete target frame first") |
| 218 | terminal.Write(secondOutput.Data) | 218 | terminal.Write(secondOutput.Data) | ||
| 219 | require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells") | 219 | require.NotContains(t, strings.Join(frameRows(terminal.Frame), "\n"), "source", "the first target-tab frame must not retain source pane cells") | ||
| 220 | } | 220 | } | ||
M internal/usecase/daemon/output_state_stream.go +211 -25
| @@ -1,8 +1,11 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 5 | + | "sync" | |||
| 4 | "sync/atomic" | 6 | "sync/atomic" | ||
| 5 | 7 | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 6 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 7 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 8 | ) | 11 | ) | ||
| @@ -11,19 +14,50 @@ import ( | |||||
| 11 | // attached client. Each incremental output is rendered from the preceding | 14 | // attached client. Each incremental output is rendered from the preceding | ||
| 12 | // emitted frame and therefore remains pipelineable without waiting for an | 15 | // emitted frame and therefore remains pipelineable without waiting for an | ||
| 13 | // ACK. Cumulative ACKs advance the bounded paint window. | 16 | // ACK. Cumulative ACKs advance the bounded paint window. | ||
| 14 | - | // A reset frame is the only dependency-free output and advertises state 0 as | 17 | + | // A reset frame starts a new epoch, clears the dependency chain, and advertises |
| 15 | - | // its base. | 18 | + | // state 0 as its base. |
| 16 | // | 19 | // | ||
| 17 | // Callers serialize access with attachedClient.sendMu. A prepared transaction | 20 | // Callers serialize access with attachedClient.sendMu. A prepared transaction | ||
| 18 | // must be sent or committed as a no-op before the next transaction is prepared. | 21 | // must be sent or committed as a no-op before the next transaction is prepared. | ||
| 19 | type outputStateStream struct { | 22 | type outputStateStream struct { | ||
| 20 | renderer *renderer.Renderer | 23 | renderer *renderer.Renderer | ||
| 24 | + | epoch uint64 | |||
| 21 | next uint64 | 25 | next uint64 | ||
| 22 | acked uint64 | 26 | acked uint64 | ||
| 27 | + | generation uint64 | |||
| 23 | maxOutstanding uint64 | 28 | maxOutstanding uint64 | ||
| 24 | outstandingAtomic atomic.Uint64 | 29 | outstandingAtomic atomic.Uint64 | ||
| 25 | maxOutstandingAtomic atomic.Uint64 | 30 | maxOutstandingAtomic atomic.Uint64 | ||
| 26 | forceSnapshot bool | 31 | forceSnapshot bool | ||
| 32 | + | initialized bool | |||
| 33 | + | attachment *attachedClient | |||
| 34 | + | stateMu sync.Mutex | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // lockView serializes an attachment's view publication with every output | |||
| 38 | + | // state transition. It deliberately does not take sendMu: callers that hold | |||
| 39 | + | // architecture locks can publish a view without reversing the paint path's | |||
| 40 | + | // sendMu -> session lock order. | |||
| 41 | + | func (s *outputStateStream) lockView() { | |||
| 42 | + | if s == nil { | |||
| 43 | + | return | |||
| 44 | + | } | |||
| 45 | + | if s.attachment == nil { | |||
| 46 | + | s.stateMu.Lock() | |||
| 47 | + | return | |||
| 48 | + | } | |||
| 49 | + | s.attachment.viewMu.Lock() | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (s *outputStateStream) unlockView() { | |||
| 53 | + | if s == nil { | |||
| 54 | + | return | |||
| 55 | + | } | |||
| 56 | + | if s.attachment == nil { | |||
| 57 | + | s.stateMu.Unlock() | |||
| 58 | + | return | |||
| 59 | + | } | |||
| 60 | + | s.attachment.viewMu.Unlock() | |||
| 27 | } | 61 | } | ||
| 28 | 62 | ||||
| 29 | func newOutputStateStream(windowSize ...uint8) *outputStateStream { | 63 | func newOutputStateStream(windowSize ...uint8) *outputStateStream { | ||
| @@ -33,6 +67,7 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream { | |||||
| 33 | } | 67 | } | ||
| 34 | stream := &outputStateStream{ | 68 | stream := &outputStateStream{ | ||
| 35 | renderer: renderer.New(renderer.Capabilities{}), | 69 | renderer: renderer.New(renderer.Capabilities{}), | ||
| 70 | + | epoch: 1, | |||
| 36 | maxOutstanding: uint64(window), | 71 | maxOutstanding: uint64(window), | ||
| 37 | } | 72 | } | ||
| 38 | stream.maxOutstandingAtomic.Store(uint64(window)) | 73 | stream.maxOutstandingAtomic.Store(uint64(window)) | ||
| @@ -41,39 +76,79 @@ func newOutputStateStream(windowSize ...uint8) *outputStateStream { | |||||
| 41 | } | 76 | } | ||
| 42 | 77 | ||||
| 43 | type preparedOutput struct { | 78 | type preparedOutput struct { | ||
| 44 | - | stream *outputStateStream | 79 | + | stream *outputStateStream |
| 45 | - | draw renderer.PreparedDraw | 80 | + | draw renderer.PreparedDraw |
| 46 | - | next uint64 | 81 | + | base uint64 |
| 47 | - | data []byte | 82 | + | next uint64 |
| 48 | - | reset bool | 83 | + | epoch uint64 |
| 49 | - | attempted bool | 84 | + | generation uint64 |
| 85 | + | connectionGeneration uint64 | |||
| 86 | + | viewRevision uint64 | |||
| 87 | + | size domain.Size | |||
| 88 | + | data []byte | |||
| 89 | + | reset bool | |||
| 90 | + | attempted bool | |||
| 91 | + | sent bool | |||
| 50 | } | 92 | } | ||
| 51 | 93 | ||||
| 52 | func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) { | 94 | func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) { | ||
| 53 | - | reset = reset || s.forceSnapshot | 95 | + | s.lockView() |
| 96 | + | defer s.unlockView() | |||
| 97 | + | if s.epoch == 0 { | |||
| 98 | + | s.epoch = 1 | |||
| 99 | + | } | |||
| 100 | + | if reset && s.initialized { | |||
| 101 | + | s.rebaseLocked() | |||
| 102 | + | } | |||
| 103 | + | reset = reset || s.forceSnapshot || !s.initialized | |||
| 104 | + | if s.next == ^uint64(0) { | |||
| 105 | + | return nil, errors.New("output state number exhausted") | |||
| 106 | + | } | |||
| 54 | draw, err := s.renderer.Prepare(frame, damage, reset) | 107 | draw, err := s.renderer.Prepare(frame, damage, reset) | ||
| 55 | if err != nil { | 108 | if err != nil { | ||
| 56 | return nil, err | 109 | return nil, err | ||
| 57 | } | 110 | } | ||
| 111 | + | s.initialized = true | |||
| 112 | + | generation, epoch, viewRevision := s.fenceLocked() | |||
| 58 | return &preparedOutput{ | 113 | return &preparedOutput{ | ||
| 59 | - | stream: s, | 114 | + | stream: s, |
| 60 | - | draw: draw, | 115 | + | draw: draw, |
| 61 | - | next: s.next + 1, | 116 | + | base: s.next, |
| 62 | - | data: draw.Bytes(), | 117 | + | next: s.next + 1, |
| 63 | - | reset: reset, | 118 | + | epoch: epoch, |
| 119 | + | generation: s.generation, | |||
| 120 | + | connectionGeneration: generation, | |||
| 121 | + | viewRevision: viewRevision, | |||
| 122 | + | size: domain.Size{Cols: frame.Width, Rows: frame.Height}, | |||
| 123 | + | data: draw.Bytes(), | |||
| 124 | + | reset: reset, | |||
| 64 | }, nil | 125 | }, nil | ||
| 65 | } | 126 | } | ||
| 66 | 127 | ||||
| 67 | func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error { | 128 | func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error { | ||
| 68 | - | if p.attempted { | 129 | + | if p == nil || p.stream == nil || p.attempted { |
| 69 | return nil | 130 | return nil | ||
| 70 | } | 131 | } | ||
| 132 | + | p.stream.lockView() | |||
| 133 | + | defer p.stream.unlockView() | |||
| 71 | p.attempted = true | 134 | p.attempted = true | ||
| 72 | - | base := p.stream.next | 135 | + | if !p.currentLocked() { |
| 136 | + | return nil | |||
| 137 | + | } | |||
| 138 | + | if len(data) == 0 { | |||
| 139 | + | p.commit() | |||
| 140 | + | p.sent = true | |||
| 141 | + | return nil | |||
| 142 | + | } | |||
| 143 | + | base := p.base | |||
| 73 | if p.reset { | 144 | if p.reset { | ||
| 74 | base = 0 | 145 | base = 0 | ||
| 75 | } | 146 | } | ||
| 76 | - | out := frameOutputState(data, base, p.next, echoAck) | 147 | + | out, err := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset) |
| 148 | + | if err != nil { | |||
| 149 | + | p.stream.forceSnapshot = true | |||
| 150 | + | return err | |||
| 151 | + | } | |||
| 77 | if err := send(out); err != nil { | 152 | if err := send(out); err != nil { | ||
| 78 | p.stream.forceSnapshot = true | 153 | p.stream.forceSnapshot = true | ||
| 79 | return err | 154 | return err | ||
| @@ -81,15 +156,22 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame | |||||
| 81 | p.commit() | 156 | p.commit() | ||
| 82 | p.stream.next = p.next | 157 | p.stream.next = p.next | ||
| 83 | p.stream.publishOutstanding() | 158 | p.stream.publishOutstanding() | ||
| 159 | + | p.sent = true | |||
| 84 | return nil | 160 | return nil | ||
| 85 | } | 161 | } | ||
| 86 | 162 | ||||
| 87 | func (p *preparedOutput) commitNoSend() { | 163 | func (p *preparedOutput) commitNoSend() { | ||
| 88 | - | if p == nil || p.attempted || len(p.data) != 0 { | 164 | + | if p == nil || p.stream == nil || p.attempted { |
| 89 | return | 165 | return | ||
| 90 | } | 166 | } | ||
| 167 | + | p.stream.lockView() | |||
| 168 | + | defer p.stream.unlockView() | |||
| 91 | p.attempted = true | 169 | p.attempted = true | ||
| 170 | + | if !p.currentLocked() || len(p.data) != 0 { | |||
| 171 | + | return | |||
| 172 | + | } | |||
| 92 | p.commit() | 173 | p.commit() | ||
| 174 | + | p.sent = true | |||
| 93 | } | 175 | } | ||
| 94 | 176 | ||||
| 95 | func (p *preparedOutput) commit() { | 177 | func (p *preparedOutput) commit() { | ||
| @@ -97,12 +179,85 @@ func (p *preparedOutput) commit() { | |||||
| 97 | p.stream.forceSnapshot = false | 179 | p.stream.forceSnapshot = false | ||
| 98 | } | 180 | } | ||
| 99 | 181 | ||||
| 100 | - | func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame { | 182 | + | func (s *outputStateStream) currentEpoch() uint64 { |
| 101 | - | return frameOutputState(data, 0, 0, echoAck) | 183 | + | s.lockView() |
| 184 | + | defer s.unlockView() | |||
| 185 | + | return s.currentEpochLocked() | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (s *outputStateStream) currentEpochLocked() uint64 { | |||
| 189 | + | if s.epoch == 0 { | |||
| 190 | + | s.epoch = 1 | |||
| 191 | + | } | |||
| 192 | + | return s.epoch | |||
| 193 | + | } | |||
| 194 | + | ||||
| 195 | + | func (s *outputStateStream) fenceLocked() (uint64, uint64, uint64) { | |||
| 196 | + | if s == nil { | |||
| 197 | + | return 0, 1, 0 | |||
| 198 | + | } | |||
| 199 | + | generation, revision := uint64(0), uint64(0) | |||
| 200 | + | if s.attachment != nil { | |||
| 201 | + | generation = s.attachment.connectionGeneration.Load() | |||
| 202 | + | revision = s.attachment.view.revision | |||
| 203 | + | } | |||
| 204 | + | return generation, s.currentEpochLocked(), revision | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | func (s *outputStateStream) preparedCurrentLocked(generation, epoch, viewRevision, base uint64) bool { | |||
| 208 | + | if s == nil || s.currentEpochLocked() != epoch || s.generation != generation || s.next != base { | |||
| 209 | + | return false | |||
| 210 | + | } | |||
| 211 | + | if s.attachment == nil { | |||
| 212 | + | return true | |||
| 213 | + | } | |||
| 214 | + | return s.attachment.view.revision == viewRevision | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func (p *preparedOutput) currentLocked() bool { | |||
| 218 | + | return p != nil && p.stream != nil && p.stream.preparedCurrentLocked(p.generation, p.epoch, p.viewRevision, p.base) && | |||
| 219 | + | (p.stream.attachment == nil || p.stream.attachment.connectionGeneration.Load() == p.connectionGeneration) | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool) (ports.Frame, error) { | |||
| 223 | + | payload, err := ports.MarshalOutput(ports.Output{ | |||
| 224 | + | Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision, | |||
| 225 | + | Size: size, Full: full, Data: data, | |||
| 226 | + | }) | |||
| 227 | + | if err != nil { | |||
| 228 | + | return ports.Frame{}, err | |||
| 229 | + | } | |||
| 230 | + | return ports.Frame{Type: ports.MsgOutput, Payload: payload}, nil | |||
| 231 | + | } | |||
| 232 | + | ||||
| 233 | + | // sideEffect builds an output frame without advancing the state stream. When | |||
| 234 | + | // an attachment is present, callers must hold its sendMu; attachment-bound | |||
| 235 | + | // sends use sideEffectLocked after validating their transport incarnation. | |||
| 236 | + | func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) { | |||
| 237 | + | s.lockView() | |||
| 238 | + | defer s.unlockView() | |||
| 239 | + | return s.sideEffectLocked(data, echoAck) | |||
| 102 | } | 240 | } | ||
| 103 | 241 | ||||
| 104 | - | func (s *outputStateStream) ack(state uint64) { | 242 | + | // sideEffectLocked requires the attachment view lock for both frame creation |
| 105 | - | if state > s.next || state <= s.acked { | 243 | + | // and transport emission. Keeping that lock across the send prevents a view |
| 244 | + | // publication from overtaking a frame that was fenced just before it. | |||
| 245 | + | func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports.Frame, error) { | |||
| 246 | + | _, epoch, viewRevision := s.fenceLocked() | |||
| 247 | + | size := domain.Size{Cols: 1, Rows: 1} | |||
| 248 | + | if s != nil && s.attachment != nil { | |||
| 249 | + | size = s.attachment.size | |||
| 250 | + | } | |||
| 251 | + | return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false) | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (s *outputStateStream) ack(epoch, state uint64) { | |||
| 255 | + | if s == nil { | |||
| 256 | + | return | |||
| 257 | + | } | |||
| 258 | + | s.lockView() | |||
| 259 | + | defer s.unlockView() | |||
| 260 | + | if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked { | |||
| 106 | return | 261 | return | ||
| 107 | } | 262 | } | ||
| 108 | s.acked = state | 263 | s.acked = state | ||
| @@ -110,11 +265,33 @@ func (s *outputStateStream) ack(state uint64) { | |||||
| 110 | } | 265 | } | ||
| 111 | 266 | ||||
| 112 | func (s *outputStateStream) rebase() { | 267 | func (s *outputStateStream) rebase() { | ||
| 113 | - | s.acked = s.next | 268 | + | if s == nil { |
| 114 | - | s.publishOutstanding() | 269 | + | return |
| 270 | + | } | |||
| 271 | + | s.lockView() | |||
| 272 | + | defer s.unlockView() | |||
| 273 | + | s.rebaseLocked() | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | // rebaseLocked is called while the attachment view lock is held. Publishing | |||
| 277 | + | // the next view revision takes this path before making that revision visible. | |||
| 278 | + | func (s *outputStateStream) rebaseLocked() { | |||
| 279 | + | if s == nil { | |||
| 280 | + | return | |||
| 281 | + | } | |||
| 282 | + | epoch := s.currentEpochLocked() | |||
| 283 | + | if epoch == ^uint64(0) { | |||
| 284 | + | return | |||
| 285 | + | } | |||
| 286 | + | s.epoch = epoch + 1 | |||
| 287 | + | s.next = 0 | |||
| 288 | + | s.acked = 0 | |||
| 289 | + | s.generation++ | |||
| 115 | s.maxOutstandingAtomic.Store(s.maxOutstanding) | 290 | s.maxOutstandingAtomic.Store(s.maxOutstanding) | ||
| 291 | + | s.publishOutstanding() | |||
| 116 | s.renderer.Reset() | 292 | s.renderer.Reset() | ||
| 117 | s.forceSnapshot = true | 293 | s.forceSnapshot = true | ||
| 294 | + | s.initialized = false | |||
| 118 | } | 295 | } | ||
| 119 | 296 | ||||
| 120 | func (s *outputStateStream) publishOutstanding() { | 297 | func (s *outputStateStream) publishOutstanding() { | ||
| @@ -130,6 +307,8 @@ func (s *outputStateStream) syncCapacityLocked() { | |||||
| 130 | if s == nil { | 307 | if s == nil { | ||
| 131 | return | 308 | return | ||
| 132 | } | 309 | } | ||
| 310 | + | s.lockView() | |||
| 311 | + | defer s.unlockView() | |||
| 133 | s.maxOutstandingAtomic.Store(s.maxOutstanding) | 312 | s.maxOutstandingAtomic.Store(s.maxOutstanding) | ||
| 134 | s.publishOutstanding() | 313 | s.publishOutstanding() | ||
| 135 | } | 314 | } | ||
| @@ -141,4 +320,11 @@ func (s *outputStateStream) atCapacity() bool { | |||||
| 141 | return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load() | 320 | return s.outstandingAtomic.Load() >= s.maxOutstandingAtomic.Load() | ||
| 142 | } | 321 | } | ||
| 143 | 322 | ||||
| 144 | - | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | 323 | + | func (s *outputStateStream) outstanding() uint64 { |
| 324 | + | if s == nil { | |||
| 325 | + | return 0 | |||
| 326 | + | } | |||
| 327 | + | s.lockView() | |||
| 328 | + | defer s.unlockView() | |||
| 329 | + | return s.next - s.acked | |||
| 330 | + | } | |||
M internal/usecase/daemon/output_state_stream_test.go +153 -12
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "runtime" | 5 | "runtime" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | "github.com/bnema/vev/internal/ports" | 9 | "github.com/bnema/vev/internal/ports" | ||
| 9 | "github.com/bnema/vev/pkg/renderer" | 10 | "github.com/bnema/vev/pkg/renderer" | ||
| 10 | "github.com/bnema/vev/pkg/vt" | 11 | "github.com/bnema/vev/pkg/vt" | ||
| @@ -20,9 +21,9 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) { | |||||
| 20 | require.True(t, ok) | 21 | require.True(t, ok) | ||
| 21 | firstOut, err := ports.UnmarshalOutput(first.Payload) | 22 | firstOut, err := ports.UnmarshalOutput(first.Payload) | ||
| 22 | require.NoError(t, err) | 23 | require.NoError(t, err) | ||
| 23 | - | require.Equal(t, uint64(0), firstOut.BaseStateNum) | 24 | + | require.Equal(t, uint64(0), firstOut.Base) |
| 24 | - | require.Equal(t, uint64(1), firstOut.NewStateNum) | 25 | + | require.Equal(t, uint64(1), firstOut.New) |
| 25 | - | require.Equal(t, uint64(7), firstOut.EchoAck) | 26 | + | require.Equal(t, uint64(7), firstOut.Echo) |
| 26 | 27 | ||||
| 27 | secondFrame := firstFrame.Clone() | 28 | secondFrame := firstFrame.Clone() | ||
| 28 | secondFrame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | 29 | secondFrame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | ||
| @@ -31,8 +32,8 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) { | |||||
| 31 | require.True(t, ok) | 32 | require.True(t, ok) | ||
| 32 | secondOut, err := ports.UnmarshalOutput(second.Payload) | 33 | secondOut, err := ports.UnmarshalOutput(second.Payload) | ||
| 33 | require.NoError(t, err) | 34 | require.NoError(t, err) | ||
| 34 | - | require.Equal(t, firstOut.NewStateNum, secondOut.BaseStateNum) | 35 | + | require.Equal(t, firstOut.New, secondOut.Base) |
| 35 | - | require.Equal(t, uint64(2), secondOut.NewStateNum) | 36 | + | require.Equal(t, uint64(2), secondOut.New) |
| 36 | require.Equal(t, uint64(2), stream.outstanding()) | 37 | require.Equal(t, uint64(2), stream.outstanding()) | ||
| 37 | } | 38 | } | ||
| 38 | 39 | ||||
| @@ -68,12 +69,148 @@ func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) { | |||||
| 68 | <-probed | 69 | <-probed | ||
| 69 | require.True(t, stream.atCapacity()) | 70 | require.True(t, stream.atCapacity()) | ||
| 70 | 71 | ||||
| 71 | - | stream.ack(1) | 72 | + | stream.ack(1, 1) |
| 72 | require.False(t, stream.atCapacity()) | 73 | require.False(t, stream.atCapacity()) | ||
| 73 | stream.rebase() | 74 | stream.rebase() | ||
| 74 | require.False(t, stream.atCapacity()) | 75 | require.False(t, stream.atCapacity()) | ||
| 75 | } | 76 | } | ||
| 76 | 77 | ||||
| 78 | + | func TestOutputStateStreamEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *testing.T) { | |||
| 79 | + | frame := renderer.NewFrame(3, 1) | |||
| 80 | + | fillOutputStateRows(frame, []string{"abc"}) | |||
| 81 | + | newAttachment := func() *outputStateStream { | |||
| 82 | + | stream := newOutputStateStream() | |||
| 83 | + | ac := &attachedClient{output: stream, size: domain.Size{Cols: 3, Rows: 1}} | |||
| 84 | + | stream.attachment = ac | |||
| 85 | + | return stream | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | first := newAttachment() | |||
| 89 | + | second := newAttachment() | |||
| 90 | + | firstPrepared, err := first.prepare(frame, nil, true) | |||
| 91 | + | require.NoError(t, err) | |||
| 92 | + | var firstFrame ports.Frame | |||
| 93 | + | require.NoError(t, firstPrepared.send(firstPrepared.data, 0, func(frame ports.Frame) error { | |||
| 94 | + | firstFrame = frame | |||
| 95 | + | return nil | |||
| 96 | + | })) | |||
| 97 | + | firstOutput, err := ports.UnmarshalOutput(firstFrame.Payload) | |||
| 98 | + | require.NoError(t, err) | |||
| 99 | + | require.Equal(t, uint64(1), firstOutput.Epoch) | |||
| 100 | + | require.Equal(t, uint64(1), firstOutput.New) | |||
| 101 | + | require.True(t, firstOutput.Full) | |||
| 102 | + | first.ack(firstOutput.Epoch, firstOutput.New) | |||
| 103 | + | ||||
| 104 | + | secondPrepared, err := second.prepare(frame, nil, true) | |||
| 105 | + | require.NoError(t, err) | |||
| 106 | + | require.NoError(t, secondPrepared.send(secondPrepared.data, 0, func(ports.Frame) error { return nil })) | |||
| 107 | + | require.Equal(t, uint64(1), second.epoch) | |||
| 108 | + | require.Equal(t, uint64(1), second.next) | |||
| 109 | + | require.Equal(t, uint64(1), first.next) | |||
| 110 | + | ||||
| 111 | + | changed := frame.Clone() | |||
| 112 | + | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 113 | + | pending, err := first.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |||
| 114 | + | require.NoError(t, err) | |||
| 115 | + | first.rebase() | |||
| 116 | + | require.Equal(t, uint64(2), first.epoch) | |||
| 117 | + | require.Zero(t, first.next) | |||
| 118 | + | require.Zero(t, first.acked) | |||
| 119 | + | var staleSent bool | |||
| 120 | + | require.NoError(t, pending.send(pending.data, 0, func(ports.Frame) error { | |||
| 121 | + | staleSent = true | |||
| 122 | + | return nil | |||
| 123 | + | })) | |||
| 124 | + | require.False(t, staleSent) | |||
| 125 | + | require.Zero(t, first.next) | |||
| 126 | + | first.ack(1, 1) | |||
| 127 | + | require.Zero(t, first.acked, "an ACK from the retired epoch must not advance the new epoch") | |||
| 128 | + | ||||
| 129 | + | reset, err := first.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |||
| 130 | + | require.NoError(t, err) | |||
| 131 | + | var resetFrame ports.Frame | |||
| 132 | + | require.NoError(t, reset.send(reset.data, 0, func(frame ports.Frame) error { | |||
| 133 | + | resetFrame = frame | |||
| 134 | + | return nil | |||
| 135 | + | })) | |||
| 136 | + | resetOutput, err := ports.UnmarshalOutput(resetFrame.Payload) | |||
| 137 | + | require.NoError(t, err) | |||
| 138 | + | require.Equal(t, uint64(2), resetOutput.Epoch) | |||
| 139 | + | require.Zero(t, resetOutput.Base) | |||
| 140 | + | require.Equal(t, uint64(1), resetOutput.New) | |||
| 141 | + | require.True(t, resetOutput.Full) | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func TestPreparedOutputDropsReplacedConnectionAndView(t *testing.T) { | |||
| 145 | + | for _, tt := range []struct { | |||
| 146 | + | name string | |||
| 147 | + | change func(*attachedClient) | |||
| 148 | + | }{ | |||
| 149 | + | {name: "connection generation", change: func(ac *attachedClient) { ac.connectionGeneration.Add(1) }}, | |||
| 150 | + | {name: "view revision", change: func(ac *attachedClient) { | |||
| 151 | + | ac.viewMu.Lock() | |||
| 152 | + | ac.view.revision++ | |||
| 153 | + | ac.viewMu.Unlock() | |||
| 154 | + | }}, | |||
| 155 | + | } { | |||
| 156 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 157 | + | stream := newOutputStateStream() | |||
| 158 | + | ac := &attachedClient{output: stream, size: domain.Size{Cols: 3, Rows: 1}} | |||
| 159 | + | stream.attachment = ac | |||
| 160 | + | frame := renderer.NewFrame(3, 1) | |||
| 161 | + | fillOutputStateRows(frame, []string{"abc"}) | |||
| 162 | + | initial, err := stream.prepare(frame, nil, true) | |||
| 163 | + | require.NoError(t, err) | |||
| 164 | + | require.NoError(t, initial.send(initial.data, 0, func(ports.Frame) error { return nil })) | |||
| 165 | + | changed := frame.Clone() | |||
| 166 | + | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 167 | + | pending, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |||
| 168 | + | require.NoError(t, err) | |||
| 169 | + | tt.change(ac) | |||
| 170 | + | var sent bool | |||
| 171 | + | require.NoError(t, pending.send(pending.data, 0, func(ports.Frame) error { | |||
| 172 | + | sent = true | |||
| 173 | + | return nil | |||
| 174 | + | })) | |||
| 175 | + | require.False(t, sent) | |||
| 176 | + | require.Equal(t, uint64(1), stream.next) | |||
| 177 | + | ||||
| 178 | + | retry, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false) | |||
| 179 | + | require.NoError(t, err) | |||
| 180 | + | require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error { return nil })) | |||
| 181 | + | require.Equal(t, uint64(2), stream.next) | |||
| 182 | + | }) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func TestOutputStateAckRequiresCurrentEpoch(t *testing.T) { | |||
| 187 | + | stream := newOutputStateStream() | |||
| 188 | + | stream.next = 2 | |||
| 189 | + | stream.ack(2, 1) | |||
| 190 | + | require.Zero(t, stream.acked, "a stale epoch must not retire output state") | |||
| 191 | + | stream.ack(1, 1) | |||
| 192 | + | require.Equal(t, uint64(1), stream.acked) | |||
| 193 | + | stream.ack(1, 3) | |||
| 194 | + | require.Equal(t, uint64(1), stream.acked, "a future state must not advance the ACK window") | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) { | |||
| 198 | + | stream := newOutputStateStream() | |||
| 199 | + | stream.ack(1, 0) | |||
| 200 | + | beforeEpoch, beforeNext, beforeAck := stream.epoch, stream.next, stream.acked | |||
| 201 | + | frame, err := stream.sideEffect([]byte("pty"), 9) | |||
| 202 | + | require.NoError(t, err) | |||
| 203 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |||
| 204 | + | require.NoError(t, err) | |||
| 205 | + | require.Equal(t, beforeEpoch, out.Epoch) | |||
| 206 | + | require.Zero(t, out.Base) | |||
| 207 | + | require.Zero(t, out.New) | |||
| 208 | + | require.False(t, out.Full) | |||
| 209 | + | require.Equal(t, uint64(9), out.Echo) | |||
| 210 | + | require.Equal(t, beforeNext, stream.next) | |||
| 211 | + | require.Equal(t, beforeAck, stream.acked) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 77 | func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) { | 214 | func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) { | ||
| 78 | stream := newOutputStateStream() | 215 | stream := newOutputStateStream() | ||
| 79 | initial := renderer.NewFrame(3, 1) | 216 | initial := renderer.NewFrame(3, 1) | ||
| @@ -108,8 +245,8 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T | |||||
| 108 | })) | 245 | })) | ||
| 109 | out, err := ports.UnmarshalOutput(sent.Payload) | 246 | out, err := ports.UnmarshalOutput(sent.Payload) | ||
| 110 | require.NoError(t, err) | 247 | require.NoError(t, err) | ||
| 111 | - | require.Zero(t, out.BaseStateNum, "retry after ambiguous send failure must be dependency-free") | 248 | + | require.Zero(t, out.Base, "retry after ambiguous send failure must be dependency-free") |
| 112 | - | require.Equal(t, uint64(2), out.NewStateNum, "state numbers remain monotonic across rebases") | 249 | + | require.Equal(t, uint64(2), out.New, "state numbers remain monotonic across rebases") |
| 113 | require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once") | 250 | require.Equal(t, uint64(2), stream.next, "successful retry advances the chain exactly once") | ||
| 114 | require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error { | 251 | require.NoError(t, retry.send(retry.data, 0, func(ports.Frame) error { | ||
| 115 | t.Fatal("completed output sent twice") | 252 | t.Fatal("completed output sent twice") | ||
| @@ -150,7 +287,7 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T | |||||
| 150 | require.NoError(t, err) | 287 | require.NoError(t, err) | ||
| 151 | require.True(t, ok) | 288 | require.True(t, ok) | ||
| 152 | mustApplyOutput(t, client, first) | 289 | mustApplyOutput(t, client, first) | ||
| 153 | - | stream.ack(1) | 290 | + | stream.ack(1, 1) |
| 154 | 291 | ||||
| 155 | scrolled := renderer.NewFrame(4, 3) | 292 | scrolled := renderer.NewFrame(4, 3) | ||
| 156 | fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "}) | 293 | fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "}) | ||
| @@ -197,7 +334,11 @@ func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAc | |||||
| 197 | if reset { | 334 | if reset { | ||
| 198 | base = 0 | 335 | base = 0 | ||
| 199 | } | 336 | } | ||
| 200 | - | return frameOutputState(data, base, stream.next, echoAck) | 337 | + | frame, err := frameOutputState(data, base, stream.next, echoAck) |
| 338 | + | if err != nil { | |||
| 339 | + | panic(err) | |||
| 340 | + | } | |||
| 341 | + | return frame | |||
| 201 | } | 342 | } | ||
| 202 | 343 | ||||
| 203 | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | 344 | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | ||
| @@ -234,7 +375,7 @@ func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing | |||||
| 234 | require.True(t, ok) | 375 | require.True(t, ok) | ||
| 235 | resizeOutput, err := ports.UnmarshalOutput(resized.Payload) | 376 | resizeOutput, err := ports.UnmarshalOutput(resized.Payload) | ||
| 236 | require.NoError(t, err) | 377 | require.NoError(t, err) | ||
| 237 | - | require.Zero(t, resizeOutput.BaseStateNum, "resize must emit the one reset frame") | 378 | + | require.Zero(t, resizeOutput.Base, "resize must emit the one reset frame") |
| 238 | screen.ClearDamage() | 379 | screen.ClearDamage() | ||
| 239 | 380 | ||||
| 240 | _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0) | 381 | _, ok, err = drawOutputState(t, stream, screen.Frame, screen.Damage(), false, 0) | ||
| @@ -247,7 +388,7 @@ func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing | |||||
| 247 | require.True(t, ok) | 388 | require.True(t, ok) | ||
| 248 | damageOutput, err := ports.UnmarshalOutput(damaged.Payload) | 389 | damageOutput, err := ports.UnmarshalOutput(damaged.Payload) | ||
| 249 | require.NoError(t, err) | 390 | require.NoError(t, err) | ||
| 250 | - | require.Equal(t, resizeOutput.NewStateNum, damageOutput.BaseStateNum, "later damage must remain incremental") | 391 | + | require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental") |
| 251 | } | 392 | } | ||
| 252 | 393 | ||||
| 253 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | 394 | func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) { | ||
M internal/usecase/daemon/output_state_sync_test.go +22 -13
| @@ -81,26 +81,35 @@ func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) { | |||||
| 81 | require.NoError(t, err) | 81 | require.NoError(t, err) | ||
| 82 | require.Same(t, sess, routed) | 82 | require.Same(t, sess, routed) | ||
| 83 | 83 | ||||
| 84 | + | // route publishes membership only; mark the attachment ready before the | |||
| 85 | + | // direct pipeline paints so coordinator admission is deterministic. | |||
| 86 | + | rc := sess.renderCoordinator() | |||
| 87 | + | require.NotNil(t, rc) | |||
| 88 | + | require.True(t, rc.markAttachmentReady(rc.attachmentLease(ac))) | |||
| 89 | + | ||||
| 84 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | 90 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 85 | d.paint(sess, ac, false, nil) | 91 | d.paint(sess, ac, false, nil) | ||
| 86 | first := awaitFrame(t, tr.sends, ports.MsgOutput) | 92 | first := awaitFrame(t, tr.sends, ports.MsgOutput) | ||
| 87 | out, err := ports.UnmarshalOutput(first.Payload) | 93 | out, err := ports.UnmarshalOutput(first.Payload) | ||
| 88 | require.NoError(t, err) | 94 | require.NoError(t, err) | ||
| 89 | - | require.Equal(t, uint64(1), out.NewStateNum) | 95 | + | require.Equal(t, uint64(1), out.New) |
| 90 | 96 | ||||
| 91 | - | // Before the MsgAck, the renderer has already advanced along the ordered | 97 | + | sess.tabs[0].focusedPane().screen.Write([]byte("\rB")) |
| 92 | - | // output dependency chain, so an unchanged repaint is a no-op. | |||
| 93 | d.paint(sess, ac, false, nil) | 98 | d.paint(sess, ac, false, nil) | ||
| 94 | - | requireNoOutputFrame(t, tr.sends) | 99 | + | updated := awaitFrame(t, tr.sends, ports.MsgOutput) |
| 95 | - | 100 | + | out, err = ports.UnmarshalOutput(updated.Payload) | |
| 96 | - | tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})} | 101 | + | require.NoError(t, err) |
| 102 | + | require.Equal(t, uint64(2), out.New) | |||
| 103 | + | // Both states were emitted before the MsgAck; the ordered dependency chain | |||
| 104 | + | // must therefore remain valid without waiting for renderer acknowledgement. | |||
| 105 | + | tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: mustMarshalAck(ports.Ack{Epoch: out.Epoch, State: out.New})} | |||
| 97 | require.NoError(t, tr.Close()) | 106 | require.NoError(t, tr.Close()) | ||
| 98 | d.runConnLoop(ac) | 107 | d.runConnLoop(ac) | ||
| 99 | 108 | ||||
| 100 | // The production MsgAck path retires retained states without moving the | 109 | // The production MsgAck path retires retained states without moving the | ||
| 101 | // renderer baseline backward. | 110 | // renderer baseline backward. | ||
| 102 | d.paint(sess, ac, false, nil) | 111 | d.paint(sess, ac, false, nil) | ||
| 103 | - | requireNoOutputFrame(t, tr.sends) | 112 | + | require.Empty(t, tr.sends) |
| 104 | } | 113 | } | ||
| 105 | 114 | ||||
| 106 | func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) { | 115 | func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) { | ||
| @@ -146,7 +155,7 @@ func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||||
| 146 | client := vt.NewScreen(80, 25) | 155 | client := vt.NewScreen(80, 25) | ||
| 147 | d.paint(sess, ac, true, nil) | 156 | d.paint(sess, ac, true, nil) | ||
| 148 | first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | 157 | first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | ||
| 149 | - | ac.ackOutputState(first.NewStateNum) | 158 | + | ac.ackOutputState(first.Epoch, first.New) |
| 150 | 159 | ||||
| 151 | // Preserve the frame after one scroll while inducing a real VT scroll | 160 | // Preserve the frame after one scroll while inducing a real VT scroll | ||
| 152 | // damage event before each production paint. On main, both unacknowledged | 161 | // damage event before each production paint. On main, both unacknowledged | ||
| @@ -226,7 +235,7 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) { | |||||
| 226 | d.paint(sess, ac, false, nil) | 235 | d.paint(sess, ac, false, nil) | ||
| 227 | awaitFrame(t, sends, ports.MsgOutput) | 236 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 228 | 237 | ||||
| 229 | - | ac.ackOutputState(out.NewStateNum) | 238 | + | ac.ackOutputState(out.Epoch, out.New) |
| 230 | d.paint(sess, ac, false, nil) | 239 | d.paint(sess, ac, false, nil) | ||
| 231 | requireNoOutputFrame(t, sends) | 240 | requireNoOutputFrame(t, sends) | ||
| 232 | } | 241 | } | ||
| @@ -244,8 +253,8 @@ func TestRawTerminalSideEffectDoesNotEnterFullOutputWindow(t *testing.T) { | |||||
| 244 | require.Len(t, sends, 1) | 253 | require.Len(t, sends, 1) | ||
| 245 | out, err := ports.UnmarshalOutput(sends[0].Payload) | 254 | out, err := ports.UnmarshalOutput(sends[0].Payload) | ||
| 246 | require.NoError(t, err) | 255 | require.NoError(t, err) | ||
| 247 | - | require.Zero(t, out.BaseStateNum) | 256 | + | require.Zero(t, out.Base) |
| 248 | - | require.Zero(t, out.NewStateNum) | 257 | + | require.Zero(t, out.New) |
| 249 | } | 258 | } | ||
| 250 | 259 | ||||
| 251 | func TestOwnedSynchronousSideEffectSkipsOuterWatchdog(t *testing.T) { | 260 | func TestOwnedSynchronousSideEffectSkipsOuterWatchdog(t *testing.T) { | ||
| @@ -295,7 +304,7 @@ func TestResizeGrowthFirstFrameIncludesConcurrentPTYRedraw(t *testing.T) { | |||||
| 295 | d.paint(sess, ac, true, nil) | 304 | d.paint(sess, ac, true, nil) | ||
| 296 | client := vt.NewScreen(80, 24) | 305 | client := vt.NewScreen(80, 24) | ||
| 297 | initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 306 | initial := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 298 | - | ac.ackOutputState(initial.NewStateNum) | 307 | + | ac.ackOutputState(initial.Epoch, initial.New) |
| 299 | 308 | ||||
| 300 | d.sessWg.Add(1) | 309 | d.sessWg.Add(1) | ||
| 301 | pane.onExit = func() {} | 310 | pane.onExit = func() {} | ||
| @@ -349,7 +358,7 @@ func TestResizeWithoutPTYOutputFlushesOneFullFrameAtDeadline(t *testing.T) { | |||||
| 349 | frame := awaitFrame(t, sends, ports.MsgOutput) | 358 | frame := awaitFrame(t, sends, ports.MsgOutput) | ||
| 350 | client := vt.NewScreen(120, 24) | 359 | client := vt.NewScreen(120, 24) | ||
| 351 | out := mustApplyOutput(t, client, frame) | 360 | out := mustApplyOutput(t, client, frame) | ||
| 352 | - | require.Zero(t, out.BaseStateNum) | 361 | + | require.Zero(t, out.Base) |
| 353 | require.Contains(t, screenLineText(client, 0), "1") | 362 | require.Contains(t, screenLineText(client, 0), "1") | ||
| 354 | require.Contains(t, screenLineText(client, 23), "work") | 363 | require.Contains(t, screenLineText(client, 23), "work") | ||
| 355 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size) | 364 | require.Equal(t, domain.Size{Cols: 120, Rows: 24}, ac.size) | ||
M internal/usecase/daemon/palette_test.go +2 -2
| @@ -373,7 +373,7 @@ func TestPaletteSelectedStoppedSessionResumesAndSwitches(t *testing.T) { | |||||
| 373 | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | 373 | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | ||
| 374 | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | 374 | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | ||
| 375 | require.NoError(t, err) | 375 | require.NoError(t, err) | ||
| 376 | - | require.Zero(t, firstOutput.BaseStateNum, "stopped-session first paint must reset moving output state") | 376 | + | require.Zero(t, firstOutput.Base, "stopped-session first paint must reset moving output state") |
| 377 | awaitFrame(t, sends, ports.MsgOutput) | 377 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 378 | } | 378 | } | ||
| 379 | 379 | ||||
| @@ -679,7 +679,7 @@ func TestPaletteCNSPromptsForSessionNameThenCreatesAndSwitches(t *testing.T) { | |||||
| 679 | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | 679 | firstPaint := awaitFrame(t, sends, ports.MsgOutput) | ||
| 680 | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | 680 | firstOutput, err := ports.UnmarshalOutput(firstPaint.Payload) | ||
| 681 | require.NoError(t, err) | 681 | require.NoError(t, err) | ||
| 682 | - | require.Zero(t, firstOutput.BaseStateNum, "new-session first paint must reset moving output state") | 682 | + | require.Zero(t, firstOutput.Base, "new-session first paint must reset moving output state") |
| 683 | finalRepaint := awaitFrame(t, sends, ports.MsgOutput) | 683 | finalRepaint := awaitFrame(t, sends, ports.MsgOutput) | ||
| 684 | finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload) | 684 | finalOutput, err := ports.UnmarshalOutput(finalRepaint.Payload) | ||
| 685 | require.NoError(t, err) | 685 | require.NoError(t, err) | ||
M internal/usecase/daemon/performance_benchmark_test.go +28 -299
| @@ -218,9 +218,9 @@ func TestProxyANSIBenchmarkFixtures(t *testing.T) { | |||||
| 218 | state := newProxyANSIApplyState(t) | 218 | state := newProxyANSIApplyState(t) | ||
| 219 | 219 | ||||
| 220 | ack, reset, changed := state.apply(ports.Output{ | 220 | ack, reset, changed := state.apply(ports.Output{ | ||
| 221 | - | BaseStateNum: 0, | 221 | + | Base: 0, |
| 222 | - | NewStateNum: 1, | 222 | + | New: 1, |
| 223 | - | Data: fixture.data, | 223 | + | Data: fixture.data, |
| 224 | }) | 224 | }) | ||
| 225 | 225 | ||||
| 226 | require.Equal(t, uint64(1), ack) | 226 | require.Equal(t, uint64(1), ack) | ||
| @@ -251,21 +251,21 @@ func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset, | |||||
| 251 | data := append([]byte(nil), out.Data...) | 251 | data := append([]byte(nil), out.Data...) | ||
| 252 | s.mu.Lock() | 252 | s.mu.Lock() | ||
| 253 | defer s.mu.Unlock() | 253 | defer s.mu.Unlock() | ||
| 254 | - | if out.NewStateNum == 0 { | 254 | + | if out.New == 0 { |
| 255 | s.screen.Write(data) | 255 | s.screen.Write(data) | ||
| 256 | return 0, false, len(data) != 0 | 256 | return 0, false, len(data) != 0 | ||
| 257 | } | 257 | } | ||
| 258 | - | if out.BaseStateNum == 0 { | 258 | + | if out.Base == 0 { |
| 259 | - | if !s.awaitingReset && out.NewStateNum <= s.appliedState { | 259 | + | if !s.awaitingReset && out.New <= s.appliedState { |
| 260 | return 0, false, false | 260 | return 0, false, false | ||
| 261 | } | 261 | } | ||
| 262 | s.screen = vt.NewScreen(s.screen.Frame.Width, s.screen.Frame.Height) | 262 | s.screen = vt.NewScreen(s.screen.Frame.Width, s.screen.Frame.Height) | ||
| 263 | s.screen.Write(data) | 263 | s.screen.Write(data) | ||
| 264 | - | s.appliedState = out.NewStateNum | 264 | + | s.appliedState = out.New |
| 265 | s.awaitingReset = false | 265 | s.awaitingReset = false | ||
| 266 | - | return out.NewStateNum, false, true | 266 | + | return out.New, false, true |
| 267 | } | 267 | } | ||
| 268 | - | if s.awaitingReset || out.BaseStateNum != s.appliedState || out.NewStateNum != out.BaseStateNum+1 { | 268 | + | if s.awaitingReset || out.Base != s.appliedState || out.New != out.Base+1 { |
| 269 | if !s.awaitingReset { | 269 | if !s.awaitingReset { | ||
| 270 | s.awaitingReset = true | 270 | s.awaitingReset = true | ||
| 271 | requestReset = true | 271 | requestReset = true | ||
| @@ -273,8 +273,8 @@ func (s *proxyANSIApplyState) apply(out ports.Output) (ack uint64, requestReset, | |||||
| 273 | return 0, requestReset, false | 273 | return 0, requestReset, false | ||
| 274 | } | 274 | } | ||
| 275 | s.screen.Write(data) | 275 | s.screen.Write(data) | ||
| 276 | - | s.appliedState = out.NewStateNum | 276 | + | s.appliedState = out.New |
| 277 | - | return out.NewStateNum, false, len(data) != 0 | 277 | + | return out.New, false, len(data) != 0 |
| 278 | } | 278 | } | ||
| 279 | 279 | ||||
| 280 | type proxyANSIBenchmarkFixture struct { | 280 | type proxyANSIBenchmarkFixture struct { | ||
| @@ -306,277 +306,6 @@ func proxyANSIBenchmarkFixtures() []proxyANSIBenchmarkFixture { | |||||
| 306 | } | 306 | } | ||
| 307 | } | 307 | } | ||
| 308 | 308 | ||||
| 309 | - | type proxyPipelineFixture struct { | |||
| 310 | - | name string | |||
| 311 | - | newFrame func() renderer.Frame | |||
| 312 | - | damage []renderer.Damage | |||
| 313 | - | mutate func(renderer.Frame, int) | |||
| 314 | - | } | |||
| 315 | - | ||||
| 316 | - | func proxyPipelineFixtures() []proxyPipelineFixture { | |||
| 317 | - | const width, height = 120, 40 | |||
| 318 | - | return []proxyPipelineFixture{ | |||
| 319 | - | { | |||
| 320 | - | name: "one-cell", | |||
| 321 | - | newFrame: func() renderer.Frame { return renderer.NewFrame(width, height) }, | |||
| 322 | - | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 59, Y: 19, Width: 1, Height: 1}}, | |||
| 323 | - | mutate: func(frame renderer.Frame, i int) { | |||
| 324 | - | cell := frame.At(59, 19) | |||
| 325 | - | cell.Rune = rune('a' + i%26) | |||
| 326 | - | frame.Set(59, 19, cell) | |||
| 327 | - | }, | |||
| 328 | - | }, | |||
| 329 | - | { | |||
| 330 | - | name: "full-line", | |||
| 331 | - | newFrame: func() renderer.Frame { return renderer.NewFrame(width, height) }, | |||
| 332 | - | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}}, | |||
| 333 | - | mutate: func(frame renderer.Frame, i int) { | |||
| 334 | - | for x := range frame.Width { | |||
| 335 | - | frame.Set(x, 0, renderer.Cell{Rune: rune('a' + (x+i)%26), Style: renderer.DefaultStyle()}) | |||
| 336 | - | } | |||
| 337 | - | }, | |||
| 338 | - | }, | |||
| 339 | - | { | |||
| 340 | - | name: "styled-line", | |||
| 341 | - | newFrame: func() renderer.Frame { return renderer.NewFrame(width, height) }, | |||
| 342 | - | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}}, | |||
| 343 | - | mutate: func(frame renderer.Frame, i int) { | |||
| 344 | - | for fragment := range 12 { | |||
| 345 | - | style := renderer.DefaultStyle() | |||
| 346 | - | style.Bold = fragment%2 == 0 | |||
| 347 | - | style.HasForegroundRGB = true | |||
| 348 | - | style.ForegroundRGB = renderer.RGB{R: uint8(fragment * 17), G: uint8(fragment * 13), B: uint8(fragment * 7)} | |||
| 349 | - | for x := fragment * 10; x < (fragment+1)*10; x++ { | |||
| 350 | - | frame.Set(x, 0, renderer.Cell{Rune: rune('a' + (fragment+i)%26), Style: style}) | |||
| 351 | - | } | |||
| 352 | - | } | |||
| 353 | - | }, | |||
| 354 | - | }, | |||
| 355 | - | { | |||
| 356 | - | name: "full-width-scroll", | |||
| 357 | - | newFrame: func() renderer.Frame { | |||
| 358 | - | frame := renderer.NewFrame(width, height) | |||
| 359 | - | for y := range height { | |||
| 360 | - | for x, r := range performanceFullWidthRow(width, 0, y) { | |||
| 361 | - | frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 362 | - | } | |||
| 363 | - | } | |||
| 364 | - | return frame | |||
| 365 | - | }, | |||
| 366 | - | damage: []renderer.Damage{ | |||
| 367 | - | {Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: width, Height: height, Count: 1}, | |||
| 368 | - | {Kind: renderer.DamageText, X: 0, Y: height - 1, Width: width, Height: 1}, | |||
| 369 | - | }, | |||
| 370 | - | mutate: func(frame renderer.Frame, i int) { | |||
| 371 | - | frame.ScrollUp(0, frame.Height-1, 1) | |||
| 372 | - | for x := range frame.Width { | |||
| 373 | - | frame.Set(x, frame.Height-1, renderer.Cell{Rune: rune('a' + i%26), Style: renderer.DefaultStyle()}) | |||
| 374 | - | } | |||
| 375 | - | }, | |||
| 376 | - | }, | |||
| 377 | - | } | |||
| 378 | - | } | |||
| 379 | - | ||||
| 380 | - | func TestProxyPipelineFixturesProduceEquivalentFrames(t *testing.T) { | |||
| 381 | - | for _, fixture := range proxyPipelineFixtures() { | |||
| 382 | - | t.Run(fixture.name, func(t *testing.T) { | |||
| 383 | - | ansiFrame := fixture.newFrame() | |||
| 384 | - | structuredFrame := ansiFrame.Clone() | |||
| 385 | - | ansiRenderer := renderer.New(renderer.Capabilities{}) | |||
| 386 | - | ansiState := newProxyANSIApplyState(t) | |||
| 387 | - | ansiSender := proxyPipelineANSISender(ansiState) | |||
| 388 | - | initialANSI, err := ansiRenderer.Prepare(ansiFrame, nil, true) | |||
| 389 | - | require.NoError(t, err) | |||
| 390 | - | require.NoError(t, ansiSender(ports.Frame{ | |||
| 391 | - | Type: ports.MsgOutput, | |||
| 392 | - | Payload: ports.MarshalOutput(ports.Output{ | |||
| 393 | - | BaseStateNum: 0, | |||
| 394 | - | NewStateNum: 1, | |||
| 395 | - | Data: initialANSI.Bytes(), | |||
| 396 | - | }), | |||
| 397 | - | })) | |||
| 398 | - | initialANSI.Commit() | |||
| 399 | - | ||||
| 400 | - | structuredState := newProxyScreenState(frameSize(structuredFrame)) | |||
| 401 | - | structuredStream := newStructuredOutputStream(newOutputStateStream()) | |||
| 402 | - | initialStructured, err := structuredStream.prepare(structuredFrame, nil, cursorOut{}, true, 0) | |||
| 403 | - | require.NoError(t, err) | |||
| 404 | - | require.NoError(t, initialStructured.send(proxyPipelineStructuredSender(structuredState))) | |||
| 405 | - | require.Equal(t, uint64(1), ansiState.appliedState) | |||
| 406 | - | require.Equal(t, uint64(1), structuredState.stateNum) | |||
| 407 | - | requireProxyPipelineFramesEqual(t, ansiState.screen.Frame, structuredState.frame) | |||
| 408 | - | ||||
| 409 | - | for i := 0; i < 3; i++ { | |||
| 410 | - | fixture.mutate(ansiFrame, i) | |||
| 411 | - | fixture.mutate(structuredFrame, i) | |||
| 412 | - | ansiDraw, err := ansiRenderer.Prepare(ansiFrame, fixture.damage, false) | |||
| 413 | - | require.NoError(t, err) | |||
| 414 | - | next := ansiState.appliedState + 1 | |||
| 415 | - | require.NoError(t, ansiSender(ports.Frame{ | |||
| 416 | - | Type: ports.MsgOutput, | |||
| 417 | - | Payload: ports.MarshalOutput(ports.Output{ | |||
| 418 | - | BaseStateNum: ansiState.appliedState, | |||
| 419 | - | NewStateNum: next, | |||
| 420 | - | Data: ansiDraw.Bytes(), | |||
| 421 | - | }), | |||
| 422 | - | })) | |||
| 423 | - | ansiDraw.Commit() | |||
| 424 | - | ||||
| 425 | - | structuredDraw, err := structuredStream.prepare(structuredFrame, fixture.damage, cursorOut{}, false, 0) | |||
| 426 | - | require.NoError(t, err) | |||
| 427 | - | require.NoError(t, structuredDraw.send(proxyPipelineStructuredSender(structuredState))) | |||
| 428 | - | require.Equal(t, next, ansiState.appliedState) | |||
| 429 | - | require.Equal(t, next, structuredState.stateNum) | |||
| 430 | - | requireProxyPipelineFramesEqual(t, ansiState.screen.Frame, structuredState.frame) | |||
| 431 | - | requireProxyPipelineFramesEqual(t, ansiFrame, ansiState.screen.Frame) | |||
| 432 | - | requireProxyPipelineFramesEqual(t, structuredFrame, structuredState.frame) | |||
| 433 | - | } | |||
| 434 | - | }) | |||
| 435 | - | } | |||
| 436 | - | } | |||
| 437 | - | ||||
| 438 | - | func requireProxyPipelineFramesEqual(t *testing.T, want, got renderer.Frame) { | |||
| 439 | - | t.Helper() | |||
| 440 | - | require.Equal(t, want.Width, got.Width) | |||
| 441 | - | require.Equal(t, want.Height, got.Height) | |||
| 442 | - | for y := range want.Height { | |||
| 443 | - | for x := range want.Width { | |||
| 444 | - | require.Truef(t, want.At(x, y).Equal(got.At(x, y)), "cell %d,%d differs: want %+v got %+v", x, y, want.At(x, y), got.At(x, y)) | |||
| 445 | - | } | |||
| 446 | - | } | |||
| 447 | - | } | |||
| 448 | - | ||||
| 449 | - | func proxyPipelineANSISender(state *proxyANSIApplyState) func(ports.Frame) error { | |||
| 450 | - | return func(frame ports.Frame) error { | |||
| 451 | - | if frame.Type != ports.MsgOutput { | |||
| 452 | - | return fmt.Errorf("ANSI benchmark sender received frame type %d", frame.Type) | |||
| 453 | - | } | |||
| 454 | - | out, err := ports.UnmarshalOutput(frame.Payload) | |||
| 455 | - | if err != nil { | |||
| 456 | - | return err | |||
| 457 | - | } | |||
| 458 | - | ack, reset, changed := state.apply(out) | |||
| 459 | - | if ack != out.NewStateNum || reset || !changed { | |||
| 460 | - | return fmt.Errorf("ANSI benchmark apply = ack %d, reset %t, changed %t; want ack %d", ack, reset, changed, out.NewStateNum) | |||
| 461 | - | } | |||
| 462 | - | return nil | |||
| 463 | - | } | |||
| 464 | - | } | |||
| 465 | - | ||||
| 466 | - | func proxyPipelineStructuredSender(state *proxyScreenState) func(ports.Frame) error { | |||
| 467 | - | return func(frame ports.Frame) error { | |||
| 468 | - | if frame.Type != ports.MsgScreenUpdate { | |||
| 469 | - | return fmt.Errorf("structured benchmark sender received frame type %d", frame.Type) | |||
| 470 | - | } | |||
| 471 | - | update, err := ports.UnmarshalScreenUpdate(frame.Payload) | |||
| 472 | - | if err != nil { | |||
| 473 | - | return err | |||
| 474 | - | } | |||
| 475 | - | if err := state.Apply(update); err != nil { | |||
| 476 | - | return err | |||
| 477 | - | } | |||
| 478 | - | generation := state.generation | |||
| 479 | - | if !state.AcknowledgeDamage(generation) { | |||
| 480 | - | return fmt.Errorf("structured benchmark damage acknowledgement failed at generation %d", generation) | |||
| 481 | - | } | |||
| 482 | - | return nil | |||
| 483 | - | } | |||
| 484 | - | } | |||
| 485 | - | ||||
| 486 | - | func BenchmarkProxyPipeline(b *testing.B) { | |||
| 487 | - | for _, fixture := range proxyPipelineFixtures() { | |||
| 488 | - | b.Run(fixture.name, func(b *testing.B) { | |||
| 489 | - | b.Run("ansi-pipeline", func(b *testing.B) { | |||
| 490 | - | benchmarkProxyANSIPipeline(b, fixture) | |||
| 491 | - | }) | |||
| 492 | - | b.Run("structured-pipeline", func(b *testing.B) { | |||
| 493 | - | benchmarkProxyStructuredPipeline(b, fixture) | |||
| 494 | - | }) | |||
| 495 | - | }) | |||
| 496 | - | } | |||
| 497 | - | } | |||
| 498 | - | ||||
| 499 | - | func benchmarkProxyANSIPipeline(b *testing.B, fixture proxyPipelineFixture) { | |||
| 500 | - | b.Helper() | |||
| 501 | - | frame := fixture.newFrame() | |||
| 502 | - | ansiRenderer := renderer.New(renderer.Capabilities{}) | |||
| 503 | - | ansiState := newProxyANSIApplyState(b) | |||
| 504 | - | ansiSender := proxyPipelineANSISender(ansiState) | |||
| 505 | - | initial, err := ansiRenderer.Prepare(frame, nil, true) | |||
| 506 | - | if err != nil { | |||
| 507 | - | b.Fatal(err) | |||
| 508 | - | } | |||
| 509 | - | if err := ansiSender(ports.Frame{ | |||
| 510 | - | Type: ports.MsgOutput, | |||
| 511 | - | Payload: ports.MarshalOutput(ports.Output{ | |||
| 512 | - | BaseStateNum: 0, | |||
| 513 | - | NewStateNum: 1, | |||
| 514 | - | Data: initial.Bytes(), | |||
| 515 | - | }), | |||
| 516 | - | }); err != nil { | |||
| 517 | - | b.Fatal(err) | |||
| 518 | - | } | |||
| 519 | - | initial.Commit() | |||
| 520 | - | ||||
| 521 | - | var wireBytes int | |||
| 522 | - | b.ReportAllocs() | |||
| 523 | - | b.ResetTimer() | |||
| 524 | - | for i := 0; i < b.N; i++ { | |||
| 525 | - | fixture.mutate(frame, i) | |||
| 526 | - | draw, err := ansiRenderer.Prepare(frame, fixture.damage, false) | |||
| 527 | - | if err != nil { | |||
| 528 | - | b.Fatal(err) | |||
| 529 | - | } | |||
| 530 | - | next := ansiState.appliedState + 1 | |||
| 531 | - | payload := ports.MarshalOutput(ports.Output{ | |||
| 532 | - | BaseStateNum: ansiState.appliedState, | |||
| 533 | - | NewStateNum: next, | |||
| 534 | - | Data: draw.Bytes(), | |||
| 535 | - | }) | |||
| 536 | - | if err := ansiSender(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil { | |||
| 537 | - | b.Fatal(err) | |||
| 538 | - | } | |||
| 539 | - | draw.Commit() | |||
| 540 | - | wireBytes += len(payload) | |||
| 541 | - | } | |||
| 542 | - | b.StopTimer() | |||
| 543 | - | b.ReportMetric(float64(wireBytes)/float64(b.N), "wirebytes/op") | |||
| 544 | - | } | |||
| 545 | - | ||||
| 546 | - | func benchmarkProxyStructuredPipeline(b *testing.B, fixture proxyPipelineFixture) { | |||
| 547 | - | b.Helper() | |||
| 548 | - | frame := fixture.newFrame() | |||
| 549 | - | stream := newStructuredOutputStream(newOutputStateStream()) | |||
| 550 | - | state := newProxyScreenState(frameSize(frame)) | |||
| 551 | - | sender := proxyPipelineStructuredSender(state) | |||
| 552 | - | initial, err := stream.prepare(frame, nil, cursorOut{}, true, 0) | |||
| 553 | - | if err != nil { | |||
| 554 | - | b.Fatal(err) | |||
| 555 | - | } | |||
| 556 | - | if err := initial.send(sender); err != nil { | |||
| 557 | - | b.Fatal(err) | |||
| 558 | - | } | |||
| 559 | - | ||||
| 560 | - | var wireBytes, spans int | |||
| 561 | - | b.ReportAllocs() | |||
| 562 | - | b.ResetTimer() | |||
| 563 | - | for i := 0; i < b.N; i++ { | |||
| 564 | - | fixture.mutate(frame, i) | |||
| 565 | - | prepared, err := stream.prepare(frame, fixture.damage, cursorOut{}, false, 0) | |||
| 566 | - | if err != nil { | |||
| 567 | - | b.Fatal(err) | |||
| 568 | - | } | |||
| 569 | - | wireBytes += len(prepared.data) | |||
| 570 | - | spans += len(prepared.update.Spans) | |||
| 571 | - | if err := prepared.send(sender); err != nil { | |||
| 572 | - | b.Fatal(err) | |||
| 573 | - | } | |||
| 574 | - | } | |||
| 575 | - | b.StopTimer() | |||
| 576 | - | b.ReportMetric(float64(wireBytes)/float64(b.N), "wirebytes/op") | |||
| 577 | - | b.ReportMetric(float64(spans)/float64(b.N), "spans/op") | |||
| 578 | - | } | |||
| 579 | - | ||||
| 580 | func TestPerformanceFixtureCounters(t *testing.T) { | 309 | func TestPerformanceFixtureCounters(t *testing.T) { | ||
| 581 | fixture := newPerformanceFixture(t, performanceConfig{}) | 310 | fixture := newPerformanceFixture(t, performanceConfig{}) | ||
| 582 | 311 | ||||
| @@ -751,9 +480,9 @@ func BenchmarkProxyANSIApply(b *testing.B) { | |||||
| 751 | for b.Loop() { | 480 | for b.Loop() { | ||
| 752 | next := state.appliedState + 1 | 481 | next := state.appliedState + 1 | ||
| 753 | ack, reset, changed := state.apply(ports.Output{ | 482 | ack, reset, changed := state.apply(ports.Output{ | ||
| 754 | - | BaseStateNum: state.appliedState, | 483 | + | Base: state.appliedState, |
| 755 | - | NewStateNum: next, | 484 | + | New: next, |
| 756 | - | Data: fixture.data, | 485 | + | Data: fixture.data, |
| 757 | }) | 486 | }) | ||
| 758 | if ack != next || reset || !changed { | 487 | if ack != next || reset || !changed { | ||
| 759 | b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next) | 488 | b.Fatalf("apply output = ack %d, reset %t, changed %t; want ack %d, reset false, changed true", ack, reset, changed, next) | ||
| @@ -846,7 +575,7 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||||
| 846 | fixture.resetMetrics() | 575 | fixture.resetMetrics() | ||
| 847 | for b.Loop() { | 576 | for b.Loop() { | ||
| 848 | run(fixture) | 577 | run(fixture) | ||
| 849 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | 578 | + | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) |
| 850 | } | 579 | } | ||
| 851 | metrics := fixture.metrics() | 580 | metrics := fixture.metrics() | ||
| 852 | if payload := fixture.output.lastPayload(); payload != nil { | 581 | if payload := fixture.output.lastPayload(); payload != nil { | ||
| @@ -865,12 +594,12 @@ func benchmarkDaemonLargeHistory(b *testing.B, workload string, run func(*perfor | |||||
| 865 | func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) { | 594 | func benchmarkDaemonCopySearch(b *testing.B, fixture *performanceFixture, run func(*performanceFixture)) { | ||
| 866 | b.Helper() | 595 | b.Helper() | ||
| 867 | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | 596 | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | ||
| 868 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | 597 | + | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) |
| 869 | b.ReportAllocs() | 598 | b.ReportAllocs() | ||
| 870 | b.ResetTimer() | 599 | b.ResetTimer() | ||
| 871 | for range b.N { | 600 | for range b.N { | ||
| 872 | run(fixture) | 601 | run(fixture) | ||
| 873 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | 602 | + | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) |
| 874 | } | 603 | } | ||
| 875 | b.StopTimer() | 604 | b.StopTimer() | ||
| 876 | if fixture.searchMatches() == 0 { | 605 | if fixture.searchMatches() == 0 { | ||
| @@ -1240,32 +969,32 @@ func TestLivePaintAllocationBudget(t *testing.T) { | |||||
| 1240 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000}) | 969 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, panes: tt.panes, historyRows: 10_000}) | ||
| 1241 | allocs := testing.AllocsPerRun(20, func() { | 970 | allocs := testing.AllocsPerRun(20, func() { | ||
| 1242 | fixture.paintLive() | 971 | fixture.paintLive() | ||
| 1243 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | 972 | + | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) |
| 1244 | }) | 973 | }) | ||
| 1245 | require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints") | 974 | require.LessOrEqual(t, allocs, float64(38), "live paint must reuse attachment-owned render scratch across warm paints") | ||
| 1246 | }) | 975 | }) | ||
| 1247 | } | 976 | } | ||
| 1248 | } | 977 | } | ||
| 1249 | 978 | ||||
| 1250 | - | // TestCopyEnterAllocationBudget protects the attachment-aware baseline plus | 979 | + | // TestCopyEnterAllocationBudget protects the byte-stream attachment baseline |
| 1251 | - | // 10%. Copy rendering borrows sealed VT history rows; allocating a copy per | 980 | + | // plus 10%. Copy rendering borrows sealed VT history rows; allocating a copy |
| 1252 | - | // viewport row is a production regression even though capture is immutable. | 981 | + | // per viewport row is a production regression even though capture is immutable. |
| 1253 | func TestCopyEnterAllocationBudget(t *testing.T) { | 982 | func TestCopyEnterAllocationBudget(t *testing.T) { | ||
| 1254 | for _, tt := range []struct { | 983 | for _, tt := range []struct { | ||
| 1255 | name string | 984 | name string | ||
| 1256 | tabs, panes, max int | 985 | tabs, panes, max int | ||
| 1257 | }{ | 986 | }{ | ||
| 1258 | - | {name: "1tab-1pane", tabs: 1, panes: 1, max: 55}, | 987 | + | {name: "1tab-1pane", tabs: 1, panes: 1, max: 66}, |
| 1259 | - | {name: "1tab-4panes", tabs: 1, panes: 4, max: 55}, | 988 | + | {name: "1tab-4panes", tabs: 1, panes: 4, max: 85}, |
| 1260 | - | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 60}, | 989 | + | {name: "4tabs-1pane", tabs: 4, panes: 1, max: 71}, |
| 1261 | - | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 60}, | 990 | + | {name: "4tabs-4panes", tabs: 4, panes: 4, max: 90}, |
| 1262 | - | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 68}, | 991 | + | {name: "8tabs-1pane", tabs: 8, panes: 1, max: 79}, |
| 1263 | } { | 992 | } { | ||
| 1264 | t.Run(tt.name, func(t *testing.T) { | 993 | t.Run(tt.name, func(t *testing.T) { | ||
| 1265 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | 994 | fixture := newPerformanceFixture(t, performanceConfig{size: domain.Size{Cols: 120, Rows: 40}, tabs: tt.tabs, panes: tt.panes, historyRows: 10_000}) | ||
| 1266 | run := func() { | 995 | run := func() { | ||
| 1267 | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | 996 | fixture.d.enterCopyMode(fixture.sess, fixture.ac) | ||
| 1268 | - | fixture.ac.ackOutputState(fixture.ac.output.next) | 997 | + | fixture.ac.ackOutputState(fixture.ac.output.currentEpoch(), fixture.ac.output.next) |
| 1269 | } | 998 | } | ||
| 1270 | 999 | ||||
| 1271 | // Always exercise and validate copy entry. The race detector adds | 1000 | // Always exercise and validate copy entry. The race detector adds | ||
| @@ -1278,7 +1007,7 @@ func TestCopyEnterAllocationBudget(t *testing.T) { | |||||
| 1278 | } | 1007 | } | ||
| 1279 | 1008 | ||||
| 1280 | allocs := testing.AllocsPerRun(20, run) | 1009 | allocs := testing.AllocsPerRun(20, run) | ||
| 1281 | - | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the attachment-aware allocation baseline") | 1010 | + | require.LessOrEqual(t, allocs, float64(tt.max), "copy enter must stay within 10% of the byte-stream attachment allocation baseline") |
| 1282 | }) | 1011 | }) | ||
| 1283 | } | 1012 | } | ||
| 1284 | } | 1013 | } | ||
M internal/usecase/daemon/picker_test.go +2 -2
| @@ -915,7 +915,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||||
| 915 | client := vt.NewScreen(80, 25) | 915 | client := vt.NewScreen(80, 25) | ||
| 916 | d.firstPaint(source, ac, ac.size) | 916 | d.firstPaint(source, ac, ac.size) | ||
| 917 | sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | 917 | sourceOutput := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | ||
| 918 | - | ac.ackOutputState(sourceOutput.NewStateNum) | 918 | + | ac.ackOutputState(sourceOutput.Epoch, sourceOutput.New) |
| 919 | 919 | ||||
| 920 | targetPane.screen.Write([]byte("TARGET")) | 920 | targetPane.screen.Write([]byte("TARGET")) | ||
| 921 | targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage. | 921 | targetPane.screen.ClearDamage() // TARGET is already rendered and has no pending VT damage. | ||
| @@ -927,7 +927,7 @@ func TestBackSessionFirstResetDoesNotReuseSamePaneIDCapture(t *testing.T) { | |||||
| 927 | d.backSession(source, ac) | 927 | d.backSession(source, ac) | ||
| 928 | firstReset := awaitFrame(t, sends, ports.MsgOutput) | 928 | firstReset := awaitFrame(t, sends, ports.MsgOutput) | ||
| 929 | out := mustApplyOutput(t, client, firstReset) | 929 | out := mustApplyOutput(t, client, firstReset) | ||
| 930 | - | require.Zero(t, out.BaseStateNum, "the first target frame must be the reset, not an eventual repair") | 930 | + | require.Zero(t, out.Base, "the first target frame must be the reset, not an eventual repair") |
| 931 | frame := strings.Join(frameRows(client.Frame), "\n") | 931 | frame := strings.Join(frameRows(client.Frame), "\n") | ||
| 932 | require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture") | 932 | require.NotContains(t, frame, "SOURCE", "first target reset must not reuse source capture") | ||
| 933 | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | 933 | require.Contains(t, frame, "TARGET", "first target reset must immediately show clean target VT state") | ||
D internal/usecase/daemon/proxied_contract_test.go +0 -353
| @@ -1,353 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "sync" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/internal/domain" | |||
| 8 | - | "github.com/bnema/vev/internal/ports" | |||
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 11 | - | "github.com/bnema/vev/pkg/vt" | |||
| 12 | - | "github.com/stretchr/testify/require" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | func TestProxiedHandshakeOrdersWelcomeMetadataAndBaseZeroContent(t *testing.T) { | |||
| 16 | - | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |||
| 17 | - | contentSize := domain.Size{Cols: 24, Rows: 4} | |||
| 18 | - | hello := ports.Hello{ | |||
| 19 | - | Version: ports.ProtocolVersion, | |||
| 20 | - | Intent: ports.IntentNew, | |||
| 21 | - | Proxied: true, | |||
| 22 | - | Name: "remote-work", | |||
| 23 | - | Size: contentSize, | |||
| 24 | - | MaxOutputInFlight: 4, | |||
| 25 | - | } | |||
| 26 | - | tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |||
| 27 | - | defer release() | |||
| 28 | - | ||||
| 29 | - | var handlers sync.WaitGroup | |||
| 30 | - | handlers.Go(func() { d.handleConn(tr) }) | |||
| 31 | - | ||||
| 32 | - | welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome) | |||
| 33 | - | welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload) | |||
| 34 | - | require.NoError(t, err) | |||
| 35 | - | require.Equal(t, ports.CapabilityResume|ports.CapabilityProxied, welcome.Capabilities) | |||
| 36 | - | ||||
| 37 | - | metaFrame := awaitFrame(t, sends, ports.MsgSessionMeta) | |||
| 38 | - | meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload) | |||
| 39 | - | require.NoError(t, err) | |||
| 40 | - | require.Equal(t, "remote-work", meta.SessionName) | |||
| 41 | - | require.Equal(t, uint16(0), meta.Active) | |||
| 42 | - | require.Equal(t, []ports.SessionTabMeta{{Index: 0}}, meta.Tabs) | |||
| 43 | - | ||||
| 44 | - | screenFrame := awaitFrame(t, sends, ports.MsgScreenUpdate) | |||
| 45 | - | update, err := ports.UnmarshalScreenUpdate(screenFrame.Payload) | |||
| 46 | - | require.NoError(t, err) | |||
| 47 | - | require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind) | |||
| 48 | - | require.Zero(t, update.BaseStateNum) | |||
| 49 | - | require.Equal(t, contentSize, update.Size) | |||
| 50 | - | require.Len(t, update.Spans, contentSize.Rows, "structured snapshots must carry every full row") | |||
| 51 | - | for y, span := range update.Spans { | |||
| 52 | - | require.Equal(t, uint16(y), span.Y) | |||
| 53 | - | require.Zero(t, span.X) | |||
| 54 | - | require.Len(t, span.Cells, contentSize.Cols) | |||
| 55 | - | require.NotContains(t, rowText(span.Cells), "remote-work", "proxied screen must not contain remote session chrome") | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | sess := firstSession(d) | |||
| 59 | - | require.NotNil(t, sess) | |||
| 60 | - | tb := sess.tabs[0] | |||
| 61 | - | tb.mu.Lock() | |||
| 62 | - | actualContentSize := tb.size | |||
| 63 | - | tb.mu.Unlock() | |||
| 64 | - | require.Equal(t, contentSize, actualContentSize, "proxied geometry must not subtract chrome a second time") | |||
| 65 | - | ||||
| 66 | - | release() | |||
| 67 | - | handlers.Wait() | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | func TestOrdinaryHandshakeRetainsChromeAndNoMetadata(t *testing.T) { | |||
| 71 | - | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |||
| 72 | - | viewport := domain.Size{Cols: 24, Rows: 6} | |||
| 73 | - | hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentNew, Name: "ordinary", Size: viewport} | |||
| 74 | - | tr, sends, release := newConn(t, ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}) | |||
| 75 | - | defer release() | |||
| 76 | - | ||||
| 77 | - | var handlers sync.WaitGroup | |||
| 78 | - | handlers.Go(func() { d.handleConn(tr) }) | |||
| 79 | - | ||||
| 80 | - | welcomeFrame := awaitFrame(t, sends, ports.MsgWelcome) | |||
| 81 | - | welcome, err := ports.UnmarshalWelcome(welcomeFrame.Payload) | |||
| 82 | - | require.NoError(t, err) | |||
| 83 | - | require.Equal(t, ports.CapabilityResume, welcome.Capabilities) | |||
| 84 | - | ||||
| 85 | - | outputFrame := awaitTestValue(t, sends, "ordinary handshake produced no post-welcome frame") | |||
| 86 | - | require.NotEqual(t, ports.MsgSessionMeta, outputFrame.Type, "an ordinary attachment must never receive session metadata") | |||
| 87 | - | require.Equal(t, ports.MsgOutput, outputFrame.Type) | |||
| 88 | - | output, err := ports.UnmarshalOutput(outputFrame.Payload) | |||
| 89 | - | require.NoError(t, err) | |||
| 90 | - | require.Zero(t, output.BaseStateNum) | |||
| 91 | - | client := vt.NewScreen(viewport.Cols, viewport.Rows) | |||
| 92 | - | client.Write(output.Data) | |||
| 93 | - | require.Contains(t, screenLineText(client, 0), "1") | |||
| 94 | - | require.Contains(t, screenLineText(client, viewport.Rows-1), "ordinary") | |||
| 95 | - | ||||
| 96 | - | sess := firstSession(d) | |||
| 97 | - | require.NotNil(t, sess) | |||
| 98 | - | tb := sess.tabs[0] | |||
| 99 | - | tb.mu.Lock() | |||
| 100 | - | actualContentSize := tb.size | |||
| 101 | - | tb.mu.Unlock() | |||
| 102 | - | require.Equal(t, tabSize(viewport), actualContentSize) | |||
| 103 | - | requireNoOutputFrame(t, sends) | |||
| 104 | - | ||||
| 105 | - | release() | |||
| 106 | - | handlers.Wait() | |||
| 107 | - | } | |||
| 108 | - | ||||
| 109 | - | func TestDesiredCapturedCursorPublishesVisibleAbsoluteCursor(t *testing.T) { | |||
| 110 | - | visible := desiredCapturedCursor(capturedCursorInputs{ | |||
| 111 | - | row: 2, col: 3, style: 0, visible: true, renderable: true, | |||
| 112 | - | content: domain.Rect{X: 4, Y: 5, Width: 8, Height: 4}, | |||
| 113 | - | }, 1) | |||
| 114 | - | require.Equal(t, cursorOut{valid: true, row: 8, col: 7, style: 1, hasStyle: true}, visible) | |||
| 115 | - | ||||
| 116 | - | hidden := desiredCapturedCursor(capturedCursorInputs{ | |||
| 117 | - | row: 2, col: 3, visible: false, renderable: true, | |||
| 118 | - | content: domain.Rect{X: 4, Y: 5, Width: 8, Height: 4}, | |||
| 119 | - | }, 1) | |||
| 120 | - | require.Equal(t, cursorOut{hidden: true}, hidden) | |||
| 121 | - | } | |||
| 122 | - | ||||
| 123 | - | func TestProxiedRemotePaintSendsMetadataBeforeScreen(t *testing.T) { | |||
| 124 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 125 | - | client, sends := newCapturingTransport(t) | |||
| 126 | - | proxy, ac := newAttachedProxyFixture(t, d, client, newProxyTestTransport()) | |||
| 127 | - | ac.proxied = true | |||
| 128 | - | ac.screenOutput = newStructuredOutputStream(ac.output) | |||
| 129 | - | require.True(t, applyTestScreenText(proxy, 0, 0, "remote")) | |||
| 130 | - | ||||
| 131 | - | ac.sendMu.Lock() | |||
| 132 | - | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true}) | |||
| 133 | - | require.True(t, ok) | |||
| 134 | - | composed := composeFrame(*state, composeCacheInput{}) | |||
| 135 | - | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |||
| 136 | - | ||||
| 137 | - | metadata := awaitTestValue(t, sends, "proxied remote metadata was not sent") | |||
| 138 | - | require.Equal(t, ports.MsgSessionMeta, metadata.Type) | |||
| 139 | - | meta, err := ports.UnmarshalSessionMeta(metadata.Payload) | |||
| 140 | - | require.NoError(t, err) | |||
| 141 | - | require.Equal(t, "work", meta.SessionName) | |||
| 142 | - | require.Equal(t, ports.MsgScreenUpdate, awaitTestValue(t, sends, "proxied remote screen was not sent").Type) | |||
| 143 | - | } | |||
| 144 | - | ||||
| 145 | - | func TestProxiedPaintPublishesAbsoluteVisibleCursorStyle(t *testing.T) { | |||
| 146 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 147 | - | ac.proxied = true | |||
| 148 | - | pane := testAttachmentTab(sess).focusedPane() | |||
| 149 | - | pane.mu.Lock() | |||
| 150 | - | pane.screen.Write([]byte("\x1b[3;6H\x1b[0 q")) | |||
| 151 | - | pane.mu.Unlock() | |||
| 152 | - | ||||
| 153 | - | d.paint(sess, ac, true, nil) | |||
| 154 | - | require.Equal(t, ports.MsgSessionMeta, awaitTestValue(t, sends, "proxied paint did not emit session metadata").Type) | |||
| 155 | - | frame := awaitTestValue(t, sends, "proxied paint did not emit structured screen update") | |||
| 156 | - | require.Equal(t, ports.MsgScreenUpdate, frame.Type) | |||
| 157 | - | update, err := ports.UnmarshalScreenUpdate(frame.Payload) | |||
| 158 | - | require.NoError(t, err) | |||
| 159 | - | require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind) | |||
| 160 | - | require.True(t, update.Cursor.Visible) | |||
| 161 | - | require.Equal(t, uint16(2), update.Cursor.Row) | |||
| 162 | - | require.Equal(t, uint16(5), update.Cursor.Col) | |||
| 163 | - | require.True(t, update.Cursor.StyleSet) | |||
| 164 | - | require.Zero(t, update.Cursor.Style) | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | func TestComposeFrameProxiedContentOnlyOmitsBothChromeRows(t *testing.T) { | |||
| 168 | - | paneFrame := renderer.NewFrame(8, 2) | |||
| 169 | - | fillOutputStateRows(paneFrame, []string{"CONTENT1", "CONTENT2"}) | |||
| 170 | - | placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 8, Height: 2}} | |||
| 171 | - | state := capturedRenderState{ | |||
| 172 | - | contentOnly: true, | |||
| 173 | - | reset: true, | |||
| 174 | - | layout: capturedTabLayout{ | |||
| 175 | - | area: domain.Rect{Width: 8, Height: 2}, focus: "pane", placements: []layout.Placement{placement}, valid: true, | |||
| 176 | - | }, | |||
| 177 | - | panes: []capturedPaneRenderState{{ | |||
| 178 | - | id: "pane", frame: paneFrame, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}, | |||
| 179 | - | }}, | |||
| 180 | - | bars: barState{status: statusSnapshot{session: "REMOTE-CHROME"}, bottomRight: "BOTTOM-CHROME"}, | |||
| 181 | - | cursor: capturedCursorInputs{visible: true, renderable: true, content: placement.Content}, | |||
| 182 | - | } | |||
| 183 | - | ||||
| 184 | - | proxied := composeFrame(state, composeCacheInput{}) | |||
| 185 | - | require.Equal(t, []string{"CONTENT1", "CONTENT2"}, frameRows(proxied.frame)) | |||
| 186 | - | require.Equal(t, 0, proxied.cursor.row) | |||
| 187 | - | ||||
| 188 | - | state.contentOnly = false | |||
| 189 | - | ordinary := composeFrame(state, composeCacheInput{}) | |||
| 190 | - | require.Equal(t, 4, ordinary.frame.Height) | |||
| 191 | - | require.Equal(t, "CONTENT1", rowText(ordinary.frame.Row(1))) | |||
| 192 | - | require.Contains(t, rowText(ordinary.frame.Row(3)), "REMOTE-") | |||
| 193 | - | require.Equal(t, 1, ordinary.cursor.row) | |||
| 194 | - | } | |||
| 195 | - | ||||
| 196 | - | func TestProxyResizeUpdatesAttachmentViewport(t *testing.T) { | |||
| 197 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 198 | - | client, _ := newCapturingTransport(t) | |||
| 199 | - | proxy, ac := newAttachedProxyFixture(t, d, client, newProxyTestTransport()) | |||
| 200 | - | ||||
| 201 | - | d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 24, Rows: 10}) | |||
| 202 | - | require.Equal(t, domain.Size{Cols: 24, Rows: 10}, ac.size) | |||
| 203 | - | } | |||
| 204 | - | ||||
| 205 | - | func TestProxiedTransactionalResizeUsesReceivedContentGeometry(t *testing.T) { | |||
| 206 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 207 | - | ac.proxied = true | |||
| 208 | - | rc := d.attachCoordinator(sess, ac, true) | |||
| 209 | - | lease := rc.attachmentLease(ac) | |||
| 210 | - | require.NotNil(t, lease) | |||
| 211 | - | ||||
| 212 | - | want := domain.Size{Cols: 100, Rows: 12} | |||
| 213 | - | require.True(t, d.requestTransactionalResizeForLease(sess, ac, lease, want, true)) | |||
| 214 | - | tb := testAttachmentTab(sess) | |||
| 215 | - | tb.mu.Lock() | |||
| 216 | - | require.Equal(t, want, tb.size) | |||
| 217 | - | tb.mu.Unlock() | |||
| 218 | - | require.Equal(t, want, ac.size) | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | func TestProxiedModeCannotChangeAcrossResume(t *testing.T) { | |||
| 222 | - | tests := []struct { | |||
| 223 | - | name string | |||
| 224 | - | initialProxied bool | |||
| 225 | - | resumedProxied bool | |||
| 226 | - | }{ | |||
| 227 | - | {name: "proxied attachment cannot resume as ordinary", initialProxied: true, resumedProxied: false}, | |||
| 228 | - | {name: "ordinary attachment cannot resume as proxied", initialProxied: false, resumedProxied: true}, | |||
| 229 | - | } | |||
| 230 | - | for _, tt := range tests { | |||
| 231 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 232 | - | d := newTestDaemon(t, newFactory(t, newQuietPTY()), stubClock{}) | |||
| 233 | - | var clientID [16]byte | |||
| 234 | - | clientID[0] = 1 | |||
| 235 | - | originalTransport := &closeTrackingTransport{} | |||
| 236 | - | hello := ports.Hello{ | |||
| 237 | - | Version: ports.ProtocolVersion, Intent: ports.IntentNew, Proxied: tt.initialProxied, | |||
| 238 | - | ClientID: clientID, Name: "immutable", Size: domain.Size{Cols: 40, Rows: 8}, | |||
| 239 | - | } | |||
| 240 | - | sess, ac, err := d.route(hello, originalTransport) | |||
| 241 | - | require.NoError(t, err) | |||
| 242 | - | token := ac.resumeToken | |||
| 243 | - | d.clientGone(sess, ac, originalTransport, false) | |||
| 244 | - | ||||
| 245 | - | resume := hello | |||
| 246 | - | resume.Intent = ports.IntentResume | |||
| 247 | - | resume.ResumeToken = token | |||
| 248 | - | resume.Proxied = tt.resumedProxied | |||
| 249 | - | _, resumed, ok, err := d.resumeParked(resume, &closeTrackingTransport{}, resume.Size) | |||
| 250 | - | require.Error(t, err) | |||
| 251 | - | require.False(t, ok) | |||
| 252 | - | require.Nil(t, resumed) | |||
| 253 | - | require.Equal(t, tt.initialProxied, ac.proxied) | |||
| 254 | - | d.mu.Lock() | |||
| 255 | - | parked := d.parked[token] | |||
| 256 | - | d.mu.Unlock() | |||
| 257 | - | require.NotNil(t, parked, "a mode-mismatched resume must leave the original attachment parked") | |||
| 258 | - | }) | |||
| 259 | - | } | |||
| 260 | - | } | |||
| 261 | - | ||||
| 262 | - | func TestOutputResetRebasesFullWindowAndSchedulesBaseZeroPaint(t *testing.T) { | |||
| 263 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 264 | - | ac.proxied = true | |||
| 265 | - | rc := d.attachCoordinator(sess, ac, true) | |||
| 266 | - | lease := rc.attachmentLease(ac) | |||
| 267 | - | require.NotNil(t, lease) | |||
| 268 | - | ||||
| 269 | - | ac.sendMu.Lock() | |||
| 270 | - | ac.output.next = ac.output.maxOutstanding | |||
| 271 | - | ac.output.acked = 0 | |||
| 272 | - | ac.sendMu.Unlock() | |||
| 273 | - | ||||
| 274 | - | token := sess.attachmentToken(ac, ac.transport()) | |||
| 275 | - | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{ | |||
| 276 | - | Type: ports.MsgOutputResetRequest, | |||
| 277 | - | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |||
| 278 | - | })) | |||
| 279 | - | ||||
| 280 | - | frame := awaitFrame(t, sends, ports.MsgScreenUpdate) | |||
| 281 | - | out, err := ports.UnmarshalScreenUpdate(frame.Payload) | |||
| 282 | - | require.NoError(t, err) | |||
| 283 | - | require.Equal(t, ports.ScreenUpdateSnapshot, out.Kind) | |||
| 284 | - | require.Zero(t, out.BaseStateNum) | |||
| 285 | - | ac.sendMu.Lock() | |||
| 286 | - | require.Equal(t, ac.output.maxOutstanding, ac.output.acked, "reset must retire the previously full output window") | |||
| 287 | - | require.Equal(t, uint64(1), ac.output.outstanding(), "only the authoritative reset paint remains outstanding") | |||
| 288 | - | require.Equal(t, ac.output.next, out.NewStateNum) | |||
| 289 | - | ac.sendMu.Unlock() | |||
| 290 | - | } | |||
| 291 | - | ||||
| 292 | - | func TestOutputResetRequiresProxiedActiveRoleAndStrictPayload(t *testing.T) { | |||
| 293 | - | tests := []struct { | |||
| 294 | - | name string | |||
| 295 | - | proxied bool | |||
| 296 | - | payload []byte | |||
| 297 | - | staleRole bool | |||
| 298 | - | }{ | |||
| 299 | - | {name: "ordinary attachment", proxied: false, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}, | |||
| 300 | - | {name: "malformed request", proxied: true, payload: []byte{0}}, | |||
| 301 | - | {name: "stale active role", proxied: true, payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), staleRole: true}, | |||
| 302 | - | } | |||
| 303 | - | for _, tt := range tests { | |||
| 304 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 305 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 306 | - | ac.proxied = tt.proxied | |||
| 307 | - | d.attachCoordinator(sess, ac, true) | |||
| 308 | - | ac.sendMu.Lock() | |||
| 309 | - | ac.output.next, ac.output.acked = 4, 0 | |||
| 310 | - | ac.sendMu.Unlock() | |||
| 311 | - | ||||
| 312 | - | token := sess.attachmentToken(ac, ac.transport()) | |||
| 313 | - | if tt.staleRole { | |||
| 314 | - | token.generation++ | |||
| 315 | - | } | |||
| 316 | - | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgOutputResetRequest, Payload: tt.payload})) | |||
| 317 | - | ac.sendMu.Lock() | |||
| 318 | - | acked := ac.output.acked | |||
| 319 | - | ac.sendMu.Unlock() | |||
| 320 | - | require.Zero(t, acked) | |||
| 321 | - | }) | |||
| 322 | - | } | |||
| 323 | - | } | |||
| 324 | - | ||||
| 325 | - | func TestOutputResetRevalidatesTransportUnderSendLock(t *testing.T) { | |||
| 326 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 327 | - | ac.proxied = true | |||
| 328 | - | d.attachCoordinator(sess, ac, true) | |||
| 329 | - | ac.sendMu.Lock() | |||
| 330 | - | ac.output.next, ac.output.acked = 4, 0 | |||
| 331 | - | ac.sendMu.Unlock() | |||
| 332 | - | token := sess.attachmentToken(ac, ac.transport()) | |||
| 333 | - | ||||
| 334 | - | admitted := make(chan struct{}) | |||
| 335 | - | var once sync.Once | |||
| 336 | - | d.afterAttachmentEffectAdmitted = func(attachmentConnectionToken) { once.Do(func() { close(admitted) }) } | |||
| 337 | - | ac.sendMu.Lock() | |||
| 338 | - | done := make(chan bool, 1) | |||
| 339 | - | go func() { | |||
| 340 | - | done <- d.handleAttachmentClientFrame(token, ports.Frame{ | |||
| 341 | - | Type: ports.MsgOutputResetRequest, | |||
| 342 | - | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | |||
| 343 | - | }) | |||
| 344 | - | }() | |||
| 345 | - | awaitTestCompletion(t, admitted, "reset did not admit its role effect") | |||
| 346 | - | ac.replaceTransport(&closeTrackingTransport{}) | |||
| 347 | - | ac.sendMu.Unlock() | |||
| 348 | - | ||||
| 349 | - | require.False(t, awaitTestValue(t, done, "reset did not finish after send lock release")) | |||
| 350 | - | ac.sendMu.Lock() | |||
| 351 | - | require.Zero(t, ac.output.acked, "a stale transport must not rebase the output stream") | |||
| 352 | - | ac.sendMu.Unlock() | |||
| 353 | - | } | |||
M internal/usecase/daemon/proxy_input.go +0 -37
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 7 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 8 | "github.com/bnema/vev/internal/usecase/keys" | 8 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 9 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 10 | "github.com/bnema/vev/internal/usecase/mouse" | 9 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 11 | "github.com/bnema/vev/internal/usecase/palette" | 10 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 12 | ) | 11 | ) | ||
| @@ -222,39 +221,3 @@ func proxiedNavConfig(cfg domain.NavConfig) domain.NavConfig { | |||||
| 222 | } | 221 | } | ||
| 223 | 222 | ||||
| 224 | func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied } | 223 | func proxiedJumpSearchesOtherSessions(proxied bool) bool { return !proxied } | ||
| 225 | - | ||||
| 226 | - | // focusDirProxied performs ordinary pane and tab overflow on the remote daemon, | |||
| 227 | - | // but never follows OverflowSessions. The local proxy daemon is the only owner | |||
| 228 | - | // allowed to change the selected session. | |||
| 229 | - | func (d *Daemon) focusDirProxied(sess *session, ac *attachedClient, dir layout.Direction) error { | |||
| 230 | - | target := resolveDaemonActionTargetForAttachment(sess, ac) | |||
| 231 | - | oldFocus := layout.PaneID("") | |||
| 232 | - | if target.pane != nil { | |||
| 233 | - | oldFocus = target.pane.id | |||
| 234 | - | } | |||
| 235 | - | span, err := d.focusDirAt(sess, target.tab, target.pane, dir, ac) | |||
| 236 | - | if err == nil { | |||
| 237 | - | if ac != nil { | |||
| 238 | - | d.finishPaneFocusForClient(sess, ac, target.tab, oldFocus, "proxy_input.go") | |||
| 239 | - | } | |||
| 240 | - | return nil | |||
| 241 | - | } | |||
| 242 | - | if !errors.Is(err, errNoNeighbor) || target.tab == nil || !overflowSourceEligible(sess, ac, target.tab) { | |||
| 243 | - | return err | |||
| 244 | - | } | |||
| 245 | - | ||||
| 246 | - | position, count := sess.tabIndexForAttachment(ac) | |||
| 247 | - | step := resolveOverflow(dir, proxiedNavConfig(d.currentNavConfig()), position, count) | |||
| 248 | - | if step.kind != overflowTabs { | |||
| 249 | - | return errNoNeighbor | |||
| 250 | - | } | |||
| 251 | - | candidate, ok := d.prepareTabOverflowForAttachment(sess, ac, target.tab, dir, span, step.delta) | |||
| 252 | - | if !ok || !d.commitTabOverflowForAttachment(sess, ac, candidate) { | |||
| 253 | - | return errNoNeighbor | |||
| 254 | - | } | |||
| 255 | - | sess.selectAttachmentTab(ac, domain.TabStableID(candidate.target.stableID)) | |||
| 256 | - | if ac != nil { | |||
| 257 | - | d.finishPaneFocusForClient(sess, ac, candidate.target, candidate.targetOldFocus, "proxy_input.go") | |||
| 258 | - | } | |||
| 259 | - | return nil | |||
| 260 | - | } | |||
M internal/usecase/daemon/proxy_input_test.go +8 -24
| @@ -216,7 +216,7 @@ func TestProxyInputAttentionOwnership(t *testing.T) { | |||||
| 216 | }) | 216 | }) | ||
| 217 | } | 217 | } | ||
| 218 | 218 | ||||
| 219 | - | func TestProxyInputProxiedRemoteOwnershipGuards(t *testing.T) { | 219 | + | func TestProxyInputRemoteOwnershipGuards(t *testing.T) { |
| 220 | _, proxy, _, link, _ := newProxyInputHarness(t) | 220 | _, proxy, _, link, _ := newProxyInputHarness(t) | ||
| 221 | require.True(t, proxy.caps.cannotAcceptMoves, "a proxy must reject move destinations locally") | 221 | require.True(t, proxy.caps.cannotAcceptMoves, "a proxy must reject move destinations locally") | ||
| 222 | require.True(t, proxy.caps.cannotYieldMoves, "a proxy must reject move sources locally") | 222 | require.True(t, proxy.caps.cannotYieldMoves, "a proxy must reject move sources locally") | ||
| @@ -355,9 +355,6 @@ func TestProxyAttachedCommandCorrelatesInterleavedWrongAndLateResults(t *testing | |||||
| 355 | require.NoError(t, err) | 355 | require.NoError(t, err) | ||
| 356 | require.Equal(t, proxyLinkResume, result) | 356 | require.Equal(t, proxyLinkResume, result) | ||
| 357 | require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type) | 357 | require.Equal(t, ports.MsgAck, awaitTestValue(t, link.sent, "interleaved output was not acknowledged").Type) | ||
| 358 | - | result, err = d.handleLinkFrame(proxy, generation, proxyMeta(proxy.key.Name)) | |||
| 359 | - | require.NoError(t, err) | |||
| 360 | - | require.Equal(t, proxyLinkResume, result) | |||
| 361 | result, err = d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}) | 358 | result, err = d.handleLinkFrame(proxy, generation, ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}) | ||
| 362 | require.NoError(t, err) | 359 | require.NoError(t, err) | ||
| 363 | require.Equal(t, proxyLinkResume, result) | 360 | require.Equal(t, proxyLinkResume, result) | ||
| @@ -499,7 +496,7 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T) | |||||
| 499 | request := requireProxyCommandRequest(t, link) | 496 | request := requireProxyCommandRequest(t, link) | ||
| 500 | _ = awaitTestValue(t, clock.timers, "command did not arm timeout") | 497 | _ = awaitTestValue(t, clock.timers, "command did not arm timeout") | ||
| 501 | oldGeneration := proxy.linkGeneration | 498 | oldGeneration := proxy.linkGeneration | ||
| 502 | - | newGeneration, _ := proxy.installTransport(newProxyTestTransport(), false) | 499 | + | newGeneration, _ := proxy.installTransport(newProxyTestTransport()) |
| 503 | require.Greater(t, newGeneration, oldGeneration) | 500 | require.Greater(t, newGeneration, oldGeneration) | ||
| 504 | require.Error(t, awaitTestValue(t, done, "replacement did not cancel command")) | 501 | require.Error(t, awaitTestValue(t, done, "replacement did not cancel command")) | ||
| 505 | _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true})) | 502 | _, err := d.handleLinkFrame(proxy, oldGeneration, commandResultFrame(ports.CommandResult{RequestID: request.RequestID, OK: true})) | ||
| @@ -509,7 +506,6 @@ func TestProxyAttachedCommandTimeoutCancelAndGenerationReplacement(t *testing.T) | |||||
| 509 | 506 | ||||
| 510 | func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *testing.T) { | 507 | func TestProxyAttachedCommandRejectsTargetsAndExecutesExactActiveSession(t *testing.T) { | ||
| 511 | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | 508 | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | ||
| 512 | - | ac.proxied = true | |||
| 513 | second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | 509 | second := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | ||
| 514 | publishTiledPaneOwners(sess, second) | 510 | publishTiledPaneOwners(sess, second) | ||
| 515 | sess.mu.Lock() | 511 | sess.mu.Lock() | ||
| @@ -600,9 +596,7 @@ func TestProxyPickerSelectionDialsOutsideLocksAndRevalidatesExactRoleAndKey(t *t | |||||
| 600 | d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | 596 | d, source, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | ||
| 601 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 597 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 602 | transport := newProxyTestTransport() | 598 | transport := newProxyTestTransport() | ||
| 603 | - | transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | 599 | + | transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)} |
| 604 | - | transport.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 605 | - | transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 606 | factory := portsmocks.NewMockRemoteDialerFactory(t) | 600 | factory := portsmocks.NewMockRemoteDialerFactory(t) | ||
| 607 | dialer := portsmocks.NewMockDialer(t) | 601 | dialer := portsmocks.NewMockDialer(t) | ||
| 608 | var daemonLockAvailable, sourceLockAvailable bool | 602 | var daemonLockAvailable, sourceLockAvailable bool | ||
| @@ -659,9 +653,7 @@ func TestRemotePickerEnterRoutesStructuredKeyThroughProxyOwnership(t *testing.T) | |||||
| 659 | d.attachCoordinator(local, ac, true) | 653 | d.attachCoordinator(local, ac, true) | ||
| 660 | key := domain.RemoteSessionKey{Host: "arch", Name: "enter"} | 654 | key := domain.RemoteSessionKey{Host: "arch", Name: "enter"} | ||
| 661 | transport := newProxyTestTransport() | 655 | transport := newProxyTestTransport() | ||
| 662 | - | transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | 656 | + | transport.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)} |
| 663 | - | transport.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 664 | - | transport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 665 | d.remoteDialerFactory = newProxyConstructionFactory(transport) | 657 | d.remoteDialerFactory = newProxyConstructionFactory(transport) | ||
| 666 | d.remoteTransportMode = ports.RemoteTransportUDP | 658 | d.remoteTransportMode = ports.RemoteTransportUDP | ||
| 667 | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | 659 | d.remoteCatalog.replaceCache([]ports.RemoteCatalogCacheEntry{{ | ||
| @@ -806,9 +798,7 @@ func TestRemotePickerCanReselectWarmProxyAfterReturningLocal(t *testing.T) { | |||||
| 806 | d.attachCoordinator(local, ac, true) | 798 | d.attachCoordinator(local, ac, true) | ||
| 807 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 799 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 808 | remote := newProxyTestTransport() | 800 | remote := newProxyTestTransport() | ||
| 809 | - | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | 801 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)} |
| 810 | - | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 811 | - | remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 812 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | 802 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | ||
| 813 | d.remoteTransportMode = ports.RemoteTransportUDP | 803 | d.remoteTransportMode = ports.RemoteTransportUDP | ||
| 814 | 804 | ||||
| @@ -899,9 +889,7 @@ func TestConnectionLoopRetriesRoleResolutionAcrossHandoff(t *testing.T) { | |||||
| 899 | 889 | ||||
| 900 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 890 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 901 | remote := newProxyTestTransport() | 891 | remote := newProxyTestTransport() | ||
| 902 | - | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | 892 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)} |
| 903 | - | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 904 | - | remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 905 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | 893 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | ||
| 906 | d.remoteTransportMode = ports.RemoteTransportUDP | 894 | d.remoteTransportMode = ports.RemoteTransportUDP | ||
| 907 | token := attachmentEffectForTest(t, local.attachmentToken(ac, client)) | 895 | token := attachmentEffectForTest(t, local.attachmentToken(ac, client)) | ||
| @@ -938,9 +926,7 @@ func TestConnectionLoopCleansCurrentProxyAfterHandoffReceiveError(t *testing.T) | |||||
| 938 | 926 | ||||
| 939 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 927 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 940 | remote := newProxyTestTransport() | 928 | remote := newProxyTestTransport() | ||
| 941 | - | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | 929 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)} |
| 942 | - | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 943 | - | remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 944 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | 930 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | ||
| 945 | d.remoteTransportMode = ports.RemoteTransportUDP | 931 | d.remoteTransportMode = ports.RemoteTransportUDP | ||
| 946 | 932 | ||||
| @@ -995,9 +981,7 @@ func TestConnectionLoopFollowsClientFromLocalToRemoteProxy(t *testing.T) { | |||||
| 995 | 981 | ||||
| 996 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 982 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 997 | remote := newProxyTestTransport() | 983 | remote := newProxyTestTransport() | ||
| 998 | - | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | 984 | + | remote.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)} |
| 999 | - | remote.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 1000 | - | remote.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 1001 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | 985 | d.remoteDialerFactory = newProxyConstructionFactory(remote) | ||
| 1002 | d.remoteTransportMode = ports.RemoteTransportUDP | 986 | d.remoteTransportMode = ports.RemoteTransportUDP | ||
| 1003 | 987 | ||||
M internal/usecase/daemon/proxy_lifecycle_test.go +14 -46
| @@ -146,30 +146,6 @@ func newRegisteredLifecycleProxy(t *testing.T, clock ports.Clock) (*Daemon, *pro | |||||
| 146 | return d, proxy, transport | 146 | return d, proxy, transport | ||
| 147 | } | 147 | } | ||
| 148 | 148 | ||||
| 149 | - | func TestNewProxySessionUsesProxyScreenSizeLimit(t *testing.T) { | |||
| 150 | - | for _, test := range []struct { | |||
| 151 | - | name string | |||
| 152 | - | size domain.Size | |||
| 153 | - | valid bool | |||
| 154 | - | }{ | |||
| 155 | - | {name: "largest valid content", size: domain.Size{Cols: 512, Rows: 514}, valid: true}, | |||
| 156 | - | {name: "one cell over content limit", size: domain.Size{Cols: 512, Rows: 515}}, | |||
| 157 | - | {name: "1000x1000", size: domain.Size{Cols: 1000, Rows: 1000}}, | |||
| 158 | - | } { | |||
| 159 | - | t.Run(test.name, func(t *testing.T) { | |||
| 160 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, test.size) | |||
| 161 | - | if !test.valid { | |||
| 162 | - | require.Error(t, err) | |||
| 163 | - | return | |||
| 164 | - | } | |||
| 165 | - | require.NoError(t, err) | |||
| 166 | - | require.Equal(t, contentSize(test.size, false), proxy.contentSize) | |||
| 167 | - | require.Equal(t, test.size.Cols, proxy.screen.frame.Width) | |||
| 168 | - | require.Equal(t, test.size.Rows-tabChromeRows, proxy.screen.frame.Height) | |||
| 169 | - | }) | |||
| 170 | - | } | |||
| 171 | - | } | |||
| 172 | - | ||||
| 173 | func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) { | 149 | func TestProxyWarmLifecycleFollowsAttachmentTransitions(t *testing.T) { | ||
| 174 | clock := newProxyLifecycleClock() | 150 | clock := newProxyLifecycleClock() | ||
| 175 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | 151 | d, local, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | ||
| @@ -255,7 +231,7 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) { | |||||
| 255 | 231 | ||||
| 256 | ac := &attachedClient{} | 232 | ac := &attachedClient{} | ||
| 257 | proxy.sessionCore.mu.Lock() | 233 | proxy.sessionCore.mu.Lock() | ||
| 258 | - | proxy.sessionCore.registerAttachmentLocked(ac) | 234 | + | proxy.registerAttachmentLocked(ac) |
| 259 | proxy.sessionCore.mu.Unlock() | 235 | proxy.sessionCore.mu.Unlock() | ||
| 260 | d.cancelProxyWarmForAttachment(proxy, ac) | 236 | d.cancelProxyWarmForAttachment(proxy, ac) | ||
| 261 | awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete") | 237 | awaitTestCompletion(t, firstToken.done, "first proxy warm lifecycle did not complete") | ||
| @@ -266,8 +242,8 @@ func TestProxyWarmReattachCancelsAndStaleTimerCannotRemoveProxy(t *testing.T) { | |||||
| 266 | d.mu.Unlock() | 242 | d.mu.Unlock() | ||
| 267 | 243 | ||||
| 268 | proxy.sessionCore.mu.Lock() | 244 | proxy.sessionCore.mu.Lock() | ||
| 269 | - | for _, attachment := range proxy.sessionCore.snapshotAttachmentsLocked() { | 245 | + | for _, attachment := range proxy.snapshotAttachmentsLocked() { |
| 270 | - | proxy.sessionCore.unregisterAttachmentLocked(attachment) | 246 | + | proxy.unregisterAttachmentLocked(attachment) |
| 271 | } | 247 | } | ||
| 272 | proxy.sessionCore.mu.Unlock() | 248 | proxy.sessionCore.mu.Unlock() | ||
| 273 | d.armProxyWarm(proxy) | 249 | d.armProxyWarm(proxy) | ||
| @@ -425,9 +401,7 @@ func TestProxyResumeBackoffResetsAfterStableConnection(t *testing.T) { | |||||
| 425 | handshakeTimer := awaitTestValue(t, clock.timers, "stable resume handshake did not arm its timeout") | 401 | handshakeTimer := awaitTestValue(t, clock.timers, "stable resume handshake did not arm its timeout") | ||
| 426 | require.Equal(t, proxyHandshakeTimeout, handshakeTimer.delay) | 402 | require.Equal(t, proxyHandshakeTimeout, handshakeTimer.delay) | ||
| 427 | _ = requireProxyHello(t, resumed) | 403 | _ = requireProxyHello(t, resumed) | ||
| 428 | - | resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume|ports.CapabilityProxied)} | 404 | + | resumed.recv <- proxyRecv{frame: proxyWelcome(proxy.key.Name, 2, ports.CapabilityResume)} |
| 429 | - | resumed.recv <- proxyRecv{frame: proxyMeta(proxy.key.Name)} | |||
| 430 | - | resumed.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 431 | require.Eventually(t, func() bool { | 405 | require.Eventually(t, func() bool { | ||
| 432 | proxy.mu.Lock() | 406 | proxy.mu.Lock() | ||
| 433 | defer proxy.mu.Unlock() | 407 | defer proxy.mu.Unlock() | ||
| @@ -466,11 +440,11 @@ func TestProxyLinkStateDisplayAndExactGeneration(t *testing.T) { | |||||
| 466 | require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session) | 440 | require.Equal(t, proxy.name+" [dead]", proxy.statusSegments(false).session) | ||
| 467 | } | 441 | } | ||
| 468 | 442 | ||||
| 469 | - | func TestIncomingDirectAttachPreservesProxiedRemoteAttachment(t *testing.T) { | 443 | + | func TestIncomingDirectAttachTerminatesRemoteAttachment(t *testing.T) { |
| 470 | d := newTestDaemon(t, nil, newProxyLifecycleClock()) | 444 | d := newTestDaemon(t, nil, newProxyLifecycleClock()) | ||
| 471 | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}} | 445 | sess := &session{sessionCore: sessionCore{id: "work", name: "work"}} | ||
| 472 | oldTransport := newProxyTestTransport() | 446 | oldTransport := newProxyTestTransport() | ||
| 473 | - | old := &attachedClient{tr: oldTransport, output: newOutputStateStream(), proxied: true} | 447 | + | old := &attachedClient{tr: oldTransport, output: newOutputStateStream()} |
| 474 | old.initOverlays() | 448 | old.initOverlays() | ||
| 475 | old.setSession(sess) | 449 | old.setSession(sess) | ||
| 476 | sess.registerAttachment(old) | 450 | sess.registerAttachment(old) | ||
| @@ -1192,9 +1166,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) { | |||||
| 1192 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 1166 | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | ||
| 1193 | first := newProxyTestTransport() | 1167 | first := newProxyTestTransport() | ||
| 1194 | second := newProxyTestTransport() | 1168 | second := newProxyTestTransport() | ||
| 1195 | - | second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityProxied)} | 1169 | + | second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 2, ports.CapabilityResume)} |
| 1196 | - | second.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 1197 | - | second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 1198 | factory := newProxyConstructionFactory(first, second) | 1170 | factory := newProxyConstructionFactory(first, second) | ||
| 1199 | d := newProxyTestDaemon(t, factory, nil) | 1171 | d := newProxyTestDaemon(t, factory, nil) | ||
| 1200 | 1172 | ||||
| @@ -1219,9 +1191,7 @@ func TestOpenProxySessionSerializesSameKeyAttachConstruction(t *testing.T) { | |||||
| 1219 | _ = requireProxyHello(t, second) | 1191 | _ = requireProxyHello(t, second) | ||
| 1220 | } | 1192 | } | ||
| 1221 | 1193 | ||||
| 1222 | - | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | 1194 | + | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)} |
| 1223 | - | first.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 1224 | - | first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 1225 | leaderResult := awaitTestValue(t, leader, "leader construction did not finish") | 1195 | leaderResult := awaitTestValue(t, leader, "leader construction did not finish") | ||
| 1226 | waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish") | 1196 | waiterResult := awaitTestValue(t, waiter, "waiter construction did not finish") | ||
| 1227 | require.NoError(t, leaderResult.err) | 1197 | require.NoError(t, leaderResult.err) | ||
| @@ -1246,7 +1216,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) { | |||||
| 1246 | require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial")) | 1216 | require.Equal(t, 1, awaitTestValue(t, factory.calls, "leader did not dial")) | ||
| 1247 | _ = requireProxyHello(t, first) | 1217 | _ = requireProxyHello(t, first) | ||
| 1248 | 1218 | ||||
| 1249 | - | base, cancel := context.WithCancel(context.Background()) | 1219 | + | base, cancel := context.WithCancel(t.Context()) |
| 1250 | observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})} | 1220 | observed := &proxyConstructionWaitContext{Context: base, observed: make(chan struct{})} | ||
| 1251 | waiter := openProxyForTest(d, observed, key) | 1221 | waiter := openProxyForTest(d, observed, key) | ||
| 1252 | select { | 1222 | select { | ||
| @@ -1261,9 +1231,7 @@ func TestOpenProxySessionSameKeyWaiterCanCancel(t *testing.T) { | |||||
| 1261 | require.Nil(t, waiterResult.proxy) | 1231 | require.Nil(t, waiterResult.proxy) | ||
| 1262 | require.Equal(t, 1, factory.callCount()) | 1232 | require.Equal(t, 1, factory.callCount()) | ||
| 1263 | 1233 | ||||
| 1264 | - | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | 1234 | + | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityResume)} |
| 1265 | - | first.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 1266 | - | first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 1267 | leaderResult := awaitTestValue(t, leader, "leader construction did not finish") | 1235 | leaderResult := awaitTestValue(t, leader, "leader construction did not finish") | ||
| 1268 | require.NoError(t, leaderResult.err) | 1236 | require.NoError(t, leaderResult.err) | ||
| 1269 | stopProxy(t, leaderResult.proxy) | 1237 | stopProxy(t, leaderResult.proxy) | ||
| @@ -1325,7 +1293,7 @@ func proxyWarmToken(p *proxySession) (*proxyWarmTimer, uint64) { | |||||
| 1325 | 1293 | ||||
| 1326 | func setProxyLifecycleClient(p *proxySession, ac *attachedClient) { | 1294 | func setProxyLifecycleClient(p *proxySession, ac *attachedClient) { | ||
| 1327 | p.sessionCore.mu.Lock() | 1295 | p.sessionCore.mu.Lock() | ||
| 1328 | - | p.sessionCore.registerAttachmentLocked(ac) | 1296 | + | p.registerAttachmentLocked(ac) |
| 1329 | p.sessionCore.mu.Unlock() | 1297 | p.sessionCore.mu.Unlock() | ||
| 1330 | } | 1298 | } | ||
| 1331 | 1299 | ||||
| @@ -1441,7 +1409,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) { | |||||
| 1441 | d, p, clock := newProxyLifecycleFixture(t) | 1409 | d, p, clock := newProxyLifecycleFixture(t) | ||
| 1442 | transport := newProxyTestTransport() | 1410 | transport := newProxyTestTransport() | ||
| 1443 | t.Cleanup(func() { _ = transport.Close() }) | 1411 | t.Cleanup(func() { _ = transport.Close() }) | ||
| 1444 | - | generation, _ := p.installTransport(transport, false) | 1412 | + | generation, _ := p.installTransport(transport) |
| 1445 | token, timer := armWarmTimer(t, d, p, clock) | 1413 | token, timer := armWarmTimer(t, d, p, clock) | ||
| 1446 | 1414 | ||||
| 1447 | require.True(t, d.markProxyReplaced(p, generation, transport)) | 1415 | require.True(t, d.markProxyReplaced(p, generation, transport)) | ||
| @@ -1465,7 +1433,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) { | |||||
| 1465 | d, p, clock := newProxyLifecycleFixture(t) | 1433 | d, p, clock := newProxyLifecycleFixture(t) | ||
| 1466 | transport := newProxyTestTransport() | 1434 | transport := newProxyTestTransport() | ||
| 1467 | t.Cleanup(func() { _ = transport.Close() }) | 1435 | t.Cleanup(func() { _ = transport.Close() }) | ||
| 1468 | - | generation, _ := p.installTransport(transport, false) | 1436 | + | generation, _ := p.installTransport(transport) |
| 1469 | 1437 | ||||
| 1470 | require.True(t, d.markProxyReplaced(p, generation, transport)) | 1438 | require.True(t, d.markProxyReplaced(p, generation, transport)) | ||
| 1471 | timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer") | 1439 | timer := awaitTestValue(t, clock.timers, "replacement did not arm a warm timer") | ||
| @@ -1482,7 +1450,7 @@ func TestMarkProxyReplacedWarmTimerOwnership(t *testing.T) { | |||||
| 1482 | d, p, _ := newProxyLifecycleFixture(t) | 1450 | d, p, _ := newProxyLifecycleFixture(t) | ||
| 1483 | transport := newProxyTestTransport() | 1451 | transport := newProxyTestTransport() | ||
| 1484 | t.Cleanup(func() { _ = transport.Close() }) | 1452 | t.Cleanup(func() { _ = transport.Close() }) | ||
| 1485 | - | generation, _ := p.installTransport(transport, false) | 1453 | + | generation, _ := p.installTransport(transport) |
| 1486 | 1454 | ||||
| 1487 | require.False(t, d.markProxyReplaced(p, generation-1, transport)) | 1455 | require.False(t, d.markProxyReplaced(p, generation-1, transport)) | ||
| 1488 | require.False(t, proxyExpired(p)) | 1456 | require.False(t, proxyExpired(p)) | ||
M internal/usecase/daemon/proxy_link.go +48 -150
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "io" | 7 | "io" | ||
| 8 | "time" | 8 | "time" | ||
| 9 | 9 | ||||
| 10 | - | "github.com/bnema/vev/internal/domain" | |||
| 11 | "github.com/bnema/vev/internal/ports" | 10 | "github.com/bnema/vev/internal/ports" | ||
| 12 | ) | 11 | ) | ||
| 13 | 12 | ||||
| @@ -54,9 +53,9 @@ const ( | |||||
| 54 | proxyLinkReplaced | 53 | proxyLinkReplaced | ||
| 55 | ) | 54 | ) | ||
| 56 | 55 | ||||
| 57 | - | // dialProxyHandshake installs a candidate transport, sends the proxied Hello | 56 | + | // dialProxyHandshake installs a candidate transport, sends an ordinary Hello |
| 58 | - | // through the session's sole sender, and requires Welcome followed immediately | 57 | + | // through the session's sole sender, and requires a matching Welcome. The |
| 59 | - | // by valid authoritative metadata. The candidate is never registry-visible. | 58 | + | // candidate is never registry-visible. |
| 60 | func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, intent uint8) (err error) { | 59 | func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, intent uint8) (err error) { | ||
| 61 | if parent == nil { | 60 | if parent == nil { | ||
| 62 | //nolint:contextcheck // nil is intentionally supported for direct internal callers; Background is the root fallback. | 61 | //nolint:contextcheck // nil is intentionally supported for direct internal callers; Background is the root fallback. | ||
| @@ -98,14 +97,12 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int | |||||
| 98 | resumeToken := p.resumeToken | 97 | resumeToken := p.resumeToken | ||
| 99 | clientID := p.clientID | 98 | clientID := p.clientID | ||
| 100 | size := p.contentSize | 99 | size := p.contentSize | ||
| 101 | - | stateFloor := p.appliedState | |||
| 102 | p.mu.Unlock() | 100 | p.mu.Unlock() | ||
| 103 | resume := intent == ports.IntentResume | 101 | resume := intent == ports.IntentResume | ||
| 104 | if !resume { | 102 | if !resume { | ||
| 105 | resumeToken = 0 | 103 | resumeToken = 0 | ||
| 106 | - | stateFloor = 0 | |||
| 107 | } | 104 | } | ||
| 108 | - | generation, previous := p.installTransport(transport, resume) | 105 | + | generation, previous := p.installTransport(transport) |
| 109 | if previous != nil && previous != transport { | 106 | if previous != nil && previous != transport { | ||
| 110 | _ = previous.Close() | 107 | _ = previous.Close() | ||
| 111 | } | 108 | } | ||
| @@ -121,7 +118,6 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int | |||||
| 121 | hello := ports.Hello{ | 118 | hello := ports.Hello{ | ||
| 122 | Version: ports.ProtocolVersion, | 119 | Version: ports.ProtocolVersion, | ||
| 123 | Intent: intent, | 120 | Intent: intent, | ||
| 124 | - | Proxied: true, | |||
| 125 | ClientID: clientID, | 121 | ClientID: clientID, | ||
| 126 | ResumeToken: resumeToken, | 122 | ResumeToken: resumeToken, | ||
| 127 | Name: p.key.Name, | 123 | Name: p.key.Name, | ||
| @@ -132,23 +128,13 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int | |||||
| 132 | return fmt.Errorf("proxy session: send hello: %w", err) | 128 | return fmt.Errorf("proxy session: send hello: %w", err) | ||
| 133 | } | 129 | } | ||
| 134 | 130 | ||||
| 135 | - | welcome, meta, snapshot, err := recvProxyHandshake(ctx, transport, p.key.Name, size, stateFloor) | 131 | + | welcome, err := recvProxyHandshake(ctx, transport, p.key.Name) |
| 136 | if err != nil { | 132 | if err != nil { | ||
| 137 | return err | 133 | return err | ||
| 138 | } | 134 | } | ||
| 139 | if err := ctx.Err(); err != nil { | 135 | if err := ctx.Err(); err != nil { | ||
| 140 | return err | 136 | return err | ||
| 141 | } | 137 | } | ||
| 142 | - | ack, reset, _ := p.applyScreenUpdateForGeneration(generation, snapshot) | |||
| 143 | - | if reset || ack != snapshot.NewStateNum { | |||
| 144 | - | return errors.New("proxy session: initial screen snapshot was not applied") | |||
| 145 | - | } | |||
| 146 | - | if err := p.sendGeneration(generation, ports.Frame{ | |||
| 147 | - | Type: ports.MsgAck, | |||
| 148 | - | Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}), | |||
| 149 | - | }); err != nil { | |||
| 150 | - | return fmt.Errorf("proxy session: send initial screen ACK: %w", err) | |||
| 151 | - | } | |||
| 152 | 138 | ||||
| 153 | p.mu.Lock() | 139 | p.mu.Lock() | ||
| 154 | if p.linkGeneration != generation || p.transport != transport { | 140 | if p.linkGeneration != generation || p.transport != transport { | ||
| @@ -156,7 +142,6 @@ func (d *Daemon) dialProxyHandshake(parent context.Context, p *proxySession, int | |||||
| 156 | return errors.New("proxy session: link replaced during handshake") | 142 | return errors.New("proxy session: link replaced during handshake") | ||
| 157 | } | 143 | } | ||
| 158 | p.resumeToken = welcome.ResumeToken | 144 | p.resumeToken = welcome.ResumeToken | ||
| 159 | - | p.replaceSessionMetaLocked(meta, d.clock.Now()) | |||
| 160 | p.linkState = ports.LinkStateConnected | 145 | p.linkState = ports.LinkStateConnected | ||
| 161 | p.expired = false | 146 | p.expired = false | ||
| 162 | p.mu.Unlock() | 147 | p.mu.Unlock() | ||
| @@ -230,43 +215,16 @@ func proxyHandshakeContextError(parent context.Context, timedOut <-chan struct{} | |||||
| 230 | return fallback | 215 | return fallback | ||
| 231 | } | 216 | } | ||
| 232 | 217 | ||||
| 233 | - | func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string, size domain.Size, stateFloor uint64) (ports.Welcome, ports.SessionMeta, ports.ScreenUpdate, error) { | 218 | + | func recvProxyHandshake(ctx context.Context, transport ports.Transport, sessionName string) (ports.Welcome, error) { |
| 234 | frame, err := recvProxyHandshakeFrame(ctx, transport) | 219 | frame, err := recvProxyHandshakeFrame(ctx, transport) | ||
| 235 | if err != nil { | 220 | if err != nil { | ||
| 236 | - | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive welcome: %w", err) | 221 | + | return ports.Welcome{}, fmt.Errorf("proxy session: receive welcome: %w", err) |
| 237 | } | 222 | } | ||
| 238 | welcome, err := validateProxyWelcome(frame, sessionName) | 223 | welcome, err := validateProxyWelcome(frame, sessionName) | ||
| 239 | if err != nil { | 224 | if err != nil { | ||
| 240 | - | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, err | 225 | + | return ports.Welcome{}, err |
| 241 | - | } | |||
| 242 | - | ||||
| 243 | - | frame, err = recvProxyHandshakeFrame(ctx, transport) | |||
| 244 | - | if err != nil { | |||
| 245 | - | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive initial metadata: %w", err) | |||
| 246 | - | } | |||
| 247 | - | meta, err := validateProxyInitialMeta(frame, sessionName) | |||
| 248 | - | if err != nil { | |||
| 249 | - | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, err | |||
| 250 | - | } | |||
| 251 | - | ||||
| 252 | - | frame, err = recvProxyHandshakeFrame(ctx, transport) | |||
| 253 | - | if err != nil { | |||
| 254 | - | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: receive initial screen snapshot: %w", err) | |||
| 255 | - | } | |||
| 256 | - | if frame.Type != ports.MsgScreenUpdate { | |||
| 257 | - | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: third server frame is %d, want ScreenUpdate", frame.Type) | |||
| 258 | - | } | |||
| 259 | - | snapshot, err := ports.UnmarshalScreenUpdate(frame.Payload) | |||
| 260 | - | if err != nil { | |||
| 261 | - | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: decode initial screen snapshot: %w", err) | |||
| 262 | - | } | |||
| 263 | - | if snapshot.Kind != ports.ScreenUpdateSnapshot || snapshot.BaseStateNum != 0 || snapshot.NewStateNum <= stateFloor { | |||
| 264 | - | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, errors.New("proxy session: initial screen frame is not a moving base-zero snapshot") | |||
| 265 | } | 226 | } | ||
| 266 | - | if snapshot.Size != size { | 227 | + | return welcome, nil |
| 267 | - | return ports.Welcome{}, ports.SessionMeta{}, ports.ScreenUpdate{}, fmt.Errorf("proxy session: initial screen geometry mismatch: got %dx%d, want %dx%d", snapshot.Size.Cols, snapshot.Size.Rows, size.Cols, size.Rows) | |||
| 268 | - | } | |||
| 269 | - | return welcome, meta, snapshot, nil | |||
| 270 | } | 228 | } | ||
| 271 | 229 | ||||
| 272 | func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) { | 230 | func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, error) { | ||
| @@ -284,29 +242,12 @@ func validateProxyWelcome(frame ports.Frame, sessionName string) (ports.Welcome, | |||||
| 284 | if err != nil { | 242 | if err != nil { | ||
| 285 | return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err) | 243 | return ports.Welcome{}, fmt.Errorf("proxy session: decode welcome: %w", err) | ||
| 286 | } | 244 | } | ||
| 287 | - | if welcome.Capabilities&ports.CapabilityProxied == 0 { | |||
| 288 | - | return ports.Welcome{}, errors.New("proxy session: remote did not negotiate proxied capability") | |||
| 289 | - | } | |||
| 290 | if welcome.SessionID == "" || welcome.SessionName != sessionName { | 245 | if welcome.SessionID == "" || welcome.SessionName != sessionName { | ||
| 291 | return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName) | 246 | return ports.Welcome{}, fmt.Errorf("proxy session: welcome identity mismatch: got %q", welcome.SessionName) | ||
| 292 | } | 247 | } | ||
| 293 | return welcome, nil | 248 | return welcome, nil | ||
| 294 | } | 249 | } | ||
| 295 | 250 | ||||
| 296 | - | func validateProxyInitialMeta(frame ports.Frame, sessionName string) (ports.SessionMeta, error) { | |||
| 297 | - | if frame.Type != ports.MsgSessionMeta { | |||
| 298 | - | return ports.SessionMeta{}, fmt.Errorf("proxy session: second server frame is %d, want SessionMeta", frame.Type) | |||
| 299 | - | } | |||
| 300 | - | meta, err := ports.UnmarshalSessionMeta(frame.Payload) | |||
| 301 | - | if err != nil { | |||
| 302 | - | return ports.SessionMeta{}, fmt.Errorf("proxy session: decode initial metadata: %w", err) | |||
| 303 | - | } | |||
| 304 | - | if meta.SessionName != sessionName { | |||
| 305 | - | return ports.SessionMeta{}, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName) | |||
| 306 | - | } | |||
| 307 | - | return meta, nil | |||
| 308 | - | } | |||
| 309 | - | ||||
| 310 | func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) { | 251 | func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (ports.Frame, error) { | ||
| 311 | result := make(chan proxyRecvResult, 1) | 252 | result := make(chan proxyRecvResult, 1) | ||
| 312 | go func() { | 253 | go func() { | ||
| @@ -326,7 +267,7 @@ func recvProxyHandshakeFrame(ctx context.Context, transport ports.Transport) (po | |||||
| 326 | } | 267 | } | ||
| 327 | } | 268 | } | ||
| 328 | 269 | ||||
| 329 | - | func (p *proxySession) installTransport(transport ports.Transport, resume bool) (uint64, ports.Transport) { | 270 | + | func (p *proxySession) installTransport(transport ports.Transport) (uint64, ports.Transport) { |
| 330 | // Link replacement participates in the same sender serialization as every | 271 | // Link replacement participates in the same sender serialization as every | ||
| 331 | // frame. A request can therefore only be published wholly before the | 272 | // frame. A request can therefore only be published wholly before the | ||
| 332 | // generation changes or wholly after it. | 273 | // generation changes or wholly after it. | ||
| @@ -336,17 +277,12 @@ func (p *proxySession) installTransport(transport ports.Transport, resume bool) | |||||
| 336 | previous := p.transport | 277 | previous := p.transport | ||
| 337 | p.linkGeneration++ | 278 | p.linkGeneration++ | ||
| 338 | generation := p.linkGeneration | 279 | generation := p.linkGeneration | ||
| 339 | - | if resume { | 280 | + | // Each transport must establish its ordinary output dependency with a |
| 340 | - | // Resume keeps the applied remote state as the handshake floor, but the | 281 | + | // dependency-free full frame before incremental bytes are forwarded. |
| 341 | - | // replacement must prove that state with a fresh base-zero snapshot. | 282 | + | p.resetOutputStateLocked() |
| 342 | - | p.screenReady = false | |||
| 343 | - | p.resetRequested = false | |||
| 344 | - | } else { | |||
| 345 | - | p.resetOutputStateLocked() | |||
| 346 | - | } | |||
| 347 | p.transport = transport | 283 | p.transport = transport | ||
| 348 | - | // Authentication is not complete until the authoritative screen snapshot | 284 | + | // Authentication is complete after the remote Welcome; ordinary output |
| 349 | - | // has been applied and acknowledged. | 285 | + | // establishes the dependency chain asynchronously. |
| 350 | p.linkState = ports.LinkStateProbing | 286 | p.linkState = ports.LinkStateProbing | ||
| 351 | p.mu.Unlock() | 287 | p.mu.Unlock() | ||
| 352 | p.failPendingCommandsLocked(errProxyCommandGeneration) | 288 | p.failPendingCommandsLocked(errProxyCommandGeneration) | ||
| @@ -367,17 +303,6 @@ func (p *proxySession) retireTransport(generation uint64, transport ports.Transp | |||||
| 367 | } | 303 | } | ||
| 368 | } | 304 | } | ||
| 369 | 305 | ||||
| 370 | - | // Every proxy Transport.Send path is serialized by sendMu: Hello, input, | |||
| 371 | - | // resize, ACK/reset, ping/pong, and command traffic. The leaf proxy lock is | |||
| 372 | - | // held only long enough to revalidate | |||
| 373 | - | // the immutable link snapshot; I/O happens after releasing it. | |||
| 374 | - | func (p *proxySession) sendCurrent(frame ports.Frame) error { | |||
| 375 | - | p.mu.Lock() | |||
| 376 | - | generation := p.linkGeneration | |||
| 377 | - | p.mu.Unlock() | |||
| 378 | - | return p.sendGeneration(generation, frame) | |||
| 379 | - | } | |||
| 380 | - | ||||
| 381 | func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error { | 306 | func (p *proxySession) sendGeneration(generation uint64, frame ports.Frame) error { | ||
| 382 | p.sendMu.Lock() | 307 | p.sendMu.Lock() | ||
| 383 | defer p.sendMu.Unlock() | 308 | defer p.sendMu.Unlock() | ||
| @@ -651,54 +576,48 @@ func (d *Daemon) runProxyTransport(ctx context.Context, p *proxySession, generat | |||||
| 651 | } | 576 | } | ||
| 652 | } | 577 | } | ||
| 653 | 578 | ||||
| 654 | - | // handleProxySideEffect fans out a stateless remote effect to every attachment | 579 | + | // handleProxySideEffect forwards a stateless remote effect to every live local |
| 655 | - | // currently owning this proxy. Each attachment gets an independent capability, | 580 | + | // attachment. Connection admission and transport incarnation checks make a stale |
| 656 | - | // effect ticket, output snapshot, and exact transport-incarnation fence. | 581 | + | // handoff a harmless drop rather than a send to the client's next session. |
| 657 | func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error { | 582 | func (d *Daemon) handleProxySideEffect(p *proxySession, generation uint64, out ports.Output) error { | ||
| 658 | if p == nil || !p.currentLinkGeneration(generation) { | 583 | if p == nil || !p.currentLinkGeneration(generation) { | ||
| 659 | return nil | 584 | return nil | ||
| 660 | } | 585 | } | ||
| 586 | + | attachments := snapshotAttachmentSession(p) | |||
| 661 | var sendErr error | 587 | var sendErr error | ||
| 662 | - | for _, ac := range snapshotAttachmentSession(p) { | 588 | + | for _, ac := range attachments { |
| 589 | + | if ac == nil || ac.output == nil { | |||
| 590 | + | continue | |||
| 591 | + | } | |||
| 592 | + | expected := ac.transportSnapshot() | |||
| 593 | + | if expected.transport == nil { | |||
| 594 | + | continue | |||
| 595 | + | } | |||
| 596 | + | token := attachmentToken(p, ac, expected.transport) | |||
| 597 | + | if token.sess != p { | |||
| 598 | + | continue | |||
| 599 | + | } | |||
| 600 | + | ticket, admitted := ac.beginAttachmentEffect(token) | |||
| 601 | + | if !admitted { | |||
| 602 | + | continue | |||
| 603 | + | } | |||
| 663 | if !p.currentLinkGeneration(generation) { | 604 | if !p.currentLinkGeneration(generation) { | ||
| 664 | - | break | 605 | + | ticket.End() |
| 606 | + | return nil | |||
| 665 | } | 607 | } | ||
| 666 | - | if err := d.sendProxySideEffect(p, generation, ac, out); err != nil && sendErr == nil { | 608 | + | err := ac.sendExpectedSideEffectForAttachment(expected, out.Data, ac.echoAck.Load(), ticket) |
| 609 | + | ticket.End() | |||
| 610 | + | if err == nil || errors.Is(err, errAttachmentTransition) { | |||
| 611 | + | continue | |||
| 612 | + | } | |||
| 613 | + | // Try every live attachment before retiring the remote link for a failed | |||
| 614 | + | // client send; one blocked or dead peer must not starve the others. | |||
| 615 | + | if sendErr == nil { | |||
| 667 | sendErr = err | 616 | sendErr = err | ||
| 668 | } | 617 | } | ||
| 669 | } | 618 | } | ||
| 670 | - | return sendErr | 619 | + | if sendErr != nil { |
| 671 | - | } | 620 | + | return fmt.Errorf("proxy session: side-effect send: %w: %w", errProxyLinkSend, sendErr) |
| 672 | - | ||||
| 673 | - | func (d *Daemon) sendProxySideEffect(p *proxySession, generation uint64, ac *attachedClient, out ports.Output) error { | |||
| 674 | - | if p == nil || ac == nil || ac.output == nil || !p.currentLinkGeneration(generation) { | |||
| 675 | - | return nil | |||
| 676 | - | } | |||
| 677 | - | expected := ac.transportSnapshot() | |||
| 678 | - | if expected.transport == nil { | |||
| 679 | - | return nil | |||
| 680 | - | } | |||
| 681 | - | token := attachmentToken(p, ac, expected.transport) | |||
| 682 | - | if token.sess != p || !token.current() { | |||
| 683 | - | return nil | |||
| 684 | - | } | |||
| 685 | - | ticket, admitted := ac.beginAttachmentEffect(token) | |||
| 686 | - | if !admitted { | |||
| 687 | - | return nil | |||
| 688 | - | } | |||
| 689 | - | defer ticket.End() | |||
| 690 | - | if !p.currentLinkGeneration(generation) || !token.current() { | |||
| 691 | - | return nil | |||
| 692 | - | } | |||
| 693 | - | frame := ac.output.sideEffect(out.Data, ac.echoAck.Load()) | |||
| 694 | - | if !p.currentLinkGeneration(generation) || !token.current() { | |||
| 695 | - | return nil | |||
| 696 | - | } | |||
| 697 | - | if err := ac.sendExpectedTransportForAttachment(expected, frame, ticket); err != nil { | |||
| 698 | - | if errors.Is(err, errAttachmentTransition) { | |||
| 699 | - | return nil | |||
| 700 | - | } | |||
| 701 | - | return fmt.Errorf("proxy session: side-effect send: %w: %w", errProxyLinkSend, err) | |||
| 702 | } | 621 | } | ||
| 703 | return nil | 622 | return nil | ||
| 704 | } | 623 | } | ||
| @@ -712,28 +631,7 @@ func (d *Daemon) handleLinkFrame(p *proxySession, generation uint64, frame ports | |||||
| 712 | if err != nil { | 631 | if err != nil { | ||
| 713 | return proxyLinkStop, err | 632 | return proxyLinkStop, err | ||
| 714 | } | 633 | } | ||
| 715 | - | if out.BaseStateNum != 0 || out.NewStateNum != 0 { | 634 | + | if err := d.handleProxyOutput(p, generation, out); err != nil { |
| 716 | - | return proxyLinkStop, fmt.Errorf("proxy session: state-bearing MsgOutput is not valid on a proxied link") | |||
| 717 | - | } | |||
| 718 | - | if err := d.handleProxySideEffect(p, generation, out); err != nil { | |||
| 719 | - | return proxyLinkStop, err | |||
| 720 | - | } | |||
| 721 | - | return proxyLinkResume, nil | |||
| 722 | - | case ports.MsgScreenUpdate: | |||
| 723 | - | update, err := ports.UnmarshalScreenUpdate(frame.Payload) | |||
| 724 | - | if err != nil { | |||
| 725 | - | return proxyLinkStop, err | |||
| 726 | - | } | |||
| 727 | - | if err := d.handleProxyScreenUpdate(p, generation, update); err != nil { | |||
| 728 | - | return proxyLinkStop, err | |||
| 729 | - | } | |||
| 730 | - | return proxyLinkResume, nil | |||
| 731 | - | case ports.MsgSessionMeta: | |||
| 732 | - | meta, err := ports.UnmarshalSessionMeta(frame.Payload) | |||
| 733 | - | if err != nil { | |||
| 734 | - | return proxyLinkStop, err | |||
| 735 | - | } | |||
| 736 | - | if _, err := d.applyProxySessionMeta(p, generation, meta); err != nil { | |||
| 737 | return proxyLinkStop, err | 635 | return proxyLinkStop, err | ||
| 738 | } | 636 | } | ||
| 739 | return proxyLinkResume, nil | 637 | return proxyLinkResume, nil | ||
M internal/usecase/daemon/proxy_output.go +68 -72
| @@ -6,104 +6,100 @@ import ( | |||||
| 6 | "github.com/bnema/vev/internal/ports" | 6 | "github.com/bnema/vev/internal/ports" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | // applyScreenUpdateForGeneration applies one semantic screen update while | 9 | + | // applyOutputForGeneration validates the remote ordinary output dependency |
| 10 | - | // holding only the proxy leaf lock. A link generation fence prevents a stale | 10 | + | // chain without retaining a second terminal or renderer state. Bytes are |
| 11 | - | // receive goroutine from advancing a replacement link's screen. | 11 | + | // forwarded only after a full frame establishes the current remote stream. |
| 12 | - | func (p *proxySession) applyScreenUpdateForGeneration(generation uint64, update ports.ScreenUpdate) (ack uint64, requestReset, changed bool) { | 12 | + | func (p *proxySession) applyOutputForGeneration(generation uint64, out ports.Output) (ack uint64, requestReset, changed bool) { |
| 13 | - | if p == nil { | |||
| 14 | - | return 0, false, false | |||
| 15 | - | } | |||
| 16 | p.mu.Lock() | 13 | p.mu.Lock() | ||
| 17 | defer p.mu.Unlock() | 14 | defer p.mu.Unlock() | ||
| 18 | - | if p.linkGeneration != generation || p.screen == nil { | 15 | + | if p.linkGeneration != generation { |
| 19 | return 0, false, false | 16 | return 0, false, false | ||
| 20 | } | 17 | } | ||
| 21 | - | 18 | + | invalid := func() (uint64, bool, bool) { | |
| 22 | - | // The wire codec validates all semantic bounds. Geometry is still checked | 19 | + | if p.outputResetRequested { |
| 23 | - | // here because it is attachment state, not merely a wire property: a | 20 | + | return 0, false, false |
| 24 | - | // remote paint for the wrong content rectangle cannot be applied locally. | 21 | + | } |
| 25 | - | if update.Size != p.contentSize { | 22 | + | p.outputResetRequested = true |
| 26 | - | return 0, p.requestScreenResetLocked(), false | 23 | + | return 0, true, false |
| 27 | } | 24 | } | ||
| 28 | - | if update.Kind == ports.ScreenUpdateSnapshot { | 25 | + | if out.Epoch == 0 { |
| 29 | - | // A base-zero paint may only move the consumer forward. This floor is | 26 | + | return invalid() |
| 30 | - | // retained across resume and resize, so resetRequested never authorizes | 27 | + | } |
| 31 | - | // replaying a stale snapshot. | 28 | + | if out.New == 0 { |
| 32 | - | if update.NewStateNum <= p.appliedState { | 29 | + | // New == 0 is an ordinary byte side effect, not a state transition. |
| 33 | - | return 0, p.requestScreenResetLocked(), false | 30 | + | // Match the thin client: it is accepted before the first state-bearing |
| 31 | + | // frame, and thereafter only within the current epoch. | |||
| 32 | + | if out.Base != 0 || out.Full || (p.outputReady && out.Epoch != p.outputEpoch) { | |||
| 33 | + | return invalid() | |||
| 34 | } | 34 | } | ||
| 35 | - | if err := p.screen.Apply(update); err != nil { | 35 | + | return 0, false, len(out.Data) != 0 |
| 36 | - | return 0, p.requestScreenResetLocked(), false | 36 | + | } |
| 37 | + | if !p.outputReady { | |||
| 38 | + | if out.Base != 0 || !out.Full { | |||
| 39 | + | return invalid() | |||
| 37 | } | 40 | } | ||
| 38 | - | p.appliedState = update.NewStateNum | 41 | + | p.outputEpoch = out.Epoch |
| 39 | - | p.screenReady = true | 42 | + | p.outputState = out.New |
| 40 | - | p.resetRequested = false | 43 | + | p.outputReady = true |
| 41 | - | return update.NewStateNum, false, true | 44 | + | p.outputResetRequested = false |
| 45 | + | return out.New, false, len(out.Data) != 0 | |||
| 42 | } | 46 | } | ||
| 43 | - | 47 | + | if out.Epoch < p.outputEpoch { | |
| 44 | - | if !p.screenReady || p.resetRequested || update.BaseStateNum != p.appliedState || update.NewStateNum != update.BaseStateNum+1 { | 48 | + | return invalid() |
| 45 | - | return 0, p.requestScreenResetLocked(), false | |||
| 46 | } | 49 | } | ||
| 47 | - | before := p.screen.cursorOut | 50 | + | if out.Epoch == p.outputEpoch { |
| 48 | - | if err := p.screen.Apply(update); err != nil { | 51 | + | if p.outputResetRequested || out.Full || out.Base != p.outputState || out.New != out.Base+1 { |
| 49 | - | return 0, p.requestScreenResetLocked(), false | 52 | + | return invalid() |
| 53 | + | } | |||
| 54 | + | p.outputState = out.New | |||
| 55 | + | return out.New, false, len(out.Data) != 0 | |||
| 50 | } | 56 | } | ||
| 51 | - | p.appliedState = update.NewStateNum | 57 | + | if out.Base != 0 || !out.Full { |
| 52 | - | return update.NewStateNum, false, update.Scroll != nil || len(update.Spans) != 0 || before != update.Cursor | 58 | + | return invalid() |
| 53 | - | } | |||
| 54 | - | ||||
| 55 | - | func (p *proxySession) requestScreenResetLocked() bool { | |||
| 56 | - | if p.resetRequested { | |||
| 57 | - | p.screenReady = false | |||
| 58 | - | return false | |||
| 59 | } | 59 | } | ||
| 60 | - | p.screenReady = false | 60 | + | p.outputEpoch = out.Epoch |
| 61 | - | p.resetRequested = true | 61 | + | p.outputState = out.New |
| 62 | - | return true | 62 | + | p.outputResetRequested = false |
| 63 | + | return out.New, false, len(out.Data) != 0 | |||
| 63 | } | 64 | } | ||
| 64 | 65 | ||||
| 65 | - | // handleProxyScreenUpdate completes screen handling after apply has released | 66 | + | // handleProxyOutput forwards ordinary remote terminal bytes to the local |
| 66 | - | // proxy.mu. Transport I/O and render invalidation never run under that lock. | 67 | + | // attachment and acknowledges a state-bearing frame only after forwarding. |
| 67 | - | func (d *Daemon) handleProxyScreenUpdate(p *proxySession, generation uint64, update ports.ScreenUpdate) error { | 68 | + | func (d *Daemon) handleProxyOutput(p *proxySession, generation uint64, out ports.Output) error { |
| 68 | - | marks := d.newRuntimeMarkBatch() | 69 | + | ack, requestReset, changed := p.applyOutputForGeneration(generation, out) |
| 69 | - | ack, requestReset, changed := p.applyScreenUpdateForGeneration(generation, update) | |||
| 70 | - | if ack != 0 { | |||
| 71 | - | if err := p.sendGeneration(generation, ports.Frame{ | |||
| 72 | - | Type: ports.MsgAck, | |||
| 73 | - | Payload: ports.MarshalAck(ports.Ack{AckedStateNum: ack}), | |||
| 74 | - | }); err != nil { | |||
| 75 | - | return fmt.Errorf("proxy screen ACK: %w: %w", errProxyLinkSend, err) | |||
| 76 | - | } | |||
| 77 | - | } | |||
| 78 | if requestReset { | 70 | if requestReset { | ||
| 79 | if err := p.sendGeneration(generation, ports.Frame{ | 71 | if err := p.sendGeneration(generation, ports.Frame{ | ||
| 80 | Type: ports.MsgOutputResetRequest, | 72 | Type: ports.MsgOutputResetRequest, | ||
| 81 | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | 73 | Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{}), | ||
| 82 | }); err != nil { | 74 | }); err != nil { | ||
| 83 | - | return fmt.Errorf("proxy screen reset request: %w: %w", errProxyLinkSend, err) | 75 | + | return fmt.Errorf("proxy output reset request: %w: %w", errProxyLinkSend, err) |
| 84 | } | 76 | } | ||
| 85 | - | marks.diagnostic(ports.RuntimeScreenResetRequested, 0, 0) | |||
| 86 | } | 77 | } | ||
| 87 | - | // The reset send released proxy sendMu and apply released proxy.mu before | 78 | + | if changed { |
| 88 | - | // observer I/O; a blocked sink cannot hold either protocol ownership lock. | 79 | + | if err := d.handleProxySideEffect(p, generation, out); err != nil { |
| 89 | - | marks.flush() | 80 | + | return err |
| 90 | - | if !changed || !p.currentLinkGeneration(generation) { | 81 | + | } |
| 91 | - | return nil | |||
| 92 | } | 82 | } | ||
| 93 | - | 83 | + | if ack != 0 { | |
| 94 | - | for _, ac := range snapshotAttachmentSession(p) { | 84 | + | payload, err := ports.MarshalAck(ports.Ack{Epoch: out.Epoch, State: ack}) |
| 95 | - | d.invalidateRender(p, ac, false, "proxy_output.go") | 85 | + | if err != nil { |
| 86 | + | return fmt.Errorf("proxy output ACK: %w", err) | |||
| 87 | + | } | |||
| 88 | + | if err := p.sendGeneration(generation, ports.Frame{ | |||
| 89 | + | Type: ports.MsgAck, | |||
| 90 | + | Payload: payload, | |||
| 91 | + | }); err != nil { | |||
| 92 | + | return fmt.Errorf("proxy output ACK: %w: %w", errProxyLinkSend, err) | |||
| 93 | + | } | |||
| 96 | } | 94 | } | ||
| 97 | return nil | 95 | return nil | ||
| 98 | } | 96 | } | ||
| 99 | 97 | ||||
| 100 | func (p *proxySession) resetOutputStateLocked() { | 98 | func (p *proxySession) resetOutputStateLocked() { | ||
| 101 | - | p.appliedState = 0 | 99 | + | p.outputEpoch = 0 |
| 102 | - | p.screenReady = false | 100 | + | p.outputState = 0 |
| 103 | - | p.resetRequested = false | 101 | + | p.outputReady = false |
| 104 | - | if p.screen != nil { | 102 | + | p.outputResetRequested = false |
| 105 | - | p.screen.stateNum = 0 | |||
| 106 | - | } | |||
| 107 | } | 103 | } | ||
| 108 | 104 | ||||
| 109 | func (p *proxySession) currentLinkGeneration(generation uint64) bool { | 105 | func (p *proxySession) currentLinkGeneration(generation uint64) bool { | ||
M internal/usecase/daemon/proxy_output_test.go +113 -1069
| @@ -1,1122 +1,166 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | - | "io" | |||
| 6 | - | "log/slog" | |||
| 7 | - | "maps" | |||
| 8 | - | "sync" | |||
| 9 | - | "sync/atomic" | |||
| 10 | "testing" | 4 | "testing" | ||
| 11 | - | "time" | |||
| 12 | 5 | ||||
| 13 | "github.com/bnema/vev/internal/domain" | 6 | "github.com/bnema/vev/internal/domain" | ||
| 14 | "github.com/bnema/vev/internal/ports" | 7 | "github.com/bnema/vev/internal/ports" | ||
| 15 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 16 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 17 | - | "github.com/stretchr/testify/mock" | |||
| 18 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 19 | ) | 9 | ) | ||
| 20 | 10 | ||||
| 21 | - | var proxyHandshakeState atomic.Uint64 | 11 | + | func newProxyOutputAttachment(t *testing.T, proxy *proxySession, id byte) (*attachedClient, *proxyTestTransport) { |
| 22 | - | ||||
| 23 | - | type proxyRecv struct { | |||
| 24 | - | frame ports.Frame | |||
| 25 | - | err error | |||
| 26 | - | } | |||
| 27 | - | ||||
| 28 | - | type proxyTestTransport struct { | |||
| 29 | - | recv chan proxyRecv | |||
| 30 | - | closed chan struct{} | |||
| 31 | - | closeOnce sync.Once | |||
| 32 | - | sent chan ports.Frame | |||
| 33 | - | ||||
| 34 | - | sendGate <-chan struct{} | |||
| 35 | - | sendEntered chan struct{} | |||
| 36 | - | ungatedSends atomic.Int32 | |||
| 37 | - | sending atomic.Int32 | |||
| 38 | - | concurrent atomic.Bool | |||
| 39 | - | receiving atomic.Int32 | |||
| 40 | - | // sendFails rejects every write while leaving Recv able to deliver queued | |||
| 41 | - | // frames, so a reply failure can be observed without also ending the receive | |||
| 42 | - | // pump. | |||
| 43 | - | sendFails atomic.Bool | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | - | func newProxyTestTransport() *proxyTestTransport { | |||
| 47 | - | return newProxyTestTransportWithSendGate(nil, nil, 0) | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | func newProxyTestTransportWithSendGate(gate <-chan struct{}, entered chan struct{}, ungatedSends int32) *proxyTestTransport { | |||
| 51 | - | transport := &proxyTestTransport{ | |||
| 52 | - | recv: make(chan proxyRecv, 16), | |||
| 53 | - | closed: make(chan struct{}), | |||
| 54 | - | sent: make(chan ports.Frame, 32), | |||
| 55 | - | sendGate: gate, | |||
| 56 | - | sendEntered: entered, | |||
| 57 | - | } | |||
| 58 | - | transport.ungatedSends.Store(ungatedSends) | |||
| 59 | - | return transport | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | func (t *proxyTestTransport) Send(frame ports.Frame) error { | |||
| 63 | - | if t.sending.Add(1) != 1 { | |||
| 64 | - | t.concurrent.Store(true) | |||
| 65 | - | } | |||
| 66 | - | defer t.sending.Add(-1) | |||
| 67 | - | if t.sendFails.Load() { | |||
| 68 | - | return io.ErrClosedPipe | |||
| 69 | - | } | |||
| 70 | - | if t.sendGate != nil && t.ungatedSends.Add(-1) < 0 { | |||
| 71 | - | if t.sendEntered != nil { | |||
| 72 | - | select { | |||
| 73 | - | case t.sendEntered <- struct{}{}: | |||
| 74 | - | default: | |||
| 75 | - | } | |||
| 76 | - | } | |||
| 77 | - | select { | |||
| 78 | - | case <-t.sendGate: | |||
| 79 | - | case <-t.closed: | |||
| 80 | - | return io.ErrClosedPipe | |||
| 81 | - | } | |||
| 82 | - | } | |||
| 83 | - | select { | |||
| 84 | - | case t.sent <- frame: | |||
| 85 | - | return nil | |||
| 86 | - | case <-t.closed: | |||
| 87 | - | return io.ErrClosedPipe | |||
| 88 | - | } | |||
| 89 | - | } | |||
| 90 | - | ||||
| 91 | - | func (t *proxyTestTransport) Recv() (ports.Frame, error) { | |||
| 92 | - | t.receiving.Add(1) | |||
| 93 | - | defer t.receiving.Add(-1) | |||
| 94 | - | select { | |||
| 95 | - | case item := <-t.recv: | |||
| 96 | - | return item.frame, item.err | |||
| 97 | - | case <-t.closed: | |||
| 98 | - | return ports.Frame{}, io.EOF | |||
| 99 | - | } | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func applyTestScreenText(p *proxySession, row, col int, text string) bool { | |||
| 103 | - | p.mu.Lock() | |||
| 104 | - | generation := p.linkGeneration | |||
| 105 | - | size := p.contentSize | |||
| 106 | - | p.mu.Unlock() | |||
| 107 | - | frame := renderer.NewFrame(size.Cols, size.Rows) | |||
| 108 | - | for x, r := range []rune(text) { | |||
| 109 | - | if col+x < frame.Width { | |||
| 110 | - | frame.Set(col+x, row, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 111 | - | } | |||
| 112 | - | } | |||
| 113 | - | spans := make([]ports.ScreenSpan, size.Rows) | |||
| 114 | - | for y := range size.Rows { | |||
| 115 | - | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: append([]renderer.Cell(nil), frame.Row(y)...)} | |||
| 116 | - | } | |||
| 117 | - | _, _, changed := p.applyScreenUpdateForGeneration(generation, ports.ScreenUpdate{ | |||
| 118 | - | NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: size, | |||
| 119 | - | Cursor: ports.ScreenCursor{Row: uint16(row), Col: uint16(col), Visible: true}, Spans: spans, | |||
| 120 | - | }) | |||
| 121 | - | return changed | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | func (t *proxyTestTransport) Close() error { | |||
| 125 | - | t.closeOnce.Do(func() { close(t.closed) }) | |||
| 126 | - | return nil | |||
| 127 | - | } | |||
| 128 | - | ||||
| 129 | - | func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame { | |||
| 130 | - | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{ | |||
| 131 | - | SessionID: "remote-id", | |||
| 132 | - | SessionName: name, | |||
| 133 | - | ResumeToken: token, | |||
| 134 | - | Capabilities: capabilities, | |||
| 135 | - | })} | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | func proxyHandshakeSnapshot() ports.Frame { | |||
| 139 | - | const cols, rows = 80, 22 | |||
| 140 | - | spans := make([]ports.ScreenSpan, rows) | |||
| 141 | - | for y := range rows { | |||
| 142 | - | cells := make([]renderer.Cell, cols) | |||
| 143 | - | for x := range cells { | |||
| 144 | - | cells[x] = renderer.BlankCell() | |||
| 145 | - | } | |||
| 146 | - | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cells} | |||
| 147 | - | } | |||
| 148 | - | payload, err := ports.MarshalScreenUpdate(ports.ScreenUpdate{ | |||
| 149 | - | NewStateNum: proxyHandshakeState.Add(1), Kind: ports.ScreenUpdateSnapshot, Size: domain.Size{Cols: cols, Rows: rows}, | |||
| 150 | - | Cursor: ports.ScreenCursor{Visible: true}, Spans: spans, | |||
| 151 | - | }) | |||
| 152 | - | if err != nil { | |||
| 153 | - | panic(err) | |||
| 154 | - | } | |||
| 155 | - | return ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload} | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | func proxyMeta(name string) ports.Frame { | |||
| 159 | - | payload, err := ports.MarshalSessionMeta(ports.SessionMeta{ | |||
| 160 | - | SessionName: name, | |||
| 161 | - | Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}, | |||
| 162 | - | }) | |||
| 163 | - | if err != nil { | |||
| 164 | - | panic(err) | |||
| 165 | - | } | |||
| 166 | - | return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload} | |||
| 167 | - | } | |||
| 168 | - | ||||
| 169 | - | // newProxyTestDaemon always installs a controllable clock so no proxy test can | |||
| 170 | - | // depend on wall-clock deadlines. Pass a signalClock to drive resume backoff. | |||
| 171 | - | func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk ports.Clock) *Daemon { | |||
| 172 | - | t.Helper() | |||
| 173 | - | if clk == nil { | |||
| 174 | - | clk = stubClock{} | |||
| 175 | - | } | |||
| 176 | - | return New(nil, clk, slog.New(slog.NewTextHandler(io.Discard, nil)), | |||
| 177 | - | WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP)) | |||
| 178 | - | } | |||
| 179 | - | ||||
| 180 | - | // proxySessionSnapshot clones the registry under d.mu so assertions never read | |||
| 181 | - | // d.sessions while a registration writes it. Assertions run on the clone, after | |||
| 182 | - | // the lock has been released. | |||
| 183 | - | func proxySessionSnapshot(d *Daemon) map[domain.SessionID]attachmentSession { | |||
| 184 | - | d.mu.Lock() | |||
| 185 | - | defer d.mu.Unlock() | |||
| 186 | - | return maps.Clone(d.sessions) | |||
| 187 | - | } | |||
| 188 | - | ||||
| 189 | - | func proxyFactoryFor(t *testing.T, key domain.RemoteSessionKey, transports ...ports.Transport) ports.RemoteDialerFactory { | |||
| 190 | t.Helper() | 12 | t.Helper() | ||
| 191 | - | factory := portsmocks.NewMockRemoteDialerFactory(t) | 13 | + | transport := newProxyTestTransport() |
| 192 | - | for _, transport := range transports { | 14 | + | ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: proxy.contentSize} |
| 193 | - | dialer := portsmocks.NewMockDialer(t) | 15 | + | ac.output.attachment = ac |
| 194 | - | factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once() | 16 | + | ac.clientID[0] = id |
| 195 | - | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | 17 | + | ac.setSession(proxy) |
| 196 | - | } | 18 | + | proxy.sessionCore.mu.Lock() |
| 197 | - | return factory | 19 | + | require.True(t, proxy.registerAttachmentLocked(ac)) |
| 198 | - | } | 20 | + | proxy.sessionCore.mu.Unlock() |
| 199 | - | 21 | + | return ac, transport | |
| 200 | - | func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello { | |||
| 201 | - | t.Helper() | |||
| 202 | - | select { | |||
| 203 | - | case frame := <-tr.sent: | |||
| 204 | - | require.Equal(t, ports.MsgHello, frame.Type) | |||
| 205 | - | hello, err := ports.UnmarshalHello(frame.Payload) | |||
| 206 | - | require.NoError(t, err) | |||
| 207 | - | return hello | |||
| 208 | - | case <-time.After(time.Second): | |||
| 209 | - | t.Fatal("timed out waiting for proxy Hello") | |||
| 210 | - | return ports.Hello{} | |||
| 211 | - | } | |||
| 212 | - | } | |||
| 213 | - | ||||
| 214 | - | func stopProxy(t *testing.T, p *proxySession) { | |||
| 215 | - | t.Helper() | |||
| 216 | - | p.stop() | |||
| 217 | - | select { | |||
| 218 | - | case <-p.done: | |||
| 219 | - | case <-time.After(time.Second): | |||
| 220 | - | t.Fatal("timed out waiting for proxy link to stop") | |||
| 221 | - | } | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) { | |||
| 225 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 226 | - | tr := newProxyTestTransport() | |||
| 227 | - | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{}) | |||
| 228 | - | ||||
| 229 | - | result := make(chan struct { | |||
| 230 | - | proxy *proxySession | |||
| 231 | - | err error | |||
| 232 | - | }, 1) | |||
| 233 | - | go func() { | |||
| 234 | - | proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | |||
| 235 | - | result <- struct { | |||
| 236 | - | proxy *proxySession | |||
| 237 | - | err error | |||
| 238 | - | }{proxy, err} | |||
| 239 | - | }() | |||
| 240 | - | ||||
| 241 | - | hello := requireProxyHello(t, tr) | |||
| 242 | - | require.Equal(t, ports.ProtocolVersion, hello.Version) | |||
| 243 | - | require.Equal(t, ports.IntentAttach, hello.Intent) | |||
| 244 | - | require.True(t, hello.Proxied) | |||
| 245 | - | require.Equal(t, key.Name, hello.Name) | |||
| 246 | - | require.Equal(t, contentSize(domain.Size{Cols: 80, Rows: 24}, false), hello.Size) | |||
| 247 | - | require.Equal(t, uint8(maxUnackedOutputStates), hello.MaxOutputInFlight) | |||
| 248 | - | require.Empty(t, proxySessionSnapshot(d), "Hello alone must not publish a proxy") | |||
| 249 | - | ||||
| 250 | - | tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 44, ports.CapabilityResume|ports.CapabilityProxied)} | |||
| 251 | - | require.Empty(t, proxySessionSnapshot(d), "Welcome alone must not publish a proxy") | |||
| 252 | - | tr.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 253 | - | tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 254 | - | ||||
| 255 | - | created := awaitTestValue(t, result, "proxy handshake did not complete") | |||
| 256 | - | require.NoError(t, created.err) | |||
| 257 | - | require.Same(t, created.proxy, proxySessionSnapshot(d)[key.ID()]) | |||
| 258 | - | require.Equal(t, key, created.proxy.key) | |||
| 259 | - | require.Equal(t, uint64(44), created.proxy.resumeToken) | |||
| 260 | - | stopProxy(t, created.proxy) | |||
| 261 | - | } | |||
| 262 | - | ||||
| 263 | - | func TestProxyHandshakeTimesOutBeforePublication(t *testing.T) { | |||
| 264 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 265 | - | tr := newProxyTestTransport() | |||
| 266 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 267 | - | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), clock) | |||
| 268 | - | ||||
| 269 | - | result := make(chan struct { | |||
| 270 | - | proxy *proxySession | |||
| 271 | - | err error | |||
| 272 | - | }, 1) | |||
| 273 | - | go func() { | |||
| 274 | - | proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | |||
| 275 | - | result <- struct { | |||
| 276 | - | proxy *proxySession | |||
| 277 | - | err error | |||
| 278 | - | }{proxy: proxy, err: err} | |||
| 279 | - | }() | |||
| 280 | - | ||||
| 281 | - | requireProxyHello(t, tr) | |||
| 282 | - | timer := awaitTestValue(t, clock.timers, "proxy handshake did not arm its timeout") | |||
| 283 | - | require.Equal(t, proxyHandshakeTimeout, timer.duration) | |||
| 284 | - | tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 285 | - | tr.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 286 | - | timer.ch <- time.Time{} | |||
| 287 | - | ||||
| 288 | - | opened := awaitTestValue(t, result, "proxy handshake timeout did not finish") | |||
| 289 | - | require.ErrorIs(t, opened.err, errProxyHandshakeTimeout) | |||
| 290 | - | require.Nil(t, opened.proxy) | |||
| 291 | - | require.Empty(t, proxySessionSnapshot(d), "timed-out handshake must not publish a proxy") | |||
| 292 | - | select { | |||
| 293 | - | case <-tr.closed: | |||
| 294 | - | default: | |||
| 295 | - | t.Fatal("timed-out handshake transport was not closed") | |||
| 296 | - | } | |||
| 297 | - | require.Zero(t, tr.receiving.Load(), "timed-out handshake receive pump outlived transport cleanup") | |||
| 298 | - | } | |||
| 299 | - | ||||
| 300 | - | func TestProxyHandshakeTimeoutClosesBlockedSends(t *testing.T) { | |||
| 301 | - | tests := []struct { | |||
| 302 | - | name string | |||
| 303 | - | ungatedSends int32 | |||
| 304 | - | queueHandshake bool | |||
| 305 | - | }{ | |||
| 306 | - | {name: "hello", queueHandshake: false}, | |||
| 307 | - | {name: "initial ACK", ungatedSends: 1, queueHandshake: true}, | |||
| 308 | - | } | |||
| 309 | - | for _, tt := range tests { | |||
| 310 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 311 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 312 | - | entered := make(chan struct{}, 1) | |||
| 313 | - | tr := newProxyTestTransportWithSendGate(make(chan struct{}), entered, tt.ungatedSends) | |||
| 314 | - | if tt.queueHandshake { | |||
| 315 | - | tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 316 | - | tr.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 317 | - | tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 318 | - | } | |||
| 319 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | |||
| 320 | - | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), clock) | |||
| 321 | - | result := make(chan error, 1) | |||
| 322 | - | go func() { | |||
| 323 | - | _, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | |||
| 324 | - | result <- err | |||
| 325 | - | }() | |||
| 326 | - | ||||
| 327 | - | if tt.queueHandshake { | |||
| 328 | - | requireProxyHello(t, tr) | |||
| 329 | - | } | |||
| 330 | - | awaitTestCompletion(t, entered, "proxy handshake send did not block") | |||
| 331 | - | timer := awaitTestValue(t, clock.timers, "proxy handshake did not arm its timeout") | |||
| 332 | - | require.Equal(t, proxyHandshakeTimeout, timer.duration) | |||
| 333 | - | timer.ch <- time.Time{} | |||
| 334 | - | ||||
| 335 | - | require.ErrorIs(t, awaitTestValue(t, result, "blocked handshake send did not finish"), errProxyHandshakeTimeout) | |||
| 336 | - | select { | |||
| 337 | - | case <-tr.closed: | |||
| 338 | - | default: | |||
| 339 | - | t.Fatal("timed-out handshake transport was not closed") | |||
| 340 | - | } | |||
| 341 | - | require.Zero(t, tr.receiving.Load(), "timed-out handshake receive pump outlived transport cleanup") | |||
| 342 | - | }) | |||
| 343 | - | } | |||
| 344 | - | } | |||
| 345 | - | ||||
| 346 | - | func TestProxyHandshakeContextUsesSystemClockWhenClockMissing(t *testing.T) { | |||
| 347 | - | d := &Daemon{} | |||
| 348 | - | ctx, timedOut, stop := d.proxyHandshakeContext(context.Background()) | |||
| 349 | - | defer stop() | |||
| 350 | - | require.NoError(t, ctx.Err()) | |||
| 351 | - | select { | |||
| 352 | - | case <-timedOut: | |||
| 353 | - | t.Fatal("missing clock caused an immediate handshake timeout") | |||
| 354 | - | default: | |||
| 355 | - | } | |||
| 356 | } | 22 | } | ||
| 357 | 23 | ||||
| 358 | - | func TestProxyHandshakeRejectsInvalidWelcomeWithoutPublication(t *testing.T) { | 24 | + | func TestProxyOutputRejectsStaleStateAndRequestsOneReset(t *testing.T) { |
| 359 | tests := []struct { | 25 | tests := []struct { | ||
| 360 | name string | 26 | name string | ||
| 361 | - | first ports.Frame | 27 | + | state ports.Output |
| 28 | + | stale ports.Output | |||
| 362 | }{ | 29 | }{ | ||
| 363 | - | {name: "missing proxied capability", first: proxyWelcome("work", 1, ports.CapabilityResume)}, | 30 | + | { |
| 364 | - | {name: "malformed welcome", first: ports.Frame{Type: ports.MsgWelcome, Payload: []byte{0xff}}}, | 31 | + | name: "duplicate full", |
| 365 | - | {name: "wrong first type", first: proxyMeta("work")}, | 32 | + | state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true}, |
| 33 | + | stale: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true, Data: []byte("duplicate")}, | |||
| 34 | + | }, | |||
| 35 | + | { | |||
| 36 | + | name: "older epoch", | |||
| 37 | + | state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true}, | |||
| 38 | + | stale: ports.Output{Epoch: 2, Base: 0, New: 5, Full: true, Data: []byte("older")}, | |||
| 39 | + | }, | |||
| 40 | + | { | |||
| 41 | + | name: "base-zero non-full", | |||
| 42 | + | state: ports.Output{Epoch: 3, Base: 0, New: 4, Full: true}, | |||
| 43 | + | stale: ports.Output{Epoch: 3, Base: 0, New: 5, Data: []byte("not full")}, | |||
| 44 | + | }, | |||
| 366 | } | 45 | } | ||
| 367 | for _, tt := range tests { | 46 | for _, tt := range tests { | ||
| 368 | t.Run(tt.name, func(t *testing.T) { | 47 | t.Run(tt.name, func(t *testing.T) { | ||
| 369 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 48 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24}) |
| 370 | - | tr := newProxyTestTransport() | 49 | + | require.NoError(t, err) |
| 371 | - | tr.recv <- proxyRecv{frame: tt.first} | 50 | + | proxy.linkGeneration = 1 |
| 372 | - | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{}) | 51 | + | ack, reset, changed := proxy.applyOutputForGeneration(1, tt.state) |
| 373 | - | 52 | + | require.Equal(t, uint64(4), ack) | |
| 374 | - | proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | 53 | + | require.False(t, reset) |
| 375 | - | require.Error(t, err) | 54 | + | require.Equal(t, len(tt.state.Data) != 0, changed) |
| 376 | - | require.Nil(t, proxy) | 55 | + | |
| 377 | - | require.Empty(t, proxySessionSnapshot(d)) | 56 | + | ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale) |
| 378 | - | select { | 57 | + | require.Zero(t, ack) |
| 379 | - | case <-tr.closed: | 58 | + | require.True(t, reset) |
| 380 | - | default: | 59 | + | require.False(t, changed) |
| 381 | - | t.Fatal("failed handshake transport was not closed") | 60 | + | ack, reset, changed = proxy.applyOutputForGeneration(1, tt.stale) |
| 382 | - | } | 61 | + | require.Zero(t, ack) |
| 62 | + | require.False(t, reset) | |||
| 63 | + | require.False(t, changed) | |||
| 383 | }) | 64 | }) | ||
| 384 | } | 65 | } | ||
| 385 | } | 66 | } | ||
| 386 | 67 | ||||
| 387 | - | func TestProxyRegistryDedupesByExactStructuredKey(t *testing.T) { | 68 | + | func TestProxyOutputFansOutToEveryLiveAttachment(t *testing.T) { |
| 388 | - | firstKey := domain.RemoteSessionKey{Host: "build.example", Name: "work"} | 69 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24}) |
| 389 | - | secondKey := domain.RemoteSessionKey{Host: "build", Name: "example-work"} | |||
| 390 | - | firstTransport := newProxyTestTransport() | |||
| 391 | - | firstTransport.recv <- proxyRecv{frame: proxyWelcome(firstKey.Name, 1, ports.CapabilityProxied)} | |||
| 392 | - | firstTransport.recv <- proxyRecv{frame: proxyMeta(firstKey.Name)} | |||
| 393 | - | firstTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 394 | - | secondTransport := newProxyTestTransport() | |||
| 395 | - | secondTransport.recv <- proxyRecv{frame: proxyWelcome(secondKey.Name, 2, ports.CapabilityProxied)} | |||
| 396 | - | secondTransport.recv <- proxyRecv{frame: proxyMeta(secondKey.Name)} | |||
| 397 | - | secondTransport.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 398 | - | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 399 | - | for key, transport := range map[domain.RemoteSessionKey]ports.Transport{firstKey: firstTransport, secondKey: secondTransport} { | |||
| 400 | - | dialer := portsmocks.NewMockDialer(t) | |||
| 401 | - | factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once() | |||
| 402 | - | dialer.EXPECT().Dial(mock.Anything).Return(transport, nil).Once() | |||
| 403 | - | } | |||
| 404 | - | d := newProxyTestDaemon(t, factory, stubClock{}) | |||
| 405 | - | ||||
| 406 | - | first, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 80, Rows: 24}) | |||
| 407 | - | require.NoError(t, err) | |||
| 408 | - | deduped, err := d.openProxySession(context.Background(), firstKey, domain.Size{Cols: 100, Rows: 30}) | |||
| 409 | - | require.NoError(t, err) | |||
| 410 | - | require.Same(t, first, deduped) | |||
| 411 | - | second, err := d.openProxySession(context.Background(), secondKey, domain.Size{Cols: 80, Rows: 24}) | |||
| 412 | - | require.NoError(t, err) | |||
| 413 | - | require.NotSame(t, first, second) | |||
| 414 | - | require.Len(t, proxySessionSnapshot(d), 2) | |||
| 415 | - | ||||
| 416 | - | stopProxy(t, first) | |||
| 417 | - | stopProxy(t, second) | |||
| 418 | - | } | |||
| 419 | - | ||||
| 420 | - | func TestProxyCancelDuringDialOrHandshakeNeverPublishes(t *testing.T) { | |||
| 421 | - | t.Run("dial", func(t *testing.T) { | |||
| 422 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 423 | - | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 424 | - | dialer := portsmocks.NewMockDialer(t) | |||
| 425 | - | factory.EXPECT().DialerForRemote(key.Host, key.Name, ports.RemoteTransportUDP, mock.Anything).Return(dialer, nil).Once() | |||
| 426 | - | dialEntered := make(chan struct{}) | |||
| 427 | - | dialer.EXPECT().Dial(mock.Anything).RunAndReturn(func(ctx context.Context) (ports.Transport, error) { | |||
| 428 | - | close(dialEntered) | |||
| 429 | - | <-ctx.Done() | |||
| 430 | - | return nil, ctx.Err() | |||
| 431 | - | }).Once() | |||
| 432 | - | d := newProxyTestDaemon(t, factory, stubClock{}) | |||
| 433 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 434 | - | result := make(chan struct { | |||
| 435 | - | proxy *proxySession | |||
| 436 | - | err error | |||
| 437 | - | }, 1) | |||
| 438 | - | go func() { | |||
| 439 | - | proxy, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24}) | |||
| 440 | - | result <- struct { | |||
| 441 | - | proxy *proxySession | |||
| 442 | - | err error | |||
| 443 | - | }{proxy: proxy, err: err} | |||
| 444 | - | }() | |||
| 445 | - | awaitTestCompletion(t, dialEntered, "proxy dial did not start") | |||
| 446 | - | cancel() | |||
| 447 | - | ||||
| 448 | - | opened := awaitTestValue(t, result, "canceled proxy dial did not finish") | |||
| 449 | - | proxy, err := opened.proxy, opened.err | |||
| 450 | - | require.ErrorIs(t, err, context.Canceled) | |||
| 451 | - | require.Nil(t, proxy) | |||
| 452 | - | require.Empty(t, proxySessionSnapshot(d)) | |||
| 453 | - | }) | |||
| 454 | - | ||||
| 455 | - | t.Run("handshake", func(t *testing.T) { | |||
| 456 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 457 | - | tr := newProxyTestTransport() | |||
| 458 | - | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{}) | |||
| 459 | - | ctx, cancel := context.WithCancel(context.Background()) | |||
| 460 | - | result := make(chan error, 1) | |||
| 461 | - | go func() { | |||
| 462 | - | _, err := d.openProxySession(ctx, key, domain.Size{Cols: 80, Rows: 24}) | |||
| 463 | - | result <- err | |||
| 464 | - | }() | |||
| 465 | - | requireProxyHello(t, tr) | |||
| 466 | - | cancel() | |||
| 467 | - | require.ErrorIs(t, awaitTestValue(t, result, "canceled proxy handshake did not finish"), context.Canceled) | |||
| 468 | - | require.Empty(t, proxySessionSnapshot(d)) | |||
| 469 | - | select { | |||
| 470 | - | case <-tr.closed: | |||
| 471 | - | default: | |||
| 472 | - | t.Fatal("cancellation did not close the handshaking transport") | |||
| 473 | - | } | |||
| 474 | - | }) | |||
| 475 | - | } | |||
| 476 | - | ||||
| 477 | - | func TestProxyLinkResumesAndReplacementIsTerminal(t *testing.T) { | |||
| 478 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 479 | - | first := newProxyTestTransport() | |||
| 480 | - | first.recv <- proxyRecv{frame: proxyWelcome(key.Name, 55, ports.CapabilityResume|ports.CapabilityProxied)} | |||
| 481 | - | first.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 482 | - | first.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 483 | - | second := newProxyTestTransport() | |||
| 484 | - | factory := proxyFactoryFor(t, key, first, second) | |||
| 485 | - | // The resume backoff is a ports.Clock deadline, so it is fired rather than | |||
| 486 | - | // waited out. The buffer also absorbs the warm timer armed by replacement. | |||
| 487 | - | clock := &signalClock{timers: make(chan *signalTimer, 8)} | |||
| 488 | - | d := newProxyTestDaemon(t, factory, clock) | |||
| 489 | - | ||||
| 490 | - | proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | |||
| 491 | require.NoError(t, err) | 70 | require.NoError(t, err) | ||
| 492 | - | firstHello := requireProxyHello(t, first) | 71 | + | remote := newProxyTestTransport() |
| 493 | - | require.Equal(t, ports.IntentAttach, firstHello.Intent) | 72 | + | proxy.transport = remote |
| 494 | - | handshakeTimer := awaitTestValue(t, clock.timers, "proxy handshake timeout was not registered") | 73 | + | proxy.linkGeneration = 1 |
| 495 | - | require.Equal(t, proxyHandshakeTimeout, handshakeTimer.duration) | 74 | + | d := &Daemon{} |
| 496 | - | ||||
| 497 | - | first.recv <- proxyRecv{err: io.EOF} | |||
| 498 | - | backoff := awaitTestValue(t, clock.timers, "proxy resume backoff timer was not armed") | |||
| 499 | - | require.Equal(t, proxyResumeInitialBackoff, backoff.duration) | |||
| 500 | - | backoff.ch <- time.Time{} | |||
| 501 | - | resumeHello := requireProxyHello(t, second) | |||
| 502 | - | require.Equal(t, ports.IntentResume, resumeHello.Intent) | |||
| 503 | - | require.Equal(t, uint64(55), resumeHello.ResumeToken) | |||
| 504 | - | require.Equal(t, firstHello.ClientID, resumeHello.ClientID) | |||
| 505 | - | second.recv <- proxyRecv{frame: proxyWelcome(key.Name, 66, ports.CapabilityResume|ports.CapabilityProxied)} | |||
| 506 | - | second.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 507 | - | second.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 508 | - | second.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonReplaced})}} | |||
| 509 | 75 | ||||
| 510 | - | select { | 76 | + | attachments := make([]*proxyTestTransport, 0, 2) |
| 511 | - | case <-proxy.done: | 77 | + | for id := byte(1); id <= 2; id++ { |
| 512 | - | case <-time.After(time.Second): | 78 | + | _, clientTransport := newProxyOutputAttachment(t, proxy, id) |
| 513 | - | t.Fatal("replacement did not terminate proxy link loop") | 79 | + | attachments = append(attachments, clientTransport) |
| 514 | } | 80 | } | ||
| 515 | - | proxy.mu.Lock() | |||
| 516 | - | require.True(t, proxy.expired) | |||
| 517 | - | require.Equal(t, uint64(66), proxy.resumeToken) | |||
| 518 | - | proxy.mu.Unlock() | |||
| 519 | - | require.Same(t, proxy, proxySessionSnapshot(d)[key.ID()], "replacement leaves the expired proxy available for local presentation") | |||
| 520 | - | } | |||
| 521 | - | ||||
| 522 | - | func TestRunProxyTransportStopsReceivePumpAfterInvalidFrame(t *testing.T) { | |||
| 523 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |||
| 524 | - | t.Cleanup(func() { _ = transport.Close() }) | |||
| 525 | - | transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}} | |||
| 526 | - | transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}} | |||
| 527 | - | transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgPong, Payload: ports.MarshalPong(ports.Pong{})}} | |||
| 528 | 81 | ||||
| 529 | - | result := d.runProxyTransport(context.Background(), proxy, generation, transport) | 82 | + | result, err := d.handleLinkFrame(proxy, 1, ports.Frame{ |
| 530 | - | require.Equal(t, proxyLinkStop, result) | 83 | + | Type: ports.MsgOutput, |
| 531 | - | select { | 84 | + | Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}), |
| 532 | - | case <-transport.closed: | 85 | + | }) |
| 533 | - | default: | |||
| 534 | - | t.Fatal("runProxyTransport returned without closing its receive pump transport") | |||
| 535 | - | } | |||
| 536 | - | require.Zero(t, transport.receiving.Load(), "receive pump outlived transport consumer") | |||
| 537 | - | } | |||
| 538 | - | ||||
| 539 | - | func TestProxySenderSerializesAllTransportWrites(t *testing.T) { | |||
| 540 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 541 | - | gate := make(chan struct{}) | |||
| 542 | - | entered := make(chan struct{}, 1) | |||
| 543 | - | tr := newProxyTestTransportWithSendGate(gate, entered, 2) | |||
| 544 | - | tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 545 | - | tr.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 546 | - | tr.recv <- proxyRecv{frame: proxyHandshakeSnapshot()} | |||
| 547 | - | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{}) | |||
| 548 | - | proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | |||
| 549 | require.NoError(t, err) | 86 | require.NoError(t, err) | ||
| 550 | - | _ = requireProxyHello(t, tr) | 87 | + | require.Equal(t, proxyLinkResume, result) |
| 551 | - | var wg sync.WaitGroup | 88 | + | for _, transport := range attachments { |
| 552 | - | errs := make(chan error, 8) | 89 | + | frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive output") |
| 553 | - | for range 8 { | 90 | + | require.Equal(t, ports.MsgOutput, frame.Type) |
| 554 | - | wg.Go(func() { | 91 | + | out, err := ports.UnmarshalOutput(frame.Payload) |
| 555 | - | errs <- proxy.sendCurrent(ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})}) | |||
| 556 | - | }) | |||
| 557 | - | } | |||
| 558 | - | // Once the first sender enters Send, the others must remain behind sendMu. | |||
| 559 | - | awaitTestCompletion(t, entered, "proxy sender did not enter transport") | |||
| 560 | - | close(gate) | |||
| 561 | - | wg.Wait() | |||
| 562 | - | close(errs) | |||
| 563 | - | for err := range errs { | |||
| 564 | require.NoError(t, err) | 92 | require.NoError(t, err) | ||
| 93 | + | require.Zero(t, out.New, "local proxy forwarding must not create a second ACK path") | |||
| 94 | + | require.Equal(t, []byte("full"), out.Data) | |||
| 565 | } | 95 | } | ||
| 566 | - | require.False(t, tr.concurrent.Load(), "Transport.Send calls overlapped") | 96 | + | ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive output acknowledgement") |
| 567 | - | stopProxy(t, proxy) | 97 | + | require.Equal(t, ports.MsgAck, ackFrame.Type) |
| 568 | } | 98 | } | ||
| 569 | 99 | ||||
| 570 | - | func TestProxyHandshakeRejectsMissingInitialScreenSnapshotWithoutPublication(t *testing.T) { | 100 | + | func TestProxyOutputAttachAfterStreamReceivesCurrentFullOutput(t *testing.T) { |
| 571 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | 101 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24}) |
| 572 | - | tr := newProxyTestTransport() | |||
| 573 | - | tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 574 | - | tr.recv <- proxyRecv{frame: proxyMeta(key.Name)} | |||
| 575 | - | tr.recv <- proxyRecv{err: io.EOF} | |||
| 576 | - | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{}) | |||
| 577 | - | ||||
| 578 | - | proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | |||
| 579 | - | require.Error(t, err) | |||
| 580 | - | require.Nil(t, proxy) | |||
| 581 | - | require.Empty(t, proxySessionSnapshot(d)) | |||
| 582 | - | } | |||
| 583 | - | ||||
| 584 | - | func TestProxyHandshakeRejectsInvalidInitialMetadata(t *testing.T) { | |||
| 585 | - | key := domain.RemoteSessionKey{Host: "arch", Name: "work"} | |||
| 586 | - | tr := newProxyTestTransport() | |||
| 587 | - | tr.recv <- proxyRecv{frame: proxyWelcome(key.Name, 1, ports.CapabilityProxied)} | |||
| 588 | - | tr.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgSessionMeta, Payload: []byte{0xff}}} | |||
| 589 | - | d := newProxyTestDaemon(t, proxyFactoryFor(t, key, tr), stubClock{}) | |||
| 590 | - | ||||
| 591 | - | proxy, err := d.openProxySession(context.Background(), key, domain.Size{Cols: 80, Rows: 24}) | |||
| 592 | - | require.Error(t, err) | |||
| 593 | - | require.Nil(t, proxy) | |||
| 594 | - | require.Empty(t, proxySessionSnapshot(d)) | |||
| 595 | - | } | |||
| 596 | - | ||||
| 597 | - | func TestProxyOpenValidatesDependenciesAndKey(t *testing.T) { | |||
| 598 | - | d := newProxyTestDaemon(t, nil, stubClock{}) | |||
| 599 | - | _, err := d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24}) | |||
| 600 | - | require.Error(t, err) | |||
| 601 | - | ||||
| 602 | - | factory := portsmocks.NewMockRemoteDialerFactory(t) | |||
| 603 | - | d = newProxyTestDaemon(t, factory, stubClock{}) | |||
| 604 | - | _, err = d.openProxySession(context.Background(), domain.RemoteSessionKey{Host: "arch", Name: "bad:name"}, domain.Size{Cols: 80, Rows: 24}) | |||
| 605 | - | require.Error(t, err) | |||
| 606 | - | require.Empty(t, proxySessionSnapshot(d)) | |||
| 607 | - | } | |||
| 608 | - | ||||
| 609 | - | func newProxyOutputSession(t *testing.T) (*Daemon, *proxySession, *proxyTestTransport, uint64) { | |||
| 610 | - | t.Helper() | |||
| 611 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24}) | |||
| 612 | - | require.NoError(t, err) | |||
| 613 | - | transport := newProxyTestTransport() | |||
| 614 | - | generation, _ := proxy.installTransport(transport, false) | |||
| 615 | - | return newProxyTestDaemon(t, nil, stubClock{}), proxy, transport, generation | |||
| 616 | - | } | |||
| 617 | - | ||||
| 618 | - | func TestProxyScreenResumeAndFreshLinksRequireMovingSnapshots(t *testing.T) { | |||
| 619 | - | d, proxy, first, generation := newProxyOutputSession(t) | |||
| 620 | - | snapshot, err := ports.UnmarshalScreenUpdate(proxyHandshakeSnapshot().Payload) | |||
| 621 | require.NoError(t, err) | 102 | require.NoError(t, err) | ||
| 622 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | 103 | + | remote := newProxyTestTransport() |
| 623 | - | _ = awaitTestValue(t, first.sent, "initial screen was not acknowledged") | 104 | + | proxy.transport = remote |
| 624 | - | proxy.mu.Lock() | 105 | + | proxy.linkGeneration = 1 |
| 625 | - | applied := proxy.appliedState | 106 | + | d := &Daemon{} |
| 626 | - | proxy.mu.Unlock() | |||
| 627 | - | ||||
| 628 | - | second := newProxyTestTransport() | |||
| 629 | - | generation, _ = proxy.installTransport(second, true) | |||
| 630 | - | proxy.mu.Lock() | |||
| 631 | - | require.Equal(t, applied, proxy.appliedState, "resume must preserve the applied state floor") | |||
| 632 | - | require.False(t, proxy.screenReady, "resume must wait for its replacement snapshot") | |||
| 633 | - | proxy.mu.Unlock() | |||
| 634 | - | gap := snapshot | |||
| 635 | - | gap.Kind = ports.ScreenUpdateDelta | |||
| 636 | - | gap.BaseStateNum, gap.NewStateNum = applied, applied+1 | |||
| 637 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap)) | |||
| 638 | - | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, second.sent, "resume delta did not request a reset").Type) | |||
| 639 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap)) | |||
| 640 | - | select { | |||
| 641 | - | case frame := <-second.sent: | |||
| 642 | - | t.Fatalf("duplicate resume gap sent %v", frame.Type) | |||
| 643 | - | default: | |||
| 644 | - | } | |||
| 645 | - | moving := snapshot | |||
| 646 | - | moving.NewStateNum = applied + 1 | |||
| 647 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, moving)) | |||
| 648 | - | require.Equal(t, ports.MsgAck, awaitTestValue(t, second.sent, "resume snapshot was not acknowledged").Type) | |||
| 649 | 107 | ||||
| 650 | - | third := newProxyTestTransport() | 108 | + | _, firstTransport := newProxyOutputAttachment(t, proxy, 1) |
| 651 | - | generation, _ = proxy.installTransport(third, false) | |||
| 652 | - | proxy.mu.Lock() | |||
| 653 | - | require.Zero(t, proxy.appliedState, "fresh link must clear the state floor") | |||
| 654 | - | require.False(t, proxy.screenReady) | |||
| 655 | - | proxy.mu.Unlock() | |||
| 656 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap)) | |||
| 657 | - | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, third.sent, "fresh delta did not request a reset").Type) | |||
| 658 | - | fresh := snapshot | |||
| 659 | - | fresh.NewStateNum = 1 | |||
| 660 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, fresh)) | |||
| 661 | - | require.Equal(t, ports.MsgAck, awaitTestValue(t, third.sent, "fresh state-one snapshot was not acknowledged").Type) | |||
| 662 | - | proxy.mu.Lock() | |||
| 663 | - | require.Equal(t, uint64(1), proxy.appliedState) | |||
| 664 | - | proxy.mu.Unlock() | |||
| 665 | - | } | |||
| 666 | 109 | ||||
| 667 | - | func TestProxyScreenStaleSnapshotsAndApplyFailuresRequestOneResetWithoutMutation(t *testing.T) { | 110 | + | handle := func(output ports.Output) { |
| 668 | - | newProxy := func(t *testing.T) (*Daemon, *proxySession, uint64) { | |||
| 669 | t.Helper() | 111 | t.Helper() | ||
| 670 | - | d, proxy, _, generation := newProxyOutputSession(t) | 112 | + | result, err := d.handleLinkFrame(proxy, 1, ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(output)}) |
| 671 | - | return d, proxy, generation | 113 | + | require.NoError(t, err) |
| 672 | - | } | 114 | + | require.Equal(t, proxyLinkResume, result) |
| 673 | - | ||||
| 674 | - | t.Run("stale snapshot while not ready", func(t *testing.T) { | |||
| 675 | - | d, proxy, generation := newProxy(t) | |||
| 676 | - | snapshot := screenSnapshotForProxy(t, proxy, 7) | |||
| 677 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | |||
| 678 | - | proxy.mu.Lock() | |||
| 679 | - | before := proxy.screen.frame.Clone() | |||
| 680 | - | proxy.screenReady = false | |||
| 681 | - | proxy.resetRequested = false | |||
| 682 | - | proxy.mu.Unlock() | |||
| 683 | - | ||||
| 684 | - | stale := snapshot | |||
| 685 | - | stale.NewStateNum = 7 | |||
| 686 | - | ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, stale) | |||
| 687 | - | require.Zero(t, ack) | |||
| 688 | - | require.True(t, reset, "a stale snapshot must request a reset while not ready") | |||
| 689 | - | require.False(t, changed) | |||
| 690 | - | proxy.mu.Lock() | |||
| 691 | - | require.Equal(t, uint64(7), proxy.appliedState) | |||
| 692 | - | require.False(t, proxy.screenReady) | |||
| 693 | - | require.True(t, proxy.resetRequested) | |||
| 694 | - | require.Equal(t, before, proxy.screen.frame) | |||
| 695 | - | proxy.mu.Unlock() | |||
| 696 | - | ||||
| 697 | - | ack, reset, changed = proxy.applyScreenUpdateForGeneration(generation, stale) | |||
| 698 | - | require.Zero(t, ack) | |||
| 699 | - | require.False(t, reset, "an already-requested reset must be deduplicated") | |||
| 700 | - | require.False(t, changed) | |||
| 701 | - | }) | |||
| 702 | - | ||||
| 703 | - | t.Run("snapshot apply failure", func(t *testing.T) { | |||
| 704 | - | d, proxy, generation := newProxy(t) | |||
| 705 | - | initial := screenSnapshotForProxy(t, proxy, 7) | |||
| 706 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial)) | |||
| 707 | - | proxy.mu.Lock() | |||
| 708 | - | before := proxy.screen.frame.Clone() | |||
| 709 | - | proxy.mu.Unlock() | |||
| 710 | - | invalid := initial | |||
| 711 | - | invalid.NewStateNum = 8 | |||
| 712 | - | invalid.Spans = invalid.Spans[:len(invalid.Spans)-1] | |||
| 713 | - | ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, invalid) | |||
| 714 | - | require.Zero(t, ack) | |||
| 715 | - | require.True(t, reset) | |||
| 716 | - | require.False(t, changed) | |||
| 717 | - | proxy.mu.Lock() | |||
| 718 | - | require.Equal(t, uint64(7), proxy.appliedState) | |||
| 719 | - | require.False(t, proxy.screenReady) | |||
| 720 | - | require.True(t, proxy.resetRequested) | |||
| 721 | - | require.Equal(t, before, proxy.screen.frame) | |||
| 722 | - | proxy.mu.Unlock() | |||
| 723 | - | _, reset, _ = proxy.applyScreenUpdateForGeneration(generation, invalid) | |||
| 724 | - | require.False(t, reset, "snapshot Apply failure reset was not deduplicated") | |||
| 725 | - | }) | |||
| 726 | - | ||||
| 727 | - | t.Run("delta apply failure", func(t *testing.T) { | |||
| 728 | - | d, proxy, generation := newProxy(t) | |||
| 729 | - | initial := screenSnapshotForProxy(t, proxy, 7) | |||
| 730 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial)) | |||
| 731 | - | proxy.mu.Lock() | |||
| 732 | - | before := proxy.screen.frame.Clone() | |||
| 733 | - | size := proxy.contentSize | |||
| 734 | - | proxy.mu.Unlock() | |||
| 735 | - | invalid := ports.ScreenUpdate{ | |||
| 736 | - | Kind: ports.ScreenUpdateDelta, BaseStateNum: 7, NewStateNum: 8, Size: size, | |||
| 737 | - | Spans: []ports.ScreenSpan{{Y: uint16(size.Rows), Cells: []renderer.Cell{{Rune: 'x'}}}}, | |||
| 738 | - | } | |||
| 739 | - | ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, invalid) | |||
| 740 | - | require.Zero(t, ack) | |||
| 741 | - | require.True(t, reset) | |||
| 742 | - | require.False(t, changed) | |||
| 743 | - | proxy.mu.Lock() | |||
| 744 | - | require.Equal(t, uint64(7), proxy.appliedState) | |||
| 745 | - | require.False(t, proxy.screenReady) | |||
| 746 | - | require.True(t, proxy.resetRequested) | |||
| 747 | - | require.Equal(t, before, proxy.screen.frame) | |||
| 748 | - | proxy.mu.Unlock() | |||
| 749 | - | _, reset, _ = proxy.applyScreenUpdateForGeneration(generation, invalid) | |||
| 750 | - | require.False(t, reset, "delta Apply failure reset was not deduplicated") | |||
| 751 | - | }) | |||
| 752 | - | } | |||
| 753 | - | ||||
| 754 | - | func screenSnapshotForProxy(t *testing.T, proxy *proxySession, state uint64) ports.ScreenUpdate { | |||
| 755 | - | t.Helper() | |||
| 756 | - | proxy.mu.Lock() | |||
| 757 | - | size := proxy.contentSize | |||
| 758 | - | proxy.mu.Unlock() | |||
| 759 | - | snapshot := blankSnapshot(size) | |||
| 760 | - | snapshot.NewStateNum = state | |||
| 761 | - | return snapshot | |||
| 762 | - | } | |||
| 763 | - | ||||
| 764 | - | func TestProxyScreenSnapshotReportsChanged(t *testing.T) { | |||
| 765 | - | _, proxy, _, generation := newProxyOutputSession(t) | |||
| 766 | - | snapshot := screenSnapshotForProxy(t, proxy, 1) | |||
| 767 | - | ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, snapshot) | |||
| 768 | - | require.Equal(t, uint64(1), ack) | |||
| 769 | - | require.False(t, reset) | |||
| 770 | - | require.True(t, changed, "every accepted snapshot invalidates the composed frame") | |||
| 771 | - | } | |||
| 772 | - | ||||
| 773 | - | func TestProxyScreenUpdate(t *testing.T) { | |||
| 774 | - | t.Run("initial snapshot", func(t *testing.T) { | |||
| 775 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |||
| 776 | - | snapshot := screenSnapshotForProxy(t, proxy, 1) | |||
| 777 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | |||
| 778 | - | require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "initial screen was not acknowledged").Type) | |||
| 779 | - | proxy.mu.Lock() | |||
| 780 | - | require.Equal(t, uint64(1), proxy.appliedState) | |||
| 781 | - | require.True(t, proxy.screenReady) | |||
| 782 | - | require.False(t, proxy.resetRequested) | |||
| 783 | - | proxy.mu.Unlock() | |||
| 784 | - | }) | |||
| 785 | - | ||||
| 786 | - | t.Run("matching delta", func(t *testing.T) { | |||
| 787 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |||
| 788 | - | snapshot := screenSnapshotForProxy(t, proxy, 1) | |||
| 789 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | |||
| 790 | - | _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged") | |||
| 791 | - | ||||
| 792 | - | delta := ports.ScreenUpdate{ | |||
| 793 | - | Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2, | |||
| 794 | - | Size: snapshot.Size, Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("delta")}}, | |||
| 795 | - | } | |||
| 796 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, delta)) | |||
| 797 | - | require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "screen delta was not acknowledged").Type) | |||
| 798 | - | proxy.mu.Lock() | |||
| 799 | - | require.Equal(t, uint64(2), proxy.appliedState) | |||
| 800 | - | require.Equal(t, 'd', proxy.screen.frame.At(0, 0).Rune) | |||
| 801 | - | proxy.mu.Unlock() | |||
| 802 | - | }) | |||
| 803 | - | ||||
| 804 | - | for _, tc := range []struct { | |||
| 805 | - | name string | |||
| 806 | - | base uint64 | |||
| 807 | - | new uint64 | |||
| 808 | - | }{ | |||
| 809 | - | {name: "duplicate", base: 0, new: 1}, | |||
| 810 | - | {name: "gap", base: 3, new: 4}, | |||
| 811 | - | } { | |||
| 812 | - | t.Run(tc.name+" requests exactly one reset", func(t *testing.T) { | |||
| 813 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |||
| 814 | - | snapshot := screenSnapshotForProxy(t, proxy, 1) | |||
| 815 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, snapshot)) | |||
| 816 | - | _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged") | |||
| 817 | - | ||||
| 818 | - | update := ports.ScreenUpdate{ | |||
| 819 | - | Kind: ports.ScreenUpdateDelta, BaseStateNum: tc.base, NewStateNum: tc.new, | |||
| 820 | - | Size: snapshot.Size, | |||
| 821 | - | } | |||
| 822 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, update)) | |||
| 823 | - | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "screen mismatch did not request reset").Type) | |||
| 824 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, update)) | |||
| 825 | - | select { | |||
| 826 | - | case frame := <-transport.sent: | |||
| 827 | - | t.Fatalf("duplicate %s sent %v", tc.name, frame.Type) | |||
| 828 | - | default: | |||
| 829 | - | } | |||
| 830 | - | proxy.mu.Lock() | |||
| 831 | - | require.Equal(t, uint64(1), proxy.appliedState) | |||
| 832 | - | require.False(t, proxy.screenReady) | |||
| 833 | - | require.True(t, proxy.resetRequested) | |||
| 834 | - | proxy.mu.Unlock() | |||
| 835 | - | }) | |||
| 836 | } | 115 | } | ||
| 116 | + | handle(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("before attach")}) | |||
| 117 | + | _ = awaitTestValue(t, firstTransport.sent, "first proxy attachment did not receive output") | |||
| 118 | + | _ = awaitTestValue(t, remote.sent, "proxy remote did not receive first acknowledgement") | |||
| 837 | 119 | ||||
| 838 | - | t.Run("awaited base-zero snapshot advances the floor", func(t *testing.T) { | 120 | + | _, secondTransport := newProxyOutputAttachment(t, proxy, 2) |
| 839 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |||
| 840 | - | initial := screenSnapshotForProxy(t, proxy, 5) | |||
| 841 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, initial)) | |||
| 842 | - | _ = awaitTestValue(t, transport.sent, "initial screen was not acknowledged") | |||
| 843 | - | ||||
| 844 | - | gap := ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, BaseStateNum: 9, NewStateNum: 10, Size: initial.Size} | |||
| 845 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, gap)) | |||
| 846 | - | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "screen gap did not request reset").Type) | |||
| 847 | - | ||||
| 848 | - | moving := initial | |||
| 849 | - | moving.NewStateNum = 6 | |||
| 850 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, moving)) | |||
| 851 | - | require.Equal(t, ports.MsgAck, awaitTestValue(t, transport.sent, "awaited snapshot was not acknowledged").Type) | |||
| 852 | - | proxy.mu.Lock() | |||
| 853 | - | require.Equal(t, uint64(6), proxy.appliedState) | |||
| 854 | - | require.True(t, proxy.screenReady) | |||
| 855 | - | require.False(t, proxy.resetRequested) | |||
| 856 | - | proxy.mu.Unlock() | |||
| 857 | - | }) | |||
| 858 | - | ||||
| 859 | - | t.Run("stale generation is ignored", func(t *testing.T) { | |||
| 860 | - | _, proxy, transport, oldGeneration := newProxyOutputSession(t) | |||
| 861 | - | replacement := newProxyTestTransport() | |||
| 862 | - | newGeneration, _ := proxy.installTransport(replacement, false) | |||
| 863 | - | require.NotEqual(t, oldGeneration, newGeneration) | |||
| 864 | - | ack, reset, changed := proxy.applyScreenUpdateForGeneration(oldGeneration, screenSnapshotForProxy(t, proxy, 1)) | |||
| 865 | - | require.Zero(t, ack) | |||
| 866 | - | require.False(t, reset) | |||
| 867 | - | require.False(t, changed) | |||
| 868 | - | proxy.mu.Lock() | |||
| 869 | - | require.Zero(t, proxy.appliedState) | |||
| 870 | - | proxy.mu.Unlock() | |||
| 871 | - | select { | |||
| 872 | - | case frame := <-transport.sent: | |||
| 873 | - | t.Fatalf("stale screen update sent %v", frame.Type) | |||
| 874 | - | default: | |||
| 875 | - | } | |||
| 876 | - | }) | |||
| 877 | - | ||||
| 878 | - | t.Run("ACK and reset send failures are resumable", func(t *testing.T) { | |||
| 879 | - | t.Run("ACK", func(t *testing.T) { | |||
| 880 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |||
| 881 | - | transport.sendFails.Store(true) | |||
| 882 | - | snapshot := screenSnapshotForProxy(t, proxy, 1) | |||
| 883 | - | payload, err := ports.MarshalScreenUpdate(snapshot) | |||
| 884 | - | require.NoError(t, err) | |||
| 885 | - | transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload}} | |||
| 886 | - | require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, generation, transport)) | |||
| 887 | - | }) | |||
| 888 | - | ||||
| 889 | - | t.Run("reset", func(t *testing.T) { | |||
| 890 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |||
| 891 | - | transport.sendFails.Store(true) | |||
| 892 | - | proxy.mu.Lock() | |||
| 893 | - | size := proxy.contentSize | |||
| 894 | - | proxy.mu.Unlock() | |||
| 895 | - | gap := ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2, Size: size} | |||
| 896 | - | payload, err := ports.MarshalScreenUpdate(gap) | |||
| 897 | - | require.NoError(t, err) | |||
| 898 | - | transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgScreenUpdate, Payload: payload}} | |||
| 899 | - | require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, generation, transport)) | |||
| 900 | - | }) | |||
| 901 | - | }) | |||
| 902 | - | ||||
| 903 | - | t.Run("blocked send releases proxy.mu", func(t *testing.T) { | |||
| 904 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |||
| 905 | - | gate := make(chan struct{}) | |||
| 906 | - | entered := make(chan struct{}, 1) | |||
| 907 | - | transport.sendGate = gate | |||
| 908 | - | transport.sendEntered = entered | |||
| 909 | - | snapshot := screenSnapshotForProxy(t, proxy, 1) | |||
| 910 | - | result := make(chan error, 1) | |||
| 911 | - | go func() { | |||
| 912 | - | result <- d.handleProxyScreenUpdate(proxy, generation, snapshot) | |||
| 913 | - | }() | |||
| 914 | - | awaitTestCompletion(t, entered, "screen ACK sender did not enter transport") | |||
| 915 | - | ||||
| 916 | - | unlocked := make(chan ports.Transport, 1) | |||
| 917 | - | go func() { | |||
| 918 | - | proxy.mu.Lock() | |||
| 919 | - | currentTransport := proxy.transport | |||
| 920 | - | proxy.mu.Unlock() | |||
| 921 | - | unlocked <- currentTransport | |||
| 922 | - | }() | |||
| 923 | - | var currentTransport ports.Transport | |||
| 924 | - | select { | |||
| 925 | - | case currentTransport = <-unlocked: | |||
| 926 | - | case <-time.After(time.Second): | |||
| 927 | - | t.Fatal("proxy.mu was held while screen send was blocked") | |||
| 928 | - | } | |||
| 929 | - | require.Same(t, transport, currentTransport) | |||
| 930 | - | require.NoError(t, transport.Close()) | |||
| 931 | - | require.Error(t, awaitTestValue(t, result, "blocked screen send did not return")) | |||
| 932 | - | }) | |||
| 933 | - | } | |||
| 934 | 121 | ||||
| 935 | - | // TestProxyLinkProtocolErrorStillStops fences malformed MsgOutput rejection | 122 | + | handle(ports.Output{Epoch: 4, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("current full")}) |
| 936 | - | // separately from resumable structured screen reply failures. | 123 | + | for _, transport := range []*proxyTestTransport{firstTransport, secondTransport} { |
| 937 | - | func TestProxyLinkProtocolErrorStillStops(t *testing.T) { | 124 | + | frame := awaitTestValue(t, transport.sent, "proxy attachment did not receive current output") |
| 938 | - | d, proxy, transport, generation := newProxyOutputSession(t) | 125 | + | require.Equal(t, ports.MsgOutput, frame.Type) |
| 939 | - | t.Cleanup(func() { _ = transport.Close() }) | 126 | + | out, err := ports.UnmarshalOutput(frame.Payload) |
| 940 | - | transport.recv <- proxyRecv{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}} | 127 | + | require.NoError(t, err) |
| 941 | - | 128 | + | require.Zero(t, out.New) | |
| 942 | - | require.Equal(t, proxyLinkStop, d.runProxyTransport(context.Background(), proxy, generation, transport)) | 129 | + | require.False(t, out.Full, "local proxy side effects must remain stateless") |
| 943 | - | } | 130 | + | require.Equal(t, []byte("current full"), out.Data) |
| 944 | - | ||||
| 945 | - | func proxySideEffectFrame(data []byte) ports.Frame { | |||
| 946 | - | return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{ | |||
| 947 | - | BaseStateNum: 0, | |||
| 948 | - | NewStateNum: 0, | |||
| 949 | - | Data: data, | |||
| 950 | - | })} | |||
| 951 | - | } | |||
| 952 | - | ||||
| 953 | - | func TestProxySideEffectForwardsOSC52ToCurrentThinClient(t *testing.T) { | |||
| 954 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 955 | - | remote := newProxyTestTransport() | |||
| 956 | - | thin := newProxyTestTransport() | |||
| 957 | - | proxy, ac := newAttachedProxyFixture(t, d, thin, remote) | |||
| 958 | - | ac.echoAck.Store(17) | |||
| 959 | - | ||||
| 960 | - | const osc52 = "\x1b]52;c;YQ==\a" | |||
| 961 | - | result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte(osc52))) | |||
| 962 | - | require.Equal(t, proxyLinkResume, result) | |||
| 963 | - | require.NoError(t, err) | |||
| 964 | - | ||||
| 965 | - | forwarded := awaitTestValue(t, thin.sent, "proxy OSC52 side effect was not forwarded") | |||
| 966 | - | require.Equal(t, ports.MsgOutput, forwarded.Type) | |||
| 967 | - | out, err := ports.UnmarshalOutput(forwarded.Payload) | |||
| 968 | - | require.NoError(t, err) | |||
| 969 | - | require.Zero(t, out.BaseStateNum) | |||
| 970 | - | require.Zero(t, out.NewStateNum) | |||
| 971 | - | require.Equal(t, uint64(17), out.EchoAck) | |||
| 972 | - | require.Equal(t, []byte(osc52), out.Data) | |||
| 973 | - | select { | |||
| 974 | - | case frame := <-thin.sent: | |||
| 975 | - | t.Fatalf("proxy OSC52 side effect forwarded more than once: %v", frame.Type) | |||
| 976 | - | default: | |||
| 977 | - | } | |||
| 978 | - | proxy.mu.Lock() | |||
| 979 | - | require.Zero(t, proxy.appliedState) | |||
| 980 | - | require.False(t, proxy.resetRequested) | |||
| 981 | - | require.True(t, proxy.screenReady, "side effect must not alter proxy screen readiness") | |||
| 982 | - | proxy.mu.Unlock() | |||
| 983 | - | select { | |||
| 984 | - | case frame := <-remote.sent: | |||
| 985 | - | t.Fatalf("side effect unexpectedly replied on proxy link: %v", frame.Type) | |||
| 986 | - | default: | |||
| 987 | } | 131 | } | ||
| 132 | + | ackFrame := awaitTestValue(t, remote.sent, "proxy remote did not receive current acknowledgement") | |||
| 133 | + | require.Equal(t, ports.MsgAck, ackFrame.Type) | |||
| 988 | } | 134 | } | ||
| 989 | 135 | ||||
| 990 | - | func TestProxySideEffectFansOutToEveryActiveAttachment(t *testing.T) { | 136 | + | func TestProxyOutputInvalidStateNeverForwardsBytes(t *testing.T) { |
| 991 | - | d := newTestDaemon(t, nil, stubClock{}) | 137 | + | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "host", Name: "session"}, domain.Size{Cols: 80, Rows: 24}) |
| 992 | - | remote := newProxyTestTransport() | |||
| 993 | - | firstTransport := newProxyTestTransport() | |||
| 994 | - | proxy, first := newAttachedProxyFixture(t, d, firstTransport, remote) | |||
| 995 | - | secondTransport := newProxyTestTransport() | |||
| 996 | - | second := &attachedClient{tr: secondTransport, output: newOutputStateStream()} | |||
| 997 | - | second.setSession(proxy) | |||
| 998 | - | proxy.sessionCore.mu.Lock() | |||
| 999 | - | require.True(t, proxy.sessionCore.registerAttachmentLocked(second)) | |||
| 1000 | - | proxy.sessionCore.mu.Unlock() | |||
| 1001 | - | first.echoAck.Store(11) | |||
| 1002 | - | second.echoAck.Store(22) | |||
| 1003 | - | ||||
| 1004 | - | result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect"))) | |||
| 1005 | - | require.Equal(t, proxyLinkResume, result) | |||
| 1006 | require.NoError(t, err) | 138 | require.NoError(t, err) | ||
| 1007 | - | for _, tc := range []struct { | |||
| 1008 | - | name string | |||
| 1009 | - | ch <-chan ports.Frame | |||
| 1010 | - | ack uint64 | |||
| 1011 | - | }{ | |||
| 1012 | - | {name: "first", ch: firstTransport.sent, ack: 11}, | |||
| 1013 | - | {name: "second", ch: secondTransport.sent, ack: 22}, | |||
| 1014 | - | } { | |||
| 1015 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 1016 | - | frame := awaitTestValue(t, tc.ch, tc.name+" attachment did not receive side effect") | |||
| 1017 | - | out, err := ports.UnmarshalOutput(frame.Payload) | |||
| 1018 | - | require.NoError(t, err) | |||
| 1019 | - | require.Equal(t, tc.ack, out.EchoAck) | |||
| 1020 | - | require.Equal(t, []byte("effect"), out.Data) | |||
| 1021 | - | }) | |||
| 1022 | - | } | |||
| 1023 | - | } | |||
| 1024 | - | ||||
| 1025 | - | func TestProxySideEffectSkipsStaleAttachmentAndForwardsLiveAttachment(t *testing.T) { | |||
| 1026 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1027 | remote := newProxyTestTransport() | 139 | remote := newProxyTestTransport() | ||
| 1028 | - | staleTransport := newProxyTestTransport() | 140 | + | proxy.transport = remote |
| 1029 | - | proxy, stale := newAttachedProxyFixture(t, d, staleTransport, remote) | 141 | + | proxy.linkGeneration = 1 |
| 1030 | - | liveTransport := newProxyTestTransport() | 142 | + | _, clientTransport := newProxyOutputAttachment(t, proxy, 1) |
| 1031 | - | live := &attachedClient{tr: liveTransport, output: newOutputStateStream()} | 143 | + | d := &Daemon{} |
| 1032 | - | live.setSession(proxy) | |||
| 1033 | - | proxy.sessionCore.mu.Lock() | |||
| 1034 | - | require.True(t, proxy.sessionCore.registerAttachmentLocked(live)) | |||
| 1035 | - | proxy.sessionCore.mu.Unlock() | |||
| 1036 | - | staleToken := attachmentToken(proxy, stale, stale.transport()) | |||
| 1037 | - | staleEffect, admitted := stale.beginAttachmentEffect(staleToken) | |||
| 1038 | - | require.True(t, admitted) | |||
| 1039 | - | staleEffect.End() | |||
| 1040 | - | stale.connectionGeneration.Add(1) | |||
| 1041 | 144 | ||||
| 1042 | - | result, err := d.handleLinkFrame(proxy, 1, proxySideEffectFrame([]byte("effect"))) | 145 | + | _, err = d.handleLinkFrame(proxy, 1, ports.Frame{ |
| 1043 | - | require.Equal(t, proxyLinkResume, result) | 146 | + | Type: ports.MsgOutput, |
| 147 | + | Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("full")}), | |||
| 148 | + | }) | |||
| 1044 | require.NoError(t, err) | 149 | require.NoError(t, err) | ||
| 1045 | - | awaitTestValue(t, liveTransport.sent, "live attachment did not receive side effect") | 150 | + | _ = awaitTestValue(t, clientTransport.sent, "proxy attachment did not receive initial output") |
| 1046 | - | select { | 151 | + | _ = awaitTestValue(t, remote.sent, "proxy remote did not receive initial acknowledgement") |
| 1047 | - | case frame := <-staleTransport.sent: | |||
| 1048 | - | t.Fatalf("stale attachment received side effect: %v", frame.Type) | |||
| 1049 | - | default: | |||
| 1050 | - | } | |||
| 1051 | - | } | |||
| 1052 | - | ||||
| 1053 | - | func TestProxySideEffectRejectsStateBearingAndMalformedMsgOutput(t *testing.T) { | |||
| 1054 | - | tests := []struct { | |||
| 1055 | - | name string | |||
| 1056 | - | frame ports.Frame | |||
| 1057 | - | }{ | |||
| 1058 | - | {name: "state bearing", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: 0, NewStateNum: 1, Data: []byte("screen")})}}, | |||
| 1059 | - | {name: "malformed", frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{1}}}, | |||
| 1060 | - | } | |||
| 1061 | - | for _, tt := range tests { | |||
| 1062 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 1063 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1064 | - | remote := newProxyTestTransport() | |||
| 1065 | - | thin := newProxyTestTransport() | |||
| 1066 | - | proxy, _ := newAttachedProxyFixture(t, d, thin, remote) | |||
| 1067 | - | ||||
| 1068 | - | result, err := d.handleLinkFrame(proxy, 1, tt.frame) | |||
| 1069 | - | require.Equal(t, proxyLinkStop, result) | |||
| 1070 | - | require.Error(t, err) | |||
| 1071 | - | select { | |||
| 1072 | - | case frame := <-thin.sent: | |||
| 1073 | - | t.Fatalf("invalid proxy output forwarded: %v", frame.Type) | |||
| 1074 | - | default: | |||
| 1075 | - | } | |||
| 1076 | - | }) | |||
| 1077 | - | } | |||
| 1078 | - | } | |||
| 1079 | - | ||||
| 1080 | - | func TestProxySideEffectDropsStaleGenerationAndRole(t *testing.T) { | |||
| 1081 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1082 | - | remote := newProxyTestTransport() | |||
| 1083 | - | thin := newProxyTestTransport() | |||
| 1084 | - | proxy, ac := newAttachedProxyFixture(t, d, thin, remote) | |||
| 1085 | - | frame := proxySideEffectFrame([]byte("effect")) | |||
| 1086 | 152 | ||||
| 1087 | - | result, err := d.handleLinkFrame(proxy, 0, frame) | 153 | + | result, err := d.handleLinkFrame(proxy, 1, ports.Frame{ |
| 1088 | - | require.Equal(t, proxyLinkResume, result) | 154 | + | Type: ports.MsgOutput, |
| 155 | + | Payload: mustMarshalOutput(ports.Output{Epoch: 3, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("stale")}), | |||
| 156 | + | }) | |||
| 1089 | require.NoError(t, err) | 157 | require.NoError(t, err) | ||
| 1090 | - | select { | |||
| 1091 | - | case forwarded := <-thin.sent: | |||
| 1092 | - | t.Fatalf("stale proxy generation forwarded side effect: %v", forwarded.Type) | |||
| 1093 | - | default: | |||
| 1094 | - | } | |||
| 1095 | - | ||||
| 1096 | - | token := attachmentToken(proxy, ac, ac.transport()) | |||
| 1097 | - | ticket, admitted := ac.beginAttachmentEffect(token) | |||
| 1098 | - | require.True(t, admitted) | |||
| 1099 | - | ticket.End() | |||
| 1100 | - | ac.connectionGeneration.Add(1) | |||
| 1101 | - | result, err = d.handleLinkFrame(proxy, 1, frame) | |||
| 1102 | require.Equal(t, proxyLinkResume, result) | 158 | require.Equal(t, proxyLinkResume, result) | ||
| 1103 | - | require.NoError(t, err) | |||
| 1104 | select { | 159 | select { | ||
| 1105 | - | case forwarded := <-thin.sent: | 160 | + | case frame := <-clientTransport.sent: |
| 1106 | - | t.Fatalf("stale attachment role forwarded side effect: %v", forwarded.Type) | 161 | + | t.Fatalf("stale proxy output forwarded as %v", frame.Type) |
| 1107 | default: | 162 | default: | ||
| 1108 | } | 163 | } | ||
| 1109 | - | } | 164 | + | reset := awaitTestValue(t, remote.sent, "proxy remote did not receive reset request") |
| 1110 | - | 165 | + | require.Equal(t, ports.MsgOutputResetRequest, reset.Type) | |
| 1111 | - | func TestProxySideEffectSendFailureResumesLink(t *testing.T) { | |||
| 1112 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 1113 | - | remote := newProxyTestTransport() | |||
| 1114 | - | thin := newProxyTestTransport() | |||
| 1115 | - | thin.sendFails.Store(true) | |||
| 1116 | - | proxy, _ := newAttachedProxyFixture(t, d, thin, remote) | |||
| 1117 | - | err := d.handleProxySideEffect(proxy, 1, ports.Output{Data: []byte("effect")}) | |||
| 1118 | - | require.ErrorContains(t, err, "link reply send failed") | |||
| 1119 | - | remote.recv <- proxyRecv{frame: proxySideEffectFrame([]byte("effect"))} | |||
| 1120 | - | ||||
| 1121 | - | require.Equal(t, proxyLinkResume, d.runProxyTransport(context.Background(), proxy, 1, remote)) | |||
| 1122 | } | 166 | } | ||
D internal/usecase/daemon/proxy_render.go +0 -166
| @@ -1,166 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "github.com/bnema/vev/internal/domain" | |||
| 5 | - | "github.com/bnema/vev/internal/ports" | |||
| 6 | - | "github.com/bnema/vev/internal/usecase/layout" | |||
| 7 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | const proxyRenderPaneID layout.PaneID = "proxy-content" | |||
| 11 | - | ||||
| 12 | - | type proxyCapture struct { | |||
| 13 | - | screen *proxyScreenState | |||
| 14 | - | frame renderer.Frame | |||
| 15 | - | damage []renderer.Damage | |||
| 16 | - | generation uint64 | |||
| 17 | - | cursor capturedCursorInputs | |||
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | // captureRenderState snapshots the remote VT while holding only proxy.mu. The | |||
| 21 | - | // snapshot is then composed by the ordinary local frame pipeline, which owns | |||
| 22 | - | // the bars, overlays, cursor, and renderer shadow. Remote ANSI is therefore | |||
| 23 | - | // never handed to a renderer. | |||
| 24 | - | func (p *proxySession) captureRenderState(ac *attachedClient, request renderCaptureRequest) (*capturedRenderState, bool) { | |||
| 25 | - | if p == nil || ac == nil { | |||
| 26 | - | return nil, false | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | p.mu.Lock() | |||
| 30 | - | if p.screen == nil { | |||
| 31 | - | p.mu.Unlock() | |||
| 32 | - | return nil, false | |||
| 33 | - | } | |||
| 34 | - | screen := p.screen | |||
| 35 | - | capture := &ac.proxyCapture | |||
| 36 | - | damage := screen.CaptureDamage() | |||
| 37 | - | if capture.screen != screen { | |||
| 38 | - | // A new screen has no relationship to the retained frame. | |||
| 39 | - | capture.frame = renderer.Frame{} | |||
| 40 | - | } | |||
| 41 | - | if capture.screen != screen || capture.generation != damage.Generation || capture.frame.Validate() != nil { | |||
| 42 | - | screen.CaptureInto(&capture.frame) | |||
| 43 | - | } | |||
| 44 | - | capture.screen = screen | |||
| 45 | - | capture.generation = damage.Generation | |||
| 46 | - | capture.damage = append(capture.damage[:0], damage.Damage...) | |||
| 47 | - | frame := capture.frame | |||
| 48 | - | capture.cursor = capturedCursorInputs{ | |||
| 49 | - | row: int(screen.cursorOut.Row), | |||
| 50 | - | col: int(screen.cursorOut.Col), | |||
| 51 | - | style: int(screen.cursorOut.Style), | |||
| 52 | - | hasStyle: screen.cursorOut.StyleSet, | |||
| 53 | - | visible: screen.cursorOut.Visible, | |||
| 54 | - | renderable: frame.Width > 0 && frame.Height > 0, | |||
| 55 | - | content: domain.Rect{Width: frame.Width, Height: frame.Height}, | |||
| 56 | - | } | |||
| 57 | - | cursor := capture.cursor | |||
| 58 | - | paneDamage := append([]renderer.Damage(nil), capture.damage...) | |||
| 59 | - | p.mu.Unlock() | |||
| 60 | - | ||||
| 61 | - | content := domain.Rect{Width: frame.Width, Height: frame.Height} | |||
| 62 | - | if uncertainDamage(paneDamage, frame.Width, frame.Height) { | |||
| 63 | - | paneDamage = []renderer.Damage{renderer.FullRedraw()} | |||
| 64 | - | } | |||
| 65 | - | state := &capturedRenderState{ | |||
| 66 | - | attachment: ac, | |||
| 67 | - | lease: request.lease, | |||
| 68 | - | reset: request.reset, | |||
| 69 | - | layout: capturedTabLayout{area: content, focus: proxyRenderPaneID, valid: true, fingerprint: "proxy"}, | |||
| 70 | - | bars: request.bars, | |||
| 71 | - | theme: request.bars.theme, | |||
| 72 | - | styles: request.styles, | |||
| 73 | - | styleGeneration: request.styleGeneration, | |||
| 74 | - | overlays: request.overlays, | |||
| 75 | - | preview: request.preview, | |||
| 76 | - | cursor: cursor, | |||
| 77 | - | receipts: []damageReceipt{{proxy: p, proxyScreen: screen, generation: damage.Generation}}, | |||
| 78 | - | panes: []capturedPaneRenderState{{ | |||
| 79 | - | id: proxyRenderPaneID, | |||
| 80 | - | frame: frame, | |||
| 81 | - | damage: paneDamage, | |||
| 82 | - | placement: layout.Placement{ID: proxyRenderPaneID, Content: content}, | |||
| 83 | - | focused: true, | |||
| 84 | - | }}, | |||
| 85 | - | } | |||
| 86 | - | return state, true | |||
| 87 | - | } | |||
| 88 | - | ||||
| 89 | - | // firstProxyPaintWithLease emits the same full first paint as a local | |||
| 90 | - | // transition, after making the remote content geometry match the local viewport. | |||
| 91 | - | func (d *Daemon) firstProxyPaintWithLease(p *proxySession, ac *attachedClient, lease *attachmentLease) { | |||
| 92 | - | if p == nil || ac == nil { | |||
| 93 | - | return | |||
| 94 | - | } | |||
| 95 | - | _, _ = p.resize(ac.size) | |||
| 96 | - | if rc := attachmentRenderCoordinator(p); rc != nil { | |||
| 97 | - | if rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"}) { | |||
| 98 | - | rc.fireCurrent(false) | |||
| 99 | - | } | |||
| 100 | - | return | |||
| 101 | - | } | |||
| 102 | - | d.invalidateRenderNow(p, ac, true, "proxy_render.go") | |||
| 103 | - | } | |||
| 104 | - | ||||
| 105 | - | // resizeProxyForLease accepts a local client resize only for its exact active | |||
| 106 | - | // attachment lease. Local chrome is repainted whenever the local content | |||
| 107 | - | // rectangle moved, including when the remote never learned about it: the local | |||
| 108 | - | // VT has already been resized either way, so skipping the repaint would leave | |||
| 109 | - | // the client showing chrome for the old geometry. | |||
| 110 | - | func (d *Daemon) resizeProxyForLease(p *proxySession, ac *attachedClient, lease *attachmentLease, size domain.Size) { | |||
| 111 | - | if p == nil || ac == nil || !size.Valid() { | |||
| 112 | - | return | |||
| 113 | - | } | |||
| 114 | - | if rc := attachmentRenderCoordinator(p); rc != nil && !rc.leaseCurrent(lease, false) { | |||
| 115 | - | return | |||
| 116 | - | } | |||
| 117 | - | if changed, _ := p.resize(size); !changed { | |||
| 118 | - | return | |||
| 119 | - | } | |||
| 120 | - | ac.sendMu.Lock() | |||
| 121 | - | ac.size = size | |||
| 122 | - | ac.sendMu.Unlock() | |||
| 123 | - | if rc := attachmentRenderCoordinator(p); rc != nil { | |||
| 124 | - | rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "proxy_render.go"}) | |||
| 125 | - | return | |||
| 126 | - | } | |||
| 127 | - | d.invalidateRender(p, ac, true, "proxy_render.go") | |||
| 128 | - | } | |||
| 129 | - | ||||
| 130 | - | // resize updates the local VT to the local content rectangle and sends that | |||
| 131 | - | // reduced geometry once to the remote daemon. The sender performs transport | |||
| 132 | - | // I/O only after proxy.mu has been released. | |||
| 133 | - | // | |||
| 134 | - | // changed reports whether the local content rectangle actually moved, which is | |||
| 135 | - | // what local chrome repaints depend on. sent reports whether the remote was | |||
| 136 | - | // told. The two are deliberately separate: a failed send leaves the local VT | |||
| 137 | - | // already resized, so the attached client still owes a repaint. | |||
| 138 | - | func (p *proxySession) resize(size domain.Size) (changed, sent bool) { | |||
| 139 | - | if p == nil || !size.Valid() { | |||
| 140 | - | return false, false | |||
| 141 | - | } | |||
| 142 | - | content := contentSize(size, false) | |||
| 143 | - | if !validProxyScreenSize(content) { | |||
| 144 | - | return false, false | |||
| 145 | - | } | |||
| 146 | - | p.mu.Lock() | |||
| 147 | - | if p.screen == nil || p.contentSize == content { | |||
| 148 | - | available := p.screen != nil | |||
| 149 | - | p.mu.Unlock() | |||
| 150 | - | return false, available | |||
| 151 | - | } | |||
| 152 | - | p.screen.ResizePlaceholder(content) | |||
| 153 | - | p.contentSize = content | |||
| 154 | - | // A resize invalidates the local replay chain, but the last applied state | |||
| 155 | - | // remains the moving snapshot floor for the new geometry. | |||
| 156 | - | p.screenReady = false | |||
| 157 | - | p.resetRequested = false | |||
| 158 | - | p.screen.stateNum = 0 | |||
| 159 | - | generation := p.linkGeneration | |||
| 160 | - | p.mu.Unlock() | |||
| 161 | - | ||||
| 162 | - | return true, p.sendGeneration(generation, ports.Frame{ | |||
| 163 | - | Type: ports.MsgResize, | |||
| 164 | - | Payload: ports.MarshalResize(ports.Resize{Size: content}), | |||
| 165 | - | }) == nil | |||
| 166 | - | } | |||
D internal/usecase/daemon/proxy_render_test.go +0 -409
| @@ -1,409 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "fmt" | |||
| 5 | - | "io" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/internal/domain" | |||
| 9 | - | "github.com/bnema/vev/internal/ports" | |||
| 10 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | - | "github.com/stretchr/testify/mock" | |||
| 13 | - | "github.com/stretchr/testify/require" | |||
| 14 | - | ) | |||
| 15 | - | ||||
| 16 | - | func TestProxyCaptureRetainsOwnedFrameAcrossSafeDamage(t *testing.T) { | |||
| 17 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 4, Rows: 3}) | |||
| 18 | - | require.NoError(t, err) | |||
| 19 | - | proxy.mu.Lock() | |||
| 20 | - | generation := proxy.linkGeneration | |||
| 21 | - | snapshot := screenSnapshot(4, 3, "abcd", "efgh", "ijkl") | |||
| 22 | - | require.NoError(t, proxy.screen.Apply(snapshot)) | |||
| 23 | - | proxy.screenReady = true | |||
| 24 | - | proxy.appliedState = snapshot.NewStateNum | |||
| 25 | - | proxy.mu.Unlock() | |||
| 26 | - | ||||
| 27 | - | ac := &attachedClient{} | |||
| 28 | - | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{}) | |||
| 29 | - | require.True(t, ok) | |||
| 30 | - | require.Equal(t, "abcdefghijkl", proxyFrameText(state.panes[0].frame)) | |||
| 31 | - | commitDamageReceipts(state.receipts) | |||
| 32 | - | ||||
| 33 | - | proxy.mu.Lock() | |||
| 34 | - | delta := ports.ScreenUpdate{ | |||
| 35 | - | Kind: ports.ScreenUpdateDelta, | |||
| 36 | - | BaseStateNum: 1, | |||
| 37 | - | NewStateNum: 2, | |||
| 38 | - | Size: domain.Size{Cols: 4, Rows: 3}, | |||
| 39 | - | Spans: []ports.ScreenSpan{{Y: 1, X: 1, Cells: cells("Z")}}, | |||
| 40 | - | Cursor: ports.ScreenCursor{Visible: true}, | |||
| 41 | - | } | |||
| 42 | - | require.NoError(t, proxy.screen.Apply(delta)) | |||
| 43 | - | proxy.mu.Unlock() | |||
| 44 | - | ||||
| 45 | - | cellsBefore := &ac.proxyCapture.frame.Cells[0] | |||
| 46 | - | state, ok = proxy.captureRenderState(ac, renderCaptureRequest{}) | |||
| 47 | - | require.True(t, ok) | |||
| 48 | - | require.Equal(t, "abcdeZghijkl", proxyFrameText(state.panes[0].frame)) | |||
| 49 | - | require.Equal(t, cellsBefore, &ac.proxyCapture.frame.Cells[0], "safe span damage must update the retained frame") | |||
| 50 | - | require.Equal(t, generation, proxy.linkGeneration) | |||
| 51 | - | } | |||
| 52 | - | ||||
| 53 | - | func TestProxyRenderComposesRemoteVTUnderExactlyOneLocalChrome(t *testing.T) { | |||
| 54 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6}) | |||
| 55 | - | require.NoError(t, err) | |||
| 56 | - | proxy.mu.Lock() | |||
| 57 | - | proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}} | |||
| 58 | - | proxy.mu.Unlock() | |||
| 59 | - | changed := applyTestScreenText(proxy, 1, 2, "remote") | |||
| 60 | - | require.True(t, changed) | |||
| 61 | - | ||||
| 62 | - | state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{ | |||
| 63 | - | bars: barState{status: proxy.statusSegments(false)}, | |||
| 64 | - | reset: true, | |||
| 65 | - | }) | |||
| 66 | - | require.True(t, ok) | |||
| 67 | - | composed := composeFrame(*state, composeCacheInput{}) | |||
| 68 | - | ||||
| 69 | - | require.Equal(t, 6, composed.frame.Height) | |||
| 70 | - | require.Contains(t, proxyRowText(composed.frame.Row(0)), "shell", "one local top bar") | |||
| 71 | - | require.Contains(t, proxyRowText(composed.frame.Row(5)), "work@arch", "one local bottom bar") | |||
| 72 | - | require.NotContains(t, proxyRowText(composed.frame.Row(1)), "shell") | |||
| 73 | - | require.NotContains(t, proxyRowText(composed.frame.Row(4)), "work@arch") | |||
| 74 | - | require.Equal(t, 'r', composed.frame.At(2, 2).Rune, "ANSI was applied only by the local VT") | |||
| 75 | - | require.False(t, composed.cursor.hidden) | |||
| 76 | - | require.Equal(t, 2, composed.cursor.row, "cursor includes exactly the one local top bar") | |||
| 77 | - | require.Contains(t, composed.damage, renderer.FullRedraw()) | |||
| 78 | - | } | |||
| 79 | - | ||||
| 80 | - | func TestProxyRenderPaintsAfterLocalToRemoteHandoff(t *testing.T) { | |||
| 81 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6}) | |||
| 82 | - | require.NoError(t, err) | |||
| 83 | - | proxy.mu.Lock() | |||
| 84 | - | proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}} | |||
| 85 | - | proxy.mu.Unlock() | |||
| 86 | - | changed := applyTestScreenText(proxy, 0, 0, "remote") | |||
| 87 | - | require.True(t, changed) | |||
| 88 | - | ||||
| 89 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 90 | - | transport, sent := newCapturingTransport(t) | |||
| 91 | - | ac := &attachedClient{tr: transport, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | |||
| 92 | - | ac.setSession(proxy) | |||
| 93 | - | proxy.sessionCore.mu.Lock() | |||
| 94 | - | proxy.sessionCore.registerAttachmentLocked(ac) | |||
| 95 | - | proxy.sessionCore.mu.Unlock() | |||
| 96 | - | ||||
| 97 | - | d.paint(proxy, ac, true, nil) | |||
| 98 | - | frame := awaitTestValue(t, sent, "handoff to remote proxy did not paint") | |||
| 99 | - | require.Equal(t, ports.MsgOutput, frame.Type, "handoff to remote proxy gets a local first paint") | |||
| 100 | - | } | |||
| 101 | - | ||||
| 102 | - | func TestProxyRenderTransitionsPaintBothLocalAndRemoteSessions(t *testing.T) { | |||
| 103 | - | d, local, ac, sent := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 104 | - | localCoordinator := d.attachCoordinator(local, ac, true) | |||
| 105 | - | localToken := attachmentToken(local, ac, ac.transport()) | |||
| 106 | - | localToken.lease = localCoordinator.attachmentLease(ac) | |||
| 107 | - | ||||
| 108 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, ac.size) | |||
| 109 | - | require.NoError(t, err) | |||
| 110 | - | proxy.mu.Lock() | |||
| 111 | - | proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}} | |||
| 112 | - | proxy.mu.Unlock() | |||
| 113 | - | changed := applyTestScreenText(proxy, 0, 0, "remote") | |||
| 114 | - | require.True(t, changed) | |||
| 115 | - | d.mu.Lock() | |||
| 116 | - | require.True(t, d.registerSessionLocked(proxy)) | |||
| 117 | - | d.mu.Unlock() | |||
| 118 | - | ||||
| 119 | - | toProxy, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 120 | - | source: local, target: proxy, next: ac, | |||
| 121 | - | ||||
| 122 | - | expectedTransport: ac.transportSnapshot(), sourceToken: &localToken, | |||
| 123 | - | action: "test transition", ready: true, | |||
| 124 | - | }) | |||
| 125 | - | require.NoError(t, err) | |||
| 126 | - | require.NotNil(t, toProxy.published.lease, "proxy transition installs a coordinator lease") | |||
| 127 | - | require.True(t, d.firstPaintForTransition(toProxy.published)) | |||
| 128 | - | require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to proxy did not paint").Type) | |||
| 129 | - | ||||
| 130 | - | toLocal, err := d.transitionAttachment(attachmentTransitionRequest{ | |||
| 131 | - | source: proxy, target: local, next: ac, | |||
| 132 | - | ||||
| 133 | - | expectedTransport: ac.transportSnapshot(), sourceToken: &toProxy.published, | |||
| 134 | - | action: "test transition", ready: true, | |||
| 135 | - | }) | |||
| 136 | - | require.NoError(t, err) | |||
| 137 | - | require.True(t, d.firstPaintForTransition(toLocal.published)) | |||
| 138 | - | require.Equal(t, ports.MsgOutput, awaitTestValue(t, sent, "transition to local session did not paint").Type) | |||
| 139 | - | } | |||
| 140 | - | ||||
| 141 | - | func TestProxyResizeRejectsOversizedGeometryBeforeMutationOrForwarding(t *testing.T) { | |||
| 142 | - | for _, size := range []domain.Size{ | |||
| 143 | - | {Cols: 512, Rows: 515}, | |||
| 144 | - | {Cols: 1000, Rows: 1000}, | |||
| 145 | - | } { | |||
| 146 | - | t.Run(fmt.Sprintf("%dx%d", size.Cols, size.Rows), func(t *testing.T) { | |||
| 147 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 80, Rows: 24}) | |||
| 148 | - | require.NoError(t, err) | |||
| 149 | - | transport := newProxyTestTransport() | |||
| 150 | - | proxy.mu.Lock() | |||
| 151 | - | proxy.transport = transport | |||
| 152 | - | proxy.linkGeneration = 1 | |||
| 153 | - | initial := proxy.contentSize | |||
| 154 | - | proxy.mu.Unlock() | |||
| 155 | - | ||||
| 156 | - | changed, sent := proxy.resize(size) | |||
| 157 | - | require.False(t, changed) | |||
| 158 | - | require.False(t, sent) | |||
| 159 | - | proxy.mu.Lock() | |||
| 160 | - | require.Equal(t, initial, proxy.contentSize) | |||
| 161 | - | proxy.mu.Unlock() | |||
| 162 | - | select { | |||
| 163 | - | case frame := <-transport.sent: | |||
| 164 | - | t.Fatalf("oversized resize was forwarded: %#v", frame) | |||
| 165 | - | default: | |||
| 166 | - | } | |||
| 167 | - | }) | |||
| 168 | - | } | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | - | func TestProxyResizeForLeaseRejectsOversizedGeometryBeforeMutationOrPaint(t *testing.T) { | |||
| 172 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 173 | - | client, clientSent := newCapturingTransport(t) | |||
| 174 | - | remote := newProxyTestTransport() | |||
| 175 | - | proxy, ac := newAttachedProxyFixture(t, d, client, remote) | |||
| 176 | - | initialContent := proxy.contentSize | |||
| 177 | - | initialViewport := ac.size | |||
| 178 | - | ||||
| 179 | - | d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 1000, Rows: 1000}) | |||
| 180 | - | ||||
| 181 | - | proxy.mu.Lock() | |||
| 182 | - | require.Equal(t, initialContent, proxy.contentSize) | |||
| 183 | - | proxy.mu.Unlock() | |||
| 184 | - | require.Equal(t, initialViewport, ac.size) | |||
| 185 | - | select { | |||
| 186 | - | case frame := <-clientSent: | |||
| 187 | - | t.Fatalf("oversized resize painted the client: %#v", frame) | |||
| 188 | - | default: | |||
| 189 | - | } | |||
| 190 | - | select { | |||
| 191 | - | case frame := <-remote.sent: | |||
| 192 | - | t.Fatalf("oversized resize was forwarded: %#v", frame) | |||
| 193 | - | default: | |||
| 194 | - | } | |||
| 195 | - | } | |||
| 196 | - | ||||
| 197 | - | func TestProxyResizeKeepsPlaceholderUntilSnapshot(t *testing.T) { | |||
| 198 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 4, Rows: 4}) | |||
| 199 | - | require.NoError(t, err) | |||
| 200 | - | transport := newProxyTestTransport() | |||
| 201 | - | proxy.mu.Lock() | |||
| 202 | - | initial := screenSnapshot(4, 2, "abcd", "efgh") | |||
| 203 | - | initial.NewStateNum = 7 | |||
| 204 | - | require.NoError(t, proxy.screen.Apply(initial)) | |||
| 205 | - | proxy.transport = transport | |||
| 206 | - | proxy.linkGeneration = 1 | |||
| 207 | - | proxy.screenReady = true | |||
| 208 | - | proxy.appliedState = initial.NewStateNum | |||
| 209 | - | proxy.mu.Unlock() | |||
| 210 | - | ||||
| 211 | - | changed, sent := proxy.resize(domain.Size{Cols: 3, Rows: 5}) | |||
| 212 | - | require.True(t, changed) | |||
| 213 | - | require.True(t, sent) | |||
| 214 | - | awaitTestValue(t, transport.sent, "proxy resize did not reach remote transport") | |||
| 215 | - | ||||
| 216 | - | proxy.mu.Lock() | |||
| 217 | - | require.Equal(t, domain.Size{Cols: 3, Rows: 3}, proxy.contentSize) | |||
| 218 | - | require.Equal(t, "abcefg ", proxyFrameText(proxy.screen.frame)) | |||
| 219 | - | require.Equal(t, uint64(7), proxy.appliedState, "resize must preserve the moving snapshot floor") | |||
| 220 | - | require.False(t, proxy.screenReady) | |||
| 221 | - | require.False(t, proxy.resetRequested) | |||
| 222 | - | require.Zero(t, proxy.screen.stateNum) | |||
| 223 | - | proxy.mu.Unlock() | |||
| 224 | - | ||||
| 225 | - | _, requestReset, changed := proxy.applyScreenUpdateForGeneration(1, ports.ScreenUpdate{ | |||
| 226 | - | Kind: ports.ScreenUpdateDelta, | |||
| 227 | - | BaseStateNum: 7, | |||
| 228 | - | NewStateNum: 8, | |||
| 229 | - | Size: domain.Size{Cols: 3, Rows: 3}, | |||
| 230 | - | Cursor: ports.ScreenCursor{Visible: true}, | |||
| 231 | - | }) | |||
| 232 | - | require.True(t, requestReset) | |||
| 233 | - | require.False(t, changed) | |||
| 234 | - | ||||
| 235 | - | moving := screenSnapshot(3, 3, "123", "456", "789") | |||
| 236 | - | moving.NewStateNum = 8 | |||
| 237 | - | _, requestReset, changed = proxy.applyScreenUpdateForGeneration(1, moving) | |||
| 238 | - | require.False(t, requestReset) | |||
| 239 | - | require.True(t, changed) | |||
| 240 | - | proxy.mu.Lock() | |||
| 241 | - | require.Equal(t, uint64(8), proxy.appliedState) | |||
| 242 | - | require.Greater(t, proxy.appliedState, uint64(7)) | |||
| 243 | - | require.True(t, proxy.screenReady) | |||
| 244 | - | require.False(t, proxy.resetRequested) | |||
| 245 | - | proxy.mu.Unlock() | |||
| 246 | - | } | |||
| 247 | - | ||||
| 248 | - | func TestProxyResizeUsesReducedRemoteGeometryOnce(t *testing.T) { | |||
| 249 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 12, Rows: 8}) | |||
| 250 | - | require.NoError(t, err) | |||
| 251 | - | transport := newProxyTestTransport() | |||
| 252 | - | proxy.mu.Lock() | |||
| 253 | - | proxy.transport = transport | |||
| 254 | - | proxy.linkGeneration = 1 | |||
| 255 | - | proxy.screenReady = true | |||
| 256 | - | proxy.mu.Unlock() | |||
| 257 | - | ||||
| 258 | - | changed, sent := proxy.resize(domain.Size{Cols: 20, Rows: 10}) | |||
| 259 | - | require.True(t, changed) | |||
| 260 | - | require.True(t, sent) | |||
| 261 | - | frame := awaitTestValue(t, transport.sent, "proxy resize did not reach remote transport") | |||
| 262 | - | require.Equal(t, ports.MsgResize, frame.Type) | |||
| 263 | - | resize, err := ports.UnmarshalResize(frame.Payload) | |||
| 264 | - | require.NoError(t, err) | |||
| 265 | - | require.Equal(t, domain.Size{Cols: 20, Rows: 8}, resize.Size) | |||
| 266 | - | select { | |||
| 267 | - | case extra := <-transport.sent: | |||
| 268 | - | t.Fatalf("unexpected second remote resize: %#v", extra) | |||
| 269 | - | default: | |||
| 270 | - | } | |||
| 271 | - | ||||
| 272 | - | state, ok := proxy.captureRenderState(&attachedClient{}, renderCaptureRequest{reset: true}) | |||
| 273 | - | require.True(t, ok) | |||
| 274 | - | composed := composeFrame(*state, composeCacheInput{}) | |||
| 275 | - | require.Equal(t, 10, composed.frame.Height, "local viewport remains full-sized") | |||
| 276 | - | require.Equal(t, 8, state.layout.area.Height) | |||
| 277 | - | } | |||
| 278 | - | ||||
| 279 | - | // newAttachedProxyFixture publishes one proxy with a local thin client bound to | |||
| 280 | - | // clientTr and its remote link bound to remoteTr, using no render coordinator so | |||
| 281 | - | // invalidation paints synchronously. | |||
| 282 | - | func newAttachedProxyFixture(t *testing.T, d *Daemon, clientTr ports.Transport, remoteTr ports.Transport) (*proxySession, *attachedClient) { | |||
| 283 | - | t.Helper() | |||
| 284 | - | proxy, err := newProxySession(domain.RemoteSessionKey{Host: "arch", Name: "work"}, domain.Size{Cols: 16, Rows: 6}) | |||
| 285 | - | require.NoError(t, err) | |||
| 286 | - | proxy.mu.Lock() | |||
| 287 | - | proxy.meta = ports.SessionMeta{SessionName: "work", Tabs: []ports.SessionTabMeta{{Index: 0, Name: "shell"}}} | |||
| 288 | - | proxy.transport = remoteTr | |||
| 289 | - | proxy.linkGeneration = 1 | |||
| 290 | - | proxy.screenReady = true | |||
| 291 | - | proxy.mu.Unlock() | |||
| 292 | - | d.mu.Lock() | |||
| 293 | - | require.True(t, d.registerSessionLocked(proxy)) | |||
| 294 | - | d.mu.Unlock() | |||
| 295 | - | ||||
| 296 | - | ac := &attachedClient{tr: clientTr, output: newOutputStateStream(), size: domain.Size{Cols: 16, Rows: 6}} | |||
| 297 | - | ac.setSession(proxy) | |||
| 298 | - | proxy.sessionCore.mu.Lock() | |||
| 299 | - | proxy.sessionCore.registerAttachmentLocked(ac) | |||
| 300 | - | proxy.sessionCore.mu.Unlock() | |||
| 301 | - | return proxy, ac | |||
| 302 | - | } | |||
| 303 | - | ||||
| 304 | - | func newFailingTransport(t *testing.T) *portsmocks.MockTransport { | |||
| 305 | - | t.Helper() | |||
| 306 | - | tr := portsmocks.NewMockTransport(t) | |||
| 307 | - | tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe() | |||
| 308 | - | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 309 | - | return tr | |||
| 310 | - | } | |||
| 311 | - | ||||
| 312 | - | func TestProxyStructuredSendFailureRetainsProxyDamage(t *testing.T) { | |||
| 313 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 314 | - | clientTr := newFailingTransport(t) | |||
| 315 | - | proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport()) | |||
| 316 | - | ac.proxied = true | |||
| 317 | - | ac.screenOutput = newStructuredOutputStream(ac.output) | |||
| 318 | - | require.True(t, applyTestScreenText(proxy, 0, 0, "pending")) | |||
| 319 | - | ||||
| 320 | - | ac.sendMu.Lock() | |||
| 321 | - | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true}) | |||
| 322 | - | require.True(t, ok) | |||
| 323 | - | composed := composeFrame(*state, composeCacheInput{}) | |||
| 324 | - | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |||
| 325 | - | ||||
| 326 | - | proxy.mu.Lock() | |||
| 327 | - | damage := proxy.screen.CaptureDamage() | |||
| 328 | - | proxy.mu.Unlock() | |||
| 329 | - | require.NotEmpty(t, damage.Damage, "a failed structured send must leave proxy damage pending") | |||
| 330 | - | } | |||
| 331 | - | ||||
| 332 | - | // TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails covers the split | |||
| 333 | - | // between "the local content rectangle moved" and "the remote was told". The | |||
| 334 | - | // local VT has already been resized, so the client owes a repaint either way. | |||
| 335 | - | func TestProxyResizeRepaintsLocalChromeWhenRemoteSendFails(t *testing.T) { | |||
| 336 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 337 | - | clientTr, sent := newCapturingTransport(t) | |||
| 338 | - | remote := newProxyTestTransport() | |||
| 339 | - | remote.sendFails.Store(true) | |||
| 340 | - | proxy, ac := newAttachedProxyFixture(t, d, clientTr, remote) | |||
| 341 | - | ||||
| 342 | - | d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 24, Rows: 10}) | |||
| 343 | - | ||||
| 344 | - | frame := awaitTestValue(t, sent, "failed remote resize did not repaint local chrome") | |||
| 345 | - | require.Equal(t, ports.MsgOutput, frame.Type) | |||
| 346 | - | proxy.mu.Lock() | |||
| 347 | - | content := proxy.contentSize | |||
| 348 | - | proxy.mu.Unlock() | |||
| 349 | - | require.Equal(t, contentSize(domain.Size{Cols: 24, Rows: 10}, false), content, "local VT resized despite the failed send") | |||
| 350 | - | } | |||
| 351 | - | ||||
| 352 | - | // TestProxyResizeWithoutGeometryChangeDoesNotRepaint is the other half: an | |||
| 353 | - | // identical size is not a reason to repaint. | |||
| 354 | - | func TestProxyResizeWithoutGeometryChangeDoesNotRepaint(t *testing.T) { | |||
| 355 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 356 | - | clientTr, sent := newCapturingTransport(t) | |||
| 357 | - | proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport()) | |||
| 358 | - | ||||
| 359 | - | d.resizeProxyForLease(proxy, ac, nil, domain.Size{Cols: 16, Rows: 6}) | |||
| 360 | - | ||||
| 361 | - | select { | |||
| 362 | - | case frame := <-sent: | |||
| 363 | - | t.Fatalf("no-op resize repainted: %v", frame.Type) | |||
| 364 | - | default: | |||
| 365 | - | } | |||
| 366 | - | } | |||
| 367 | - | ||||
| 368 | - | // TestProxyPrepareFailureRepaintsWithoutLocalSession covers a failed render | |||
| 369 | - | // transaction on an attachment that has no local session to report through. | |||
| 370 | - | func TestProxyPrepareFailureRepaintsWithoutLocalSession(t *testing.T) { | |||
| 371 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 372 | - | clientTr, sent := newCapturingTransport(t) | |||
| 373 | - | proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport()) | |||
| 374 | - | ||||
| 375 | - | ac.sendMu.Lock() // emitFrame releases the transaction lock. | |||
| 376 | - | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{bars: barState{status: proxy.statusSegments(false)}, reset: true}) | |||
| 377 | - | require.True(t, ok) | |||
| 378 | - | composed := composeFrame(*state, composeCacheInput{}) | |||
| 379 | - | // A malformed frame is what makes outputStateStream.prepare fail. | |||
| 380 | - | composed.frame = renderer.Frame{Width: 1} | |||
| 381 | - | ||||
| 382 | - | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |||
| 383 | - | frame := awaitTestValue(t, sent, "prepare failure on a proxy did not schedule a repaint") | |||
| 384 | - | require.Equal(t, ports.MsgOutput, frame.Type) | |||
| 385 | - | } | |||
| 386 | - | ||||
| 387 | - | // TestProxySendErrorDetachesProxyAttachment covers the transport-failure | |||
| 388 | - | // cleanup path for an attachment with no local session lifecycle. | |||
| 389 | - | func TestProxySendErrorDetachesProxyAttachment(t *testing.T) { | |||
| 390 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 391 | - | clientTr := newFailingTransport(t) | |||
| 392 | - | proxy, ac := newAttachedProxyFixture(t, d, clientTr, newProxyTestTransport()) | |||
| 393 | - | ||||
| 394 | - | d.paint(proxy, ac, true, nil) | |||
| 395 | - | ||||
| 396 | - | proxy.sessionCore.mu.Lock() | |||
| 397 | - | attachments := proxy.sessionCore.snapshotAttachmentsLocked() | |||
| 398 | - | proxy.sessionCore.mu.Unlock() | |||
| 399 | - | require.Empty(t, attachments, "a failed send must release the proxy attachment") | |||
| 400 | - | require.Nil(t, ac.currentAttachmentSession(), "the client must no longer point at the proxy") | |||
| 401 | - | } | |||
| 402 | - | ||||
| 403 | - | func proxyRowText(row []renderer.Cell) string { | |||
| 404 | - | runes := make([]rune, len(row)) | |||
| 405 | - | for i, cell := range row { | |||
| 406 | - | runes[i] = cell.Rune | |||
| 407 | - | } | |||
| 408 | - | return string(runes) | |||
| 409 | - | } | |||
D internal/usecase/daemon/proxy_screen_state.go +0 -374
| @@ -1,374 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "fmt" | |||
| 6 | - | "math" | |||
| 7 | - | "unicode/utf8" | |||
| 8 | - | ||||
| 9 | - | "github.com/bnema/vev/internal/domain" | |||
| 10 | - | "github.com/bnema/vev/internal/ports" | |||
| 11 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 12 | - | ) | |||
| 13 | - | ||||
| 14 | - | const ( | |||
| 15 | - | maxProxyScreenCells = 1 << 18 | |||
| 16 | - | maxProxyScreenDamage = 1024 | |||
| 17 | - | maxProxyScreenSpans = 4096 | |||
| 18 | - | ) | |||
| 19 | - | ||||
| 20 | - | type proxyScreenDamageCapture struct { | |||
| 21 | - | Damage []renderer.Damage | |||
| 22 | - | Generation uint64 | |||
| 23 | - | } | |||
| 24 | - | ||||
| 25 | - | // proxyScreenState is the replayable semantic screen owned by a proxy link. | |||
| 26 | - | // frame and scratch are independent frames and equal whenever Apply returns. | |||
| 27 | - | // A delta is applied to scratch, the frames are swapped, and the same delta is | |||
| 28 | - | // applied to the old frame. This avoids copying a complete frame on deltas. | |||
| 29 | - | type proxyScreenState struct { | |||
| 30 | - | frame renderer.Frame | |||
| 31 | - | scratch renderer.Frame | |||
| 32 | - | cursorOut ports.ScreenCursor | |||
| 33 | - | ||||
| 34 | - | damage []renderer.Damage | |||
| 35 | - | generation uint64 | |||
| 36 | - | damageFullRedrawSticky bool | |||
| 37 | - | stateNum uint64 | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | func newProxyScreenState(size domain.Size) *proxyScreenState { | |||
| 41 | - | if !validProxyScreenSize(size) { | |||
| 42 | - | return &proxyScreenState{} | |||
| 43 | - | } | |||
| 44 | - | state := &proxyScreenState{ | |||
| 45 | - | frame: renderer.NewFrame(size.Cols, size.Rows), | |||
| 46 | - | scratch: renderer.NewFrame(size.Cols, size.Rows), | |||
| 47 | - | cursorOut: ports.ScreenCursor{Visible: true}, | |||
| 48 | - | generation: 1, | |||
| 49 | - | } | |||
| 50 | - | state.setFullRedraw() | |||
| 51 | - | return state | |||
| 52 | - | } | |||
| 53 | - | ||||
| 54 | - | // Apply validates the complete semantic update before mutating either frame. | |||
| 55 | - | func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error { | |||
| 56 | - | if s == nil { | |||
| 57 | - | return errors.New("proxy screen state is nil") | |||
| 58 | - | } | |||
| 59 | - | if err := s.validateUpdate(update); err != nil { | |||
| 60 | - | return err | |||
| 61 | - | } | |||
| 62 | - | ||||
| 63 | - | if update.Kind == ports.ScreenUpdateSnapshot { | |||
| 64 | - | next := renderer.NewFrame(update.Size.Cols, update.Size.Rows) | |||
| 65 | - | nextScratch := renderer.NewFrame(update.Size.Cols, update.Size.Rows) | |||
| 66 | - | applyScreenUpdate(&next, update) | |||
| 67 | - | applyScreenUpdate(&nextScratch, update) | |||
| 68 | - | s.frame, s.scratch = next, nextScratch | |||
| 69 | - | } else { | |||
| 70 | - | applyScreenUpdate(&s.scratch, update) | |||
| 71 | - | s.frame, s.scratch = s.scratch, s.frame | |||
| 72 | - | applyScreenUpdate(&s.scratch, update) | |||
| 73 | - | } | |||
| 74 | - | s.cursorOut = update.Cursor | |||
| 75 | - | if update.NewStateNum != 0 { | |||
| 76 | - | s.stateNum = update.NewStateNum | |||
| 77 | - | } | |||
| 78 | - | s.recordUpdate(update) | |||
| 79 | - | return nil | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | func (s *proxyScreenState) validateUpdate(update ports.ScreenUpdate) error { | |||
| 83 | - | if update.Kind != ports.ScreenUpdateSnapshot && update.Kind != ports.ScreenUpdateDelta { | |||
| 84 | - | return fmt.Errorf("proxy screen: unknown update kind %d", update.Kind) | |||
| 85 | - | } | |||
| 86 | - | if !validProxyScreenSize(update.Size) { | |||
| 87 | - | return fmt.Errorf("proxy screen: invalid size %dx%d", update.Size.Cols, update.Size.Rows) | |||
| 88 | - | } | |||
| 89 | - | if update.Kind == ports.ScreenUpdateDelta { | |||
| 90 | - | if s.frame.Width != update.Size.Cols || s.frame.Height != update.Size.Rows { | |||
| 91 | - | return fmt.Errorf("proxy screen: delta size %dx%d does not match %dx%d", update.Size.Cols, update.Size.Rows, s.frame.Width, s.frame.Height) | |||
| 92 | - | } | |||
| 93 | - | } else if update.Scroll != nil { | |||
| 94 | - | return fmt.Errorf("proxy screen: snapshot cannot contain scroll") | |||
| 95 | - | } | |||
| 96 | - | if update.Cursor.Row >= uint16(update.Size.Rows) || update.Cursor.Col >= uint16(update.Size.Cols) { | |||
| 97 | - | return fmt.Errorf("proxy screen: cursor %d,%d outside %dx%d", update.Cursor.Col, update.Cursor.Row, update.Size.Cols, update.Size.Rows) | |||
| 98 | - | } | |||
| 99 | - | if !update.Cursor.StyleSet && update.Cursor.Style != 0 { | |||
| 100 | - | return fmt.Errorf("proxy screen: cursor style is not set") | |||
| 101 | - | } | |||
| 102 | - | if update.Cursor.StyleSet && update.Cursor.Style > 6 { | |||
| 103 | - | return fmt.Errorf("proxy screen: cursor style is out of range") | |||
| 104 | - | } | |||
| 105 | - | if len(update.Spans) > maxProxyScreenSpans { | |||
| 106 | - | return fmt.Errorf("proxy screen: too many spans") | |||
| 107 | - | } | |||
| 108 | - | if update.Kind == ports.ScreenUpdateSnapshot { | |||
| 109 | - | if len(update.Spans) != update.Size.Rows { | |||
| 110 | - | return fmt.Errorf("proxy screen: snapshot must cover every row") | |||
| 111 | - | } | |||
| 112 | - | for y, span := range update.Spans { | |||
| 113 | - | if int(span.Y) != y || span.X != 0 || len(span.Cells) != update.Size.Cols { | |||
| 114 | - | return fmt.Errorf("proxy screen: snapshot span %d is not a complete row", y) | |||
| 115 | - | } | |||
| 116 | - | } | |||
| 117 | - | } | |||
| 118 | - | if err := s.validateStateNumbers(update); err != nil { | |||
| 119 | - | return err | |||
| 120 | - | } | |||
| 121 | - | if scroll := update.Scroll; scroll != nil { | |||
| 122 | - | if scroll.Height == 0 || scroll.Count == 0 || scroll.Count >= scroll.Height { | |||
| 123 | - | return fmt.Errorf("proxy screen: invalid scroll %d,%d,%d", scroll.Top, scroll.Height, scroll.Count) | |||
| 124 | - | } | |||
| 125 | - | if uint64(scroll.Top)+uint64(scroll.Height) > uint64(update.Size.Rows) { | |||
| 126 | - | return fmt.Errorf("proxy screen: scroll outside screen") | |||
| 127 | - | } | |||
| 128 | - | } | |||
| 129 | - | var previousY uint16 | |||
| 130 | - | var previousEnd uint32 | |||
| 131 | - | for i, span := range update.Spans { | |||
| 132 | - | if len(span.Cells) == 0 { | |||
| 133 | - | return fmt.Errorf("proxy screen: span %d is empty", i) | |||
| 134 | - | } | |||
| 135 | - | if uint64(span.Y) >= uint64(update.Size.Rows) || uint64(span.X) >= uint64(update.Size.Cols) || | |||
| 136 | - | uint64(span.X)+uint64(len(span.Cells)) > uint64(update.Size.Cols) { | |||
| 137 | - | return fmt.Errorf("proxy screen: span %d outside screen", i) | |||
| 138 | - | } | |||
| 139 | - | if i > 0 && (span.Y < previousY || (span.Y == previousY && uint32(span.X) < previousEnd)) { | |||
| 140 | - | return fmt.Errorf("proxy screen: spans overlap or are out of order") | |||
| 141 | - | } | |||
| 142 | - | for _, cell := range span.Cells { | |||
| 143 | - | if cell.Continuation && cell.Rune != 0 || !cell.Continuation && !utf8.ValidRune(cell.Rune) { | |||
| 144 | - | return fmt.Errorf("proxy screen: invalid cell rune") | |||
| 145 | - | } | |||
| 146 | - | if !validProxyScreenStyle(cell.Style) { | |||
| 147 | - | return fmt.Errorf("proxy screen: invalid cell style") | |||
| 148 | - | } | |||
| 149 | - | } | |||
| 150 | - | previousY = span.Y | |||
| 151 | - | previousEnd = uint32(span.X) + uint32(len(span.Cells)) | |||
| 152 | - | } | |||
| 153 | - | return nil | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | - | func (s *proxyScreenState) validateStateNumbers(update ports.ScreenUpdate) error { | |||
| 157 | - | if update.NewStateNum == 0 || update.NewStateNum <= update.BaseStateNum { | |||
| 158 | - | return fmt.Errorf("proxy screen: invalid state transition %d to %d", update.BaseStateNum, update.NewStateNum) | |||
| 159 | - | } | |||
| 160 | - | if update.Kind == ports.ScreenUpdateSnapshot { | |||
| 161 | - | if update.BaseStateNum != 0 { | |||
| 162 | - | return fmt.Errorf("proxy screen: snapshot base state must be zero") | |||
| 163 | - | } | |||
| 164 | - | return nil | |||
| 165 | - | } | |||
| 166 | - | if update.BaseStateNum == 0 || update.NewStateNum != update.BaseStateNum+1 { | |||
| 167 | - | return fmt.Errorf("proxy screen: delta state transition %d to %d is not consecutive", update.BaseStateNum, update.NewStateNum) | |||
| 168 | - | } | |||
| 169 | - | if s.stateNum == 0 || update.BaseStateNum != s.stateNum { | |||
| 170 | - | return fmt.Errorf("proxy screen: delta base state %d does not match %d", update.BaseStateNum, s.stateNum) | |||
| 171 | - | } | |||
| 172 | - | return nil | |||
| 173 | - | } | |||
| 174 | - | ||||
| 175 | - | func applyScreenUpdate(frame *renderer.Frame, update ports.ScreenUpdate) { | |||
| 176 | - | if update.Scroll != nil { | |||
| 177 | - | scroll := update.Scroll | |||
| 178 | - | frame.ScrollUp(int(scroll.Top), int(scroll.Top)+int(scroll.Height)-1, int(scroll.Count)) | |||
| 179 | - | } | |||
| 180 | - | for _, span := range update.Spans { | |||
| 181 | - | copy(frame.Row(int(span.Y))[int(span.X):], span.Cells) | |||
| 182 | - | } | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | func (s *proxyScreenState) recordUpdate(update ports.ScreenUpdate) { | |||
| 186 | - | if update.Kind == ports.ScreenUpdateSnapshot { | |||
| 187 | - | s.generation++ | |||
| 188 | - | s.setFullRedraw() | |||
| 189 | - | return | |||
| 190 | - | } | |||
| 191 | - | needed := len(update.Spans) | |||
| 192 | - | if update.Scroll != nil { | |||
| 193 | - | needed++ | |||
| 194 | - | } | |||
| 195 | - | if needed == 0 { | |||
| 196 | - | return | |||
| 197 | - | } | |||
| 198 | - | s.generation++ | |||
| 199 | - | if s.damageFullRedrawSticky { | |||
| 200 | - | return | |||
| 201 | - | } | |||
| 202 | - | if update.Scroll != nil && len(s.damage) > 0 && (len(s.damage) != 1 || s.damage[0].Kind != renderer.DamageFullRedraw) { | |||
| 203 | - | s.setFullRedraw() | |||
| 204 | - | return | |||
| 205 | - | } | |||
| 206 | - | if needed > maxProxyScreenDamage-len(s.damage) { | |||
| 207 | - | s.setFullRedraw() | |||
| 208 | - | return | |||
| 209 | - | } | |||
| 210 | - | if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw { | |||
| 211 | - | s.damage = s.damage[:0] | |||
| 212 | - | } | |||
| 213 | - | if update.Scroll != nil { | |||
| 214 | - | s.damage = append(s.damage, renderer.Damage{ | |||
| 215 | - | Kind: renderer.DamageScrollUp, | |||
| 216 | - | X: 0, | |||
| 217 | - | Y: int(update.Scroll.Top), | |||
| 218 | - | Width: s.frame.Width, | |||
| 219 | - | Height: int(update.Scroll.Height), | |||
| 220 | - | Count: int(update.Scroll.Count), | |||
| 221 | - | }) | |||
| 222 | - | } | |||
| 223 | - | for _, span := range update.Spans { | |||
| 224 | - | s.damage = append(s.damage, renderer.Damage{ | |||
| 225 | - | Kind: renderer.DamageText, | |||
| 226 | - | X: int(span.X), | |||
| 227 | - | Y: int(span.Y), | |||
| 228 | - | Width: len(span.Cells), | |||
| 229 | - | Height: 1, | |||
| 230 | - | Count: 1, | |||
| 231 | - | }) | |||
| 232 | - | } | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | - | func (s *proxyScreenState) setFullRedraw() { | |||
| 236 | - | s.damage = []renderer.Damage{renderer.FullRedraw()} | |||
| 237 | - | s.damageFullRedrawSticky = true | |||
| 238 | - | } | |||
| 239 | - | ||||
| 240 | - | // CaptureDamage returns an owned view of pending damage at one generation. | |||
| 241 | - | func (s *proxyScreenState) CaptureDamage() proxyScreenDamageCapture { | |||
| 242 | - | if s == nil { | |||
| 243 | - | return proxyScreenDamageCapture{} | |||
| 244 | - | } | |||
| 245 | - | return proxyScreenDamageCapture{ | |||
| 246 | - | Damage: append([]renderer.Damage(nil), s.damage...), | |||
| 247 | - | Generation: s.generation, | |||
| 248 | - | } | |||
| 249 | - | } | |||
| 250 | - | ||||
| 251 | - | // AcknowledgeDamage consumes damage only for the exact generation captured. | |||
| 252 | - | // A stale acknowledgement forces a sticky full redraw so intervening updates | |||
| 253 | - | // cannot be lost. | |||
| 254 | - | func (s *proxyScreenState) AcknowledgeDamage(generation uint64) bool { | |||
| 255 | - | if s == nil { | |||
| 256 | - | return false | |||
| 257 | - | } | |||
| 258 | - | if generation != s.generation { | |||
| 259 | - | s.generation++ | |||
| 260 | - | s.setFullRedraw() | |||
| 261 | - | return false | |||
| 262 | - | } | |||
| 263 | - | s.damage = s.damage[:0] | |||
| 264 | - | s.damageFullRedrawSticky = false | |||
| 265 | - | return true | |||
| 266 | - | } | |||
| 267 | - | ||||
| 268 | - | // CaptureInto updates dst from the live frame. Safe scroll/span damage is | |||
| 269 | - | // replayed incrementally; snapshots, uncertain damage, and dimensions use a | |||
| 270 | - | // complete clone. | |||
| 271 | - | func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) { | |||
| 272 | - | if s == nil || dst == nil || !validProxyFrame(s) { | |||
| 273 | - | return | |||
| 274 | - | } | |||
| 275 | - | if dst.Width != s.frame.Width || dst.Height != s.frame.Height || dst.Validate() != nil { | |||
| 276 | - | *dst = s.frame.Clone() | |||
| 277 | - | return | |||
| 278 | - | } | |||
| 279 | - | if len(s.damage) == 0 { | |||
| 280 | - | return | |||
| 281 | - | } | |||
| 282 | - | for _, damage := range s.damage { | |||
| 283 | - | switch damage.Kind { | |||
| 284 | - | case renderer.DamageScrollUp: | |||
| 285 | - | if damage.X != 0 || damage.Width != s.frame.Width || damage.Y < 0 || damage.Height <= 0 || damage.Count <= 0 || damage.Count > damage.Height || damage.Y+damage.Height > s.frame.Height { | |||
| 286 | - | *dst = s.frame.Clone() | |||
| 287 | - | return | |||
| 288 | - | } | |||
| 289 | - | dst.ScrollUp(damage.Y, damage.Y+damage.Height-1, damage.Count) | |||
| 290 | - | case renderer.DamageText, renderer.DamageClear: | |||
| 291 | - | if damage.X < 0 || damage.Y < 0 || damage.Width <= 0 || damage.Height <= 0 || damage.X+damage.Width > s.frame.Width || damage.Y+damage.Height > s.frame.Height { | |||
| 292 | - | *dst = s.frame.Clone() | |||
| 293 | - | return | |||
| 294 | - | } | |||
| 295 | - | for y := damage.Y; y < damage.Y+damage.Height; y++ { | |||
| 296 | - | copy(dst.Row(y)[damage.X:damage.X+damage.Width], s.frame.Row(y)[damage.X:damage.X+damage.Width]) | |||
| 297 | - | } | |||
| 298 | - | default: | |||
| 299 | - | *dst = s.frame.Clone() | |||
| 300 | - | return | |||
| 301 | - | } | |||
| 302 | - | } | |||
| 303 | - | } | |||
| 304 | - | ||||
| 305 | - | // ResizePlaceholder changes geometry while preserving the overlapping logical | |||
| 306 | - | // cells. It deliberately requests a full redraw and resets the state chain. | |||
| 307 | - | func (s *proxyScreenState) ResizePlaceholder(size domain.Size) bool { | |||
| 308 | - | if s == nil || !validProxyScreenSize(size) { | |||
| 309 | - | return false | |||
| 310 | - | } | |||
| 311 | - | if s.frame.Width == size.Cols && s.frame.Height == size.Rows { | |||
| 312 | - | return false | |||
| 313 | - | } | |||
| 314 | - | next := renderer.NewFrame(size.Cols, size.Rows) | |||
| 315 | - | nextScratch := renderer.NewFrame(size.Cols, size.Rows) | |||
| 316 | - | copyFrameOverlap(&next, s.frame) | |||
| 317 | - | copyFrameOverlap(&nextScratch, s.frame) | |||
| 318 | - | s.frame, s.scratch = next, nextScratch | |||
| 319 | - | if int(s.cursorOut.Row) >= size.Rows { | |||
| 320 | - | s.cursorOut.Row = uint16(size.Rows - 1) | |||
| 321 | - | } | |||
| 322 | - | if int(s.cursorOut.Col) >= size.Cols { | |||
| 323 | - | s.cursorOut.Col = uint16(size.Cols - 1) | |||
| 324 | - | } | |||
| 325 | - | s.stateNum = 0 | |||
| 326 | - | s.generation++ | |||
| 327 | - | s.setFullRedraw() | |||
| 328 | - | return true | |||
| 329 | - | } | |||
| 330 | - | ||||
| 331 | - | func copyFrameOverlap(dst *renderer.Frame, src renderer.Frame) { | |||
| 332 | - | width := min(src.Width, dst.Width) | |||
| 333 | - | height := min(src.Height, dst.Height) | |||
| 334 | - | for y := range height { | |||
| 335 | - | copy(dst.Row(y)[:width], src.Row(y)[:width]) | |||
| 336 | - | } | |||
| 337 | - | } | |||
| 338 | - | ||||
| 339 | - | func validProxyScreenSize(size domain.Size) bool { | |||
| 340 | - | if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 { | |||
| 341 | - | return false | |||
| 342 | - | } | |||
| 343 | - | return size.Rows <= maxProxyScreenCells/size.Cols | |||
| 344 | - | } | |||
| 345 | - | ||||
| 346 | - | func validProxyFrame(s *proxyScreenState) bool { | |||
| 347 | - | return s != nil && s.frame.Validate() == nil && s.scratch.Validate() == nil | |||
| 348 | - | } | |||
| 349 | - | ||||
| 350 | - | // validProxyScreenStyle mirrors the wire validator without exporting its | |||
| 351 | - | // wire-specific error API; inactive color indices are canonicalized on encode. | |||
| 352 | - | func validProxyScreenStyle(style renderer.Style) bool { | |||
| 353 | - | if style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 || style.UnderlineStyle > renderer.UnderlineDashed { | |||
| 354 | - | return false | |||
| 355 | - | } | |||
| 356 | - | if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > math.MaxUint8 || style.ForegroundRGB != (renderer.RGB{})) { | |||
| 357 | - | return false | |||
| 358 | - | } | |||
| 359 | - | if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > math.MaxUint8 || style.BackgroundRGB != (renderer.RGB{})) { | |||
| 360 | - | return false | |||
| 361 | - | } | |||
| 362 | - | if style.HasUnderlineColorRGB && style.HasUnderlineColor { | |||
| 363 | - | return false | |||
| 364 | - | } else if style.HasUnderlineColorRGB { | |||
| 365 | - | // The indexed value is inactive when RGB is selected. | |||
| 366 | - | } else if style.HasUnderlineColor { | |||
| 367 | - | if style.UnderlineColor < 0 || style.UnderlineColor > math.MaxUint8 || style.UnderlineColorRGB != (renderer.RGB{}) { | |||
| 368 | - | return false | |||
| 369 | - | } | |||
| 370 | - | } else if style.UnderlineColorRGB != (renderer.RGB{}) { | |||
| 371 | - | return false | |||
| 372 | - | } | |||
| 373 | - | return true | |||
| 374 | - | } | |||
D internal/usecase/daemon/proxy_screen_state_test.go +0 -531
| @@ -1,531 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "reflect" | |||
| 5 | - | "testing" | |||
| 6 | - | ||||
| 7 | - | "github.com/bnema/vev/internal/domain" | |||
| 8 | - | "github.com/bnema/vev/internal/ports" | |||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | func TestProxyScreenStateApplyIsAtomicAndReplaysScrolls(t *testing.T) { | |||
| 13 | - | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3}) | |||
| 14 | - | if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil { | |||
| 15 | - | t.Fatal(err) | |||
| 16 | - | } | |||
| 17 | - | if !reflect.DeepEqual(s.frame, s.scratch) { | |||
| 18 | - | t.Fatal("live and scratch frames diverged after snapshot") | |||
| 19 | - | } | |||
| 20 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 21 | - | Size: domain.Size{Cols: 4, Rows: 3}, | |||
| 22 | - | Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1}, | |||
| 23 | - | Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("WXYZ")}}, | |||
| 24 | - | }) | |||
| 25 | - | for range 7 { | |||
| 26 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 27 | - | Size: domain.Size{Cols: 4, Rows: 3}, | |||
| 28 | - | Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1}, | |||
| 29 | - | Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("WXYZ")}}, | |||
| 30 | - | }) | |||
| 31 | - | } | |||
| 32 | - | if !reflect.DeepEqual(s.frame, s.scratch) { | |||
| 33 | - | t.Fatal("live and scratch frames diverged after repeated scrolls") | |||
| 34 | - | } | |||
| 35 | - | if got := proxyFrameText(s.frame); got != "WXYZWXYZWXYZ" { | |||
| 36 | - | t.Fatalf("replayed scroll state = %q", got) | |||
| 37 | - | } | |||
| 38 | - | } | |||
| 39 | - | ||||
| 40 | - | func TestProxyScreenStateInvalidUpdateIsAtomic(t *testing.T) { | |||
| 41 | - | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2}) | |||
| 42 | - | if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil { | |||
| 43 | - | t.Fatal(err) | |||
| 44 | - | } | |||
| 45 | - | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |||
| 46 | - | beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum | |||
| 47 | - | invalid := ports.ScreenUpdate{ | |||
| 48 | - | Kind: ports.ScreenUpdateDelta, | |||
| 49 | - | BaseStateNum: 1, | |||
| 50 | - | NewStateNum: 2, | |||
| 51 | - | Size: domain.Size{Cols: 4, Rows: 2}, | |||
| 52 | - | Scroll: &ports.ScreenScroll{Top: 1, Height: 2, Count: 1}, | |||
| 53 | - | Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("x")}}, | |||
| 54 | - | Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true}, | |||
| 55 | - | } | |||
| 56 | - | if err := s.Apply(invalid); err == nil { | |||
| 57 | - | t.Fatal("invalid geometry was accepted") | |||
| 58 | - | } | |||
| 59 | - | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) { | |||
| 60 | - | t.Fatal("invalid update mutated a frame") | |||
| 61 | - | } | |||
| 62 | - | if s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState { | |||
| 63 | - | t.Fatal("invalid update mutated metadata") | |||
| 64 | - | } | |||
| 65 | - | ||||
| 66 | - | invalid = ports.ScreenUpdate{ | |||
| 67 | - | Kind: ports.ScreenUpdateDelta, | |||
| 68 | - | BaseStateNum: 1, | |||
| 69 | - | NewStateNum: 2, | |||
| 70 | - | Size: domain.Size{Cols: 4, Rows: 2}, | |||
| 71 | - | Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 2}, | |||
| 72 | - | Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true}, | |||
| 73 | - | } | |||
| 74 | - | if err := s.Apply(invalid); err == nil { | |||
| 75 | - | t.Fatal("full-height scroll was accepted") | |||
| 76 | - | } | |||
| 77 | - | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState { | |||
| 78 | - | t.Fatal("invalid full-height scroll mutated state") | |||
| 79 | - | } | |||
| 80 | - | } | |||
| 81 | - | ||||
| 82 | - | func TestProxyScreenStateScreenAreaLimitIsAtomic(t *testing.T) { | |||
| 83 | - | const boundaryCols = 512 | |||
| 84 | - | boundaryRows := maxProxyScreenCells / boundaryCols | |||
| 85 | - | boundary := newProxyScreenState(domain.Size{Cols: boundaryCols, Rows: boundaryRows}) | |||
| 86 | - | if boundary.frame.Width != boundaryCols || boundary.frame.Height != boundaryRows || boundary.scratch.Width != boundaryCols || boundary.scratch.Height != boundaryRows { | |||
| 87 | - | t.Fatalf("boundary state dimensions = %dx%d and %dx%d", boundary.frame.Width, boundary.frame.Height, boundary.scratch.Width, boundary.scratch.Height) | |||
| 88 | - | } | |||
| 89 | - | if boundary.ResizePlaceholder(domain.Size{Cols: boundaryCols, Rows: boundaryRows + 1}) { | |||
| 90 | - | t.Fatal("over-cap resize was accepted") | |||
| 91 | - | } | |||
| 92 | - | if boundary.frame.Width != boundaryCols || boundary.frame.Height != boundaryRows || boundary.scratch.Width != boundaryCols || boundary.scratch.Height != boundaryRows { | |||
| 93 | - | t.Fatal("over-cap resize changed boundary state") | |||
| 94 | - | } | |||
| 95 | - | if !boundary.damageFullRedrawSticky { | |||
| 96 | - | t.Fatal("initial full redraw must remain sticky until acknowledged") | |||
| 97 | - | } | |||
| 98 | - | ||||
| 99 | - | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |||
| 100 | - | if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil { | |||
| 101 | - | t.Fatal(err) | |||
| 102 | - | } | |||
| 103 | - | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |||
| 104 | - | beforeCursor, beforeGeneration, beforeState, beforeDamage := s.cursorOut, s.generation, s.stateNum, s.CaptureDamage() | |||
| 105 | - | if err := s.Apply(ports.ScreenUpdate{ | |||
| 106 | - | NewStateNum: 2, | |||
| 107 | - | Kind: ports.ScreenUpdateSnapshot, | |||
| 108 | - | Size: domain.Size{Cols: 513, Rows: 512}, | |||
| 109 | - | Cursor: ports.ScreenCursor{Visible: true}, | |||
| 110 | - | }); err == nil { | |||
| 111 | - | t.Fatal("over-cap snapshot was accepted") | |||
| 112 | - | } | |||
| 113 | - | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState || !reflect.DeepEqual(s.CaptureDamage(), beforeDamage) { | |||
| 114 | - | t.Fatal("over-cap snapshot mutated state") | |||
| 115 | - | } | |||
| 116 | - | if got := newProxyScreenState(domain.Size{Cols: 513, Rows: 512}); got.frame.Validate() == nil || got.scratch.Validate() == nil { | |||
| 117 | - | t.Fatal("over-cap constructor allocated a valid frame") | |||
| 118 | - | } | |||
| 119 | - | } | |||
| 120 | - | ||||
| 121 | - | func TestProxyScreenStateRejectsInvalidStateNumbersAtomically(t *testing.T) { | |||
| 122 | - | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |||
| 123 | - | if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil { | |||
| 124 | - | t.Fatal(err) | |||
| 125 | - | } | |||
| 126 | - | if got := proxyFrameText(s.frame); got != "abcd" || !reflect.DeepEqual(s.frame, s.scratch) { | |||
| 127 | - | t.Fatalf("2x2 snapshot = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch)) | |||
| 128 | - | } | |||
| 129 | - | updates := []ports.ScreenUpdate{ | |||
| 130 | - | func() ports.ScreenUpdate { | |||
| 131 | - | m := screenSnapshot(2, 2, "ef", "gh") | |||
| 132 | - | m.NewStateNum = 0 | |||
| 133 | - | return m | |||
| 134 | - | }(), | |||
| 135 | - | func() ports.ScreenUpdate { | |||
| 136 | - | m := screenSnapshot(2, 2, "ef", "gh") | |||
| 137 | - | m.BaseStateNum, m.NewStateNum = 1, 2 | |||
| 138 | - | return m | |||
| 139 | - | }(), | |||
| 140 | - | {Kind: ports.ScreenUpdateDelta, BaseStateNum: 0, NewStateNum: 0, Size: domain.Size{Cols: 2, Rows: 2}}, | |||
| 141 | - | {Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}}, | |||
| 142 | - | {Kind: ports.ScreenUpdateDelta, BaseStateNum: 2, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}}, | |||
| 143 | - | } | |||
| 144 | - | for i, update := range updates { | |||
| 145 | - | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |||
| 146 | - | beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum | |||
| 147 | - | if err := s.Apply(update); err == nil { | |||
| 148 | - | t.Fatalf("invalid state update %d was accepted", i) | |||
| 149 | - | } | |||
| 150 | - | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState { | |||
| 151 | - | t.Fatalf("invalid state update %d mutated live state", i) | |||
| 152 | - | } | |||
| 153 | - | } | |||
| 154 | - | } | |||
| 155 | - | ||||
| 156 | - | func TestProxyScreenStateRejectsWireInvalidSemanticUpdatesAtomically(t *testing.T) { | |||
| 157 | - | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |||
| 158 | - | if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil { | |||
| 159 | - | t.Fatal(err) | |||
| 160 | - | } | |||
| 161 | - | assertUnchanged := func(beforeFrame, beforeScratch renderer.Frame, beforeCursor ports.ScreenCursor, beforeGeneration, beforeState uint64, beforeDamage proxyScreenDamageCapture) { | |||
| 162 | - | t.Helper() | |||
| 163 | - | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState || !reflect.DeepEqual(s.CaptureDamage(), beforeDamage) { | |||
| 164 | - | t.Fatal("invalid semantic update mutated live state") | |||
| 165 | - | } | |||
| 166 | - | } | |||
| 167 | - | ||||
| 168 | - | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |||
| 169 | - | beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum | |||
| 170 | - | beforeDamage := s.CaptureDamage() | |||
| 171 | - | invalidCursor := nextProxyDelta(s, ports.ScreenUpdate{ | |||
| 172 | - | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 173 | - | Cursor: ports.ScreenCursor{Style: 7, StyleSet: true}, | |||
| 174 | - | }) | |||
| 175 | - | if err := s.Apply(invalidCursor); err == nil { | |||
| 176 | - | t.Fatal("cursor style 7 was accepted") | |||
| 177 | - | } | |||
| 178 | - | assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage) | |||
| 179 | - | ||||
| 180 | - | beforeFrame, beforeScratch = s.frame.Clone(), s.scratch.Clone() | |||
| 181 | - | beforeCursor, beforeGeneration, beforeState = s.cursorOut, s.generation, s.stateNum | |||
| 182 | - | beforeDamage = s.CaptureDamage() | |||
| 183 | - | invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{ | |||
| 184 | - | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 185 | - | Spans: make([]ports.ScreenSpan, maxProxyScreenSpans+1), | |||
| 186 | - | }) | |||
| 187 | - | if err := s.Apply(invalidSpans); err == nil { | |||
| 188 | - | t.Fatal("too many spans were accepted") | |||
| 189 | - | } | |||
| 190 | - | assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage) | |||
| 191 | - | } | |||
| 192 | - | ||||
| 193 | - | func TestProxyScreenStateAcceptsConsecutiveHorizontalSpans(t *testing.T) { | |||
| 194 | - | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 1}) | |||
| 195 | - | if err := s.Apply(screenSnapshot(4, 1, "abcd")); err != nil { | |||
| 196 | - | t.Fatal(err) | |||
| 197 | - | } | |||
| 198 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 199 | - | Size: domain.Size{Cols: 4, Rows: 1}, | |||
| 200 | - | Spans: []ports.ScreenSpan{ | |||
| 201 | - | {Y: 0, X: 0, Cells: cells("xy")}, | |||
| 202 | - | {Y: 0, X: 2, Cells: cells("Zq")}, | |||
| 203 | - | }, | |||
| 204 | - | }) | |||
| 205 | - | if got := proxyFrameText(s.frame); got != "xyZq" || !reflect.DeepEqual(s.frame, s.scratch) { | |||
| 206 | - | t.Fatalf("adjacent spans replay = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch)) | |||
| 207 | - | } | |||
| 208 | - | } | |||
| 209 | - | ||||
| 210 | - | func TestProxyScreenStateDamageGenerationAndCapture(t *testing.T) { | |||
| 211 | - | s := newProxyScreenState(domain.Size{Cols: 3, Rows: 2}) | |||
| 212 | - | if err := s.Apply(screenSnapshot(3, 2, " ", " ")); err != nil { | |||
| 213 | - | t.Fatal(err) | |||
| 214 | - | } | |||
| 215 | - | first := s.CaptureDamage() | |||
| 216 | - | if len(first.Damage) != 1 || first.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 217 | - | t.Fatalf("initial damage = %+v", first.Damage) | |||
| 218 | - | } | |||
| 219 | - | if !s.AcknowledgeDamage(first.Generation) { | |||
| 220 | - | t.Fatal("exact initial acknowledgement failed") | |||
| 221 | - | } | |||
| 222 | - | if len(s.CaptureDamage().Damage) != 0 { | |||
| 223 | - | t.Fatal("exact acknowledgement left damage") | |||
| 224 | - | } | |||
| 225 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 226 | - | Size: domain.Size{Cols: 3, Rows: 2}, | |||
| 227 | - | Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("xyz")}}, | |||
| 228 | - | }) | |||
| 229 | - | captured := s.CaptureDamage() | |||
| 230 | - | if len(captured.Damage) != 1 || captured.Damage[0].Width != 3 { | |||
| 231 | - | t.Fatalf("span damage = %+v", captured.Damage) | |||
| 232 | - | } | |||
| 233 | - | if s.AcknowledgeDamage(captured.Generation - 1) { | |||
| 234 | - | t.Fatal("stale acknowledgement succeeded") | |||
| 235 | - | } | |||
| 236 | - | forced := s.CaptureDamage() | |||
| 237 | - | if len(forced.Damage) != 1 || forced.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 238 | - | t.Fatalf("stale acknowledgement damage = %+v", forced.Damage) | |||
| 239 | - | } | |||
| 240 | - | if !s.AcknowledgeDamage(forced.Generation) || len(s.CaptureDamage().Damage) != 0 { | |||
| 241 | - | t.Fatal("full redraw did not clear on exact acknowledgement") | |||
| 242 | - | } | |||
| 243 | - | } | |||
| 244 | - | ||||
| 245 | - | func TestProxyScreenStateCaptureAndResize(t *testing.T) { | |||
| 246 | - | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2}) | |||
| 247 | - | if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil { | |||
| 248 | - | t.Fatal(err) | |||
| 249 | - | } | |||
| 250 | - | _ = s.AcknowledgeDamage(s.CaptureDamage().Generation) | |||
| 251 | - | var dst renderer.Frame | |||
| 252 | - | s.CaptureInto(&dst) | |||
| 253 | - | if got := proxyFrameText(dst); got != "abcdefgh" { | |||
| 254 | - | t.Fatalf("captured frame = %q", got) | |||
| 255 | - | } | |||
| 256 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 257 | - | Size: domain.Size{Cols: 4, Rows: 2}, | |||
| 258 | - | Spans: []ports.ScreenSpan{{Y: 0, X: 1, Cells: cells("Z")}}, | |||
| 259 | - | }) | |||
| 260 | - | s.CaptureInto(&dst) | |||
| 261 | - | if got := proxyFrameText(dst); got != "aZcdefgh" { | |||
| 262 | - | t.Fatalf("incrementally captured frame = %q", got) | |||
| 263 | - | } | |||
| 264 | - | if !s.ResizePlaceholder(domain.Size{Cols: 3, Rows: 3}) { | |||
| 265 | - | t.Fatal("resize placeholder rejected valid size") | |||
| 266 | - | } | |||
| 267 | - | if got := proxyFrameText(s.frame); got != "aZcefg " { | |||
| 268 | - | t.Fatalf("overlap-preserving resize = %q", got) | |||
| 269 | - | } | |||
| 270 | - | capture := s.CaptureDamage() | |||
| 271 | - | if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 272 | - | t.Fatalf("resize damage = %+v", capture.Damage) | |||
| 273 | - | } | |||
| 274 | - | } | |||
| 275 | - | ||||
| 276 | - | func TestProxyScreenStateCaptureFallsBackAfterSpanThenScroll(t *testing.T) { | |||
| 277 | - | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2}) | |||
| 278 | - | if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil { | |||
| 279 | - | t.Fatal(err) | |||
| 280 | - | } | |||
| 281 | - | _ = s.AcknowledgeDamage(s.CaptureDamage().Generation) | |||
| 282 | - | dst := s.frame.Clone() | |||
| 283 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 284 | - | Size: domain.Size{Cols: 4, Rows: 2}, | |||
| 285 | - | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("Z")}}, | |||
| 286 | - | }) | |||
| 287 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 288 | - | Size: domain.Size{Cols: 4, Rows: 2}, | |||
| 289 | - | Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 1}, | |||
| 290 | - | Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("IJKL")}}, | |||
| 291 | - | }) | |||
| 292 | - | capture := s.CaptureDamage() | |||
| 293 | - | if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 294 | - | t.Fatalf("span-then-scroll damage = %+v", capture.Damage) | |||
| 295 | - | } | |||
| 296 | - | s.CaptureInto(&dst) | |||
| 297 | - | if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want { | |||
| 298 | - | t.Fatalf("span-then-scroll capture = %q, want %q", got, want) | |||
| 299 | - | } | |||
| 300 | - | } | |||
| 301 | - | ||||
| 302 | - | func TestProxyScreenStateCaptureFallsBackAfterRepeatedScroll(t *testing.T) { | |||
| 303 | - | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3}) | |||
| 304 | - | if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil { | |||
| 305 | - | t.Fatal(err) | |||
| 306 | - | } | |||
| 307 | - | _ = s.AcknowledgeDamage(s.CaptureDamage().Generation) | |||
| 308 | - | dst := s.frame.Clone() | |||
| 309 | - | scroll := func(line string) { | |||
| 310 | - | t.Helper() | |||
| 311 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 312 | - | Size: domain.Size{Cols: 4, Rows: 3}, | |||
| 313 | - | Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1}, | |||
| 314 | - | Spans: []ports.ScreenSpan{{Y: 2, Cells: cells(line)}}, | |||
| 315 | - | }) | |||
| 316 | - | } | |||
| 317 | - | scroll("WXYZ") | |||
| 318 | - | if len(s.CaptureDamage().Damage) != 2 { | |||
| 319 | - | t.Fatalf("first scroll damage = %+v", s.CaptureDamage().Damage) | |||
| 320 | - | } | |||
| 321 | - | scroll("QRST") | |||
| 322 | - | capture := s.CaptureDamage() | |||
| 323 | - | if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 324 | - | t.Fatalf("repeated-scroll damage = %+v", capture.Damage) | |||
| 325 | - | } | |||
| 326 | - | s.CaptureInto(&dst) | |||
| 327 | - | if got, want := proxyFrameText(dst), proxyFrameText(s.frame); got != want { | |||
| 328 | - | t.Fatalf("repeated-scroll capture = %q, want %q", got, want) | |||
| 329 | - | } | |||
| 330 | - | } | |||
| 331 | - | ||||
| 332 | - | func TestProxyScreenStateCursorOnlyPreservesCapturedDamageGeneration(t *testing.T) { | |||
| 333 | - | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |||
| 334 | - | if err := s.Apply(screenSnapshot(2, 2, " ", " ")); err != nil { | |||
| 335 | - | t.Fatal(err) | |||
| 336 | - | } | |||
| 337 | - | snapshotDamage := s.CaptureDamage() | |||
| 338 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 339 | - | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 340 | - | Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true}, | |||
| 341 | - | }) | |||
| 342 | - | if s.generation != snapshotDamage.Generation { | |||
| 343 | - | t.Fatalf("cursor-only update changed damage generation from %d to %d", snapshotDamage.Generation, s.generation) | |||
| 344 | - | } | |||
| 345 | - | if !s.AcknowledgeDamage(snapshotDamage.Generation) || len(s.damage) != 0 || s.damageFullRedrawSticky { | |||
| 346 | - | t.Fatalf("cursor-only interleaving invalidated captured damage: generation=%d damage=%+v sticky=%v", s.generation, s.damage, s.damageFullRedrawSticky) | |||
| 347 | - | } | |||
| 348 | - | ||||
| 349 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 350 | - | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 351 | - | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}}, | |||
| 352 | - | }) | |||
| 353 | - | actualDamage := s.CaptureDamage() | |||
| 354 | - | if actualDamage.Generation <= snapshotDamage.Generation { | |||
| 355 | - | t.Fatalf("actual damage did not advance generation: snapshot=%d actual=%d", snapshotDamage.Generation, actualDamage.Generation) | |||
| 356 | - | } | |||
| 357 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 358 | - | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 359 | - | Cursor: ports.ScreenCursor{Row: 0, Col: 1, Visible: true}, | |||
| 360 | - | }) | |||
| 361 | - | if s.generation != actualDamage.Generation || !s.AcknowledgeDamage(actualDamage.Generation) || len(s.damage) != 0 || s.damageFullRedrawSticky { | |||
| 362 | - | t.Fatalf("cursor-only update invalidated actual damage: generation=%d want=%d damage=%+v sticky=%v", s.generation, actualDamage.Generation, s.damage, s.damageFullRedrawSticky) | |||
| 363 | - | } | |||
| 364 | - | } | |||
| 365 | - | ||||
| 366 | - | func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) { | |||
| 367 | - | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |||
| 368 | - | if err := s.Apply(screenSnapshot(2, 2, " ", " ")); err != nil { | |||
| 369 | - | t.Fatal(err) | |||
| 370 | - | } | |||
| 371 | - | _ = s.AcknowledgeDamage(s.generation) | |||
| 372 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 373 | - | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 374 | - | Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true}, | |||
| 375 | - | }) | |||
| 376 | - | if s.cursorOut.Row != 1 || s.cursorOut.Col != 1 || len(s.damage) != 0 { | |||
| 377 | - | t.Fatalf("cursor-only update = cursor=%+v damage=%+v", s.cursorOut, s.damage) | |||
| 378 | - | } | |||
| 379 | - | for range maxProxyScreenDamage { | |||
| 380 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 381 | - | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 382 | - | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}}, | |||
| 383 | - | }) | |||
| 384 | - | } | |||
| 385 | - | if len(s.damage) != maxProxyScreenDamage { | |||
| 386 | - | t.Fatalf("damage length = %d, want %d", len(s.damage), maxProxyScreenDamage) | |||
| 387 | - | } | |||
| 388 | - | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 389 | - | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 390 | - | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("y")}}, | |||
| 391 | - | }) | |||
| 392 | - | if len(s.damage) != 1 || s.damage[0].Kind != renderer.DamageFullRedraw { | |||
| 393 | - | t.Fatalf("saturated damage = %+v", s.damage) | |||
| 394 | - | } | |||
| 395 | - | } | |||
| 396 | - | ||||
| 397 | - | func TestProxyScreenStateStyleValidationMatchesWireSemantics(t *testing.T) { | |||
| 398 | - | styles := []struct { | |||
| 399 | - | name string | |||
| 400 | - | style renderer.Style | |||
| 401 | - | }{ | |||
| 402 | - | {name: "indexed foreground", style: renderer.Style{Foreground: 255, Background: -1}}, | |||
| 403 | - | {name: "indexed foreground out of range", style: renderer.Style{Foreground: 256, Background: -1}}, | |||
| 404 | - | {name: "foreground RGB ignores index", style: renderer.Style{Foreground: 1 << 20, Background: -1, HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 1}}}, | |||
| 405 | - | {name: "background RGB ignores index", style: renderer.Style{Foreground: -1, Background: 1 << 20, HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{G: 2}}}, | |||
| 406 | - | {name: "indexed underline", style: renderer.Style{Foreground: -1, Background: -1, HasUnderlineColor: true, UnderlineColor: 255}}, | |||
| 407 | - | {name: "indexed underline with RGB", style: renderer.Style{Foreground: -1, Background: -1, HasUnderlineColor: true, UnderlineColor: 1, UnderlineColorRGB: renderer.RGB{B: 3}}}, | |||
| 408 | - | {name: "underline RGB ignores index", style: renderer.Style{Foreground: -1, Background: -1, HasUnderlineColorRGB: true, UnderlineColor: 1 << 20, UnderlineColorRGB: renderer.RGB{R: 4}}}, | |||
| 409 | - | {name: "both underline modes", style: renderer.Style{Foreground: -1, Background: -1, HasUnderlineColor: true, HasUnderlineColorRGB: true}}, | |||
| 410 | - | {name: "unset underline ignores index", style: renderer.Style{Foreground: -1, Background: -1, UnderlineColor: 1 << 20}}, | |||
| 411 | - | } | |||
| 412 | - | for _, tt := range styles { | |||
| 413 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 414 | - | update := ports.ScreenUpdate{ | |||
| 415 | - | NewStateNum: 1, | |||
| 416 | - | Kind: ports.ScreenUpdateSnapshot, | |||
| 417 | - | Size: domain.Size{Cols: 1, Rows: 1}, | |||
| 418 | - | Cursor: ports.ScreenCursor{Visible: true}, | |||
| 419 | - | Spans: []ports.ScreenSpan{{Cells: []renderer.Cell{{Rune: 'x', Style: tt.style}}}}, | |||
| 420 | - | } | |||
| 421 | - | _, wireErr := ports.MarshalScreenUpdate(update) | |||
| 422 | - | stateErr := newProxyScreenState(update.Size).Apply(update) | |||
| 423 | - | if (wireErr == nil) != (stateErr == nil) { | |||
| 424 | - | t.Fatalf("daemon/wire style acceptance differs: wire=%v daemon=%v", wireErr, stateErr) | |||
| 425 | - | } | |||
| 426 | - | }) | |||
| 427 | - | } | |||
| 428 | - | } | |||
| 429 | - | ||||
| 430 | - | // BenchmarkProxyScreenStateApply is a component diagnostic for applying an | |||
| 431 | - | // already-decoded update; it is not a pipeline comparison with ANSI apply. | |||
| 432 | - | func BenchmarkProxyScreenStateApply(b *testing.B) { | |||
| 433 | - | size := domain.Size{Cols: 120, Rows: 40} | |||
| 434 | - | cases := []struct { | |||
| 435 | - | name string | |||
| 436 | - | update ports.ScreenUpdate | |||
| 437 | - | cells, spans int | |||
| 438 | - | }{ | |||
| 439 | - | {name: "one-cell", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 1, X: 0, Cells: cells("x")}}}, cells: 1, spans: 1}, | |||
| 440 | - | {name: "full-line", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 2, Cells: cellsRepeat('x', 120)}}}, cells: 120, spans: 1}, | |||
| 441 | - | {name: "styled-fragments", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 3, Cells: styledCells(120)}}}, cells: 120, spans: 1}, | |||
| 442 | - | {name: "full-screen-scroll", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Scroll: &ports.ScreenScroll{Top: 0, Height: 40, Count: 1}, Spans: []ports.ScreenSpan{{Y: 39, Cells: cellsRepeat('x', 120)}}}, cells: 120 * 40, spans: 2}, | |||
| 443 | - | } | |||
| 444 | - | for _, tc := range cases { | |||
| 445 | - | b.Run(tc.name, func(b *testing.B) { | |||
| 446 | - | s := newProxyScreenState(size) | |||
| 447 | - | if err := s.Apply(blankSnapshot(size)); err != nil { | |||
| 448 | - | b.Fatal(err) | |||
| 449 | - | } | |||
| 450 | - | _ = s.AcknowledgeDamage(s.generation) | |||
| 451 | - | // Warm the bounded damage buffer before timing the replay loop. | |||
| 452 | - | if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil { | |||
| 453 | - | b.Fatal(err) | |||
| 454 | - | } | |||
| 455 | - | _ = s.AcknowledgeDamage(s.generation) | |||
| 456 | - | b.ResetTimer() | |||
| 457 | - | b.ReportMetric(float64(tc.cells), "cells/op") | |||
| 458 | - | b.ReportMetric(float64(tc.spans), "spans/op") | |||
| 459 | - | for b.Loop() { | |||
| 460 | - | if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil { | |||
| 461 | - | b.Fatal(err) | |||
| 462 | - | } | |||
| 463 | - | _ = s.AcknowledgeDamage(s.generation) | |||
| 464 | - | } | |||
| 465 | - | }) | |||
| 466 | - | } | |||
| 467 | - | } | |||
| 468 | - | ||||
| 469 | - | func applyProxyDelta(t *testing.T, s *proxyScreenState, update ports.ScreenUpdate) { | |||
| 470 | - | t.Helper() | |||
| 471 | - | if err := s.Apply(nextProxyDelta(s, update)); err != nil { | |||
| 472 | - | t.Fatal(err) | |||
| 473 | - | } | |||
| 474 | - | } | |||
| 475 | - | ||||
| 476 | - | func nextProxyDelta(s *proxyScreenState, update ports.ScreenUpdate) ports.ScreenUpdate { | |||
| 477 | - | update.Kind = ports.ScreenUpdateDelta | |||
| 478 | - | update.BaseStateNum = s.stateNum | |||
| 479 | - | update.NewStateNum = s.stateNum + 1 | |||
| 480 | - | return update | |||
| 481 | - | } | |||
| 482 | - | ||||
| 483 | - | func screenSnapshot(cols, rows int, lines ...string) ports.ScreenUpdate { | |||
| 484 | - | spans := make([]ports.ScreenSpan, len(lines)) | |||
| 485 | - | for y, line := range lines { | |||
| 486 | - | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cells(line)} | |||
| 487 | - | } | |||
| 488 | - | return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: domain.Size{Cols: cols, Rows: rows}, Spans: spans} | |||
| 489 | - | } | |||
| 490 | - | ||||
| 491 | - | func blankSnapshot(size domain.Size) ports.ScreenUpdate { | |||
| 492 | - | spans := make([]ports.ScreenSpan, size.Rows) | |||
| 493 | - | for y := range spans { | |||
| 494 | - | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cellsRepeat(' ', size.Cols)} | |||
| 495 | - | } | |||
| 496 | - | return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: size, Spans: spans} | |||
| 497 | - | } | |||
| 498 | - | ||||
| 499 | - | func cells(text string) []renderer.Cell { | |||
| 500 | - | out := make([]renderer.Cell, 0, len(text)) | |||
| 501 | - | for _, r := range text { | |||
| 502 | - | out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 503 | - | } | |||
| 504 | - | return out | |||
| 505 | - | } | |||
| 506 | - | ||||
| 507 | - | func cellsRepeat(r rune, n int) []renderer.Cell { | |||
| 508 | - | out := make([]renderer.Cell, n) | |||
| 509 | - | for i := range out { | |||
| 510 | - | out[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |||
| 511 | - | } | |||
| 512 | - | return out | |||
| 513 | - | } | |||
| 514 | - | ||||
| 515 | - | func styledCells(n int) []renderer.Cell { | |||
| 516 | - | out := cellsRepeat('s', n) | |||
| 517 | - | for i := range out { | |||
| 518 | - | out[i].Style.Bold = i%2 == 0 | |||
| 519 | - | } | |||
| 520 | - | return out | |||
| 521 | - | } | |||
| 522 | - | ||||
| 523 | - | func proxyFrameText(frame renderer.Frame) string { | |||
| 524 | - | out := make([]rune, 0, frame.Width*frame.Height) | |||
| 525 | - | for y := 0; y < frame.Height; y++ { | |||
| 526 | - | for _, cell := range frame.Row(y) { | |||
| 527 | - | out = append(out, cell.Rune) | |||
| 528 | - | } | |||
| 529 | - | } | |||
| 530 | - | return string(out) | |||
| 531 | - | } | |||
M internal/usecase/daemon/proxy_session.go +51 -85
| @@ -13,35 +13,32 @@ import ( | |||||
| 13 | "github.com/bnema/vev/internal/ports" | 13 | "github.com/bnema/vev/internal/ports" | ||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | - | // proxySession is the local attachment identity for one structured remote | 16 | + | // proxySession is the local attachment identity for one remote session key. mu is a leaf lock: callers may take it below d.mu or sessionCore |
| 17 | - | // session key. mu is a leaf lock: callers may take it below d.mu or sessionCore | |||
| 18 | // locks, but must not acquire another architecture lock or perform transport I/O | 17 | // locks, but must not acquire another architecture lock or perform transport I/O | ||
| 19 | // while holding it. All Transport.Send calls are owned by sendMu. | 18 | // while holding it. All Transport.Send calls are owned by sendMu. | ||
| 20 | type proxySession struct { | 19 | type proxySession struct { | ||
| 21 | sessionCore | 20 | sessionCore | ||
| 22 | key domain.RemoteSessionKey | 21 | key domain.RemoteSessionKey | ||
| 23 | 22 | ||||
| 24 | - | mu sync.Mutex | 23 | + | mu sync.Mutex |
| 25 | - | generation uint64 | 24 | + | generation uint64 |
| 26 | - | linkGeneration uint64 | 25 | + | linkGeneration uint64 |
| 27 | - | transport ports.Transport | 26 | + | transport ports.Transport |
| 28 | - | resumeToken uint64 | 27 | + | resumeToken uint64 |
| 29 | - | appliedState uint64 | 28 | + | outputEpoch uint64 |
| 30 | - | // screenReady is true only after a moving snapshot establishes the | 29 | + | outputState uint64 |
| 31 | - | // dependency required by subsequent deltas. | 30 | + | outputReady bool |
| 32 | - | screenReady bool | 31 | + | outputResetRequested bool |
| 33 | - | resetRequested bool | 32 | + | meta ports.SessionMeta |
| 34 | - | screen *proxyScreenState | 33 | + | attentionAt []time.Time |
| 35 | - | meta ports.SessionMeta | 34 | + | linkState ports.LinkState |
| 36 | - | attentionAt []time.Time | 35 | + | expired bool |
| 37 | - | linkState ports.LinkState | 36 | + | warm *proxyWarmTimer |
| 38 | - | expired bool | 37 | + | cancel context.CancelFunc |
| 39 | - | warm *proxyWarmTimer | 38 | + | done chan struct{} |
| 40 | - | cancel context.CancelFunc | 39 | + | doneOnce sync.Once |
| 41 | - | done chan struct{} | 40 | + | clientID [16]byte |
| 42 | - | doneOnce sync.Once | 41 | + | contentSize domain.Size |
| 43 | - | clientID [16]byte | |||
| 44 | - | contentSize domain.Size | |||
| 45 | 42 | ||||
| 46 | sendMu sync.Mutex | 43 | sendMu sync.Mutex | ||
| 47 | inputNext uint64 | 44 | inputNext uint64 | ||
| @@ -61,73 +58,44 @@ func (p *proxySession) core() *sessionCore { | |||||
| 61 | 58 | ||||
| 62 | func (p *proxySession) isProxy() bool { return true } | 59 | func (p *proxySession) isProxy() bool { return true } | ||
| 63 | 60 | ||||
| 64 | - | func (p *proxySession) sessionMetaSnapshot() (ports.SessionMeta, bool) { | 61 | + | func (p *proxySession) captureRenderState(*attachedClient, renderCaptureRequest) (*capturedRenderState, bool) { |
| 65 | - | if p == nil { | 62 | + | return nil, false |
| 66 | - | return ports.SessionMeta{}, false | |||
| 67 | - | } | |||
| 68 | - | p.mu.Lock() | |||
| 69 | - | defer p.mu.Unlock() | |||
| 70 | - | if len(p.meta.Tabs) == 0 || int(p.meta.Active) >= len(p.meta.Tabs) { | |||
| 71 | - | return ports.SessionMeta{}, false | |||
| 72 | - | } | |||
| 73 | - | return cloneSessionMeta(p.meta), true | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | - | // replaceSessionMetaLocked atomically replaces the relayed snapshot while | |||
| 77 | - | // preserving the first-observed timestamp of tabs that remain attentive. | |||
| 78 | - | // Callers must validate meta and hold p.mu. | |||
| 79 | - | func (p *proxySession) replaceSessionMetaLocked(meta ports.SessionMeta, now time.Time) { | |||
| 80 | - | attentionAt := make([]time.Time, len(meta.Tabs)) | |||
| 81 | - | for i, tab := range meta.Tabs { | |||
| 82 | - | if !tab.Attention { | |||
| 83 | - | continue | |||
| 84 | - | } | |||
| 85 | - | if i < len(p.meta.Tabs) && p.meta.Tabs[i].Attention && i < len(p.attentionAt) { | |||
| 86 | - | attentionAt[i] = p.attentionAt[i] | |||
| 87 | - | continue | |||
| 88 | - | } | |||
| 89 | - | attentionAt[i] = now | |||
| 90 | - | } | |||
| 91 | - | p.meta = cloneSessionMeta(meta) | |||
| 92 | - | p.attentionAt = attentionAt | |||
| 93 | } | 63 | } | ||
| 94 | 64 | ||||
| 95 | - | // applyProxySessionMeta validates and atomically applies relayed metadata only | 65 | + | func (p *proxySession) resize(size domain.Size) (changed, sent bool) { |
| 96 | - | // for the exact current link generation. A remote session rename cannot mutate | 66 | + | if p == nil || !size.Valid() { |
| 97 | - | // the immutable local identity, so it expires that exact proxy instead. | 67 | + | return false, false |
| 98 | - | func (d *Daemon) applyProxySessionMeta(p *proxySession, generation uint64, meta ports.SessionMeta) (bool, error) { | |||
| 99 | - | if d == nil || p == nil { | |||
| 100 | - | return false, errors.New("proxy session: metadata target is unavailable") | |||
| 101 | } | 68 | } | ||
| 102 | - | if _, err := ports.MarshalSessionMeta(meta); err != nil { | 69 | + | payload, err := ports.MarshalResize(ports.Resize{Size: size}) |
| 103 | - | return false, fmt.Errorf("proxy session: invalid metadata: %w", err) | 70 | + | if err != nil { |
| 71 | + | return false, false | |||
| 104 | } | 72 | } | ||
| 105 | - | meta = cloneSessionMeta(meta) | |||
| 106 | - | now := d.clock.Now() | |||
| 107 | - | ||||
| 108 | p.mu.Lock() | 73 | p.mu.Lock() | ||
| 109 | - | if p.linkGeneration != generation { | 74 | + | if p.contentSize == size { |
| 110 | - | p.mu.Unlock() | 75 | + | available := p.transport != nil |
| 111 | - | return false, nil | |||
| 112 | - | } | |||
| 113 | - | if meta.SessionName != p.key.Name { | |||
| 114 | - | transport := p.transport | |||
| 115 | p.mu.Unlock() | 76 | p.mu.Unlock() | ||
| 116 | - | if transport != nil { | 77 | + | return false, available |
| 117 | - | d.markProxyReplaced(p, generation, transport) | |||
| 118 | - | } | |||
| 119 | - | return false, fmt.Errorf("proxy session: metadata identity mismatch: got %q", meta.SessionName) | |||
| 120 | } | 78 | } | ||
| 121 | - | p.replaceSessionMetaLocked(meta, now) | 79 | + | p.contentSize = size |
| 80 | + | generation := p.linkGeneration | |||
| 122 | p.mu.Unlock() | 81 | p.mu.Unlock() | ||
| 82 | + | return true, p.sendGeneration(generation, ports.Frame{ | |||
| 83 | + | Type: ports.MsgResize, | |||
| 84 | + | Payload: payload, | |||
| 85 | + | }) == nil | |||
| 86 | + | } | |||
| 123 | 87 | ||||
| 124 | - | d.pokeAttentionTicker() | 88 | + | func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta { |
| 125 | - | for _, ac := range snapshotAttachmentSession(p) { | 89 | + | meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...) |
| 126 | - | d.invalidateRender(p, ac, false, "proxy_session.go") | 90 | + | return meta |
| 127 | - | } | |||
| 128 | - | return true, nil | |||
| 129 | } | 91 | } | ||
| 130 | 92 | ||||
| 93 | + | // replaceSessionMetaLocked atomically replaces the relayed snapshot while | |||
| 94 | + | // preserving the first-observed timestamp of tabs that remain attentive. | |||
| 95 | + | // Callers must validate meta and hold p.mu. | |||
| 96 | + | // applyProxySessionMeta validates and atomically applies relayed metadata only | |||
| 97 | + | // for the exact current link generation. A remote session rename cannot mutate | |||
| 98 | + | // the immutable local identity, so it expires that exact proxy instead. | |||
| 131 | func (p *proxySession) snapshotView(opts viewOptions) sessionView { | 99 | func (p *proxySession) snapshotView(opts viewOptions) sessionView { | ||
| 132 | if p == nil { | 100 | if p == nil { | ||
| 133 | return sessionView{} | 101 | return sessionView{} | ||
| @@ -204,9 +172,8 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi | |||||
| 204 | if err := key.Validate(); err != nil { | 172 | if err := key.Validate(); err != nil { | ||
| 205 | return nil, fmt.Errorf("proxy session key: %w", err) | 173 | return nil, fmt.Errorf("proxy session key: %w", err) | ||
| 206 | } | 174 | } | ||
| 207 | - | content := contentSize(size, false) | 175 | + | if !size.Valid() { |
| 208 | - | if size.Valid() && !validProxyScreenSize(content) { | 176 | + | return nil, fmt.Errorf("proxy session size: invalid size %dx%d", size.Cols, size.Rows) |
| 209 | - | return nil, fmt.Errorf("proxy session size: invalid content size %dx%d", content.Cols, content.Rows) | |||
| 210 | } | 177 | } | ||
| 211 | var clientID [16]byte | 178 | var clientID [16]byte | ||
| 212 | if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil { | 179 | if _, err := io.ReadFull(rand.Reader, clientID[:]); err != nil { | ||
| @@ -220,11 +187,10 @@ func newProxySession(key domain.RemoteSessionKey, size domain.Size) (*proxySessi | |||||
| 220 | }, | 187 | }, | ||
| 221 | key: key, | 188 | key: key, | ||
| 222 | generation: 1, | 189 | generation: 1, | ||
| 223 | - | screen: newProxyScreenState(content), | |||
| 224 | linkState: ports.LinkStateConnected, | 190 | linkState: ports.LinkStateConnected, | ||
| 225 | done: make(chan struct{}), | 191 | done: make(chan struct{}), | ||
| 226 | clientID: clientID, | 192 | clientID: clientID, | ||
| 227 | - | contentSize: content, | 193 | + | contentSize: size, |
| 228 | commandPending: make(map[uint64]proxyCommandPending), | 194 | commandPending: make(map[uint64]proxyCommandPending), | ||
| 229 | } | 195 | } | ||
| 230 | return p, nil | 196 | return p, nil | ||
| @@ -238,7 +204,7 @@ type proxyConstruction struct { | |||||
| 238 | var errProxySessionExpired = errors.New("proxy session: remote attachment has expired") | 204 | var errProxySessionExpired = errors.New("proxy session: remote attachment has expired") | ||
| 239 | 205 | ||||
| 240 | // openProxySession returns the exact already-published proxy for key, or elects | 206 | // openProxySession returns the exact already-published proxy for key, or elects | ||
| 241 | - | // one constructor for that structured key. Waiters never dial: they wait | 207 | + | // one constructor for that remote key. Waiters never dial: they wait |
| 242 | // cancellably, then revalidate the exact registry winner under d.mu. The | 208 | // cancellably, then revalidate the exact registry winner under d.mu. The | ||
| 243 | // reservation itself is held only under d.mu; dial and handshake I/O run after | 209 | // reservation itself is held only under d.mu; dial and handshake I/O run after | ||
| 244 | // releasing every daemon, core, and proxy architecture lock. | 210 | // releasing every daemon, core, and proxy architecture lock. | ||
A internal/usecase/daemon/proxy_test_helpers_test.go +127 -0
| @@ -0,0 +1,127 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "io" | |||
| 5 | + | "log/slog" | |||
| 6 | + | "sync" | |||
| 7 | + | "sync/atomic" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | "github.com/stretchr/testify/require" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type proxyRecv struct { | |||
| 17 | + | frame ports.Frame | |||
| 18 | + | err error | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | type proxyTestTransport struct { | |||
| 22 | + | recv chan proxyRecv | |||
| 23 | + | closed chan struct{} | |||
| 24 | + | sent chan ports.Frame | |||
| 25 | + | once sync.Once | |||
| 26 | + | ||||
| 27 | + | sendFails atomic.Bool | |||
| 28 | + | sendGate <-chan struct{} | |||
| 29 | + | sendEntered chan struct{} | |||
| 30 | + | receiving atomic.Int32 | |||
| 31 | + | concurrent atomic.Bool | |||
| 32 | + | sending atomic.Int32 | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func newProxyTestTransport() *proxyTestTransport { | |||
| 36 | + | return &proxyTestTransport{recv: make(chan proxyRecv, 16), closed: make(chan struct{}), sent: make(chan ports.Frame, 32)} | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (t *proxyTestTransport) Send(frame ports.Frame) error { | |||
| 40 | + | if t.sending.Add(1) != 1 { | |||
| 41 | + | t.concurrent.Store(true) | |||
| 42 | + | } | |||
| 43 | + | defer t.sending.Add(-1) | |||
| 44 | + | if t.sendFails.Load() { | |||
| 45 | + | return io.ErrClosedPipe | |||
| 46 | + | } | |||
| 47 | + | if t.sendGate != nil { | |||
| 48 | + | if t.sendEntered != nil { | |||
| 49 | + | select { | |||
| 50 | + | case t.sendEntered <- struct{}{}: | |||
| 51 | + | default: | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | select { | |||
| 55 | + | case <-t.sendGate: | |||
| 56 | + | case <-t.closed: | |||
| 57 | + | return io.ErrClosedPipe | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | select { | |||
| 61 | + | case t.sent <- frame: | |||
| 62 | + | return nil | |||
| 63 | + | case <-t.closed: | |||
| 64 | + | return io.ErrClosedPipe | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (t *proxyTestTransport) Recv() (ports.Frame, error) { | |||
| 69 | + | t.receiving.Add(1) | |||
| 70 | + | defer t.receiving.Add(-1) | |||
| 71 | + | select { | |||
| 72 | + | case item := <-t.recv: | |||
| 73 | + | return item.frame, item.err | |||
| 74 | + | case <-t.closed: | |||
| 75 | + | return ports.Frame{}, io.EOF | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (t *proxyTestTransport) Close() error { | |||
| 80 | + | t.once.Do(func() { close(t.closed) }) | |||
| 81 | + | return nil | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func proxyWelcome(name string, token uint64, capabilities uint32) ports.Frame { | |||
| 85 | + | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{ | |||
| 86 | + | SessionID: "remote-id", SessionName: name, ResumeToken: token, Capabilities: capabilities, | |||
| 87 | + | })} | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func proxyHandshakeSnapshot() ports.Frame { | |||
| 91 | + | return ports.Frame{Type: ports.MsgOutput, Payload: mustMarshalOutput(ports.Output{ | |||
| 92 | + | Epoch: 1, Base: 0, New: 1, Size: domain.Size{Cols: 1, Rows: 1}, Full: true, Data: []byte("remote initial\n"), | |||
| 93 | + | })} | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk ports.Clock) *Daemon { | |||
| 97 | + | t.Helper() | |||
| 98 | + | if clk == nil { | |||
| 99 | + | clk = stubClock{} | |||
| 100 | + | } | |||
| 101 | + | return New(nil, clk, slog.New(slog.NewTextHandler(io.Discard, nil)), | |||
| 102 | + | WithRemoteDiscovery(nil, nil, nil, factory, ports.RemoteTransportUDP)) | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func requireProxyHello(t *testing.T, tr *proxyTestTransport) ports.Hello { | |||
| 106 | + | t.Helper() | |||
| 107 | + | select { | |||
| 108 | + | case frame := <-tr.sent: | |||
| 109 | + | require.Equal(t, ports.MsgHello, frame.Type) | |||
| 110 | + | hello, err := ports.UnmarshalHello(frame.Payload) | |||
| 111 | + | require.NoError(t, err) | |||
| 112 | + | return hello | |||
| 113 | + | case <-time.After(time.Second): | |||
| 114 | + | t.Fatal("timed out waiting for proxy Hello") | |||
| 115 | + | return ports.Hello{} | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func stopProxy(t *testing.T, p *proxySession) { | |||
| 120 | + | t.Helper() | |||
| 121 | + | p.stop() | |||
| 122 | + | select { | |||
| 123 | + | case <-p.done: | |||
| 124 | + | case <-time.After(time.Second): | |||
| 125 | + | t.Fatal("timed out waiting for proxy link to stop") | |||
| 126 | + | } | |||
| 127 | + | } | |||
M internal/usecase/daemon/render.go +20 -20
| @@ -281,17 +281,6 @@ func (b *runtimeMarkBatch) span(start, end ports.RuntimeMarkKind, bytes uint64) | |||||
| 281 | } | 281 | } | ||
| 282 | } | 282 | } | ||
| 283 | 283 | ||||
| 284 | - | // diagnostic records a completed measurement for observer I/O after the | |||
| 285 | - | // render transaction releases attachment ownership. | |||
| 286 | - | func (b *runtimeMarkBatch) diagnostic(kind ports.RuntimeMarkKind, bytes, fragments uint64) { | |||
| 287 | - | if b == nil || b.observer == nil { | |||
| 288 | - | return | |||
| 289 | - | } | |||
| 290 | - | mark := ports.NewRuntimeMark("daemon", kind, bytes, true) | |||
| 291 | - | mark.Fragments = fragments | |||
| 292 | - | b.marks = append(b.marks, mark) | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | func (b *runtimeMarkBatch) flush() { | 284 | func (b *runtimeMarkBatch) flush() { | ||
| 296 | if b == nil || b.observer == nil { | 285 | if b == nil || b.observer == nil { | ||
| 297 | return | 286 | return | ||
| @@ -479,15 +468,23 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |||||
| 479 | } | 468 | } | ||
| 480 | 469 | ||||
| 481 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) { | 470 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) { | ||
| 482 | - | rows := min(r.Height, src.Height) | 471 | + | startX, startY := max(r.X, 0), max(r.Y, 0) |
| 483 | - | cols := min(r.Width, src.Width) | 472 | + | endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height) |
| 484 | - | for y := range rows { | 473 | + | for y := startY; y < endY; y++ { |
| 485 | - | for x := range cols { | 474 | + | sy := y - r.Y |
| 486 | - | cell := src.At(x, y) | 475 | + | if sy < 0 || sy >= src.Height { |
| 476 | + | continue | |||
| 477 | + | } | |||
| 478 | + | for x := startX; x < endX; x++ { | |||
| 479 | + | sx := x - r.X | |||
| 480 | + | if sx < 0 || sx >= src.Width { | |||
| 481 | + | continue | |||
| 482 | + | } | |||
| 483 | + | cell := src.At(sx, sy) | |||
| 487 | if dim { | 484 | if dim { | ||
| 488 | cell.Style = dimmer.Dim(cell.Style) | 485 | cell.Style = dimmer.Dim(cell.Style) | ||
| 489 | } | 486 | } | ||
| 490 | - | dst.Set(r.X+x, r.Y+y, cell) | 487 | + | dst.Set(x, y, cell) |
| 491 | } | 488 | } | ||
| 492 | } | 489 | } | ||
| 493 | } | 490 | } | ||
| @@ -502,9 +499,12 @@ func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style | |||||
| 502 | if d.Dir == layout.Vertical { | 499 | if d.Dir == layout.Vertical { | ||
| 503 | glyph = '─' | 500 | glyph = '─' | ||
| 504 | } | 501 | } | ||
| 505 | - | y0 := d.Rect.Y + dy | 502 | + | y0 := max(d.Rect.Y+dy, 0) |
| 506 | - | for y := y0; y < y0+d.Rect.Height; y++ { | 503 | + | y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height) |
| 507 | - | for x := d.Rect.X; x < d.Rect.X+d.Rect.Width; x++ { | 504 | + | x0 := max(d.Rect.X, 0) |
| 505 | + | x1 := min(d.Rect.X+d.Rect.Width, frame.Width) | |||
| 506 | + | for y := y0; y < y1; y++ { | |||
| 507 | + | for x := x0; x < x1; x++ { | |||
| 508 | frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style}) | 508 | frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style}) | ||
| 509 | } | 509 | } | ||
| 510 | } | 510 | } | ||
M internal/usecase/daemon/render_capture.go +18 -6
| @@ -35,17 +35,16 @@ type renderCaptureScratch struct { | |||||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | type damageReceipt struct { | 37 | type damageReceipt struct { | ||
| 38 | - | pane *pane | 38 | + | pane *pane |
| 39 | - | proxy *proxySession | 39 | + | generation uint64 |
| 40 | - | proxyScreen *proxyScreenState | |||
| 41 | - | generation uint64 | |||
| 42 | } | 40 | } | ||
| 43 | 41 | ||||
| 44 | type capturedRenderState struct { | 42 | type capturedRenderState struct { | ||
| 45 | attachment *attachedClient // identity only; never dereferenced by composition | 43 | attachment *attachedClient // identity only; never dereferenced by composition | ||
| 46 | lease *attachmentLease | 44 | lease *attachmentLease | ||
| 45 | + | view attachmentView | |||
| 46 | + | window domain.Size | |||
| 47 | reset bool | 47 | reset bool | ||
| 48 | - | contentOnly bool | |||
| 49 | layout capturedTabLayout | 48 | layout capturedTabLayout | ||
| 50 | panes []capturedPaneRenderState | 49 | panes []capturedPaneRenderState | ||
| 51 | floating capturedFloatingRenderState | 50 | floating capturedFloatingRenderState | ||
| @@ -225,6 +224,13 @@ func captureLocalRenderState( | |||||
| 225 | return nil, false | 224 | return nil, false | ||
| 226 | } | 225 | } | ||
| 227 | 226 | ||||
| 227 | + | // A reset is an attachment-local capture boundary. The shared pane damage | |||
| 228 | + | // receipt may have been acknowledged by another fan-out attachment before | |||
| 229 | + | // this capture runs, so retaining this attachment's prior pane snapshots | |||
| 230 | + | // would compose an old frame with a fresh reset epoch. | |||
| 231 | + | if reset { | |||
| 232 | + | ac.captureFrames = nil | |||
| 233 | + | } | |||
| 228 | scratch := &ac.renderScratch | 234 | scratch := &ac.renderScratch | ||
| 229 | scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...) | 235 | scratch.statusTabs = append(scratch.statusTabs[:0], bars.status.tabs...) | ||
| 230 | bars.status.tabs = scratch.statusTabs | 236 | bars.status.tabs = scratch.statusTabs | ||
| @@ -266,8 +272,14 @@ func captureLocalRenderState( | |||||
| 266 | layoutSnap.dividers = scratch.dividers | 272 | layoutSnap.dividers = scratch.dividers | ||
| 267 | } | 273 | } | ||
| 268 | state := &scratch.state | 274 | state := &scratch.state | ||
| 275 | + | view := ac.viewSnapshot() | |||
| 276 | + | window := domain.Size{} | |||
| 277 | + | if view.windowSet { | |||
| 278 | + | window = ac.size | |||
| 279 | + | } | |||
| 269 | *state = capturedRenderState{ | 280 | *state = capturedRenderState{ | ||
| 270 | - | attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme, | 281 | + | attachment: ac, lease: lease, view: view, window: window, |
| 282 | + | reset: reset, bars: bars, theme: bars.theme, | |||
| 271 | styles: request.styles, styleGeneration: request.styleGeneration, | 283 | styles: request.styles, styleGeneration: request.styleGeneration, | ||
| 272 | overlays: overlays, preview: preview, | 284 | overlays: overlays, preview: preview, | ||
| 273 | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | 285 | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | ||
M internal/usecase/daemon/render_coordinator_test.go +2 -3
| @@ -1161,11 +1161,10 @@ func TestProducerInvalidations(t *testing.T) { | |||||
| 1161 | }, | 1161 | }, | ||
| 1162 | { | 1162 | { | ||
| 1163 | file: "input.go", | 1163 | file: "input.go", | ||
| 1164 | - | name: "proxied jump attention switches a local tab", | 1164 | + | name: "jump attention switches a local tab", |
| 1165 | tabs: 2, | 1165 | tabs: 2, | ||
| 1166 | - | producer: "input.go", | 1166 | + | producer: "attention.go", |
| 1167 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | 1167 | run: func(t *testing.T, d *Daemon, sess *session, ac *attachedClient) { | ||
| 1168 | - | ac.proxied = true | |||
| 1169 | sess.tabs[1].attention = true | 1168 | sess.tabs[1].attention = true | ||
| 1170 | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil) | 1169 | daemonKeyHandler{d: d, ac: ac}.Action(keys.ActionJumpAttention, nil) | ||
| 1171 | require.Equal(t, 1, testAttachmentTabIndex(sess)) | 1170 | require.Equal(t, 1, testAttachmentTabIndex(sess)) | ||
M internal/usecase/daemon/render_damage_receipt_test.go +27 -0
| @@ -11,6 +11,33 @@ import ( | |||||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 12 | ) | 12 | ) | ||
| 13 | 13 | ||||
| 14 | + | func TestResetCaptureRefreshesPaneAfterPeerAcknowledgesSharedDamage(t *testing.T) { | |||
| 15 | + | _, sess, ac, _ := newManualSessionWithPTYs(t, nil) | |||
| 16 | + | p := sess.tabs[0].focusedPane() | |||
| 17 | + | p.mu.Lock() | |||
| 18 | + | p.screen.ClearDamage() | |||
| 19 | + | p.screen.Write([]byte("before")) | |||
| 20 | + | p.mu.Unlock() | |||
| 21 | + | ||||
| 22 | + | ac.sendMu.Lock() | |||
| 23 | + | state, ok := captureRenderState(sess, ac, renderCaptureRequest{reset: false}) | |||
| 24 | + | require.True(t, ok) | |||
| 25 | + | before := state.panes[0].frame.Clone() | |||
| 26 | + | ac.sendMu.Unlock() | |||
| 27 | + | ||||
| 28 | + | p.mu.Lock() | |||
| 29 | + | p.screen.Write([]byte("after")) | |||
| 30 | + | damage := p.screen.CaptureDamage() | |||
| 31 | + | p.screen.AcknowledgeDamage(damage.Generation) | |||
| 32 | + | p.mu.Unlock() | |||
| 33 | + | ||||
| 34 | + | ac.sendMu.Lock() | |||
| 35 | + | state, ok = captureRenderState(sess, ac, renderCaptureRequest{reset: true}) | |||
| 36 | + | ac.sendMu.Unlock() | |||
| 37 | + | require.True(t, ok) | |||
| 38 | + | require.NotEqual(t, before, state.panes[0].frame, "reset capture must not reuse the pane snapshot retained before shared damage was acknowledged") | |||
| 39 | + | } | |||
| 40 | + | ||||
| 14 | func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) { | 41 | func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) { | ||
| 15 | _, sess, ac, _ := newManualSessionWithPTYs(t, nil) | 42 | _, sess, ac, _ := newManualSessionWithPTYs(t, nil) | ||
| 16 | p := sess.tabs[0].focusedPane() | 43 | p := sess.tabs[0].focusedPane() | ||
M internal/usecase/daemon/render_pipeline.go +53 -109
| @@ -68,6 +68,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 68 | scratch = scratchIn[0] | 68 | scratch = scratchIn[0] | ||
| 69 | } | 69 | } | ||
| 70 | width, rows := state.layout.area.Width, state.layout.area.Height | 70 | width, rows := state.layout.area.Width, state.layout.area.Height | ||
| 71 | + | if state.window.Valid() { | |||
| 72 | + | window := contentSize(state.window) | |||
| 73 | + | width, rows = window.Cols, window.Rows | |||
| 74 | + | } | |||
| 71 | if width <= 0 || rows < 0 { | 75 | if width <= 0 || rows < 0 { | ||
| 72 | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | 76 | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | ||
| 73 | } | 77 | } | ||
| @@ -75,10 +79,6 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 75 | // copied before incremental drawing so prepare/send failures cannot change it. | 79 | // copied before incremental drawing so prepare/send failures cannot change it. | ||
| 76 | contentY := 1 | 80 | contentY := 1 | ||
| 77 | frameHeight := rows + tabChromeRows | 81 | frameHeight := rows + tabChromeRows | ||
| 78 | - | if state.contentOnly { | |||
| 79 | - | contentY = 0 | |||
| 80 | - | frameHeight = rows | |||
| 81 | - | } | |||
| 82 | canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight | 82 | canReuseFrame := scratch.frame.Width == width && scratch.frame.Height == frameHeight | ||
| 83 | frame := scratch.frame | 83 | frame := scratch.frame | ||
| 84 | if !canReuseFrame { | 84 | if !canReuseFrame { | ||
| @@ -99,10 +99,8 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 99 | defaultDimmer := themeui.NewDimmer(state.theme) | 99 | defaultDimmer := themeui.NewDimmer(state.theme) | ||
| 100 | neutralBorder := styles.NeutralBorder | 100 | neutralBorder := styles.NeutralBorder | ||
| 101 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | 101 | inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming)) | ||
| 102 | - | if !state.contentOnly { | 102 | + | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) |
| 103 | - | drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles) | 103 | + | drawStatusBarState(frame.Row(rows+1), state.bars, styles) |
| 104 | - | drawStatusBarState(frame.Row(rows+1), state.bars, styles) | |||
| 105 | - | } | |||
| 106 | content := domain.Rect{Y: contentY, Width: width, Height: rows} | 104 | content := domain.Rect{Y: contentY, Width: width, Height: rows} | ||
| 107 | if state.layout.valid { | 105 | if state.layout.valid { | ||
| 108 | drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder)) | 106 | drawDividers(frame, state.layout.dividers, content.Y, defaultDimmer.Dim(neutralBorder)) | ||
| @@ -166,7 +164,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 166 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | 164 | frame, damage = composeCapturedCopyMode(state, frame, damage, content) | ||
| 167 | frame, damage = composeCapturedOverlays(state, frame, damage) | 165 | frame, damage = composeCapturedOverlays(state, frame, damage) | ||
| 168 | } | 166 | } | ||
| 169 | - | if !state.contentOnly && !full { | 167 | + | if !full { |
| 170 | if !sameCells(in.bars.top, baseFrame.Row(0)) { | 168 | if !sameCells(in.bars.top, baseFrame.Row(0)) { | ||
| 171 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | 169 | damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1}) | ||
| 172 | } | 170 | } | ||
| @@ -188,23 +186,22 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 188 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | 186 | cursorInputs.hiddenByOverlay = cursorInputs.hiddenByOverlay || overlaysActive | ||
| 189 | cursor := desiredCapturedCursor(cursorInputs, contentY) | 187 | cursor := desiredCapturedCursor(cursorInputs, contentY) | ||
| 190 | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | 188 | outCache := composeCacheInput{valid: !overlaysActive, frame: baseFrame, layoutFingerprint: state.layout.fingerprint, theme: state.theme, styleGeneration: state.styleGeneration, titleGenerations: titles, damage: damage, toastFootprints: append(scratch.toastFootprints[:0], toastFootprints...), floatingVisible: state.floating.visible, floatingFocused: state.floating.focused, floatingGeneration: state.floating.generation, floatingGeometry: state.floating.geometry.translate(content.X, content.Y), floatingTitleGeneration: state.floating.titleGeneration, bars: scratch.bars} | ||
| 191 | - | if !state.contentOnly { | 189 | + | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) |
| 192 | - | outCache.bars.capture(baseFrame.Row(0), baseFrame.Row(rows+1)) | |||
| 193 | - | } else { | |||
| 194 | - | outCache.bars.Reset() | |||
| 195 | - | } | |||
| 196 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | 190 | return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()} | ||
| 197 | } | 191 | } | ||
| 198 | 192 | ||||
| 199 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | 193 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | ||
| 194 | + | if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height { | |||
| 195 | + | return | |||
| 196 | + | } | |||
| 200 | style := styles.StatusBar | 197 | style := styles.StatusBar | ||
| 201 | if !focused { | 198 | if !focused { | ||
| 202 | style = dimmer.Dim(neutralBorder) | 199 | style = dimmer.Dim(neutralBorder) | ||
| 203 | } | 200 | } | ||
| 204 | - | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | 201 | + | for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ { |
| 205 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | 202 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | ||
| 206 | } | 203 | } | ||
| 207 | - | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | 204 | + | ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style) |
| 208 | } | 205 | } | ||
| 209 | 206 | ||||
| 210 | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | 207 | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | ||
| @@ -235,9 +232,7 @@ func captureOverlayLayers(state *capturedRenderState, snap *overlayRenderSnapsho | |||||
| 235 | styles = fallbackChromeStyles | 232 | styles = fallbackChromeStyles | ||
| 236 | } | 233 | } | ||
| 237 | rows := state.layout.area.Height | 234 | rows := state.layout.area.Height | ||
| 238 | - | if !state.contentOnly { | 235 | + | rows += tabChromeRows |
| 239 | - | rows += tabChromeRows | |||
| 240 | - | } | |||
| 241 | size := domain.Size{Cols: state.layout.area.Width, Rows: rows} | 236 | size := domain.Size{Cols: state.layout.area.Width, Rows: rows} | ||
| 242 | if snap.copySearchModel != nil { | 237 | if snap.copySearchModel != nil { | ||
| 243 | presentation := copySearchModal.Resolve(size) | 238 | presentation := copySearchModal.Resolve(size) | ||
| @@ -428,13 +423,6 @@ func commitDamageReceipts(receipts []damageReceipt) { | |||||
| 428 | receipt.pane.mu.Unlock() | 423 | receipt.pane.mu.Unlock() | ||
| 429 | continue | 424 | continue | ||
| 430 | } | 425 | } | ||
| 431 | - | if receipt.proxy != nil { | |||
| 432 | - | receipt.proxy.mu.Lock() | |||
| 433 | - | if receipt.proxy.screen != nil && receipt.proxy.screen == receipt.proxyScreen { | |||
| 434 | - | receipt.proxy.screen.AcknowledgeDamage(receipt.generation) | |||
| 435 | - | } | |||
| 436 | - | receipt.proxy.mu.Unlock() | |||
| 437 | - | } | |||
| 438 | } | 426 | } | ||
| 439 | } | 427 | } | ||
| 440 | 428 | ||||
| @@ -474,33 +462,19 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||||
| 474 | } | 462 | } | ||
| 475 | endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0) | 463 | endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0) | ||
| 476 | var ( | 464 | var ( | ||
| 477 | - | preparedANSI *preparedOutput | 465 | + | preparedANSI *preparedOutput |
| 478 | - | preparedScreen *preparedStructuredOutput | 466 | + | err error |
| 479 | - | err error | 467 | + | cursor cursorCandidate |
| 480 | - | cursor cursorCandidate | 468 | + | data []byte |
| 481 | - | data []byte | |||
| 482 | ) | 469 | ) | ||
| 483 | - | if ac.proxied { | 470 | + | preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset) |
| 484 | - | screenOutput := ac.ensureScreenOutput() | 471 | + | if err == nil { |
| 485 | - | if screenOutput == nil { | 472 | + | cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0) |
| 486 | - | err = errors.New("proxied attachment has no structured output stream") | 473 | + | data = append([]byte(nil), preparedANSI.data...) |
| 487 | - | } else { | 474 | + | data = append(data, cursor.data...) |
| 488 | - | preparedScreen, err = screenOutput.prepare(composed.frame, composed.damage, composed.cursor, composed.reset, ac.echoAck.Load()) | |||
| 489 | - | if err == nil { | |||
| 490 | - | data = preparedScreen.data | |||
| 491 | - | } | |||
| 492 | - | } | |||
| 493 | - | } else { | |||
| 494 | - | preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset) | |||
| 495 | - | if err == nil { | |||
| 496 | - | cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0) | |||
| 497 | - | data = append([]byte(nil), preparedANSI.data...) | |||
| 498 | - | data = append(data, cursor.data...) | |||
| 499 | - | } | |||
| 500 | } | 475 | } | ||
| 501 | endDiff(0, err == nil) | 476 | endDiff(0, err == nil) | ||
| 502 | if err != nil { | 477 | if err != nil { | ||
| 503 | - | ac.discardProxyCapture() | |||
| 504 | ac.sendMu.Unlock() | 478 | ac.sendMu.Unlock() | ||
| 505 | d.log.Error("render draw failed", "err", err, "session", entry.core().name) | 479 | d.log.Error("render draw failed", "err", err, "session", entry.core().name) | ||
| 506 | // Without a coordinator reportError repaints synchronously. Suppress only | 480 | // Without a coordinator reportError repaints synchronously. Suppress only | ||
| @@ -525,81 +499,54 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||||
| 525 | } | 499 | } | ||
| 526 | var sendTr ports.Transport | 500 | var sendTr ports.Transport | ||
| 527 | var sendErr error | 501 | var sendErr error | ||
| 528 | - | // Metadata precedes any output bytes and is published on its own when the | 502 | + | if len(data) > 0 { |
| 529 | - | // terminal frame is empty, so a proxied client never keeps a stale snapshot. | |||
| 530 | - | if len(data) > 0 || ac.proxied { | |||
| 531 | sendTransport := ac.transportSnapshot() | 503 | sendTransport := ac.transportSnapshot() | ||
| 532 | sendTr = sendTransport.transport | 504 | sendTr = sendTransport.transport | ||
| 533 | - | if ac.proxied { | 505 | + | endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data))) |
| 534 | - | if sess, ok := localSession(entry); ok { | 506 | + | if sendTr == nil { |
| 535 | - | sendErr = ac.sendSessionMetaIfChanged(sess, sendTransport, marks.attachmentEffect) | 507 | + | sendErr = errors.New("client transport is nil") |
| 536 | - | } else if proxy, ok := entry.(*proxySession); ok { | 508 | + | } else { |
| 537 | - | meta, metaOK := proxy.sessionMetaSnapshot() | 509 | + | send := sendTr.Send |
| 538 | - | if !metaOK { | 510 | + | if async, ok := sendTr.(ports.AsyncTransport); ok { |
| 539 | - | sendErr = errSessionMetaUnavailable | 511 | + | send = async.SendAsync |
| 540 | - | } else { | |||
| 541 | - | sendErr = ac.sendSessionMetaSnapshot(meta, sendTransport, marks.attachmentEffect) | |||
| 542 | - | } | |||
| 543 | } | 512 | } | ||
| 544 | - | } | 513 | + | interruptible := false |
| 545 | - | if len(data) > 0 { | 514 | + | if marks.attachmentEffect != nil { |
| 546 | - | endEmit := marks.span(ports.RuntimeEmitStart, ports.RuntimeEmitEnd, uint64(len(data))) | 515 | + | interruptible = marks.attachmentEffect.beginTransportSend(sendTransport) |
| 547 | - | if sendErr == nil && sendTr == nil { | 516 | + | if !interruptible { |
| 548 | - | sendErr = errors.New("client transport is nil") | 517 | + | sendErr = errAttachmentTransition |
| 518 | + | } | |||
| 549 | } | 519 | } | ||
| 550 | if sendErr == nil { | 520 | if sendErr == nil { | ||
| 551 | - | interruptible := false | 521 | + | sendErr = preparedANSI.send(data, ac.echoAck.Load(), send) |
| 552 | - | if marks.attachmentEffect != nil { | |||
| 553 | - | interruptible = marks.attachmentEffect.beginTransportSend(sendTransport) | |||
| 554 | - | if !interruptible { | |||
| 555 | - | sendErr = errAttachmentTransition | |||
| 556 | - | } | |||
| 557 | - | } | |||
| 558 | - | if sendErr == nil { | |||
| 559 | - | send := sendTr.Send | |||
| 560 | - | if async, ok := sendTr.(ports.AsyncTransport); ok { | |||
| 561 | - | send = async.SendAsync | |||
| 562 | - | } | |||
| 563 | - | if ac.proxied { | |||
| 564 | - | sendErr = preparedScreen.send(send) | |||
| 565 | - | } else { | |||
| 566 | - | sendErr = preparedANSI.send(data, ac.echoAck.Load(), send) | |||
| 567 | - | } | |||
| 568 | - | } | |||
| 569 | - | if marks.attachmentEffect != nil && interruptible { | |||
| 570 | - | if sendErr != nil { | |||
| 571 | - | marks.attachmentEffect.reportTransportFailure(sendTransport) | |||
| 572 | - | } | |||
| 573 | - | marks.attachmentEffect.endTransportSend() | |||
| 574 | - | } | |||
| 575 | } | 522 | } | ||
| 576 | - | endEmit(uint64(len(data)), sendErr == nil) | 523 | + | if marks.attachmentEffect != nil && interruptible { |
| 577 | - | if sendErr == nil && ac.proxied { | 524 | + | if sendErr != nil { |
| 578 | - | kind := ports.RuntimeScreenDelta | 525 | + | marks.attachmentEffect.reportTransportFailure(sendTransport) |
| 579 | - | if preparedScreen.update.Kind == ports.ScreenUpdateSnapshot { | |||
| 580 | - | kind = ports.RuntimeScreenSnapshot | |||
| 581 | } | 526 | } | ||
| 582 | - | marks.diagnostic(kind, uint64(len(preparedScreen.data)), uint64(len(preparedScreen.update.Spans))) | 527 | + | marks.attachmentEffect.endTransportSend() |
| 583 | } | 528 | } | ||
| 584 | } | 529 | } | ||
| 530 | + | emitted := sendErr == nil && preparedANSI.sent | |||
| 531 | + | endEmit(uint64(len(data)), emitted) | |||
| 532 | + | if sendErr == nil && !emitted { | |||
| 533 | + | ac.sendMu.Unlock() | |||
| 534 | + | return true | |||
| 535 | + | } | |||
| 585 | } | 536 | } | ||
| 586 | if sendErr == nil { | 537 | if sendErr == nil { | ||
| 587 | if len(data) == 0 { | 538 | if len(data) == 0 { | ||
| 588 | - | if ac.proxied { | 539 | + | preparedANSI.commitNoSend() |
| 589 | - | preparedScreen.commitNoSend() | 540 | + | if !preparedANSI.sent { |
| 590 | - | } else { | 541 | + | ac.sendMu.Unlock() |
| 591 | - | preparedANSI.commitNoSend() | 542 | + | return true |
| 592 | } | 543 | } | ||
| 593 | } | 544 | } | ||
| 594 | // Publish only after output preparation and transport emission both | 545 | // Publish only after output preparation and transport emission both | ||
| 595 | // succeed. A cross-session transition may publish concurrently, but its | 546 | // succeed. A cross-session transition may publish concurrently, but its | ||
| 596 | // mandatory first-paint rebase waits for sendMu and therefore follows this | 547 | // mandatory first-paint rebase waits for sendMu and therefore follows this | ||
| 597 | // completed output transaction. | 548 | // completed output transaction. | ||
| 598 | - | if ac.proxied { | 549 | + | ac.lastCursor = cursor.next |
| 599 | - | ac.lastCursor = composed.cursor | |||
| 600 | - | } else { | |||
| 601 | - | ac.lastCursor = cursor.next | |||
| 602 | - | } | |||
| 603 | ac.pipelineScratch = ac.pipelineCache | 550 | ac.pipelineScratch = ac.pipelineCache | ||
| 604 | ac.pipelineCache = composed.cache | 551 | ac.pipelineCache = composed.cache | ||
| 605 | 552 | ||||
| @@ -611,9 +558,6 @@ func (d *Daemon) emitFrame(entry attachmentSession, ac *attachedClient, state *c | |||||
| 611 | ac.renderStages.emit() | 558 | ac.renderStages.emit() | ||
| 612 | } | 559 | } | ||
| 613 | } | 560 | } | ||
| 614 | - | if sendErr != nil { | |||
| 615 | - | ac.discardProxyCapture() | |||
| 616 | - | } | |||
| 617 | ac.sendMu.Unlock() | 561 | ac.sendMu.Unlock() | ||
| 618 | if sendErr != nil { | 562 | if sendErr != nil { | ||
| 619 | // A transport failure may invalidate the role gate. Release this render's | 563 | // A transport failure may invalidate the role gate. Release this render's | ||
M internal/usecase/daemon/render_pipeline_test.go +6 -3
| @@ -59,7 +59,7 @@ func TestTransportReplayFinalShadowAndTerminalBytes(t *testing.T) { | |||||
| 59 | require.NoError(t, transport.Send(frame)) | 59 | require.NoError(t, transport.Send(frame)) | ||
| 60 | output, err := ports.UnmarshalOutput(frame.Payload) | 60 | output, err := ports.UnmarshalOutput(frame.Payload) | ||
| 61 | require.NoError(t, err) | 61 | require.NoError(t, err) | ||
| 62 | - | require.Equal(t, frame.Payload, ports.MarshalOutput(output), "output payload must remain byte exact") | 62 | + | require.Equal(t, frame.Payload, mustMarshalOutput(output), "output payload must remain byte exact") |
| 63 | terminal.Write(output.Data) | 63 | terminal.Write(output.Data) | ||
| 64 | } | 64 | } | ||
| 65 | require.Equal(t, len(frames), transport.next) | 65 | require.Equal(t, len(frames), transport.next) | ||
| @@ -128,6 +128,9 @@ func TestPaintACKBlockedDoesNotDestructivelyCapture(t *testing.T) { | |||||
| 128 | t.Cleanup(release) | 128 | t.Cleanup(release) | ||
| 129 | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | 129 | d, sess, _, sends := newManualSessionWithPTYs(t, pty) | ||
| 130 | ac := sess.snapshotAttachments()[0] | 130 | ac := sess.snapshotAttachments()[0] | ||
| 131 | + | // Resolve the initial attachment view before filling the output window; a | |||
| 132 | + | // first target repair is an epoch boundary by design. | |||
| 133 | + | require.NotNil(t, sess.tabForAttachment(ac)) | |||
| 131 | ac.sendMu.Lock() | 134 | ac.sendMu.Lock() | ||
| 132 | ac.output.next = ac.output.maxOutstanding | 135 | ac.output.next = ac.output.maxOutstanding | ||
| 133 | ac.output.acked = 0 | 136 | ac.output.acked = 0 | ||
| @@ -252,8 +255,8 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) { | |||||
| 252 | require.Equal(t, uint64(1), ac.output.next) | 255 | require.Equal(t, uint64(1), ac.output.next) | ||
| 253 | out, err := ports.UnmarshalOutput((<-sends).Payload) | 256 | out, err := ports.UnmarshalOutput((<-sends).Payload) | ||
| 254 | require.NoError(t, err) | 257 | require.NoError(t, err) | ||
| 255 | - | require.Zero(t, out.BaseStateNum) | 258 | + | require.Zero(t, out.Base) |
| 256 | - | require.Equal(t, uint64(1), out.NewStateNum) | 259 | + | require.Equal(t, uint64(1), out.New) |
| 257 | } | 260 | } | ||
| 258 | 261 | ||||
| 259 | func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) { | 262 | func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T) { | ||
M internal/usecase/daemon/render_test.go +1 -1
| @@ -86,7 +86,7 @@ func TestFirstPaintRetainedFloatingPaneEmitsOneReset(t *testing.T) { | |||||
| 86 | frame := awaitFrame(t, sends, ports.MsgOutput) | 86 | frame := awaitFrame(t, sends, ports.MsgOutput) | ||
| 87 | output, err := ports.UnmarshalOutput(frame.Payload) | 87 | output, err := ports.UnmarshalOutput(frame.Payload) | ||
| 88 | require.NoError(t, err) | 88 | require.NoError(t, err) | ||
| 89 | - | require.Zero(t, output.BaseStateNum, "first paint must be a mandatory reset") | 89 | + | require.Zero(t, output.Base, "first paint must be a mandatory reset") |
| 90 | select { | 90 | select { | ||
| 91 | case extra := <-sends: | 91 | case extra := <-sends: | ||
| 92 | t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra) | 92 | t.Fatalf("first paint emitted duplicate frame after floating activation resize: %#v", extra) | ||
M internal/usecase/daemon/resume.go +2 -11
| @@ -459,7 +459,6 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||||
| 459 | oldSnap transportSnapshot | 459 | oldSnap transportSnapshot | ||
| 460 | credentialMatch bool | 460 | credentialMatch bool | ||
| 461 | clientMismatch bool | 461 | clientMismatch bool | ||
| 462 | - | modeMismatch bool | |||
| 463 | ) | 462 | ) | ||
| 464 | if sess != nil { | 463 | if sess != nil { | ||
| 465 | sess.mu.Lock() | 464 | sess.mu.Lock() | ||
| @@ -470,8 +469,6 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||||
| 470 | ac = candidate | 469 | ac = candidate | ||
| 471 | if ac.clientID != h.ClientID { | 470 | if ac.clientID != h.ClientID { | ||
| 472 | clientMismatch = true | 471 | clientMismatch = true | ||
| 473 | - | } else if ac.proxied != h.Proxied { | |||
| 474 | - | modeMismatch = true | |||
| 475 | } else { | 472 | } else { | ||
| 476 | oldSnap = ac.transportSnapshot() | 473 | oldSnap = ac.transportSnapshot() | ||
| 477 | credentialMatch = oldSnap.transport != nil | 474 | credentialMatch = oldSnap.transport != nil | ||
| @@ -487,11 +484,8 @@ func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz doma | |||||
| 487 | } | 484 | } | ||
| 488 | d.mu.Unlock() | 485 | d.mu.Unlock() | ||
| 489 | 486 | ||||
| 490 | - | if clientMismatch || modeMismatch { | 487 | + | if clientMismatch { |
| 491 | reason := "client id mismatch" | 488 | reason := "client id mismatch" | ||
| 492 | - | if modeMismatch { | |||
| 493 | - | reason = "proxied mode mismatch" | |||
| 494 | - | } | |||
| 495 | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | 489 | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | ||
| 496 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | 490 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | ||
| 497 | } | 491 | } | ||
| @@ -603,11 +597,8 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||||
| 603 | if parked.claimed { | 597 | if parked.claimed { | ||
| 604 | return nil, nil, false, errResumeTokenLifecycleRace | 598 | return nil, nil, false, errResumeTokenLifecycleRace | ||
| 605 | } | 599 | } | ||
| 606 | - | if ac.clientID != h.ClientID || ac.proxied != h.Proxied { | 600 | + | if ac.clientID != h.ClientID { |
| 607 | reason := "client id mismatch" | 601 | reason := "client id mismatch" | ||
| 608 | - | if ac.proxied != h.Proxied { | |||
| 609 | - | reason = "proxied mode mismatch" | |||
| 610 | - | } | |||
| 611 | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | 602 | d.log.Warn("resume rejected", "session", sess.name, "err", reason) | ||
| 612 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | 603 | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | ||
| 613 | } | 604 | } | ||
M internal/usecase/daemon/resume_test.go +19 -18
| @@ -280,7 +280,7 @@ func TestHandleHelloResumeDefersFreshOutputUntilWelcome(t *testing.T) { | |||||
| 280 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | 280 | output := awaitFrame(t, tr.sends, ports.MsgOutput) | ||
| 281 | first, err := ports.UnmarshalOutput(output.Payload) | 281 | first, err := ports.UnmarshalOutput(output.Payload) | ||
| 282 | require.NoError(t, err) | 282 | require.NoError(t, err) | ||
| 283 | - | require.Zero(t, first.BaseStateNum) | 283 | + | require.Zero(t, first.Base) |
| 284 | tr.finish() | 284 | tr.finish() | ||
| 285 | <-done | 285 | <-done | ||
| 286 | requireNoCoordinatorOutputFrame(t, tr.sends) | 286 | requireNoCoordinatorOutputFrame(t, tr.sends) | ||
| @@ -974,9 +974,10 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||||
| 974 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | 974 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | ||
| 975 | ac := &attachedClient{output: newOutputStateStream()} | 975 | ac := &attachedClient{output: newOutputStateStream()} | ||
| 976 | ac.output.next = 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() | ||
| @@ -988,8 +989,8 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |||||
| 988 | ac.sendMu.Unlock() | 989 | ac.sendMu.Unlock() | ||
| 989 | out, err := ports.UnmarshalOutput(f.Payload) | 990 | out, err := ports.UnmarshalOutput(f.Payload) | ||
| 990 | require.NoError(t, err) | 991 | require.NoError(t, err) | ||
| 991 | - | require.Equal(t, uint64(5), out.BaseStateNum, "output should remain incremental unless an explicit reset is requested") | 992 | + | require.Equal(t, uint64(5), out.Base, "output should remain incremental unless an explicit reset is requested") |
| 992 | - | require.Equal(t, uint64(6), out.NewStateNum) | 993 | + | require.Equal(t, uint64(6), out.New) |
| 993 | 994 | ||||
| 994 | ac.sendMu.Lock() | 995 | ac.sendMu.Lock() | ||
| 995 | reset = true | 996 | reset = true | ||
| @@ -998,8 +999,8 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |||||
| 998 | ac.sendMu.Unlock() | 999 | ac.sendMu.Unlock() | ||
| 999 | fullOut, err := ports.UnmarshalOutput(full.Payload) | 1000 | fullOut, err := ports.UnmarshalOutput(full.Payload) | ||
| 1000 | require.NoError(t, err) | 1001 | require.NoError(t, err) | ||
| 1001 | - | require.Equal(t, uint64(0), fullOut.BaseStateNum) | 1002 | + | require.Equal(t, uint64(0), fullOut.Base) |
| 1002 | - | require.Equal(t, uint64(7), fullOut.NewStateNum) | 1003 | + | require.Equal(t, uint64(7), fullOut.New) |
| 1003 | } | 1004 | } | ||
| 1004 | 1005 | ||||
| 1005 | func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) { | 1006 | func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) { | ||
| @@ -1081,9 +1082,9 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||||
| 1081 | require.Len(t, sends, 1) | 1082 | require.Len(t, sends, 1) | ||
| 1082 | first, err := ports.UnmarshalOutput(sends[0].Payload) | 1083 | first, err := ports.UnmarshalOutput(sends[0].Payload) | ||
| 1083 | require.NoError(t, err) | 1084 | require.NoError(t, err) | ||
| 1084 | - | require.Zero(t, first.BaseStateNum) | 1085 | + | require.Zero(t, first.Base) |
| 1085 | - | require.Equal(t, uint64(maxUnackedOutputStates+1), first.NewStateNum) | 1086 | + | require.Equal(t, uint64(1), first.New) |
| 1086 | - | resumedAC.ackOutputState(first.NewStateNum) | 1087 | + | resumedAC.ackOutputState(first.Epoch, first.New) |
| 1087 | 1088 | ||||
| 1088 | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | 1089 | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 1089 | d.paint(resumedSess, resumedAC, false, nil) | 1090 | d.paint(resumedSess, resumedAC, false, nil) | ||
| @@ -1091,7 +1092,7 @@ func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||||
| 1091 | require.Len(t, sends, 2) | 1092 | require.Len(t, sends, 2) | ||
| 1092 | second, err := ports.UnmarshalOutput(sends[1].Payload) | 1093 | second, err := ports.UnmarshalOutput(sends[1].Payload) | ||
| 1093 | require.NoError(t, err) | 1094 | require.NoError(t, err) | ||
| 1094 | - | require.Equal(t, first.NewStateNum, second.BaseStateNum) | 1095 | + | require.Equal(t, first.New, second.Base) |
| 1095 | } | 1096 | } | ||
| 1096 | 1097 | ||||
| 1097 | func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | 1098 | func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | ||
| @@ -1141,7 +1142,7 @@ func TestParkingReleasesPaneCapturesBeforeHeadlessCloseAndResume(t *testing.T) { | |||||
| 1141 | require.Len(t, sends, 1) | 1142 | require.Len(t, sends, 1) | ||
| 1142 | output, err := ports.UnmarshalOutput(sends[0].Payload) | 1143 | output, err := ports.UnmarshalOutput(sends[0].Payload) | ||
| 1143 | require.NoError(t, err) | 1144 | require.NoError(t, err) | ||
| 1144 | - | require.Zero(t, output.BaseStateNum, "resume must start with a complete frame") | 1145 | + | require.Zero(t, output.Base, "resume must start with a complete frame") |
| 1145 | terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows) | 1146 | terminal := vt.NewScreen(resumedAC.size.Cols, resumedAC.size.Rows) | ||
| 1146 | terminal.Write(output.Data) | 1147 | terminal.Write(output.Data) | ||
| 1147 | contents := strings.Join(frameRows(terminal.Frame), "\n") | 1148 | contents := strings.Join(frameRows(terminal.Frame), "\n") | ||
| @@ -1445,13 +1446,13 @@ func TestRawTerminalSideEffectsAreOutputStateNeutral(t *testing.T) { | |||||
| 1445 | require.NoError(t, err) | 1446 | require.NoError(t, err) | ||
| 1446 | second, err := ports.UnmarshalOutput(sends[1].Payload) | 1447 | second, err := ports.UnmarshalOutput(sends[1].Payload) | ||
| 1447 | require.NoError(t, err) | 1448 | require.NoError(t, err) | ||
| 1448 | - | require.Zero(t, first.BaseStateNum) | 1449 | + | require.Zero(t, first.Base) |
| 1449 | - | require.Zero(t, first.NewStateNum) | 1450 | + | require.Zero(t, first.New) |
| 1450 | - | require.Zero(t, second.BaseStateNum) | 1451 | + | require.Zero(t, second.Base) |
| 1451 | - | require.Zero(t, second.NewStateNum) | 1452 | + | require.Zero(t, second.New) |
| 1452 | } | 1453 | } | ||
| 1453 | 1454 | ||||
| 1454 | - | func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) { | 1455 | + | func TestSequencedInputDoesNotPrematurelyEcho(t *testing.T) { |
| 1455 | p, release := newBlockingPTY(t) | 1456 | p, release := newBlockingPTY(t) | ||
| 1456 | defer release() | 1457 | defer release() | ||
| 1457 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | 1458 | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | ||
D internal/usecase/daemon/screen_output_stream.go +0 -244
| @@ -1,244 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/internal/domain" | |||
| 7 | - | "github.com/bnema/vev/internal/ports" | |||
| 8 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 9 | - | ) | |||
| 10 | - | ||||
| 11 | - | // structuredOutputStream is the semantic counterpart of outputStateStream. | |||
| 12 | - | // The output state owns the shared state-number and ACK window; this stream | |||
| 13 | - | // owns only its planner shadow and the last committed absolute cursor. | |||
| 14 | - | // Callers serialize access with attachedClient.sendMu. | |||
| 15 | - | type structuredOutputStream struct { | |||
| 16 | - | state *outputStateStream | |||
| 17 | - | ||||
| 18 | - | shadow renderer.Frame | |||
| 19 | - | cursorOut cursorOut | |||
| 20 | - | ||||
| 21 | - | forceSnapshot bool | |||
| 22 | - | } | |||
| 23 | - | ||||
| 24 | - | // newStructuredOutputStream accepts the shared output state so ANSI and | |||
| 25 | - | // structured output use one dependency chain and one bounded ACK window. | |||
| 26 | - | func newStructuredOutputStream(state *outputStateStream) *structuredOutputStream { | |||
| 27 | - | return &structuredOutputStream{state: state, forceSnapshot: true} | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | // prepare creates an owned structured payload without changing state. | |||
| 31 | - | func (s *structuredOutputStream) prepare(frame renderer.Frame, damage []renderer.Damage, cursor cursorOut, reset bool, echoAck uint64) (*preparedStructuredOutput, error) { | |||
| 32 | - | if s == nil || s.state == nil { | |||
| 33 | - | return nil, errors.New("structured output stream is nil") | |||
| 34 | - | } | |||
| 35 | - | if err := frame.Validate(); err != nil { | |||
| 36 | - | return nil, err | |||
| 37 | - | } | |||
| 38 | - | size := frameSize(frame) | |||
| 39 | - | if !validProxyScreenSize(size) { | |||
| 40 | - | return nil, errors.New("structured output: frame geometry is outside wire limits") | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | - | reset = reset || s.forceSnapshot || s.state.next == 0 || s.shadow.Validate() != nil || | |||
| 44 | - | s.shadow.Width != frame.Width || s.shadow.Height != frame.Height | |||
| 45 | - | candidate, err := renderer.PlanDelta(frame, damage, s.shadow, reset) | |||
| 46 | - | if err != nil { | |||
| 47 | - | return nil, err | |||
| 48 | - | } | |||
| 49 | - | ||||
| 50 | - | plan := candidate.Plan | |||
| 51 | - | snapshot := reset || plan.Snapshot | |||
| 52 | - | cursorChanged := cursor != s.cursorOut | |||
| 53 | - | if !snapshot && plan.Scroll.Height == 0 && len(plan.Spans) == 0 && !cursorChanged { | |||
| 54 | - | return &preparedStructuredOutput{ | |||
| 55 | - | stream: s, | |||
| 56 | - | candidate: candidate, | |||
| 57 | - | cursor: cursor, | |||
| 58 | - | echoAck: echoAck, | |||
| 59 | - | }, nil | |||
| 60 | - | } | |||
| 61 | - | if s.state.next == ^uint64(0) { | |||
| 62 | - | return nil, errors.New("structured output state number exhausted") | |||
| 63 | - | } | |||
| 64 | - | ||||
| 65 | - | wireCursor, err := wireScreenCursor(cursor, frame) | |||
| 66 | - | if err != nil { | |||
| 67 | - | return nil, err | |||
| 68 | - | } | |||
| 69 | - | update := ports.ScreenUpdate{ | |||
| 70 | - | BaseStateNum: s.state.next, | |||
| 71 | - | NewStateNum: s.state.next + 1, | |||
| 72 | - | EchoAck: echoAck, | |||
| 73 | - | Kind: ports.ScreenUpdateDelta, | |||
| 74 | - | Size: frameSize(frame), | |||
| 75 | - | Cursor: wireCursor, | |||
| 76 | - | } | |||
| 77 | - | if snapshot { | |||
| 78 | - | update.BaseStateNum = 0 | |||
| 79 | - | update.Kind = ports.ScreenUpdateSnapshot | |||
| 80 | - | update.Spans = make([]ports.ScreenSpan, frame.Height) | |||
| 81 | - | for y := range frame.Height { | |||
| 82 | - | update.Spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: copyScreenCells(frame.Row(y))} | |||
| 83 | - | } | |||
| 84 | - | } else { | |||
| 85 | - | if plan.Scroll.Height != 0 { | |||
| 86 | - | if plan.Scroll.Y < 0 || plan.Scroll.Height <= 0 || plan.Scroll.Count <= 0 || | |||
| 87 | - | plan.Scroll.Y+plan.Scroll.Height > frame.Height || plan.Scroll.Count >= plan.Scroll.Height { | |||
| 88 | - | return nil, errors.New("structured output: invalid planned scroll") | |||
| 89 | - | } | |||
| 90 | - | update.Scroll = &ports.ScreenScroll{ | |||
| 91 | - | Top: uint16(plan.Scroll.Y), | |||
| 92 | - | Height: uint16(plan.Scroll.Height), | |||
| 93 | - | Count: uint16(plan.Scroll.Count), | |||
| 94 | - | } | |||
| 95 | - | } | |||
| 96 | - | update.Spans = make([]ports.ScreenSpan, 0, len(plan.Spans)) | |||
| 97 | - | for _, span := range plan.Spans { | |||
| 98 | - | if span.Y < 0 || span.X < 0 || span.Width <= 0 || span.Y >= frame.Height || span.X > frame.Width-span.Width { | |||
| 99 | - | return nil, errors.New("structured output: invalid planned span") | |||
| 100 | - | } | |||
| 101 | - | update.Spans = append(update.Spans, ports.ScreenSpan{ | |||
| 102 | - | Y: uint16(span.Y), | |||
| 103 | - | X: uint16(span.X), | |||
| 104 | - | Cells: copyScreenCells(frame.Row(span.Y)[span.X : span.X+span.Width]), | |||
| 105 | - | }) | |||
| 106 | - | } | |||
| 107 | - | } | |||
| 108 | - | data, err := ports.MarshalScreenUpdate(update) | |||
| 109 | - | if err != nil { | |||
| 110 | - | return nil, err | |||
| 111 | - | } | |||
| 112 | - | return &preparedStructuredOutput{ | |||
| 113 | - | stream: s, | |||
| 114 | - | candidate: candidate, | |||
| 115 | - | cursor: cursor, | |||
| 116 | - | update: update, | |||
| 117 | - | next: update.NewStateNum, | |||
| 118 | - | data: data, | |||
| 119 | - | echoAck: echoAck, | |||
| 120 | - | snapshot: snapshot, | |||
| 121 | - | }, nil | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | func frameSize(frame renderer.Frame) domain.Size { | |||
| 125 | - | return domain.Size{Cols: frame.Width, Rows: frame.Height} | |||
| 126 | - | } | |||
| 127 | - | ||||
| 128 | - | func copyScreenCells(cells []renderer.Cell) []renderer.Cell { | |||
| 129 | - | return append([]renderer.Cell(nil), cells...) | |||
| 130 | - | } | |||
| 131 | - | ||||
| 132 | - | func wireScreenCursor(cursor cursorOut, frame renderer.Frame) (ports.ScreenCursor, error) { | |||
| 133 | - | out := ports.ScreenCursor{Visible: cursor.valid && !cursor.hidden} | |||
| 134 | - | if !cursor.valid { | |||
| 135 | - | return out, nil | |||
| 136 | - | } | |||
| 137 | - | if cursor.row < 0 || cursor.row >= frame.Height || cursor.col < 0 || cursor.col >= frame.Width { | |||
| 138 | - | return ports.ScreenCursor{}, errors.New("structured output: cursor outside frame") | |||
| 139 | - | } | |||
| 140 | - | if cursor.hasStyle { | |||
| 141 | - | if cursor.style < 0 || cursor.style > 6 { | |||
| 142 | - | return ports.ScreenCursor{}, errors.New("structured output: cursor style out of range") | |||
| 143 | - | } | |||
| 144 | - | out.Style = uint8(cursor.style) | |||
| 145 | - | out.StyleSet = true | |||
| 146 | - | } else if cursor.style != 0 { | |||
| 147 | - | return ports.ScreenCursor{}, errors.New("structured output: cursor style is not set") | |||
| 148 | - | } | |||
| 149 | - | out.Row = uint16(cursor.row) | |||
| 150 | - | out.Col = uint16(cursor.col) | |||
| 151 | - | return out, nil | |||
| 152 | - | } | |||
| 153 | - | ||||
| 154 | - | type preparedStructuredOutput struct { | |||
| 155 | - | stream *structuredOutputStream | |||
| 156 | - | candidate renderer.DeltaCandidate | |||
| 157 | - | cursor cursorOut | |||
| 158 | - | update ports.ScreenUpdate | |||
| 159 | - | next uint64 | |||
| 160 | - | echoAck uint64 | |||
| 161 | - | data []byte | |||
| 162 | - | snapshot bool | |||
| 163 | - | attempted bool | |||
| 164 | - | committed bool | |||
| 165 | - | } | |||
| 166 | - | ||||
| 167 | - | func (p *preparedStructuredOutput) send(sender func(ports.Frame) error) error { | |||
| 168 | - | if p == nil || p.attempted { | |||
| 169 | - | return nil | |||
| 170 | - | } | |||
| 171 | - | if len(p.data) == 0 { | |||
| 172 | - | p.attempted = true | |||
| 173 | - | p.commit() | |||
| 174 | - | return nil | |||
| 175 | - | } | |||
| 176 | - | p.attempted = true | |||
| 177 | - | if sender == nil { | |||
| 178 | - | p.stream.forceSnapshot = true | |||
| 179 | - | return errors.New("structured output: nil sender") | |||
| 180 | - | } | |||
| 181 | - | if err := sender(ports.Frame{Type: ports.MsgScreenUpdate, Payload: p.data}); err != nil { | |||
| 182 | - | p.stream.forceSnapshot = true | |||
| 183 | - | return err | |||
| 184 | - | } | |||
| 185 | - | p.commit() | |||
| 186 | - | p.stream.state.next = p.next | |||
| 187 | - | p.stream.state.publishOutstanding() | |||
| 188 | - | return nil | |||
| 189 | - | } | |||
| 190 | - | ||||
| 191 | - | // commitNoSend commits a prepared noop without adding a state-bearing frame. | |||
| 192 | - | // A failed send is already attempted, so it cannot accidentally commit after | |||
| 193 | - | // the failure and thereby destroy the required snapshot retry. | |||
| 194 | - | func (p *preparedStructuredOutput) commitNoSend() { | |||
| 195 | - | if p == nil || p.attempted { | |||
| 196 | - | return | |||
| 197 | - | } | |||
| 198 | - | p.attempted = true | |||
| 199 | - | if len(p.data) != 0 { | |||
| 200 | - | if p.stream != nil { | |||
| 201 | - | p.stream.forceSnapshot = true | |||
| 202 | - | } | |||
| 203 | - | return | |||
| 204 | - | } | |||
| 205 | - | p.commit() | |||
| 206 | - | } | |||
| 207 | - | ||||
| 208 | - | func (p *preparedStructuredOutput) commit() { | |||
| 209 | - | if p == nil || p.committed || p.stream == nil { | |||
| 210 | - | return | |||
| 211 | - | } | |||
| 212 | - | p.candidate.Commit(&p.stream.shadow) | |||
| 213 | - | p.stream.cursorOut = p.cursor | |||
| 214 | - | p.stream.forceSnapshot = false | |||
| 215 | - | p.committed = true | |||
| 216 | - | } | |||
| 217 | - | ||||
| 218 | - | func (s *structuredOutputStream) ack(state uint64) { | |||
| 219 | - | if s != nil && s.state != nil { | |||
| 220 | - | s.state.ack(state) | |||
| 221 | - | } | |||
| 222 | - | } | |||
| 223 | - | ||||
| 224 | - | func (s *structuredOutputStream) atCapacity() bool { | |||
| 225 | - | return s == nil || s.state == nil || s.state.atCapacity() | |||
| 226 | - | } | |||
| 227 | - | ||||
| 228 | - | func (s *structuredOutputStream) outstanding() uint64 { | |||
| 229 | - | if s == nil || s.state == nil { | |||
| 230 | - | return 0 | |||
| 231 | - | } | |||
| 232 | - | return s.state.outstanding() | |||
| 233 | - | } | |||
| 234 | - | ||||
| 235 | - | // rebase discards only the local planner dependency. The shared state number | |||
| 236 | - | // is rebased once by attachedClient.rebaseOutput. | |||
| 237 | - | func (s *structuredOutputStream) rebase() { | |||
| 238 | - | if s == nil { | |||
| 239 | - | return | |||
| 240 | - | } | |||
| 241 | - | s.shadow = renderer.Frame{} | |||
| 242 | - | s.cursorOut = cursorOut{} | |||
| 243 | - | s.forceSnapshot = true | |||
| 244 | - | } | |||
D internal/usecase/daemon/screen_output_stream_test.go +0 -438
| @@ -1,438 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "math" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/internal/ports" | |||
| 9 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 10 | - | "github.com/stretchr/testify/require" | |||
| 11 | - | ) | |||
| 12 | - | ||||
| 13 | - | func TestStructuredOutputStreamSnapshotsThenDeltas(t *testing.T) { | |||
| 14 | - | state := newOutputStateStream() | |||
| 15 | - | stream := newStructuredOutputStream(state) | |||
| 16 | - | frame := structuredTestFrame("abc", "def") | |||
| 17 | - | cursor := cursorOut{valid: true} | |||
| 18 | - | ||||
| 19 | - | first, err := stream.prepare(frame, nil, cursor, true, 7) | |||
| 20 | - | require.NoError(t, err) | |||
| 21 | - | firstUpdate := decodeStructured(t, first.data) | |||
| 22 | - | require.Equal(t, ports.ScreenUpdateSnapshot, firstUpdate.Kind) | |||
| 23 | - | require.Zero(t, firstUpdate.BaseStateNum) | |||
| 24 | - | require.Equal(t, uint64(1), firstUpdate.NewStateNum) | |||
| 25 | - | require.Len(t, firstUpdate.Spans, 2) | |||
| 26 | - | for y, span := range firstUpdate.Spans { | |||
| 27 | - | require.Equal(t, uint16(y), span.Y) | |||
| 28 | - | require.Zero(t, span.X) | |||
| 29 | - | require.Len(t, span.Cells, frame.Width) | |||
| 30 | - | for x, cell := range span.Cells { | |||
| 31 | - | require.Equal(t, frame.At(x, y).Rune, cell.Rune) | |||
| 32 | - | require.Equal(t, frame.At(x, y).Continuation, cell.Continuation) | |||
| 33 | - | } | |||
| 34 | - | } | |||
| 35 | - | require.NoError(t, first.send(func(got ports.Frame) error { | |||
| 36 | - | require.Equal(t, ports.MsgScreenUpdate, got.Type) | |||
| 37 | - | gotUpdate, err := ports.UnmarshalScreenUpdate(got.Payload) | |||
| 38 | - | require.NoError(t, err) | |||
| 39 | - | require.Equal(t, uint64(7), gotUpdate.EchoAck) | |||
| 40 | - | return nil | |||
| 41 | - | })) | |||
| 42 | - | require.Equal(t, uint64(1), state.next) | |||
| 43 | - | ||||
| 44 | - | changed := frame.Clone() | |||
| 45 | - | changed.Set(1, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |||
| 46 | - | second, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, X: 1, Y: 0, Width: 1, Height: 1}}, cursor, false, 0) | |||
| 47 | - | require.NoError(t, err) | |||
| 48 | - | secondUpdate := decodeStructured(t, second.data) | |||
| 49 | - | require.Equal(t, ports.ScreenUpdateDelta, secondUpdate.Kind) | |||
| 50 | - | require.Equal(t, uint64(1), secondUpdate.BaseStateNum) | |||
| 51 | - | require.Equal(t, uint64(2), secondUpdate.NewStateNum) | |||
| 52 | - | require.Len(t, secondUpdate.Spans, 1) | |||
| 53 | - | require.Equal(t, uint16(1), secondUpdate.Spans[0].X) | |||
| 54 | - | require.NoError(t, second.send(func(ports.Frame) error { return nil })) | |||
| 55 | - | require.Equal(t, uint64(2), state.next) | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func TestStructuredOutputStreamCursorOnlyAndNoopOwnership(t *testing.T) { | |||
| 59 | - | state := newOutputStateStream() | |||
| 60 | - | stream := newStructuredOutputStream(state) | |||
| 61 | - | frame := structuredTestFrame("abc") | |||
| 62 | - | cursor := cursorOut{valid: true} | |||
| 63 | - | ||||
| 64 | - | first, err := stream.prepare(frame, nil, cursor, true, 0) | |||
| 65 | - | require.NoError(t, err) | |||
| 66 | - | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |||
| 67 | - | ||||
| 68 | - | moved := cursor | |||
| 69 | - | moved.col = 2 | |||
| 70 | - | cursorOnly, err := stream.prepare(frame, nil, moved, false, 0) | |||
| 71 | - | require.NoError(t, err) | |||
| 72 | - | cursorUpdate := decodeStructured(t, cursorOnly.data) | |||
| 73 | - | require.Equal(t, ports.ScreenUpdateDelta, cursorUpdate.Kind) | |||
| 74 | - | require.Empty(t, cursorUpdate.Spans) | |||
| 75 | - | require.Nil(t, cursorUpdate.Scroll) | |||
| 76 | - | require.NoError(t, cursorOnly.send(func(ports.Frame) error { return nil })) | |||
| 77 | - | require.Equal(t, uint64(2), state.next) | |||
| 78 | - | ||||
| 79 | - | noop, err := stream.prepare(frame, nil, moved, false, 0) | |||
| 80 | - | require.NoError(t, err) | |||
| 81 | - | require.Empty(t, noop.data) | |||
| 82 | - | require.NoError(t, noop.send(func(ports.Frame) error { | |||
| 83 | - | t.Fatal("noop must not send a frame") | |||
| 84 | - | return nil | |||
| 85 | - | })) | |||
| 86 | - | require.Equal(t, uint64(2), state.next) | |||
| 87 | - | require.True(t, stream.cursorOut.valid) | |||
| 88 | - | require.Equal(t, moved.row, stream.cursorOut.row) | |||
| 89 | - | require.Equal(t, moved.col, stream.cursorOut.col) | |||
| 90 | - | require.Equal(t, !moved.hidden, !stream.cursorOut.hidden) | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | func TestStructuredOutputStreamDeltaCommitReusesShadowStorage(t *testing.T) { | |||
| 94 | - | state := newOutputStateStream() | |||
| 95 | - | stream := newStructuredOutputStream(state) | |||
| 96 | - | cursor := cursorOut{valid: true} | |||
| 97 | - | frame := structuredTestFrame("abc", "def") | |||
| 98 | - | ||||
| 99 | - | initial, err := stream.prepare(frame, nil, cursor, true, 0) | |||
| 100 | - | require.NoError(t, err) | |||
| 101 | - | require.NoError(t, initial.send(func(ports.Frame) error { return nil })) | |||
| 102 | - | shadowCells := stream.shadow.Cells | |||
| 103 | - | require.NotEmpty(t, shadowCells) | |||
| 104 | - | shadowStart := &shadowCells[0] | |||
| 105 | - | shadowCap := cap(shadowCells) | |||
| 106 | - | ||||
| 107 | - | changed := frame.Clone() | |||
| 108 | - | changed.Set(1, 0, renderer.Cell{Rune: 'X', Style: renderer.DefaultStyle()}) | |||
| 109 | - | delta, err := stream.prepare(changed, []renderer.Damage{{Kind: renderer.DamageText, X: 1, Y: 0, Width: 1, Height: 1}}, cursor, false, 0) | |||
| 110 | - | require.NoError(t, err) | |||
| 111 | - | require.NoError(t, delta.send(func(ports.Frame) error { return nil })) | |||
| 112 | - | ||||
| 113 | - | require.Same(t, shadowStart, &stream.shadow.Cells[0]) | |||
| 114 | - | require.Equal(t, shadowCap, cap(stream.shadow.Cells)) | |||
| 115 | - | require.Equal(t, changed.At(1, 0), stream.shadow.At(1, 0)) | |||
| 116 | - | require.Equal(t, uint64(2), state.next) | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | func TestStructuredOutputStreamResetAndResizeUseBaseZero(t *testing.T) { | |||
| 120 | - | state := newOutputStateStream() | |||
| 121 | - | stream := newStructuredOutputStream(state) | |||
| 122 | - | cursor := cursorOut{valid: true} | |||
| 123 | - | frame := structuredTestFrame("abc") | |||
| 124 | - | ||||
| 125 | - | first, err := stream.prepare(frame, nil, cursor, true, 0) | |||
| 126 | - | require.NoError(t, err) | |||
| 127 | - | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |||
| 128 | - | changed := frame.Clone() | |||
| 129 | - | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 130 | - | delta, err := stream.prepare(changed, nil, cursor, false, 0) | |||
| 131 | - | require.NoError(t, err) | |||
| 132 | - | require.NoError(t, delta.send(func(ports.Frame) error { return nil })) | |||
| 133 | - | ||||
| 134 | - | resized := structuredTestFrame("abcd", "efgh") | |||
| 135 | - | reset, err := stream.prepare(resized, nil, cursor, true, 0) | |||
| 136 | - | require.NoError(t, err) | |||
| 137 | - | resetUpdate := decodeStructured(t, reset.data) | |||
| 138 | - | require.Equal(t, ports.ScreenUpdateSnapshot, resetUpdate.Kind) | |||
| 139 | - | require.Zero(t, resetUpdate.BaseStateNum) | |||
| 140 | - | require.Equal(t, uint64(3), resetUpdate.NewStateNum) | |||
| 141 | - | require.Len(t, resetUpdate.Spans, resized.Height) | |||
| 142 | - | for _, span := range resetUpdate.Spans { | |||
| 143 | - | require.Equal(t, uint16(resized.Width), uint16(len(span.Cells))) | |||
| 144 | - | } | |||
| 145 | - | } | |||
| 146 | - | ||||
| 147 | - | func TestStructuredOutputStreamFailedSendRetainsTransactionAndRetriesSnapshot(t *testing.T) { | |||
| 148 | - | state := newOutputStateStream() | |||
| 149 | - | stream := newStructuredOutputStream(state) | |||
| 150 | - | cursor := cursorOut{valid: true} | |||
| 151 | - | frame := structuredTestFrame("abc") | |||
| 152 | - | ||||
| 153 | - | first, err := stream.prepare(frame, nil, cursor, true, 0) | |||
| 154 | - | require.NoError(t, err) | |||
| 155 | - | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |||
| 156 | - | before := stream.shadow.Clone() | |||
| 157 | - | beforeCursor := stream.cursorOut | |||
| 158 | - | ||||
| 159 | - | changed := frame.Clone() | |||
| 160 | - | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 161 | - | changedCursor := cursorOut{valid: true, row: 0, col: 1, hidden: true, hasStyle: true} | |||
| 162 | - | pending, err := stream.prepare(changed, nil, changedCursor, false, 0) | |||
| 163 | - | require.NoError(t, err) | |||
| 164 | - | sendErr := errors.New("send failed") | |||
| 165 | - | require.ErrorIs(t, pending.send(func(ports.Frame) error { return sendErr }), sendErr) | |||
| 166 | - | require.Equal(t, uint64(1), state.next) | |||
| 167 | - | require.Equal(t, before, stream.shadow) | |||
| 168 | - | require.Equal(t, beforeCursor, stream.cursorOut) | |||
| 169 | - | require.True(t, stream.forceSnapshot) | |||
| 170 | - | ||||
| 171 | - | retry, err := stream.prepare(changed, nil, changedCursor, false, 0) | |||
| 172 | - | require.NoError(t, err) | |||
| 173 | - | retryUpdate := decodeStructured(t, retry.data) | |||
| 174 | - | require.Equal(t, ports.ScreenUpdateSnapshot, retryUpdate.Kind) | |||
| 175 | - | require.Zero(t, retryUpdate.BaseStateNum) | |||
| 176 | - | require.Equal(t, uint64(2), retryUpdate.NewStateNum) | |||
| 177 | - | require.Len(t, retryUpdate.Spans, changed.Height) | |||
| 178 | - | require.NoError(t, retry.send(func(ports.Frame) error { return nil })) | |||
| 179 | - | require.Equal(t, uint64(2), state.next) | |||
| 180 | - | require.False(t, stream.forceSnapshot) | |||
| 181 | - | require.NoError(t, retry.send(func(ports.Frame) error { | |||
| 182 | - | t.Fatal("completed send must not send twice") | |||
| 183 | - | return nil | |||
| 184 | - | })) | |||
| 185 | - | } | |||
| 186 | - | ||||
| 187 | - | func TestStructuredOutputStreamNonNoopCommitForcesSnapshotRetry(t *testing.T) { | |||
| 188 | - | state := newOutputStateStream() | |||
| 189 | - | stream := newStructuredOutputStream(state) | |||
| 190 | - | cursor := cursorOut{valid: true} | |||
| 191 | - | frame := structuredTestFrame("abc") | |||
| 192 | - | ||||
| 193 | - | first, err := stream.prepare(frame, nil, cursor, true, 0) | |||
| 194 | - | require.NoError(t, err) | |||
| 195 | - | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |||
| 196 | - | before := stream.shadow.Clone() | |||
| 197 | - | ||||
| 198 | - | changed := frame.Clone() | |||
| 199 | - | changed.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 200 | - | pending, err := stream.prepare(changed, nil, cursor, false, 0) | |||
| 201 | - | require.NoError(t, err) | |||
| 202 | - | require.NotEmpty(t, pending.data) | |||
| 203 | - | pending.commitNoSend() | |||
| 204 | - | var senderCalled bool | |||
| 205 | - | require.NoError(t, pending.send(func(ports.Frame) error { | |||
| 206 | - | senderCalled = true | |||
| 207 | - | return nil | |||
| 208 | - | })) | |||
| 209 | - | require.False(t, senderCalled) | |||
| 210 | - | require.Equal(t, before, stream.shadow, "non-noop commit must not publish its candidate") | |||
| 211 | - | require.Equal(t, uint64(1), state.next) | |||
| 212 | - | require.True(t, stream.forceSnapshot) | |||
| 213 | - | ||||
| 214 | - | retry, err := stream.prepare(changed, nil, cursor, false, 0) | |||
| 215 | - | require.NoError(t, err) | |||
| 216 | - | retryUpdate := decodeStructured(t, retry.data) | |||
| 217 | - | require.Equal(t, ports.ScreenUpdateSnapshot, retryUpdate.Kind) | |||
| 218 | - | require.Zero(t, retryUpdate.BaseStateNum) | |||
| 219 | - | require.NoError(t, retry.send(func(ports.Frame) error { return nil })) | |||
| 220 | - | require.Equal(t, uint64(2), state.next) | |||
| 221 | - | } | |||
| 222 | - | ||||
| 223 | - | func TestStructuredOutputStreamComparesCursorValidityAndHidden(t *testing.T) { | |||
| 224 | - | state := newOutputStateStream() | |||
| 225 | - | stream := newStructuredOutputStream(state) | |||
| 226 | - | frame := structuredTestFrame("a") | |||
| 227 | - | visible := cursorOut{valid: true, row: 0, col: 0} | |||
| 228 | - | first, err := stream.prepare(frame, nil, visible, true, 0) | |||
| 229 | - | require.NoError(t, err) | |||
| 230 | - | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |||
| 231 | - | ||||
| 232 | - | hidden := visible | |||
| 233 | - | hidden.hidden = true | |||
| 234 | - | second, err := stream.prepare(frame, nil, hidden, false, 0) | |||
| 235 | - | require.NoError(t, err) | |||
| 236 | - | secondUpdate := decodeStructured(t, second.data) | |||
| 237 | - | require.Empty(t, secondUpdate.Spans) | |||
| 238 | - | require.False(t, secondUpdate.Cursor.Visible) | |||
| 239 | - | require.NoError(t, second.send(func(ports.Frame) error { return nil })) | |||
| 240 | - | ||||
| 241 | - | invalid := hidden | |||
| 242 | - | invalid.valid = false | |||
| 243 | - | third, err := stream.prepare(frame, nil, invalid, false, 0) | |||
| 244 | - | require.NoError(t, err) | |||
| 245 | - | thirdUpdate := decodeStructured(t, third.data) | |||
| 246 | - | require.False(t, thirdUpdate.Cursor.Visible) | |||
| 247 | - | require.NoError(t, third.send(func(ports.Frame) error { return nil })) | |||
| 248 | - | ||||
| 249 | - | noop, err := stream.prepare(frame, nil, invalid, false, 0) | |||
| 250 | - | require.NoError(t, err) | |||
| 251 | - | require.Empty(t, noop.data) | |||
| 252 | - | } | |||
| 253 | - | ||||
| 254 | - | func TestStructuredOutputStreamRejectsInvalidCursorAndGeometry(t *testing.T) { | |||
| 255 | - | cases := []struct { | |||
| 256 | - | name string | |||
| 257 | - | frame renderer.Frame | |||
| 258 | - | cursor cursorOut | |||
| 259 | - | }{ | |||
| 260 | - | {name: "cursor row outside frame", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, row: 1}}, | |||
| 261 | - | {name: "cursor col outside frame", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, col: 1}}, | |||
| 262 | - | {name: "cursor style out of range", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, hasStyle: true, style: 7}}, | |||
| 263 | - | {name: "screen cell cap", frame: renderer.NewFrame(513, 512), cursor: cursorOut{valid: true}}, | |||
| 264 | - | {name: "uint16 dimension cap", frame: renderer.NewFrame(math.MaxUint16+1, 1), cursor: cursorOut{valid: true}}, | |||
| 265 | - | } | |||
| 266 | - | for _, tc := range cases { | |||
| 267 | - | t.Run(tc.name, func(t *testing.T) { | |||
| 268 | - | stream := newStructuredOutputStream(newOutputStateStream()) | |||
| 269 | - | _, err := stream.prepare(tc.frame, nil, tc.cursor, true, 0) | |||
| 270 | - | require.Error(t, err) | |||
| 271 | - | require.Zero(t, stream.state.next, "rejected geometry must not advance state") | |||
| 272 | - | }) | |||
| 273 | - | } | |||
| 274 | - | } | |||
| 275 | - | ||||
| 276 | - | func TestStructuredOutputStreamPreservesExplicitZeroCursorStyle(t *testing.T) { | |||
| 277 | - | state := newOutputStateStream() | |||
| 278 | - | stream := newStructuredOutputStream(state) | |||
| 279 | - | frame := structuredTestFrame("a") | |||
| 280 | - | unset := cursorOut{valid: true} | |||
| 281 | - | first, err := stream.prepare(frame, nil, unset, true, 0) | |||
| 282 | - | require.NoError(t, err) | |||
| 283 | - | require.NoError(t, first.send(func(ports.Frame) error { return nil })) | |||
| 284 | - | ||||
| 285 | - | explicit := unset | |||
| 286 | - | explicit.hasStyle = true | |||
| 287 | - | second, err := stream.prepare(frame, nil, explicit, false, 0) | |||
| 288 | - | require.NoError(t, err) | |||
| 289 | - | got := decodeStructured(t, second.data) | |||
| 290 | - | require.True(t, got.Cursor.StyleSet) | |||
| 291 | - | require.Zero(t, got.Cursor.Style) | |||
| 292 | - | require.NoError(t, second.send(func(ports.Frame) error { return nil })) | |||
| 293 | - | } | |||
| 294 | - | ||||
| 295 | - | func TestStructuredOutputStreamSharesACKWindow(t *testing.T) { | |||
| 296 | - | state := newOutputStateStream(1) | |||
| 297 | - | stream := newStructuredOutputStream(state) | |||
| 298 | - | prepared, err := stream.prepare(structuredTestFrame("a"), nil, cursorOut{valid: true}, true, 0) | |||
| 299 | - | require.NoError(t, err) | |||
| 300 | - | require.NoError(t, prepared.send(func(ports.Frame) error { return nil })) | |||
| 301 | - | require.True(t, stream.atCapacity()) | |||
| 302 | - | stream.ack(1) | |||
| 303 | - | require.False(t, stream.atCapacity()) | |||
| 304 | - | require.Equal(t, uint64(0), stream.outstanding()) | |||
| 305 | - | } | |||
| 306 | - | ||||
| 307 | - | // BenchmarkStructuredOutputPrepare is a component diagnostic for structured | |||
| 308 | - | // planning, encoding, and commit without decode/apply; it is not a pipeline | |||
| 309 | - | // comparison with ANSI apply. | |||
| 310 | - | func BenchmarkStructuredOutputPrepare(b *testing.B) { | |||
| 311 | - | fixtures := []struct { | |||
| 312 | - | name string | |||
| 313 | - | frame renderer.Frame | |||
| 314 | - | damage []renderer.Damage | |||
| 315 | - | mutate func(renderer.Frame, int) | |||
| 316 | - | }{ | |||
| 317 | - | { | |||
| 318 | - | name: "one-cell", | |||
| 319 | - | frame: structuredRows(120, 40), | |||
| 320 | - | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1}}, | |||
| 321 | - | mutate: func(frame renderer.Frame, i int) { | |||
| 322 | - | cell := frame.At(0, 0) | |||
| 323 | - | cell.Rune = rune('a' + i%26) | |||
| 324 | - | frame.Set(0, 0, cell) | |||
| 325 | - | }, | |||
| 326 | - | }, | |||
| 327 | - | { | |||
| 328 | - | name: "full-line", | |||
| 329 | - | frame: structuredRows(120, 40), | |||
| 330 | - | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 120, Height: 1}}, | |||
| 331 | - | mutate: func(frame renderer.Frame, i int) { | |||
| 332 | - | for x := range frame.Width { | |||
| 333 | - | cell := frame.At(x, 0) | |||
| 334 | - | cell.Rune = rune('a' + (x+i)%26) | |||
| 335 | - | frame.Set(x, 0, cell) | |||
| 336 | - | } | |||
| 337 | - | }, | |||
| 338 | - | }, | |||
| 339 | - | { | |||
| 340 | - | name: "styled-fragment", | |||
| 341 | - | frame: structuredStyledFrame(120, 40), | |||
| 342 | - | damage: []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 120, Height: 1}}, | |||
| 343 | - | mutate: func(frame renderer.Frame, i int) { | |||
| 344 | - | for x := range frame.Width { | |||
| 345 | - | cell := frame.At(x, 0) | |||
| 346 | - | cell.Rune = rune('a' + (x+i)%26) | |||
| 347 | - | frame.Set(x, 0, cell) | |||
| 348 | - | } | |||
| 349 | - | }, | |||
| 350 | - | }, | |||
| 351 | - | { | |||
| 352 | - | name: "full-screen-scroll", | |||
| 353 | - | frame: structuredRows(120, 40), | |||
| 354 | - | damage: []renderer.Damage{{Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 120, Height: 40, Count: 1}, {Kind: renderer.DamageText, X: 0, Y: 39, Width: 120, Height: 1}}, | |||
| 355 | - | mutate: func(frame renderer.Frame, i int) { | |||
| 356 | - | frame.ScrollUp(0, 39, 1) | |||
| 357 | - | for x := range frame.Width { | |||
| 358 | - | frame.Set(x, 39, renderer.Cell{Rune: rune('a' + i%26), Style: renderer.DefaultStyle()}) | |||
| 359 | - | } | |||
| 360 | - | }, | |||
| 361 | - | }, | |||
| 362 | - | } | |||
| 363 | - | for _, fixture := range fixtures { | |||
| 364 | - | b.Run(fixture.name, func(b *testing.B) { | |||
| 365 | - | stream := newStructuredOutputStream(newOutputStateStream()) | |||
| 366 | - | cursor := cursorOut{valid: true} | |||
| 367 | - | initial, err := stream.prepare(fixture.frame, nil, cursor, true, 0) | |||
| 368 | - | if err != nil { | |||
| 369 | - | b.Fatal(err) | |||
| 370 | - | } | |||
| 371 | - | if err := initial.send(func(ports.Frame) error { return nil }); err != nil { | |||
| 372 | - | b.Fatal(err) | |||
| 373 | - | } | |||
| 374 | - | var wireBytes, spans, snapshots, mutation int | |||
| 375 | - | b.ResetTimer() | |||
| 376 | - | for b.Loop() { | |||
| 377 | - | fixture.mutate(fixture.frame, mutation) | |||
| 378 | - | mutation++ | |||
| 379 | - | prepared, err := stream.prepare(fixture.frame, fixture.damage, cursor, false, 0) | |||
| 380 | - | if err != nil { | |||
| 381 | - | b.Fatal(err) | |||
| 382 | - | } | |||
| 383 | - | wireBytes += len(prepared.data) | |||
| 384 | - | spans += len(prepared.update.Spans) | |||
| 385 | - | if prepared.update.Kind == ports.ScreenUpdateSnapshot { | |||
| 386 | - | snapshots++ | |||
| 387 | - | } | |||
| 388 | - | if err := prepared.send(func(ports.Frame) error { return nil }); err != nil { | |||
| 389 | - | b.Fatal(err) | |||
| 390 | - | } | |||
| 391 | - | } | |||
| 392 | - | b.StopTimer() | |||
| 393 | - | if snapshots != 0 { | |||
| 394 | - | b.Fatalf("unexpected snapshots/op: %d", snapshots) | |||
| 395 | - | } | |||
| 396 | - | b.ReportMetric(float64(wireBytes)/float64(b.N), "wirebytes/op") | |||
| 397 | - | b.ReportMetric(float64(spans)/float64(b.N), "spans/op") | |||
| 398 | - | b.ReportMetric(float64(snapshots)/float64(b.N), "snapshots/op") | |||
| 399 | - | }) | |||
| 400 | - | } | |||
| 401 | - | } | |||
| 402 | - | ||||
| 403 | - | func decodeStructured(t *testing.T, data []byte) ports.ScreenUpdate { | |||
| 404 | - | t.Helper() | |||
| 405 | - | require.NotEmpty(t, data) | |||
| 406 | - | update, err := ports.UnmarshalScreenUpdate(data) | |||
| 407 | - | require.NoError(t, err) | |||
| 408 | - | return update | |||
| 409 | - | } | |||
| 410 | - | ||||
| 411 | - | func structuredTestFrame(lines ...string) renderer.Frame { | |||
| 412 | - | width := len([]rune(lines[0])) | |||
| 413 | - | frame := renderer.NewFrame(width, len(lines)) | |||
| 414 | - | for y, line := range lines { | |||
| 415 | - | for x, r := range []rune(line) { | |||
| 416 | - | frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 417 | - | } | |||
| 418 | - | } | |||
| 419 | - | return frame | |||
| 420 | - | } | |||
| 421 | - | ||||
| 422 | - | func structuredRows(width, height int) renderer.Frame { | |||
| 423 | - | frame := renderer.NewFrame(width, height) | |||
| 424 | - | for y := range height { | |||
| 425 | - | for x := range width { | |||
| 426 | - | frame.Set(x, y, renderer.Cell{Rune: rune('0' + y%10), Style: renderer.DefaultStyle()}) | |||
| 427 | - | } | |||
| 428 | - | } | |||
| 429 | - | return frame | |||
| 430 | - | } | |||
| 431 | - | ||||
| 432 | - | func structuredStyledFrame(width, height int) renderer.Frame { | |||
| 433 | - | frame := structuredRows(width, height) | |||
| 434 | - | for x := range width { | |||
| 435 | - | frame.Set(x, 0, renderer.Cell{Rune: 'x', Style: renderer.Style{Bold: x%2 == 0, Foreground: x % 8, Background: (x + 1) % 8}}) | |||
| 436 | - | } | |||
| 437 | - | return frame | |||
| 438 | - | } | |||
M internal/usecase/daemon/session.go +13 -23
| @@ -184,10 +184,10 @@ func (d *Daemon) touchMRU(entry attachmentSession) { | |||||
| 184 | } | 184 | } | ||
| 185 | 185 | ||||
| 186 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | 186 | func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { | ||
| 187 | - | return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, false, restoredTabNames...) | 187 | + | return d.createSessionLockedWithMode(name, ephemeral, cwd, sz, term, env, restoredTabNames...) |
| 188 | } | 188 | } | ||
| 189 | 189 | ||||
| 190 | - | func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, proxied bool, restoredTabNames ...[]string) (*session, error) { | 190 | + | func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, env []string, restoredTabNames ...[]string) (*session, error) { |
| 191 | env = copyEnvironment(env) | 191 | env = copyEnvironment(env) | ||
| 192 | if _, reserved := d.creating[name]; reserved { | 192 | if _, reserved := d.creating[name]; reserved { | ||
| 193 | return nil, errSessionNameInUse | 193 | return nil, errSessionNameInUse | ||
| @@ -244,7 +244,7 @@ func (d *Daemon) createSessionLockedWithMode(name string, ephemeral bool, cwd st | |||||
| 244 | if d.ptys == nil { | 244 | if d.ptys == nil { | ||
| 245 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable")) | 245 | return nil, domain.UserErr(domain.NoticeSessionSpawn, "couldn't create session", errors.New("daemon: PTY factory is unavailable")) | ||
| 246 | } | 246 | } | ||
| 247 | - | tbSize := contentSize(sz, proxied) | 247 | + | tbSize := contentSize(sz) |
| 248 | var names []string | 248 | var names []string | ||
| 249 | if len(restoredTabNames) > 0 { | 249 | if len(restoredTabNames) > 0 { | ||
| 250 | names = append([]string(nil), restoredTabNames[0]...) | 250 | names = append([]string(nil), restoredTabNames[0]...) | ||
| @@ -425,7 +425,7 @@ func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name | |||||
| 425 | return errors.New("client detached") | 425 | return errors.New("client detached") | ||
| 426 | } | 426 | } | ||
| 427 | 427 | ||||
| 428 | - | newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env, ac.proxied) | 428 | + | newSess, err := d.createSessionLockedWithMode(name, false, cwd, sz, term, env) |
| 429 | d.mu.Unlock() | 429 | d.mu.Unlock() | ||
| 430 | if err != nil { | 430 | if err != nil { | ||
| 431 | return err | 431 | return err | ||
| @@ -490,7 +490,7 @@ func (d *Daemon) createSessionAndSwitchForAttachment(token attachmentConnectionT | |||||
| 490 | cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env) | 490 | cwd, term, env := source.cwd, source.terminal, copyEnvironment(source.env) | ||
| 491 | source.mu.Unlock() | 491 | source.mu.Unlock() | ||
| 492 | var createErr error | 492 | var createErr error | ||
| 493 | - | created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env, token.ac.proxied) | 493 | + | created, createErr = d.createSessionLockedWithMode(name, false, cwd, token.ac.size, term, env) |
| 494 | return created, createErr | 494 | return created, createErr | ||
| 495 | }, | 495 | }, | ||
| 496 | }) | 496 | }) | ||
| @@ -523,15 +523,7 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do | |||||
| 523 | env := copyEnvironment(sess.env) | 523 | env := copyEnvironment(sess.env) | ||
| 524 | attachments := sess.snapshotAttachmentsLocked() | 524 | attachments := sess.snapshotAttachmentsLocked() | ||
| 525 | sess.mu.Unlock() | 525 | sess.mu.Unlock() | ||
| 526 | - | // A client-requested tab must use that client's negotiated viewport and | 526 | + | tbSize := contentSize(sz) |
| 527 | - | // theme. Only the explicitly headless helper path falls back to the stable | |||
| 528 | - | // first attachment snapshot. | |||
| 529 | - | themeClient := ac | |||
| 530 | - | if themeClient == nil && len(attachments) != 0 { | |||
| 531 | - | themeClient = attachments[0] | |||
| 532 | - | } | |||
| 533 | - | proxied := themeClient != nil && themeClient.proxied | |||
| 534 | - | tbSize := contentSize(sz, proxied) | |||
| 535 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | 527 | tabStableID, paneStableID, err := d.newTabPaneStableIDs() | ||
| 536 | if err != nil { | 528 | if err != nil { | ||
| 537 | return err | 529 | return err | ||
| @@ -548,6 +540,10 @@ func (d *Daemon) createTabForAttachment(sess *session, ac *attachedClient, sz do | |||||
| 548 | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) | 540 | return domain.UserErr(domain.NoticeTabSpawn, "couldn't open tab: shell failed to start", err) | ||
| 549 | } | 541 | } | ||
| 550 | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | 542 | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | ||
| 543 | + | themeClient := ac | |||
| 544 | + | if themeClient == nil && len(attachments) != 0 { | |||
| 545 | + | themeClient = attachments[0] | |||
| 546 | + | } | |||
| 551 | if themeClient != nil { | 547 | if themeClient != nil { | ||
| 552 | t := d.effectiveTheme(themeClient.getClientTheme()) | 548 | t := d.effectiveTheme(themeClient.getClientTheme()) | ||
| 553 | tb.mu.Lock() | 549 | tb.mu.Lock() | ||
| @@ -653,19 +649,14 @@ func (tb *tab) closeAllPanes() { | |||||
| 653 | const tabChromeRows = 2 | 649 | const tabChromeRows = 2 | ||
| 654 | 650 | ||||
| 655 | func tabSize(clientSize domain.Size) domain.Size { | 651 | func tabSize(clientSize domain.Size) domain.Size { | ||
| 656 | - | return contentSize(clientSize, false) | 652 | + | return contentSize(clientSize) |
| 657 | } | 653 | } | ||
| 658 | 654 | ||||
| 659 | - | // contentSize maps the negotiated viewport to PTY geometry. A proxied client | 655 | + | // contentSize maps the negotiated viewport to PTY geometry. |
| 660 | - | // has already removed its local chrome rows, so the remote daemon must consume | 656 | + | func contentSize(clientSize domain.Size) domain.Size { |
| 661 | - | // the received size verbatim instead of subtracting chrome a second time. | |||
| 662 | - | func contentSize(clientSize domain.Size, proxied bool) domain.Size { | |||
| 663 | if !clientSize.Valid() { | 657 | if !clientSize.Valid() { | ||
| 664 | clientSize = defaultSize | 658 | clientSize = defaultSize | ||
| 665 | } | 659 | } | ||
| 666 | - | if proxied { | |||
| 667 | - | return clientSize | |||
| 668 | - | } | |||
| 669 | rows := max(clientSize.Rows-tabChromeRows, 1) | 660 | rows := max(clientSize.Rows-tabChromeRows, 1) | ||
| 670 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | 661 | return domain.Size{Cols: clientSize.Cols, Rows: rows} | ||
| 671 | } | 662 | } | ||
| @@ -713,7 +704,6 @@ func (d *Daemon) startPaneGoroutines(sess *session, tb *tab, p *pane) { | |||||
| 713 | return | 704 | return | ||
| 714 | } | 705 | } | ||
| 715 | if owner.session != sess || owner.tab != tb { | 706 | if owner.session != sess || owner.tab != tb { | ||
| 716 | - | d.log.Warn("pane owner mismatch", "pane", p.id) | |||
| 717 | return | 707 | return | ||
| 718 | } | 708 | } | ||
| 719 | sess.mu.Lock() | 709 | sess.mu.Lock() | ||
D internal/usecase/daemon/session_meta.go +0 -136
| @@ -1,136 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | ||||
| 6 | - | "github.com/bnema/vev/internal/domain" | |||
| 7 | - | "github.com/bnema/vev/internal/ports" | |||
| 8 | - | ) | |||
| 9 | - | ||||
| 10 | - | var errSessionMetaUnavailable = errors.New("session metadata is unavailable") | |||
| 11 | - | ||||
| 12 | - | // sessionMetaSnapshot captures the authoritative tab order while session.mu | |||
| 13 | - | // owns every field represented on the wire. | |||
| 14 | - | func (s *session) sessionMetaSnapshot() (ports.SessionMeta, bool) { | |||
| 15 | - | return s.sessionMetaSnapshotFor(nil) | |||
| 16 | - | } | |||
| 17 | - | ||||
| 18 | - | func (s *session) sessionMetaSnapshotFor(ac *attachedClient) (ports.SessionMeta, bool) { | |||
| 19 | - | if s == nil { | |||
| 20 | - | return ports.SessionMeta{}, false | |||
| 21 | - | } | |||
| 22 | - | s.mu.Lock() | |||
| 23 | - | defer s.mu.Unlock() | |||
| 24 | - | if len(s.tabs) == 0 { | |||
| 25 | - | return ports.SessionMeta{}, false | |||
| 26 | - | } | |||
| 27 | - | active := 0 | |||
| 28 | - | if ac != nil { | |||
| 29 | - | view := ac.viewSnapshot() | |||
| 30 | - | for i, tb := range s.tabs { | |||
| 31 | - | if domain.TabStableID(tb.stableID) == view.tabID { | |||
| 32 | - | active = i | |||
| 33 | - | break | |||
| 34 | - | } | |||
| 35 | - | } | |||
| 36 | - | } | |||
| 37 | - | meta := ports.SessionMeta{ | |||
| 38 | - | SessionName: s.name, | |||
| 39 | - | Active: uint16(active), | |||
| 40 | - | Tabs: make([]ports.SessionTabMeta, len(s.tabs)), | |||
| 41 | - | } | |||
| 42 | - | for i, tb := range s.tabs { | |||
| 43 | - | meta.Tabs[i] = ports.SessionTabMeta{ | |||
| 44 | - | Index: uint16(i), | |||
| 45 | - | Name: tb.name, | |||
| 46 | - | Attention: tb.attention, | |||
| 47 | - | } | |||
| 48 | - | } | |||
| 49 | - | return meta, true | |||
| 50 | - | } | |||
| 51 | - | ||||
| 52 | - | func frameSessionMeta(meta ports.SessionMeta) (ports.Frame, error) { | |||
| 53 | - | payload, err := ports.MarshalSessionMeta(meta) | |||
| 54 | - | if err != nil { | |||
| 55 | - | return ports.Frame{}, err | |||
| 56 | - | } | |||
| 57 | - | return ports.Frame{Type: ports.MsgSessionMeta, Payload: payload}, nil | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | func sameSessionMeta(a, b ports.SessionMeta) bool { | |||
| 61 | - | if a.SessionName != b.SessionName || a.Active != b.Active || len(a.Tabs) != len(b.Tabs) { | |||
| 62 | - | return false | |||
| 63 | - | } | |||
| 64 | - | for i := range a.Tabs { | |||
| 65 | - | if a.Tabs[i] != b.Tabs[i] { | |||
| 66 | - | return false | |||
| 67 | - | } | |||
| 68 | - | } | |||
| 69 | - | return true | |||
| 70 | - | } | |||
| 71 | - | ||||
| 72 | - | func cloneSessionMeta(meta ports.SessionMeta) ports.SessionMeta { | |||
| 73 | - | meta.Tabs = append([]ports.SessionTabMeta(nil), meta.Tabs...) | |||
| 74 | - | return meta | |||
| 75 | - | } | |||
| 76 | - | ||||
| 77 | - | // sendSessionMetaIfChanged sends metadata before a proxied output frame. The | |||
| 78 | - | // caller holds ac.sendMu, which serializes this snapshot shadow with every | |||
| 79 | - | // output write. The shadow is committed only after the transport accepts the | |||
| 80 | - | // metadata frame. | |||
| 81 | - | func (ac *attachedClient) sendSessionMetaIfChanged(sess *session, expected transportSnapshot, ticket *attachmentEffectTicket) error { | |||
| 82 | - | if ac == nil || sess == nil || !ac.proxied { | |||
| 83 | - | return nil | |||
| 84 | - | } | |||
| 85 | - | meta, ok := sess.sessionMetaSnapshotFor(ac) | |||
| 86 | - | if !ok { | |||
| 87 | - | return errSessionMetaUnavailable | |||
| 88 | - | } | |||
| 89 | - | return ac.sendSessionMetaSnapshot(meta, expected, ticket) | |||
| 90 | - | } | |||
| 91 | - | ||||
| 92 | - | // sendSessionMetaSnapshot sends an already captured metadata snapshot while | |||
| 93 | - | // the caller holds ac.sendMu. | |||
| 94 | - | func (ac *attachedClient) sendSessionMetaSnapshot(meta ports.SessionMeta, expected transportSnapshot, ticket *attachmentEffectTicket) error { | |||
| 95 | - | if ac == nil || !ac.proxied { | |||
| 96 | - | return nil | |||
| 97 | - | } | |||
| 98 | - | if ac.sessionMetaSent && sameSessionMeta(ac.sessionMeta, meta) { | |||
| 99 | - | return nil | |||
| 100 | - | } | |||
| 101 | - | frame, err := frameSessionMeta(meta) | |||
| 102 | - | if err != nil { | |||
| 103 | - | return err | |||
| 104 | - | } | |||
| 105 | - | if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil { | |||
| 106 | - | return err | |||
| 107 | - | } | |||
| 108 | - | send := expected.transport.Send | |||
| 109 | - | if async, ok := expected.transport.(ports.AsyncTransport); ok { | |||
| 110 | - | send = async.SendAsync | |||
| 111 | - | } | |||
| 112 | - | err = send(frame) | |||
| 113 | - | if ticket != nil { | |||
| 114 | - | if err != nil { | |||
| 115 | - | ticket.reportTransportFailure(expected) | |||
| 116 | - | } | |||
| 117 | - | ticket.endTransportSend() | |||
| 118 | - | } | |||
| 119 | - | if err == nil { | |||
| 120 | - | ac.sessionMeta = cloneSessionMeta(meta) | |||
| 121 | - | ac.sessionMetaSent = true | |||
| 122 | - | } | |||
| 123 | - | return err | |||
| 124 | - | } | |||
| 125 | - | ||||
| 126 | - | // markSessionMetaSent records a successfully sent snapshot. It acquires | |||
| 127 | - | // ac.sendMu itself, so callers must not hold that lock. | |||
| 128 | - | func (ac *attachedClient) markSessionMetaSent(meta ports.SessionMeta) { | |||
| 129 | - | if ac == nil { | |||
| 130 | - | return | |||
| 131 | - | } | |||
| 132 | - | ac.sendMu.Lock() | |||
| 133 | - | ac.sessionMeta = cloneSessionMeta(meta) | |||
| 134 | - | ac.sessionMetaSent = true | |||
| 135 | - | ac.sendMu.Unlock() | |||
| 136 | - | } | |||
D internal/usecase/daemon/session_meta_test.go +0 -230
| @@ -1,230 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "errors" | |||
| 5 | - | "io" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/internal/ports" | |||
| 9 | - | "github.com/stretchr/testify/require" | |||
| 10 | - | ) | |||
| 11 | - | ||||
| 12 | - | type sessionMetaFailTransport struct{} | |||
| 13 | - | ||||
| 14 | - | func (sessionMetaFailTransport) Send(ports.Frame) error { return errors.New("metadata send failed") } | |||
| 15 | - | func (sessionMetaFailTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 16 | - | func (sessionMetaFailTransport) Close() error { return nil } | |||
| 17 | - | ||||
| 18 | - | func TestProxiedMetadataPrecedesChangedOutput(t *testing.T) { | |||
| 19 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 20 | - | ac.proxied = true | |||
| 21 | - | ||||
| 22 | - | emit := func(title string) { | |||
| 23 | - | state := cacheBarState(title, true) | |||
| 24 | - | state.attachment = ac | |||
| 25 | - | composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |||
| 26 | - | ac.sendMu.Lock() | |||
| 27 | - | require.True(t, d.emitFrame(sess, ac, &state, composed)) | |||
| 28 | - | } | |||
| 29 | - | ||||
| 30 | - | emit("initial") | |||
| 31 | - | firstMetaFrame := <-sends | |||
| 32 | - | firstScreenFrame := <-sends | |||
| 33 | - | require.Equal(t, ports.MsgSessionMeta, firstMetaFrame.Type) | |||
| 34 | - | require.Equal(t, ports.MsgScreenUpdate, firstScreenFrame.Type) | |||
| 35 | - | firstScreen, err := ports.UnmarshalScreenUpdate(firstScreenFrame.Payload) | |||
| 36 | - | require.NoError(t, err) | |||
| 37 | - | require.Equal(t, ports.ScreenUpdateSnapshot, firstScreen.Kind) | |||
| 38 | - | firstMeta, err := ports.UnmarshalSessionMeta(firstMetaFrame.Payload) | |||
| 39 | - | require.NoError(t, err) | |||
| 40 | - | require.Equal(t, "work", firstMeta.SessionName) | |||
| 41 | - | ||||
| 42 | - | sess.mu.Lock() | |||
| 43 | - | sess.tabs[0].name = "renamed" | |||
| 44 | - | sess.mu.Unlock() | |||
| 45 | - | emit("changed") | |||
| 46 | - | changedMetaFrame := <-sends | |||
| 47 | - | changedScreenFrame := <-sends | |||
| 48 | - | require.Equal(t, ports.MsgSessionMeta, changedMetaFrame.Type) | |||
| 49 | - | require.Equal(t, ports.MsgScreenUpdate, changedScreenFrame.Type) | |||
| 50 | - | changedScreen, err := ports.UnmarshalScreenUpdate(changedScreenFrame.Payload) | |||
| 51 | - | require.NoError(t, err) | |||
| 52 | - | require.Equal(t, ports.ScreenUpdateSnapshot, changedScreen.Kind) | |||
| 53 | - | changedMeta, err := ports.UnmarshalSessionMeta(changedMetaFrame.Payload) | |||
| 54 | - | require.NoError(t, err) | |||
| 55 | - | require.Equal(t, "renamed", changedMeta.Tabs[0].Name) | |||
| 56 | - | } | |||
| 57 | - | ||||
| 58 | - | func TestProxiedMetadataSendsWithoutOutputBytes(t *testing.T) { | |||
| 59 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 60 | - | ac.proxied = true | |||
| 61 | - | ||||
| 62 | - | emit := func(reset bool) { | |||
| 63 | - | state := cacheBarState("steady", reset) | |||
| 64 | - | state.attachment = ac | |||
| 65 | - | composed := composeFrame(state, ac.pipelineCache, ac.pipelineScratch) | |||
| 66 | - | ac.sendMu.Lock() | |||
| 67 | - | require.True(t, d.emitFrame(sess, ac, &state, composed)) | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | emit(true) | |||
| 71 | - | require.Equal(t, ports.MsgSessionMeta, (<-sends).Type) | |||
| 72 | - | require.Equal(t, ports.MsgScreenUpdate, (<-sends).Type) | |||
| 73 | - | ||||
| 74 | - | sess.mu.Lock() | |||
| 75 | - | sess.tabs[0].name = "renamed" | |||
| 76 | - | sess.mu.Unlock() | |||
| 77 | - | // The recomposed frame is identical, so this transaction carries metadata | |||
| 78 | - | // only: a zero-byte terminal frame must still refresh the proxied snapshot. | |||
| 79 | - | emit(false) | |||
| 80 | - | ||||
| 81 | - | metaFrame := awaitTestValue(t, sends, "a zero-byte frame did not publish changed metadata") | |||
| 82 | - | require.Equal(t, ports.MsgSessionMeta, metaFrame.Type) | |||
| 83 | - | meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload) | |||
| 84 | - | require.NoError(t, err) | |||
| 85 | - | require.Equal(t, "renamed", meta.Tabs[0].Name) | |||
| 86 | - | select { | |||
| 87 | - | case frame := <-sends: | |||
| 88 | - | t.Fatalf("unexpected frame after metadata-only paint: %v", frame.Type) | |||
| 89 | - | default: | |||
| 90 | - | } | |||
| 91 | - | } | |||
| 92 | - | ||||
| 93 | - | func TestSessionMetaSnapshotIsImmutable(t *testing.T) { | |||
| 94 | - | _, sess, _, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 95 | - | meta, ok := sess.sessionMetaSnapshot() | |||
| 96 | - | require.True(t, ok) | |||
| 97 | - | ||||
| 98 | - | sess.mu.Lock() | |||
| 99 | - | sess.name = "renamed-session" | |||
| 100 | - | sess.tabs[0].name = "renamed-tab" | |||
| 101 | - | sess.mu.Unlock() | |||
| 102 | - | ||||
| 103 | - | require.Equal(t, "work", meta.SessionName) | |||
| 104 | - | require.Equal(t, "", meta.Tabs[0].Name) | |||
| 105 | - | } | |||
| 106 | - | ||||
| 107 | - | func TestSessionMetaShadowUpdatesOnlyAfterSuccessfulSend(t *testing.T) { | |||
| 108 | - | _, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 109 | - | ac.proxied = true | |||
| 110 | - | ac.replaceTransport(sessionMetaFailTransport{}) | |||
| 111 | - | ||||
| 112 | - | ac.sendMu.Lock() | |||
| 113 | - | err := ac.sendSessionMetaIfChanged(sess, ac.transportSnapshot(), nil) | |||
| 114 | - | ac.sendMu.Unlock() | |||
| 115 | - | require.Error(t, err) | |||
| 116 | - | require.False(t, ac.sessionMetaSent) | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | func TestProxiedAttachedCommandMutationFailureDoesNotReportExecution(t *testing.T) { | |||
| 120 | - | d, sess, ac, _ := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 121 | - | ac.proxied = true | |||
| 122 | - | d.attachCoordinator(sess, ac, true) | |||
| 123 | - | d.ptys = failingPTYFactory{err: errors.New("tab open failed")} | |||
| 124 | - | token := sess.attachmentToken(ac, ac.transport()) | |||
| 125 | - | ||||
| 126 | - | result := d.executeAttachedCommand(token, ports.CommandRequest{ | |||
| 127 | - | Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "new-tab", | |||
| 128 | - | }) | |||
| 129 | - | ||||
| 130 | - | require.False(t, result.OK) | |||
| 131 | - | require.Equal(t, ports.ErrInternal, result.Code) | |||
| 132 | - | require.Contains(t, result.Text, "tab open failed") | |||
| 133 | - | sess.mu.Lock() | |||
| 134 | - | require.Len(t, sess.tabs, 1, "failed attached command must not publish a new tab") | |||
| 135 | - | sess.mu.Unlock() | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | - | func TestProxiedAttachedCommandValidationPreservesRequestID(t *testing.T) { | |||
| 139 | - | tests := []struct { | |||
| 140 | - | name string | |||
| 141 | - | request ports.CommandRequest | |||
| 142 | - | ok bool | |||
| 143 | - | code uint16 | |||
| 144 | - | text string | |||
| 145 | - | }{ | |||
| 146 | - | { | |||
| 147 | - | name: "valid attached command", | |||
| 148 | - | request: ports.CommandRequest{ | |||
| 149 | - | Version: ports.ProtocolVersion, RequestID: 41, Attached: true, Slug: "next-tab", | |||
| 150 | - | }, | |||
| 151 | - | ok: true, | |||
| 152 | - | }, | |||
| 153 | - | { | |||
| 154 | - | name: "target override is rejected", | |||
| 155 | - | request: ports.CommandRequest{ | |||
| 156 | - | Version: ports.ProtocolVersion, RequestID: 42, Attached: true, Slug: "next-tab", TargetSession: "other", | |||
| 157 | - | }, | |||
| 158 | - | code: ports.ErrInvalidCommandArgs, | |||
| 159 | - | text: "attached commands cannot override their active session target", | |||
| 160 | - | }, | |||
| 161 | - | { | |||
| 162 | - | name: "missing attached flag is rejected", | |||
| 163 | - | request: ports.CommandRequest{ | |||
| 164 | - | Version: ports.ProtocolVersion, RequestID: 43, Slug: "next-tab", | |||
| 165 | - | }, | |||
| 166 | - | code: ports.ErrInvalidCommandArgs, | |||
| 167 | - | text: "attached command flag is required", | |||
| 168 | - | }, | |||
| 169 | - | { | |||
| 170 | - | name: "unsupported version is rejected", | |||
| 171 | - | request: ports.CommandRequest{ | |||
| 172 | - | Version: ports.ProtocolVersion + 1, RequestID: 44, Attached: true, Slug: "next-tab", | |||
| 173 | - | }, | |||
| 174 | - | code: ports.ErrInvalidCommandArgs, | |||
| 175 | - | text: "unsupported command protocol version", | |||
| 176 | - | }, | |||
| 177 | - | { | |||
| 178 | - | name: "missing request id is rejected", | |||
| 179 | - | request: ports.CommandRequest{ | |||
| 180 | - | Version: ports.ProtocolVersion, Attached: true, Slug: "next-tab", | |||
| 181 | - | }, | |||
| 182 | - | code: ports.ErrInvalidCommandArgs, | |||
| 183 | - | text: "command request id is required", | |||
| 184 | - | }, | |||
| 185 | - | { | |||
| 186 | - | name: "self target is rejected", | |||
| 187 | - | request: ports.CommandRequest{ | |||
| 188 | - | Version: ports.ProtocolVersion, RequestID: 45, Attached: true, Self: true, Slug: "next-tab", | |||
| 189 | - | }, | |||
| 190 | - | code: ports.ErrInvalidCommandArgs, | |||
| 191 | - | text: "attached commands cannot target themselves", | |||
| 192 | - | }, | |||
| 193 | - | { | |||
| 194 | - | name: "unknown slug is rejected", | |||
| 195 | - | request: ports.CommandRequest{ | |||
| 196 | - | Version: ports.ProtocolVersion, RequestID: 46, Attached: true, Slug: "missing", | |||
| 197 | - | }, | |||
| 198 | - | code: ports.ErrUnknownCommand, | |||
| 199 | - | text: "unknown command: missing", | |||
| 200 | - | }, | |||
| 201 | - | { | |||
| 202 | - | name: "local detach is not scriptable", | |||
| 203 | - | request: ports.CommandRequest{ | |||
| 204 | - | Version: ports.ProtocolVersion, RequestID: 47, Attached: true, Slug: "detach", | |||
| 205 | - | }, | |||
| 206 | - | code: ports.ErrNotScriptable, | |||
| 207 | - | text: "detach is owned by the local proxy daemon", | |||
| 208 | - | }, | |||
| 209 | - | } | |||
| 210 | - | for _, tt := range tests { | |||
| 211 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 212 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, newQuietPTY()) | |||
| 213 | - | ac.proxied = true | |||
| 214 | - | d.attachCoordinator(sess, ac, true) | |||
| 215 | - | payload, err := ports.MarshalCommandRequest(tt.request) | |||
| 216 | - | require.NoError(t, err) | |||
| 217 | - | ||||
| 218 | - | token := sess.attachmentToken(ac, ac.transport()) | |||
| 219 | - | require.False(t, d.handleAttachmentClientFrame(token, ports.Frame{Type: ports.MsgCommand, Payload: payload})) | |||
| 220 | - | ||||
| 221 | - | reply := awaitFrame(t, sends, ports.MsgCommandResult) | |||
| 222 | - | result, err := ports.UnmarshalCommandResult(reply.Payload) | |||
| 223 | - | require.NoError(t, err) | |||
| 224 | - | require.Equal(t, tt.request.RequestID, result.RequestID) | |||
| 225 | - | require.Equal(t, tt.ok, result.OK) | |||
| 226 | - | require.Equal(t, tt.code, result.Code) | |||
| 227 | - | require.Equal(t, tt.text, result.Text) | |||
| 228 | - | }) | |||
| 229 | - | } | |||
| 230 | - | } | |||
M internal/usecase/daemon/session_test.go +1 -2
| @@ -171,7 +171,6 @@ func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testi | |||||
| 171 | Intent: ports.IntentAttach, | 171 | Intent: ports.IntentAttach, | ||
| 172 | Name: "work", | 172 | Name: "work", | ||
| 173 | ClientID: [16]byte{2}, | 173 | ClientID: [16]byte{2}, | ||
| 174 | - | Proxied: true, | |||
| 175 | Size: domain.Size{Cols: 80, Rows: 24}, | 174 | Size: domain.Size{Cols: 80, Rows: 24}, | ||
| 176 | }, secondTransport) | 175 | }, secondTransport) | ||
| 177 | require.NoError(t, err) | 176 | require.NoError(t, err) | ||
| @@ -192,7 +191,7 @@ func TestCreateTabForAttachmentUsesRequestingAttachmentViewportAndTheme(t *testi | |||||
| 192 | gotSize := tb.size | 191 | gotSize := tb.size | ||
| 193 | pane := tb.focusedPane() | 192 | pane := tb.focusedPane() | ||
| 194 | tb.mu.Unlock() | 193 | tb.mu.Unlock() | ||
| 195 | - | require.Equal(t, domain.Size{Cols: 80, Rows: 24}, gotSize, "proxied sizing must come from the requesting attachment") | 194 | + | require.Equal(t, contentSize(domain.Size{Cols: 80, Rows: 24}), gotSize, "sizing must come from the requesting attachment") |
| 196 | assertPaneDefaultColors(t, pane, requesterTheme.Foreground, requesterTheme.Background) | 195 | assertPaneDefaultColors(t, pane, requesterTheme.Foreground, requesterTheme.Background) | ||
| 197 | 196 | ||||
| 198 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | 197 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false)) | ||
M internal/usecase/daemon/status_test.go +1 -1
| @@ -1507,7 +1507,7 @@ func TestStatusCoalescesCreateSwitchAndResize(t *testing.T) { | |||||
| 1507 | frameInput([]byte("\x1b ")), | 1507 | frameInput([]byte("\x1b ")), | ||
| 1508 | frameInput([]byte("CNT\r")), | 1508 | frameInput([]byte("CNT\r")), | ||
| 1509 | frameInput([]byte("\x1b1")), | 1509 | frameInput([]byte("\x1b1")), | ||
| 1510 | - | ports.Frame{Type: ports.MsgResize, Payload: ports.MarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})}, | 1510 | + | ports.Frame{Type: ports.MsgResize, Payload: mustMarshalResize(ports.Resize{Size: domain.Size{Cols: 22, Rows: 6}})}, |
| 1511 | ) | 1511 | ) | ||
| 1512 | 1512 | ||||
| 1513 | var hg sync.WaitGroup | 1513 | var hg sync.WaitGroup | ||
D internal/usecase/daemon/structured_diagnostics_test.go +0 -201
| @@ -1,201 +0,0 @@ | |||||
| 1 | - | package daemon | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "io" | |||
| 5 | - | "sync" | |||
| 6 | - | "testing" | |||
| 7 | - | ||||
| 8 | - | "github.com/bnema/vev/internal/domain" | |||
| 9 | - | "github.com/bnema/vev/internal/ports" | |||
| 10 | - | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |||
| 11 | - | "github.com/stretchr/testify/mock" | |||
| 12 | - | "github.com/stretchr/testify/require" | |||
| 13 | - | ) | |||
| 14 | - | ||||
| 15 | - | func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing.T) { | |||
| 16 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 17 | - | observer := &daemonRuntimeObserver{} | |||
| 18 | - | d.runtimeObserver = observer | |||
| 19 | - | clientTransport, sent := newCapturingTransport(t) | |||
| 20 | - | proxy, ac := newAttachedProxyFixture(t, d, clientTransport, newProxyTestTransport()) | |||
| 21 | - | ac.proxied = true | |||
| 22 | - | ac.screenOutput = newStructuredOutputStream(ac.output) | |||
| 23 | - | require.True(t, applyTestScreenText(proxy, 0, 0, "snapshot")) | |||
| 24 | - | ||||
| 25 | - | emit := func(reset bool) ports.ScreenUpdate { | |||
| 26 | - | t.Helper() | |||
| 27 | - | ac.sendMu.Lock() | |||
| 28 | - | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: reset}) | |||
| 29 | - | require.True(t, ok) | |||
| 30 | - | composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch) | |||
| 31 | - | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |||
| 32 | - | frame := awaitTestValue(t, sent, "structured screen update") | |||
| 33 | - | if frame.Type == ports.MsgSessionMeta { | |||
| 34 | - | frame = awaitTestValue(t, sent, "structured screen update after metadata") | |||
| 35 | - | } | |||
| 36 | - | require.Equal(t, ports.MsgScreenUpdate, frame.Type) | |||
| 37 | - | update, err := ports.UnmarshalScreenUpdate(frame.Payload) | |||
| 38 | - | require.NoError(t, err) | |||
| 39 | - | return update | |||
| 40 | - | } | |||
| 41 | - | ||||
| 42 | - | snapshot := emit(true) | |||
| 43 | - | marks := structuredScreenMarks(t, observer) | |||
| 44 | - | require.Len(t, marks, 1) | |||
| 45 | - | require.Equal(t, ports.RuntimeScreenSnapshot, marks[0].Kind) | |||
| 46 | - | require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, snapshot))), marks[0].Bytes) | |||
| 47 | - | require.Equal(t, uint64(len(snapshot.Spans)), marks[0].Fragments) | |||
| 48 | - | ||||
| 49 | - | proxy.mu.Lock() | |||
| 50 | - | size := proxy.contentSize | |||
| 51 | - | proxy.mu.Unlock() | |||
| 52 | - | _, _, changed := proxy.applyScreenUpdateForGeneration(1, ports.ScreenUpdate{ | |||
| 53 | - | Kind: ports.ScreenUpdateDelta, BaseStateNum: snapshot.NewStateNum, NewStateNum: snapshot.NewStateNum + 1, | |||
| 54 | - | Size: size, Spans: []ports.ScreenSpan{{Y: 0, X: 0, Cells: cells("delta")}}, | |||
| 55 | - | }) | |||
| 56 | - | require.True(t, changed) | |||
| 57 | - | observer.marks = nil | |||
| 58 | - | ||||
| 59 | - | delta := emit(false) | |||
| 60 | - | marks = structuredScreenMarks(t, observer) | |||
| 61 | - | require.Len(t, marks, 1) | |||
| 62 | - | require.Equal(t, ports.RuntimeScreenDelta, marks[0].Kind) | |||
| 63 | - | require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, delta))), marks[0].Bytes) | |||
| 64 | - | require.Equal(t, uint64(len(delta.Spans)), marks[0].Fragments) | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | - | func TestP4StructuredRuntimeDiagnosticsDoNotRecordFailedSend(t *testing.T) { | |||
| 68 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 69 | - | observer := &daemonRuntimeObserver{} | |||
| 70 | - | d.runtimeObserver = observer | |||
| 71 | - | clientTransport := newStructuredDiagnosticFailingTransport(t) | |||
| 72 | - | proxy, ac := newAttachedProxyFixture(t, d, clientTransport, newProxyTestTransport()) | |||
| 73 | - | ac.proxied = true | |||
| 74 | - | ac.screenOutput = newStructuredOutputStream(ac.output) | |||
| 75 | - | require.True(t, applyTestScreenText(proxy, 0, 0, "failed")) | |||
| 76 | - | ||||
| 77 | - | ac.sendMu.Lock() | |||
| 78 | - | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true}) | |||
| 79 | - | require.True(t, ok) | |||
| 80 | - | composed := composeFrame(*state, composeCacheInput{}) | |||
| 81 | - | require.True(t, d.emitFrame(proxy, ac, state, composed)) | |||
| 82 | - | require.Empty(t, structuredScreenMarks(t, observer)) | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | - | func TestP4StructuredRuntimeDiagnosticsResetOnlyAfterAcceptedRequest(t *testing.T) { | |||
| 86 | - | d, proxy, transport, generation := newProxyOutputSession(t) | |||
| 87 | - | observer := &daemonRuntimeObserver{} | |||
| 88 | - | d.runtimeObserver = observer | |||
| 89 | - | invalid := ports.ScreenUpdate{ | |||
| 90 | - | Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 2, | |||
| 91 | - | Size: domain.Size{Cols: 80, Rows: 22}, | |||
| 92 | - | } | |||
| 93 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid)) | |||
| 94 | - | marks := structuredScreenMarks(t, observer) | |||
| 95 | - | require.Len(t, marks, 1) | |||
| 96 | - | require.Equal(t, ports.RuntimeScreenResetRequested, marks[0].Kind) | |||
| 97 | - | require.Zero(t, marks[0].Bytes) | |||
| 98 | - | require.Zero(t, marks[0].Fragments) | |||
| 99 | - | require.Equal(t, ports.MsgOutputResetRequest, awaitTestValue(t, transport.sent, "reset request").Type) | |||
| 100 | - | ||||
| 101 | - | // The already-requested reset is not sent or measured a second time. | |||
| 102 | - | require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid)) | |||
| 103 | - | require.Len(t, structuredScreenMarks(t, observer), 1) | |||
| 104 | - | ||||
| 105 | - | d, proxy, transport, generation = newProxyOutputSession(t) | |||
| 106 | - | d.runtimeObserver = &daemonRuntimeObserver{} | |||
| 107 | - | transport.sendFails.Store(true) | |||
| 108 | - | require.Error(t, d.handleProxyScreenUpdate(proxy, generation, invalid)) | |||
| 109 | - | require.Empty(t, structuredScreenMarks(t, d.runtimeObserver)) | |||
| 110 | - | } | |||
| 111 | - | ||||
| 112 | - | func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLocks(t *testing.T) { | |||
| 113 | - | d := newTestDaemon(t, nil, stubClock{}) | |||
| 114 | - | observer := &blockingStructuredDiagnosticObserver{entered: make(chan struct{}), release: make(chan struct{})} | |||
| 115 | - | d.runtimeObserver = observer | |||
| 116 | - | clientTransport, sent := newCapturingTransport(t) | |||
| 117 | - | proxy, ac := newAttachedProxyFixture(t, d, clientTransport, newProxyTestTransport()) | |||
| 118 | - | ac.proxied = true | |||
| 119 | - | ac.screenOutput = newStructuredOutputStream(ac.output) | |||
| 120 | - | require.True(t, applyTestScreenText(proxy, 0, 0, "locks")) | |||
| 121 | - | ||||
| 122 | - | done := make(chan struct{}) | |||
| 123 | - | go func() { | |||
| 124 | - | ac.sendMu.Lock() | |||
| 125 | - | state, ok := proxy.captureRenderState(ac, renderCaptureRequest{reset: true}) | |||
| 126 | - | if !ok { | |||
| 127 | - | ac.sendMu.Unlock() | |||
| 128 | - | close(done) | |||
| 129 | - | return | |||
| 130 | - | } | |||
| 131 | - | composed := composeFrame(*state, composeCacheInput{}) | |||
| 132 | - | d.emitFrame(proxy, ac, state, composed) | |||
| 133 | - | close(done) | |||
| 134 | - | }() | |||
| 135 | - | awaitDaemonObserver(t, observer.entered, "structured diagnostic observer") | |||
| 136 | - | _ = awaitTestValue(t, sent, "structured screen update") | |||
| 137 | - | ||||
| 138 | - | attachmentUnlocked := make(chan struct{}) | |||
| 139 | - | go func() { | |||
| 140 | - | ac.sendMu.Lock() | |||
| 141 | - | _ = ac.size | |||
| 142 | - | ac.sendMu.Unlock() | |||
| 143 | - | close(attachmentUnlocked) | |||
| 144 | - | }() | |||
| 145 | - | proxyUnlocked := make(chan struct{}) | |||
| 146 | - | go func() { | |||
| 147 | - | proxy.mu.Lock() | |||
| 148 | - | _ = proxy.contentSize | |||
| 149 | - | proxy.mu.Unlock() | |||
| 150 | - | close(proxyUnlocked) | |||
| 151 | - | }() | |||
| 152 | - | awaitDaemonObserver(t, attachmentUnlocked, "attachment lock") | |||
| 153 | - | awaitDaemonObserver(t, proxyUnlocked, "proxy lock") | |||
| 154 | - | close(observer.release) | |||
| 155 | - | awaitDaemonObserver(t, done, "structured render") | |||
| 156 | - | } | |||
| 157 | - | ||||
| 158 | - | func structuredScreenMarks(t *testing.T, observer ports.RuntimeObserver) []ports.RuntimeMark { | |||
| 159 | - | t.Helper() | |||
| 160 | - | collector, ok := observer.(*daemonRuntimeObserver) | |||
| 161 | - | require.True(t, ok, "runtime observer must be the daemon test collector") | |||
| 162 | - | var marks []ports.RuntimeMark | |||
| 163 | - | for _, mark := range collector.marks { | |||
| 164 | - | switch mark.Kind { | |||
| 165 | - | case ports.RuntimeScreenSnapshot, ports.RuntimeScreenDelta, ports.RuntimeScreenResetRequested: | |||
| 166 | - | marks = append(marks, mark) | |||
| 167 | - | } | |||
| 168 | - | } | |||
| 169 | - | return marks | |||
| 170 | - | } | |||
| 171 | - | ||||
| 172 | - | func mustMarshalScreenUpdate(t *testing.T, update ports.ScreenUpdate) []byte { | |||
| 173 | - | t.Helper() | |||
| 174 | - | data, err := ports.MarshalScreenUpdate(update) | |||
| 175 | - | require.NoError(t, err) | |||
| 176 | - | return data | |||
| 177 | - | } | |||
| 178 | - | ||||
| 179 | - | type blockingStructuredDiagnosticObserver struct { | |||
| 180 | - | entered chan struct{} | |||
| 181 | - | release chan struct{} | |||
| 182 | - | once sync.Once | |||
| 183 | - | } | |||
| 184 | - | ||||
| 185 | - | func (o *blockingStructuredDiagnosticObserver) ObserveRuntime(mark ports.RuntimeMark) { | |||
| 186 | - | if mark.Kind != ports.RuntimeScreenSnapshot && mark.Kind != ports.RuntimeScreenDelta { | |||
| 187 | - | return | |||
| 188 | - | } | |||
| 189 | - | o.once.Do(func() { | |||
| 190 | - | close(o.entered) | |||
| 191 | - | <-o.release | |||
| 192 | - | }) | |||
| 193 | - | } | |||
| 194 | - | ||||
| 195 | - | func newStructuredDiagnosticFailingTransport(t *testing.T) *portsmocks.MockTransport { | |||
| 196 | - | t.Helper() | |||
| 197 | - | tr := portsmocks.NewMockTransport(t) | |||
| 198 | - | tr.EXPECT().Send(mock.Anything).Return(io.ErrClosedPipe).Maybe() | |||
| 199 | - | tr.EXPECT().Close().Return(nil).Maybe() | |||
| 200 | - | return tr | |||
| 201 | - | } | |||
M internal/usecase/daemon/transactional_resize.go +2 -6
| @@ -535,17 +535,13 @@ func (d *Daemon) releasePreparedSessionGates(plans []*preparedTabLayout) { | |||||
| 535 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | 535 | // coordinator admits the epoch before any tab size, rectangle, VT screen, | ||
| 536 | // snapshot dirtiness, or resize telemetry becomes visible. | 536 | // snapshot dirtiness, or resize telemetry becomes visible. | ||
| 537 | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | 537 | func (d *Daemon) applySessionLayout(sess *session, size domain.Size, current, admit func() bool) ([]resizeMember, bool) { | ||
| 538 | - | return d.applySessionLayoutWithMode(sess, size, false, current, admit) | |||
| 539 | - | } | |||
| 540 | - | ||||
| 541 | - | func (d *Daemon) applySessionLayoutWithMode(sess *session, size domain.Size, proxied bool, current, admit func() bool) ([]resizeMember, bool) { | |||
| 542 | if sess == nil { | 538 | if sess == nil { | ||
| 543 | return nil, false | 539 | return nil, false | ||
| 544 | } | 540 | } | ||
| 545 | sess.layoutApplyMu.Lock() | 541 | sess.layoutApplyMu.Lock() | ||
| 546 | defer sess.layoutApplyMu.Unlock() | 542 | defer sess.layoutApplyMu.Unlock() | ||
| 547 | 543 | ||||
| 548 | - | target := contentSize(size, proxied) | 544 | + | target := contentSize(size) |
| 549 | for { | 545 | for { | ||
| 550 | if sess.ctx != nil && sess.ctx.Err() != nil { | 546 | if sess.ctx != nil && sess.ctx.Err() != nil { | ||
| 551 | return nil, false | 547 | return nil, false | ||
| @@ -676,7 +672,7 @@ func (d *Daemon) runResizeTransaction(sess *session, ac *attachedClient, lease * | |||||
| 676 | return false | 672 | return false | ||
| 677 | } | 673 | } | ||
| 678 | d.exitCopyMode(ac) | 674 | d.exitCopyMode(ac) | ||
| 679 | - | failed, ok := d.applySessionLayoutWithMode(sess, snap.size, ac.proxied, current, func() bool { | 675 | + | failed, ok := d.applySessionLayout(sess, snap.size, current, func() bool { |
| 680 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | 676 | return rc.resizeCurrentForLease(epoch, ac, lease, true) | ||
| 681 | }) | 677 | }) | ||
| 682 | if !ok { | 678 | if !ok { | ||
M internal/usecase/daemon/transactional_resize_test.go +1 -1
| @@ -838,7 +838,7 @@ func TestTransactionalResizeObsoleteTimerCallbacksCommitOnlyLatestEpoch(t *testi | |||||
| 838 | require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame") | 838 | require.Equal(t, ports.MsgOutput, frame.Type, "the committed epoch emits a full frame") | ||
| 839 | output, err := ports.UnmarshalOutput(frame.Payload) | 839 | output, err := ports.UnmarshalOutput(frame.Payload) | ||
| 840 | require.NoError(t, err) | 840 | require.NoError(t, err) | ||
| 841 | - | require.Zero(t, output.BaseStateNum, "the accepted resize frame must reset the renderer state") | 841 | + | require.Zero(t, output.Base, "the accepted resize frame must reset the renderer state") |
| 842 | select { | 842 | select { | ||
| 843 | case extra := <-frames: | 843 | case extra := <-frames: | ||
| 844 | t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra) | 844 | t.Fatalf("obsolete epoch emitted an additional frame: %#v", extra) | ||
M internal/usecase/daemon/transport_observability_test.go +2 -2
| @@ -36,7 +36,7 @@ func TestTransportObservabilityDaemonBoundaries(t *testing.T) { | |||||
| 36 | if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) { | 36 | if !d.resizeForFirstPaint(sess, ac, domain.Size{Cols: 100, Rows: 26}) { | ||
| 37 | t.Fatal("second resize was not accepted") | 37 | t.Fatal("second resize was not accepted") | ||
| 38 | } | 38 | } | ||
| 39 | - | ac.ackOutputState(output.NewStateNum) | 39 | + | ac.ackOutputState(output.Epoch, output.New) |
| 40 | rc.notifyAck() | 40 | rc.notifyAck() | ||
| 41 | second := awaitFrame(t, sends, ports.MsgOutput) | 41 | second := awaitFrame(t, sends, ports.MsgOutput) | ||
| 42 | if second.Type != ports.MsgOutput { | 42 | if second.Type != ports.MsgOutput { | ||
| @@ -123,7 +123,7 @@ func TestTransportObservabilityBlockedRenderObserverReleasesArchitectureLocks(t | |||||
| 123 | if err != nil { | 123 | if err != nil { | ||
| 124 | t.Fatalf("decode emitted output: %v", err) | 124 | t.Fatalf("decode emitted output: %v", err) | ||
| 125 | } | 125 | } | ||
| 126 | - | ac.ackOutputState(output.NewStateNum) | 126 | + | ac.ackOutputState(output.Epoch, output.New) |
| 127 | 127 | ||||
| 128 | pane := testAttachmentTab(sess).focusedPane() | 128 | pane := testAttachmentTab(sess).focusedPane() | ||
| 129 | for _, lock := range []struct { | 129 | for _, lock := range []struct { | ||
M internal/usecase/daemon/wire.go +11 -7
| @@ -2,19 +2,16 @@ | |||||
| 2 | package daemon | 2 | package daemon | ||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | + | "github.com/bnema/vev/internal/domain" | |||
| 5 | "github.com/bnema/vev/internal/ports" | 6 | "github.com/bnema/vev/internal/ports" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| 8 | func frameWelcome(s *session, ac *attachedClient) ports.Frame { | 9 | func frameWelcome(s *session, ac *attachedClient) ports.Frame { | ||
| 9 | - | capabilities := ports.CapabilityResume | |||
| 10 | - | if ac.proxied { | |||
| 11 | - | capabilities |= ports.CapabilityProxied | |||
| 12 | - | } | |||
| 13 | w := ports.Welcome{ | 10 | w := ports.Welcome{ | ||
| 14 | SessionID: string(s.id), | 11 | SessionID: string(s.id), | ||
| 15 | SessionName: s.name, | 12 | SessionName: s.name, | ||
| 16 | Ephemeral: s.ephemeral, | 13 | Ephemeral: s.ephemeral, | ||
| 17 | - | Capabilities: capabilities, | 14 | + | Capabilities: ports.CapabilityResume, |
| 18 | ResumeToken: ac.resumeToken, | 15 | ResumeToken: ac.resumeToken, | ||
| 19 | } | 16 | } | ||
| 20 | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)} | 17 | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)} | ||
| @@ -24,8 +21,15 @@ func frameError(code uint16, text string) ports.Frame { | |||||
| 24 | return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})} | 21 | return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})} | ||
| 25 | } | 22 | } | ||
| 26 | 23 | ||||
| 27 | - | func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) ports.Frame { | 24 | + | func frameOutputState(b []byte, baseState uint64, state uint64, echoAck uint64) (ports.Frame, error) { |
| 28 | - | return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{BaseStateNum: baseState, NewStateNum: state, EchoAck: echoAck, Data: b})} | 25 | + | payload, err := ports.MarshalOutput(ports.Output{ |
| 26 | + | Epoch: 1, Base: baseState, New: state, Echo: echoAck, | |||
| 27 | + | Size: domain.Size{Cols: 1, Rows: 1}, Full: state != 0 && baseState == 0, Data: b, | |||
| 28 | + | }) | |||
| 29 | + | if err != nil { | |||
| 30 | + | return ports.Frame{}, err | |||
| 31 | + | } | |||
| 32 | + | return ports.Frame{Type: ports.MsgOutput, Payload: payload}, nil | |||
| 29 | } | 33 | } | ||
| 30 | 34 | ||||
| 31 | func frameDetached(reason uint8) ports.Frame { | 35 | func frameDetached(reason uint8) ports.Frame { | ||
A internal/usecase/daemon/wire_test_helpers_test.go +27 -0
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/vev/internal/ports" | |||
| 4 | + | ||||
| 5 | + | func mustMarshalOutput(m ports.Output) []byte { | |||
| 6 | + | payload, err := ports.MarshalOutput(m) | |||
| 7 | + | if err != nil { | |||
| 8 | + | panic(err) | |||
| 9 | + | } | |||
| 10 | + | return payload | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | func mustMarshalAck(m ports.Ack) []byte { | |||
| 14 | + | payload, err := ports.MarshalAck(m) | |||
| 15 | + | if err != nil { | |||
| 16 | + | panic(err) | |||
| 17 | + | } | |||
| 18 | + | return payload | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func mustMarshalResize(m ports.Resize) []byte { | |||
| 22 | + | payload, err := ports.MarshalResize(m) | |||
| 23 | + | if err != nil { | |||
| 24 | + | panic(err) | |||
| 25 | + | } | |||
| 26 | + | return payload | |||
| 27 | + | } | |||