feat(client): scope attachment render state
- commit
- 4e32793d263f5e024ec56a70ffbcf977f102b98a
- author
- brice · · 2026-08-03 08:04 UTC
- committer
- brice · · 2026-08-04 03:19 UTC
- parent
- 4c3ccc7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +353 -86
M internal/usecase/client/client.go +52 -28
| @@ -832,6 +832,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 832 | 832 | clip = clipboard | |
| 833 | 833 | } | |
| 834 | 834 | awaitingReconnectReset := false | |
| 835 | + | outputState := outputApplyState{} | |
| 836 | + | outputResetRequested := false | |
| 835 | 837 | if reconnect.showing { | |
| 836 | 838 | if reconnect.remote { | |
| 837 | 839 | // Before the asynchronous sender starts, preserve transport ordering by | |
| @@ -990,19 +992,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 990 | 992 | if derr != nil { | |
| 991 | 993 | return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr)) | |
| 992 | 994 | } | |
| 993 | - | // Dismissing or changing a local overlay requests a same-size resize, | |
| 994 | - | // whose stateful base-zero Output is the daemon's authoritative reset. | |
| 995 | - | // ACK intervening increments so the output window keeps moving, but do | |
| 996 | - | // not apply diffs based on cells hidden by the client-local overlay. | |
| 997 | - | resetFrame := isReconnectResetFrame(o) | |
| 998 | - | if awaitingReconnectReset && !resetFrame && o.NewStateNum != 0 { | |
| 999 | - | ackQueue.offer(o.NewStateNum) | |
| 995 | + | nextState, accepted := outputState.next(o) | |
| 996 | + | if !accepted { | |
| 997 | + | // A discarded frame was never written and must never be ACKed. | |
| 998 | + | // Ask the daemon for one authoritative full reset, coalescing | |
| 999 | + | // repeated gaps while that reset is in flight. | |
| 1000 | + | if !outputResetRequested { | |
| 1001 | + | select { | |
| 1002 | + | case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}: | |
| 1003 | + | outputResetRequested = true | |
| 1004 | + | case <-loopCtx.Done(): | |
| 1005 | + | return loopCanceledResult() | |
| 1006 | + | } | |
| 1007 | + | } | |
| 1000 | 1008 | continue | |
| 1001 | 1009 | } | |
| 1002 | 1010 | if _, werr := term.Out().Write(o.Data); werr != nil { | |
| 1003 | 1011 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | |
| 1004 | 1012 | } | |
| 1005 | - | if awaitingReconnectReset && resetFrame { | |
| 1013 | + | if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 { | |
| 1006 | 1014 | awaitingReconnectReset = false | |
| 1007 | 1015 | } | |
| 1008 | 1016 | if reconnect.showing && reconnect.remote { | |
| @@ -1016,13 +1024,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1016 | 1024 | } else if ferr := term.Flush(); ferr != nil { | |
| 1017 | 1025 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | |
| 1018 | 1026 | } | |
| 1027 | + | outputState = nextState | |
| 1028 | + | if o.New != 0 { | |
| 1029 | + | ackQueue.offer(o.Epoch, o.New) | |
| 1030 | + | outputResetRequested = false | |
| 1031 | + | } | |
| 1019 | 1032 | // The terminal boundary is after a successful flush and before ACK. | |
| 1020 | 1033 | if observer != nil { | |
| 1021 | 1034 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true)) | |
| 1022 | 1035 | } | |
| 1023 | - | if o.NewStateNum != 0 { | |
| 1024 | - | ackQueue.offer(o.NewStateNum) | |
| 1025 | - | } | |
| 1026 | 1036 | if !ms.firstOutput { | |
| 1027 | 1037 | ms.firstOutput = true | |
| 1028 | 1038 | log.Debug("received first output") | |
| @@ -1049,13 +1059,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 1049 | 1059 | } | |
| 1050 | 1060 | } | |
| 1051 | 1061 | ||
| 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 | 1062 | // recvResult carries one framed message (or a read error) from the receive | |
| 1060 | 1063 | // pump to the main loop. | |
| 1061 | 1064 | type recvResult struct { | |
| @@ -1084,27 +1087,48 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu | |||
| 1084 | 1087 | // resize pumps through one goroutine keeps the transport's single-writer | |
| 1085 | 1088 | // contract intact. A send failure cancels the loop and is surfaced once. | |
| 1086 | 1089 | type cumulativeAckQueue struct { | |
| 1087 | - | latest atomic.Uint64 | |
| 1088 | - | wake chan struct{} | |
| 1090 | + | mu sync.Mutex | |
| 1091 | + | latestEpoch uint64 | |
| 1092 | + | latestState uint64 | |
| 1093 | + | wake chan struct{} | |
| 1089 | 1094 | } | |
| 1090 | 1095 | ||
| 1091 | 1096 | func newCumulativeAckQueue() *cumulativeAckQueue { | |
| 1092 | 1097 | return &cumulativeAckQueue{wake: make(chan struct{}, 1)} | |
| 1093 | 1098 | } | |
| 1094 | 1099 | ||
| 1095 | - | func (q *cumulativeAckQueue) offer(state uint64) { | |
| 1096 | - | for { | |
| 1097 | - | current := q.latest.Load() | |
| 1098 | - | if state <= current || q.latest.CompareAndSwap(current, state) { | |
| 1099 | - | break | |
| 1100 | - | } | |
| 1100 | + | func (q *cumulativeAckQueue) offer(values ...uint64) { | |
| 1101 | + | epoch, state := uint64(1), uint64(0) | |
| 1102 | + | switch len(values) { | |
| 1103 | + | case 1: | |
| 1104 | + | state = values[0] | |
| 1105 | + | case 2: | |
| 1106 | + | epoch, state = values[0], values[1] | |
| 1107 | + | default: | |
| 1108 | + | return | |
| 1109 | + | } | |
| 1110 | + | if epoch == 0 || state == 0 { | |
| 1111 | + | return | |
| 1112 | + | } | |
| 1113 | + | q.mu.Lock() | |
| 1114 | + | if epoch > q.latestEpoch || (epoch == q.latestEpoch && state > q.latestState) { | |
| 1115 | + | q.latestEpoch, q.latestState = epoch, state | |
| 1101 | 1116 | } | |
| 1117 | + | q.mu.Unlock() | |
| 1102 | 1118 | select { | |
| 1103 | 1119 | case q.wake <- struct{}{}: | |
| 1104 | 1120 | default: | |
| 1105 | 1121 | } | |
| 1106 | 1122 | } | |
| 1107 | 1123 | ||
| 1124 | + | func (q *cumulativeAckQueue) take() (uint64, uint64) { | |
| 1125 | + | q.mu.Lock() | |
| 1126 | + | defer q.mu.Unlock() | |
| 1127 | + | epoch, state := q.latestEpoch, q.latestState | |
| 1128 | + | q.latestEpoch, q.latestState = 0, 0 | |
| 1129 | + | return epoch, state | |
| 1130 | + | } | |
| 1131 | + | ||
| 1108 | 1132 | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) { | |
| 1109 | 1133 | defer log.Debug("sender pump exited") | |
| 1110 | 1134 | send := func(f ports.Frame) bool { | |
| @@ -1119,8 +1143,8 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 1119 | 1143 | return true | |
| 1120 | 1144 | } | |
| 1121 | 1145 | sendAck := func() bool { | |
| 1122 | - | state := acks.latest.Swap(0) | |
| 1123 | - | return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: state})}) | |
| 1146 | + | epoch, state := acks.take() | |
| 1147 | + | return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})}) | |
| 1124 | 1148 | } | |
| 1125 | 1149 | for { | |
| 1126 | 1150 | select { | |
| @@ -832,6 +832,8 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 832 | clip = clipboard | 832 | clip = clipboard | ||
| 833 | } | 833 | } | ||
| 834 | awaitingReconnectReset := false | 834 | awaitingReconnectReset := false | ||
| 835 | + | outputState := outputApplyState{} | |||
| 836 | + | outputResetRequested := false | |||
| 835 | if reconnect.showing { | 837 | if reconnect.showing { | ||
| 836 | if reconnect.remote { | 838 | if reconnect.remote { | ||
| 837 | // Before the asynchronous sender starts, preserve transport ordering by | 839 | // Before the asynchronous sender starts, preserve transport ordering by | ||
| @@ -990,19 +992,25 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 990 | if derr != nil { | 992 | if derr != nil { | ||
| 991 | return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr)) | 993 | return welcomedResult(fmt.Errorf("vev: decoding output: %w", derr)) | ||
| 992 | } | 994 | } | ||
| 993 | - | // Dismissing or changing a local overlay requests a same-size resize, | 995 | + | nextState, accepted := outputState.next(o) |
| 994 | - | // whose stateful base-zero Output is the daemon's authoritative reset. | 996 | + | if !accepted { |
| 995 | - | // ACK intervening increments so the output window keeps moving, but do | 997 | + | // A discarded frame was never written and must never be ACKed. |
| 996 | - | // not apply diffs based on cells hidden by the client-local overlay. | 998 | + | // Ask the daemon for one authoritative full reset, coalescing |
| 997 | - | resetFrame := isReconnectResetFrame(o) | 999 | + | // repeated gaps while that reset is in flight. |
| 998 | - | if awaitingReconnectReset && !resetFrame && o.NewStateNum != 0 { | 1000 | + | if !outputResetRequested { |
| 999 | - | ackQueue.offer(o.NewStateNum) | 1001 | + | select { |
| 1002 | + | case sendCh <- ports.Frame{Type: ports.MsgOutputResetRequest, Payload: ports.MarshalOutputResetRequest(ports.OutputResetRequest{})}: | |||
| 1003 | + | outputResetRequested = true | |||
| 1004 | + | case <-loopCtx.Done(): | |||
| 1005 | + | return loopCanceledResult() | |||
| 1006 | + | } | |||
| 1007 | + | } | |||
| 1000 | continue | 1008 | continue | ||
| 1001 | } | 1009 | } | ||
| 1002 | if _, werr := term.Out().Write(o.Data); werr != nil { | 1010 | if _, werr := term.Out().Write(o.Data); werr != nil { | ||
| 1003 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | 1011 | return welcomedResult(fmt.Errorf("vev: writing terminal output: %w", werr)) | ||
| 1004 | } | 1012 | } | ||
| 1005 | - | if awaitingReconnectReset && resetFrame { | 1013 | + | if awaitingReconnectReset && o.Full && o.Base == 0 && o.New != 0 { |
| 1006 | awaitingReconnectReset = false | 1014 | awaitingReconnectReset = false | ||
| 1007 | } | 1015 | } | ||
| 1008 | if reconnect.showing && reconnect.remote { | 1016 | if reconnect.showing && reconnect.remote { | ||
| @@ -1016,13 +1024,15 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1016 | } else if ferr := term.Flush(); ferr != nil { | 1024 | } else if ferr := term.Flush(); ferr != nil { | ||
| 1017 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | 1025 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | ||
| 1018 | } | 1026 | } | ||
| 1027 | + | outputState = nextState | |||
| 1028 | + | if o.New != 0 { | |||
| 1029 | + | ackQueue.offer(o.Epoch, o.New) | |||
| 1030 | + | outputResetRequested = false | |||
| 1031 | + | } | |||
| 1019 | // The terminal boundary is after a successful flush and before ACK. | 1032 | // The terminal boundary is after a successful flush and before ACK. | ||
| 1020 | if observer != nil { | 1033 | if observer != nil { | ||
| 1021 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true)) | 1034 | observer.ObserveRuntime(ports.NewRuntimeMark("client", ports.RuntimeTerminalFlushed, uint64(len(o.Data)), true)) | ||
| 1022 | } | 1035 | } | ||
| 1023 | - | if o.NewStateNum != 0 { | |||
| 1024 | - | ackQueue.offer(o.NewStateNum) | |||
| 1025 | - | } | |||
| 1026 | if !ms.firstOutput { | 1036 | if !ms.firstOutput { | ||
| 1027 | ms.firstOutput = true | 1037 | ms.firstOutput = true | ||
| 1028 | log.Debug("received first output") | 1038 | log.Debug("received first output") | ||
| @@ -1049,13 +1059,6 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||||
| 1049 | } | 1059 | } | ||
| 1050 | } | 1060 | } | ||
| 1051 | 1061 | ||||
| 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 | 1062 | // recvResult carries one framed message (or a read error) from the receive | ||
| 1060 | // pump to the main loop. | 1063 | // pump to the main loop. | ||
| 1061 | type recvResult struct { | 1064 | type recvResult struct { | ||
| @@ -1084,27 +1087,48 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu | |||||
| 1084 | // resize pumps through one goroutine keeps the transport's single-writer | 1087 | // resize pumps through one goroutine keeps the transport's single-writer | ||
| 1085 | // contract intact. A send failure cancels the loop and is surfaced once. | 1088 | // contract intact. A send failure cancels the loop and is surfaced once. | ||
| 1086 | type cumulativeAckQueue struct { | 1089 | type cumulativeAckQueue struct { | ||
| 1087 | - | latest atomic.Uint64 | 1090 | + | mu sync.Mutex |
| 1088 | - | wake chan struct{} | 1091 | + | latestEpoch uint64 |
| 1092 | + | latestState uint64 | |||
| 1093 | + | wake chan struct{} | |||
| 1089 | } | 1094 | } | ||
| 1090 | 1095 | ||||
| 1091 | func newCumulativeAckQueue() *cumulativeAckQueue { | 1096 | func newCumulativeAckQueue() *cumulativeAckQueue { | ||
| 1092 | return &cumulativeAckQueue{wake: make(chan struct{}, 1)} | 1097 | return &cumulativeAckQueue{wake: make(chan struct{}, 1)} | ||
| 1093 | } | 1098 | } | ||
| 1094 | 1099 | ||||
| 1095 | - | func (q *cumulativeAckQueue) offer(state uint64) { | 1100 | + | func (q *cumulativeAckQueue) offer(values ...uint64) { |
| 1096 | - | for { | 1101 | + | epoch, state := uint64(1), uint64(0) |
| 1097 | - | current := q.latest.Load() | 1102 | + | switch len(values) { |
| 1098 | - | if state <= current || q.latest.CompareAndSwap(current, state) { | 1103 | + | case 1: |
| 1099 | - | break | 1104 | + | state = values[0] |
| 1100 | - | } | 1105 | + | case 2: |
| 1106 | + | epoch, state = values[0], values[1] | |||
| 1107 | + | default: | |||
| 1108 | + | return | |||
| 1109 | + | } | |||
| 1110 | + | if epoch == 0 || state == 0 { | |||
| 1111 | + | return | |||
| 1112 | + | } | |||
| 1113 | + | q.mu.Lock() | |||
| 1114 | + | if epoch > q.latestEpoch || (epoch == q.latestEpoch && state > q.latestState) { | |||
| 1115 | + | q.latestEpoch, q.latestState = epoch, state | |||
| 1101 | } | 1116 | } | ||
| 1117 | + | q.mu.Unlock() | |||
| 1102 | select { | 1118 | select { | ||
| 1103 | case q.wake <- struct{}{}: | 1119 | case q.wake <- struct{}{}: | ||
| 1104 | default: | 1120 | default: | ||
| 1105 | } | 1121 | } | ||
| 1106 | } | 1122 | } | ||
| 1107 | 1123 | ||||
| 1124 | + | func (q *cumulativeAckQueue) take() (uint64, uint64) { | |||
| 1125 | + | q.mu.Lock() | |||
| 1126 | + | defer q.mu.Unlock() | |||
| 1127 | + | epoch, state := q.latestEpoch, q.latestState | |||
| 1128 | + | q.latestEpoch, q.latestState = 0, 0 | |||
| 1129 | + | return epoch, state | |||
| 1130 | + | } | |||
| 1131 | + | ||||
| 1108 | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) { | 1132 | 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") | 1133 | defer log.Debug("sender pump exited") | ||
| 1110 | send := func(f ports.Frame) bool { | 1134 | send := func(f ports.Frame) bool { | ||
| @@ -1119,8 +1143,8 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 1119 | return true | 1143 | return true | ||
| 1120 | } | 1144 | } | ||
| 1121 | sendAck := func() bool { | 1145 | sendAck := func() bool { | ||
| 1122 | - | state := acks.latest.Swap(0) | 1146 | + | epoch, state := acks.take() |
| 1123 | - | return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: state})}) | 1147 | + | return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{Epoch: epoch, State: state})}) |
| 1124 | } | 1148 | } | ||
| 1125 | for { | 1149 | for { | ||
| 1126 | select { | 1150 | 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 | + | } | |
| @@ -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 | + | } | |
| @@ -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/daemon/attachment_registry.go +5 -3
| @@ -17,6 +17,7 @@ type attachmentView struct { | |||
| 17 | 17 | paneID domain.PaneStableID | |
| 18 | 18 | windowTop int | |
| 19 | 19 | windowRows int | |
| 20 | + | windowSet bool | |
| 20 | 21 | bookmark vt.RowID | |
| 21 | 22 | liveBottom bool | |
| 22 | 23 | revision uint64 | |
| @@ -241,6 +242,7 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment | |||
| 241 | 242 | view.paneID = "" | |
| 242 | 243 | view.windowTop = 0 | |
| 243 | 244 | view.windowRows = 0 | |
| 245 | + | view.windowSet = false | |
| 244 | 246 | view.bookmark = 0 | |
| 245 | 247 | view.liveBottom = true | |
| 246 | 248 | return view | |
| @@ -293,7 +295,7 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool { | |||
| 293 | 295 | } | |
| 294 | 296 | before := ac.viewSnapshot() | |
| 295 | 297 | 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 { | |
| 298 | + | 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 | 299 | return false | |
| 298 | 300 | } | |
| 299 | 301 | after.revision++ | |
| @@ -369,7 +371,7 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab { | |||
| 369 | 371 | } | |
| 370 | 372 | view := ac.viewSnapshot() | |
| 371 | 373 | 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 { | |
| 374 | + | 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 | 375 | repaired.revision++ | |
| 374 | 376 | ac.publishView(repaired) | |
| 375 | 377 | } | |
| @@ -476,7 +478,7 @@ func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachme | |||
| 476 | 478 | next = s.repairAttachmentViewLocked(ac, next) | |
| 477 | 479 | next.revision++ | |
| 478 | 480 | 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 | |
| 481 | + | 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 | 482 | return nil | |
| 481 | 483 | }) | |
| 482 | 484 | return changed | |
| @@ -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 | ||
| @@ -241,6 +242,7 @@ func (s *session) repairAttachmentViewLocked(ac *attachedClient, view attachment | |||||
| 241 | view.paneID = "" | 242 | view.paneID = "" | ||
| 242 | view.windowTop = 0 | 243 | view.windowTop = 0 | ||
| 243 | view.windowRows = 0 | 244 | view.windowRows = 0 | ||
| 245 | + | view.windowSet = false | |||
| 244 | view.bookmark = 0 | 246 | view.bookmark = 0 | ||
| 245 | view.liveBottom = true | 247 | view.liveBottom = true | ||
| 246 | return view | 248 | return view | ||
| @@ -293,7 +295,7 @@ func (s *session) repairAttachmentView(ac *attachedClient) bool { | |||||
| 293 | } | 295 | } | ||
| 294 | before := ac.viewSnapshot() | 296 | before := ac.viewSnapshot() | ||
| 295 | after := s.repairAttachmentViewLocked(ac, before) | 297 | 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 { | 298 | + | 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 | 299 | return false | ||
| 298 | } | 300 | } | ||
| 299 | after.revision++ | 301 | after.revision++ | ||
| @@ -369,7 +371,7 @@ func (s *session) tabForAttachment(ac *attachedClient) *tab { | |||||
| 369 | } | 371 | } | ||
| 370 | view := ac.viewSnapshot() | 372 | view := ac.viewSnapshot() | ||
| 371 | repaired := s.repairAttachmentViewLocked(ac, view) | 373 | 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 { | 374 | + | 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++ | 375 | repaired.revision++ | ||
| 374 | ac.publishView(repaired) | 376 | ac.publishView(repaired) | ||
| 375 | } | 377 | } | ||
| @@ -476,7 +478,7 @@ func (s *session) updateAttachmentView(ac *attachedClient, update func(*attachme | |||||
| 476 | next = s.repairAttachmentViewLocked(ac, next) | 478 | next = s.repairAttachmentViewLocked(ac, next) | ||
| 477 | next.revision++ | 479 | next.revision++ | ||
| 478 | ac.publishView(next) | 480 | 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 | 481 | + | 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 | 482 | return nil | ||
| 481 | }) | 483 | }) | ||
| 482 | return changed | 484 | return changed | ||
A internal/usecase/daemon/attachment_render_test.go +110 -0
| @@ -0,0 +1,110 @@ | |||
| 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 | + | case <-time.After(time.Second): | |
| 107 | + | t.Fatal("second attachment waited for first attachment transport") | |
| 108 | + | } | |
| 109 | + | close(firstTransport.release) | |
| 110 | + | } | |
| @@ -0,0 +1,110 @@ | |||||
| 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 | + | case <-time.After(time.Second): | |||
| 107 | + | t.Fatal("second attachment waited for first attachment transport") | |||
| 108 | + | } | |||
| 109 | + | close(firstTransport.release) | |||
| 110 | + | } | |||
M internal/usecase/daemon/client.go +49 -27
| @@ -569,6 +569,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||
| 569 | 569 | tr: tr, | |
| 570 | 570 | output: output, | |
| 571 | 571 | size: sz, | |
| 572 | + | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | |
| 572 | 573 | clientID: opts.clientID, | |
| 573 | 574 | resumeCapable: opts.resumeCapable, | |
| 574 | 575 | resumeToken: resumeToken, | |
| @@ -724,15 +725,14 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |||
| 724 | 725 | return true | |
| 725 | 726 | } | |
| 726 | 727 | ||
| 727 | - | // firstPaint guarantees the freshly attached client sees the full screen: if | |
| 728 | - | // the tab size differs from the client's it resizes first, then immediately | |
| 729 | - | // emits a full redraw. It is retained for direct test/headless setup; active | |
| 730 | - | // attachment transitions use firstPaintForTransition with their exact lease. | |
| 728 | + | // firstPaint guarantees the freshly attached client sees the full screen. The | |
| 729 | + | // session content geometry is fixed by the first valid Hello; an attachment's | |
| 730 | + | // window is rendered without resizing shared tabs or PTYs. | |
| 731 | 731 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | |
| 732 | 732 | d.firstPaintWithLease(sess, ac, clientSize, nil) | |
| 733 | 733 | } | |
| 734 | 734 | ||
| 735 | - | func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) { | |
| 735 | + | func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain.Size, lease *attachmentLease) { | |
| 736 | 736 | // Global notices raised while nothing was attached surface on this client. | |
| 737 | 737 | // Drained before the early return below so a session without an active tab | |
| 738 | 738 | // cannot swallow the queue. | |
| @@ -741,29 +741,14 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||
| 741 | 741 | if tb == nil { | |
| 742 | 742 | return | |
| 743 | 743 | } | |
| 744 | - | tb.mu.Lock() | |
| 745 | - | wsz := tb.size | |
| 746 | - | tb.mu.Unlock() | |
| 747 | - | ||
| 748 | - | outerResizeAccepted := false | |
| 749 | - | if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) { | |
| 750 | - | if lease == nil { | |
| 751 | - | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |
| 752 | - | } else { | |
| 753 | - | outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true) | |
| 754 | - | } | |
| 755 | - | } | |
| 756 | 744 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | |
| 757 | - | // Activation can synchronously resize a retained floating pane. An accepted | |
| 758 | - | // synchronous outer request already includes that pane and emits the reset, | |
| 759 | - | // but activation still performs its warmup work. | |
| 760 | - | activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease) | |
| 761 | - | if !outerResizeAccepted && !activationResized { | |
| 762 | - | if lease == nil { | |
| 763 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 764 | - | } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) { | |
| 765 | - | rc.fireCurrentForLease(lease) | |
| 766 | - | } | |
| 745 | + | // Activation still performs attachment-local warmup, but it must not turn a | |
| 746 | + | // new window into a shared session resize. | |
| 747 | + | d.ensureFloatingWarm(sess, tb) | |
| 748 | + | if lease == nil { | |
| 749 | + | d.invalidateRenderNow(sess, ac, true, "client.go") | |
| 750 | + | } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) { | |
| 751 | + | rc.fireCurrentForLease(lease) | |
| 767 | 752 | } | |
| 768 | 753 | } | |
| 769 | 754 | ||
| @@ -817,6 +802,43 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 817 | 802 | d.requestTransactionalResize(sess, ac, sz, false) | |
| 818 | 803 | } | |
| 819 | 804 | ||
| 805 | + | // resizeAttachmentForLease changes only the sender's window/view. Session tab | |
| 806 | + | // geometry and PTY sizes are deliberately outside this mutation boundary. | |
| 807 | + | // Callers hold the attachment effect admission; output state is rebased before | |
| 808 | + | // the targeted redraw so every subsequent frame starts a new epoch. | |
| 809 | + | func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool { | |
| 810 | + | if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() { | |
| 811 | + | return false | |
| 812 | + | } | |
| 813 | + | ac := token.ac | |
| 814 | + | ac.sendMu.Lock() | |
| 815 | + | if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) { | |
| 816 | + | ac.sendMu.Unlock() | |
| 817 | + | return false | |
| 818 | + | } | |
| 819 | + | if ac.size == size { | |
| 820 | + | ac.sendMu.Unlock() | |
| 821 | + | return false | |
| 822 | + | } | |
| 823 | + | ac.size = size | |
| 824 | + | view := ac.viewSnapshot() | |
| 825 | + | view.windowRows = size.Rows | |
| 826 | + | view.windowSet = true | |
| 827 | + | view.windowTop = 0 | |
| 828 | + | view.revision++ | |
| 829 | + | ac.publishView(view) | |
| 830 | + | ac.rebaseOutput() | |
| 831 | + | ac.pipelineCache = composeCacheInput{} | |
| 832 | + | ac.pipelineScratch = composeCacheInput{} | |
| 833 | + | ac.sendMu.Unlock() | |
| 834 | + | ||
| 835 | + | entry := token.sess | |
| 836 | + | // Rendering is attachment-scoped. Run it independently of the connection | |
| 837 | + | // reader so a blocked transport cannot hold session dispatch or peers. | |
| 838 | + | go d.paint(entry, ac, true, token.lease) | |
| 839 | + | return true | |
| 840 | + | } | |
| 841 | + | ||
| 820 | 842 | // handleClientNotice maps the closed client-event enum to daemon-owned notice | |
| 821 | 843 | // content. routingMu makes ownership validation and toast mutation one atomic | |
| 822 | 844 | // attachment-routing operation: attachment publication also takes routingMu | |
| @@ -569,6 +569,7 @@ func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, | |||||
| 569 | tr: tr, | 569 | tr: tr, | ||
| 570 | output: output, | 570 | output: output, | ||
| 571 | size: sz, | 571 | size: sz, | ||
| 572 | + | view: attachmentView{windowRows: sz.Rows, windowSet: true}, | |||
| 572 | clientID: opts.clientID, | 573 | clientID: opts.clientID, | ||
| 573 | resumeCapable: opts.resumeCapable, | 574 | resumeCapable: opts.resumeCapable, | ||
| 574 | resumeToken: resumeToken, | 575 | resumeToken: resumeToken, | ||
| @@ -724,15 +725,14 @@ func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool { | |||||
| 724 | return true | 725 | return true | ||
| 725 | } | 726 | } | ||
| 726 | 727 | ||||
| 727 | - | // firstPaint guarantees the freshly attached client sees the full screen: if | 728 | + | // firstPaint guarantees the freshly attached client sees the full screen. The |
| 728 | - | // the tab size differs from the client's it resizes first, then immediately | 729 | + | // session content geometry is fixed by the first valid Hello; an attachment's |
| 729 | - | // emits a full redraw. It is retained for direct test/headless setup; active | 730 | + | // window is rendered without resizing shared tabs or PTYs. |
| 730 | - | // attachment transitions use firstPaintForTransition with their exact lease. | |||
| 731 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | 731 | func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain.Size) { | ||
| 732 | d.firstPaintWithLease(sess, ac, clientSize, nil) | 732 | d.firstPaintWithLease(sess, ac, clientSize, nil) | ||
| 733 | } | 733 | } | ||
| 734 | 734 | ||||
| 735 | - | func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSize domain.Size, lease *attachmentLease) { | 735 | + | func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, _ domain.Size, lease *attachmentLease) { |
| 736 | // Global notices raised while nothing was attached surface on this client. | 736 | // Global notices raised while nothing was attached surface on this client. | ||
| 737 | // Drained before the early return below so a session without an active tab | 737 | // Drained before the early return below so a session without an active tab | ||
| 738 | // cannot swallow the queue. | 738 | // cannot swallow the queue. | ||
| @@ -741,29 +741,14 @@ func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, clientSi | |||||
| 741 | if tb == nil { | 741 | if tb == nil { | ||
| 742 | return | 742 | return | ||
| 743 | } | 743 | } | ||
| 744 | - | tb.mu.Lock() | |||
| 745 | - | wsz := tb.size | |||
| 746 | - | tb.mu.Unlock() | |||
| 747 | - | ||||
| 748 | - | outerResizeAccepted := false | |||
| 749 | - | if clientSize.Valid() && wsz != contentSize(clientSize, ac.proxied) { | |||
| 750 | - | if lease == nil { | |||
| 751 | - | outerResizeAccepted = d.resizeForFirstPaint(sess, ac, clientSize) | |||
| 752 | - | } else { | |||
| 753 | - | outerResizeAccepted = d.requestTransactionalResizeForLease(sess, ac, lease, clientSize, true) | |||
| 754 | - | } | |||
| 755 | - | } | |||
| 756 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | 744 | d.refreshBarScriptsIfDue(sess, d.clock.Now(), true) | ||
| 757 | - | // Activation can synchronously resize a retained floating pane. An accepted | 745 | + | // Activation still performs attachment-local warmup, but it must not turn a |
| 758 | - | // synchronous outer request already includes that pane and emits the reset, | 746 | + | // new window into a shared session resize. |
| 759 | - | // but activation still performs its warmup work. | 747 | + | d.ensureFloatingWarm(sess, tb) |
| 760 | - | activationResized := d.activateTabAfterResizeForLease(sess, tb, outerResizeAccepted, ac, lease) | 748 | + | if lease == nil { |
| 761 | - | if !outerResizeAccepted && !activationResized { | 749 | + | d.invalidateRenderNow(sess, ac, true, "client.go") |
| 762 | - | if lease == nil { | 750 | + | } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) { |
| 763 | - | d.invalidateRenderNow(sess, ac, true, "client.go") | 751 | + | rc.fireCurrentForLease(lease) |
| 764 | - | } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) { | |||
| 765 | - | rc.fireCurrentForLease(lease) | |||
| 766 | - | } | |||
| 767 | } | 752 | } | ||
| 768 | } | 753 | } | ||
| 769 | 754 | ||||
| @@ -817,6 +802,43 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||||
| 817 | d.requestTransactionalResize(sess, ac, sz, false) | 802 | d.requestTransactionalResize(sess, ac, sz, false) | ||
| 818 | } | 803 | } | ||
| 819 | 804 | ||||
| 805 | + | // resizeAttachmentForLease changes only the sender's window/view. Session tab | |||
| 806 | + | // geometry and PTY sizes are deliberately outside this mutation boundary. | |||
| 807 | + | // Callers hold the attachment effect admission; output state is rebased before | |||
| 808 | + | // the targeted redraw so every subsequent frame starts a new epoch. | |||
| 809 | + | func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool { | |||
| 810 | + | if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() { | |||
| 811 | + | return false | |||
| 812 | + | } | |||
| 813 | + | ac := token.ac | |||
| 814 | + | ac.sendMu.Lock() | |||
| 815 | + | if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) { | |||
| 816 | + | ac.sendMu.Unlock() | |||
| 817 | + | return false | |||
| 818 | + | } | |||
| 819 | + | if ac.size == size { | |||
| 820 | + | ac.sendMu.Unlock() | |||
| 821 | + | return false | |||
| 822 | + | } | |||
| 823 | + | ac.size = size | |||
| 824 | + | view := ac.viewSnapshot() | |||
| 825 | + | view.windowRows = size.Rows | |||
| 826 | + | view.windowSet = true | |||
| 827 | + | view.windowTop = 0 | |||
| 828 | + | view.revision++ | |||
| 829 | + | ac.publishView(view) | |||
| 830 | + | ac.rebaseOutput() | |||
| 831 | + | ac.pipelineCache = composeCacheInput{} | |||
| 832 | + | ac.pipelineScratch = composeCacheInput{} | |||
| 833 | + | ac.sendMu.Unlock() | |||
| 834 | + | ||||
| 835 | + | entry := token.sess | |||
| 836 | + | // Rendering is attachment-scoped. Run it independently of the connection | |||
| 837 | + | // reader so a blocked transport cannot hold session dispatch or peers. | |||
| 838 | + | go d.paint(entry, ac, true, token.lease) | |||
| 839 | + | return true | |||
| 840 | + | } | |||
| 841 | + | ||||
| 820 | // handleClientNotice maps the closed client-event enum to daemon-owned notice | 842 | // handleClientNotice maps the closed client-event enum to daemon-owned notice | ||
| 821 | // content. routingMu makes ownership validation and toast mutation one atomic | 843 | // content. routingMu makes ownership validation and toast mutation one atomic | ||
| 822 | // attachment-routing operation: attachment publication also takes routingMu | 844 | // attachment-routing operation: attachment publication also takes routingMu | ||
M internal/usecase/daemon/client_frame_routing.go +7 -12
| @@ -102,11 +102,7 @@ func (d *Daemon) handleAttachmentClientFrame(token attachmentConnectionToken, f | |||
| 102 | 102 | } | |
| 103 | 103 | case ports.MsgResize: | |
| 104 | 104 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil && token.attachmentEffectCurrent() { | |
| 105 | - | if sess, ok := localSession(token.sess); ok { | |
| 106 | - | d.requestTransactionalResizeForLease(sess, token.ac, token.lease, rz.Size, false) | |
| 107 | - | } else if proxy, ok := token.sess.(*proxySession); ok { | |
| 108 | - | d.resizeProxyForLease(proxy, token.ac, token.lease, rz.Size) | |
| 109 | - | } | |
| 105 | + | d.resizeAttachmentForLease(token, rz.Size) | |
| 110 | 106 | } | |
| 111 | 107 | case ports.MsgTheme: | |
| 112 | 108 | if th, derr := ports.UnmarshalTheme(f.Payload); derr == nil { | |
| @@ -246,21 +242,20 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool { | |||
| 246 | 242 | // transport revalidation under that lock rejects a link replaced while waiting. | |
| 247 | 243 | func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | |
| 248 | 244 | ac := token.ac | |
| 249 | - | if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() { | |
| 245 | + | if ac == nil || token.effect == nil || token.effect.ended.Load() { | |
| 250 | 246 | return false | |
| 251 | 247 | } | |
| 252 | 248 | ac.sendMu.Lock() | |
| 253 | - | if token.effect.ended.Load() || !ac.proxied || !token.attachmentCurrent() || ac.output == nil { | |
| 249 | + | if token.effect.ended.Load() || !token.attachmentCurrent() || ac.output == nil { | |
| 254 | 250 | ac.sendMu.Unlock() | |
| 255 | 251 | return false | |
| 256 | 252 | } | |
| 257 | 253 | ac.rebaseOutput() | |
| 254 | + | ac.pipelineCache = composeCacheInput{} | |
| 255 | + | ac.pipelineScratch = composeCacheInput{} | |
| 258 | 256 | ac.sendMu.Unlock() | |
| 259 | - | ||
| 260 | - | rc := token.sess.core().coordinator.Load() | |
| 261 | - | return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{ | |
| 262 | - | class: invalidateUrgent, reset: true, producer: "output-reset-request", | |
| 263 | - | }) | |
| 257 | + | go d.paint(token.sess, ac, true, token.lease) | |
| 258 | + | return true | |
| 264 | 259 | } | |
| 265 | 260 | ||
| 266 | 261 | func (d *Daemon) ackOutput(token attachmentConnectionToken, values ...uint64) bool { | |
| @@ -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 { | ||
| @@ -246,21 +242,20 @@ func proxyAttachedCommandOwnedRemotely(slug string) bool { | |||||
| 246 | // transport revalidation under that lock rejects a link replaced while waiting. | 242 | // transport revalidation under that lock rejects a link replaced while waiting. | ||
| 247 | func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | 243 | func (d *Daemon) resetOutput(token attachmentConnectionToken) bool { | ||
| 248 | ac := token.ac | 244 | ac := token.ac | ||
| 249 | - | if ac == nil || !ac.proxied || token.effect == nil || token.effect.ended.Load() { | 245 | + | if ac == nil || token.effect == nil || token.effect.ended.Load() { |
| 250 | return false | 246 | return false | ||
| 251 | } | 247 | } | ||
| 252 | ac.sendMu.Lock() | 248 | ac.sendMu.Lock() | ||
| 253 | - | if token.effect.ended.Load() || !ac.proxied || !token.attachmentCurrent() || ac.output == nil { | 249 | + | if token.effect.ended.Load() || !token.attachmentCurrent() || ac.output == nil { |
| 254 | ac.sendMu.Unlock() | 250 | ac.sendMu.Unlock() | ||
| 255 | return false | 251 | return false | ||
| 256 | } | 252 | } | ||
| 257 | ac.rebaseOutput() | 253 | ac.rebaseOutput() | ||
| 254 | + | ac.pipelineCache = composeCacheInput{} | |||
| 255 | + | ac.pipelineScratch = composeCacheInput{} | |||
| 258 | ac.sendMu.Unlock() | 256 | ac.sendMu.Unlock() | ||
| 259 | - | 257 | + | go d.paint(token.sess, ac, true, token.lease) | |
| 260 | - | rc := token.sess.core().coordinator.Load() | 258 | + | return true |
| 261 | - | return rc != nil && rc.invalidateForLease(ac, token.lease, renderInvalidation{ | |||
| 262 | - | class: invalidateUrgent, reset: true, producer: "output-reset-request", | |||
| 263 | - | }) | |||
| 264 | } | 259 | } | ||
| 265 | 260 | ||||
| 266 | func (d *Daemon) ackOutput(token attachmentConnectionToken, values ...uint64) bool { | 261 | func (d *Daemon) ackOutput(token attachmentConnectionToken, values ...uint64) bool { | ||
M internal/usecase/daemon/daemon.go +5 -4
| @@ -64,7 +64,8 @@ func normalizeOutputWindow(window uint8) uint8 { | |||
| 64 | 64 | ||
| 65 | 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 | 69 | var defaultSize = domain.Size{Cols: 80, Rows: 24} | |
| 69 | 70 | ||
| 70 | 71 | type Daemon struct { | |
| @@ -1266,9 +1267,6 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |||
| 1266 | 1267 | // the session for ephemeral/new intents. | |
| 1267 | 1268 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | |
| 1268 | 1269 | sz := h.Size | |
| 1269 | - | if !sz.Valid() { | |
| 1270 | - | sz = defaultSize | |
| 1271 | - | } | |
| 1272 | 1270 | term := terminalEnv{TrueColor: h.TrueColor} | |
| 1273 | 1271 | ||
| 1274 | 1272 | // A non-zero token is an authoritative resume credential. If it is unknown, | |
| @@ -1317,6 +1315,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 1317 | 1315 | return nil, nil, err | |
| 1318 | 1316 | } | |
| 1319 | 1317 | } | |
| 1318 | + | if !sz.Valid() { | |
| 1319 | + | return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"} | |
| 1320 | + | } | |
| 1320 | 1321 | ||
| 1321 | 1322 | d.mu.Lock() | |
| 1322 | 1323 | // Shutdown/create interlock: once shutdown has begun (last session removed, | |
| @@ -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 { | ||
| @@ -1266,9 +1267,6 @@ func (d *Daemon) waitForTargetRestore(ctx context.Context, name string) error { | |||||
| 1266 | // the session for ephemeral/new intents. | 1267 | // the session for ephemeral/new intents. | ||
| 1267 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | 1268 | func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedClient, error) { | ||
| 1268 | sz := h.Size | 1269 | sz := h.Size | ||
| 1269 | - | if !sz.Valid() { | |||
| 1270 | - | sz = defaultSize | |||
| 1271 | - | } | |||
| 1272 | term := terminalEnv{TrueColor: h.TrueColor} | 1270 | term := terminalEnv{TrueColor: h.TrueColor} | ||
| 1273 | 1271 | ||||
| 1274 | // A non-zero token is an authoritative resume credential. If it is unknown, | 1272 | // A non-zero token is an authoritative resume credential. If it is unknown, | ||
| @@ -1317,6 +1315,9 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 1317 | return nil, nil, err | 1315 | return nil, nil, err | ||
| 1318 | } | 1316 | } | ||
| 1319 | } | 1317 | } | ||
| 1318 | + | if !sz.Valid() { | |||
| 1319 | + | return nil, nil, &protoErr{ports.ErrInternal, "invalid terminal size"} | |||
| 1320 | + | } | |||
| 1320 | 1321 | ||||
| 1321 | d.mu.Lock() | 1322 | d.mu.Lock() | ||
| 1322 | // Shutdown/create interlock: once shutdown has begun (last session removed, | 1323 | // Shutdown/create interlock: once shutdown has begun (last session removed, | ||
M internal/usecase/daemon/render.go +20 -9
| @@ -479,15 +479,23 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |||
| 479 | 479 | } | |
| 480 | 480 | ||
| 481 | 481 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) { | |
| 482 | - | rows := min(r.Height, src.Height) | |
| 483 | - | cols := min(r.Width, src.Width) | |
| 484 | - | for y := range rows { | |
| 485 | - | for x := range cols { | |
| 486 | - | cell := src.At(x, y) | |
| 482 | + | startX, startY := max(r.X, 0), max(r.Y, 0) | |
| 483 | + | endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height) | |
| 484 | + | for y := startY; y < endY; y++ { | |
| 485 | + | sy := y - r.Y | |
| 486 | + | if sy < 0 || sy >= src.Height { | |
| 487 | + | continue | |
| 488 | + | } | |
| 489 | + | for x := startX; x < endX; x++ { | |
| 490 | + | sx := x - r.X | |
| 491 | + | if sx < 0 || sx >= src.Width { | |
| 492 | + | continue | |
| 493 | + | } | |
| 494 | + | cell := src.At(sx, sy) | |
| 487 | 495 | if dim { | |
| 488 | 496 | cell.Style = dimmer.Dim(cell.Style) | |
| 489 | 497 | } | |
| 490 | - | dst.Set(r.X+x, r.Y+y, cell) | |
| 498 | + | dst.Set(x, y, cell) | |
| 491 | 499 | } | |
| 492 | 500 | } | |
| 493 | 501 | } | |
| @@ -502,9 +510,12 @@ func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style | |||
| 502 | 510 | if d.Dir == layout.Vertical { | |
| 503 | 511 | glyph = '─' | |
| 504 | 512 | } | |
| 505 | - | y0 := d.Rect.Y + dy | |
| 506 | - | for y := y0; y < y0+d.Rect.Height; y++ { | |
| 507 | - | for x := d.Rect.X; x < d.Rect.X+d.Rect.Width; x++ { | |
| 513 | + | y0 := max(d.Rect.Y+dy, 0) | |
| 514 | + | y1 := min(d.Rect.Y+dy+d.Rect.Height, frame.Height) | |
| 515 | + | x0 := max(d.Rect.X, 0) | |
| 516 | + | x1 := min(d.Rect.X+d.Rect.Width, frame.Width) | |
| 517 | + | for y := y0; y < y1; y++ { | |
| 518 | + | for x := x0; x < x1; x++ { | |
| 508 | 519 | frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style}) | |
| 509 | 520 | } | |
| 510 | 521 | } | |
| @@ -479,15 +479,23 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement { | |||||
| 479 | } | 479 | } | ||
| 480 | 480 | ||||
| 481 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) { | 481 | func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) { | ||
| 482 | - | rows := min(r.Height, src.Height) | 482 | + | startX, startY := max(r.X, 0), max(r.Y, 0) |
| 483 | - | cols := min(r.Width, src.Width) | 483 | + | endX, endY := min(r.X+r.Width, dst.Width), min(r.Y+r.Height, dst.Height) |
| 484 | - | for y := range rows { | 484 | + | for y := startY; y < endY; y++ { |
| 485 | - | for x := range cols { | 485 | + | sy := y - r.Y |
| 486 | - | cell := src.At(x, y) | 486 | + | if sy < 0 || sy >= src.Height { |
| 487 | + | continue | |||
| 488 | + | } | |||
| 489 | + | for x := startX; x < endX; x++ { | |||
| 490 | + | sx := x - r.X | |||
| 491 | + | if sx < 0 || sx >= src.Width { | |||
| 492 | + | continue | |||
| 493 | + | } | |||
| 494 | + | cell := src.At(sx, sy) | |||
| 487 | if dim { | 495 | if dim { | ||
| 488 | cell.Style = dimmer.Dim(cell.Style) | 496 | cell.Style = dimmer.Dim(cell.Style) | ||
| 489 | } | 497 | } | ||
| 490 | - | dst.Set(r.X+x, r.Y+y, cell) | 498 | + | dst.Set(x, y, cell) |
| 491 | } | 499 | } | ||
| 492 | } | 500 | } | ||
| 493 | } | 501 | } | ||
| @@ -502,9 +510,12 @@ func drawDividers(frame renderer.Frame, dividers []layout.Divider, dy int, style | |||||
| 502 | if d.Dir == layout.Vertical { | 510 | if d.Dir == layout.Vertical { | ||
| 503 | glyph = '─' | 511 | glyph = '─' | ||
| 504 | } | 512 | } | ||
| 505 | - | y0 := d.Rect.Y + dy | 513 | + | y0 := max(d.Rect.Y+dy, 0) |
| 506 | - | for y := y0; y < y0+d.Rect.Height; y++ { | 514 | + | 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++ { | 515 | + | x0 := max(d.Rect.X, 0) |
| 516 | + | x1 := min(d.Rect.X+d.Rect.Width, frame.Width) | |||
| 517 | + | for y := y0; y < y1; y++ { | |||
| 518 | + | for x := x0; x < x1; x++ { | |||
| 508 | frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style}) | 519 | frame.Set(x, y, renderer.Cell{Rune: glyph, Style: style}) | ||
| 509 | } | 520 | } | ||
| 510 | } | 521 | } | ||
M internal/usecase/daemon/render_capture.go +9 -1
| @@ -44,6 +44,8 @@ type damageReceipt struct { | |||
| 44 | 44 | type capturedRenderState struct { | |
| 45 | 45 | attachment *attachedClient // identity only; never dereferenced by composition | |
| 46 | 46 | lease *attachmentLease | |
| 47 | + | view attachmentView | |
| 48 | + | window domain.Size | |
| 47 | 49 | reset bool | |
| 48 | 50 | contentOnly bool | |
| 49 | 51 | layout capturedTabLayout | |
| @@ -266,8 +268,14 @@ func captureLocalRenderState( | |||
| 266 | 268 | layoutSnap.dividers = scratch.dividers | |
| 267 | 269 | } | |
| 268 | 270 | state := &scratch.state | |
| 271 | + | view := ac.viewSnapshot() | |
| 272 | + | window := domain.Size{} | |
| 273 | + | if view.windowSet { | |
| 274 | + | window = ac.size | |
| 275 | + | } | |
| 269 | 276 | *state = capturedRenderState{ | |
| 270 | - | attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme, | |
| 277 | + | attachment: ac, lease: lease, view: view, window: window, | |
| 278 | + | reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme, | |
| 271 | 279 | styles: request.styles, styleGeneration: request.styleGeneration, | |
| 272 | 280 | overlays: overlays, preview: preview, | |
| 273 | 281 | layout: capturedTabLayout{area: layoutSnap.area, focus: layoutSnap.focus, placements: scratch.placements, dividers: scratch.dividers, fingerprint: layoutSnap.fingerprint, valid: layoutSnap.ok}, | |
| @@ -44,6 +44,8 @@ type damageReceipt struct { | |||||
| 44 | type capturedRenderState struct { | 44 | type capturedRenderState struct { | ||
| 45 | attachment *attachedClient // identity only; never dereferenced by composition | 45 | attachment *attachedClient // identity only; never dereferenced by composition | ||
| 46 | lease *attachmentLease | 46 | lease *attachmentLease | ||
| 47 | + | view attachmentView | |||
| 48 | + | window domain.Size | |||
| 47 | reset bool | 49 | reset bool | ||
| 48 | contentOnly bool | 50 | contentOnly bool | ||
| 49 | layout capturedTabLayout | 51 | layout capturedTabLayout | ||
| @@ -266,8 +268,14 @@ func captureLocalRenderState( | |||||
| 266 | layoutSnap.dividers = scratch.dividers | 268 | layoutSnap.dividers = scratch.dividers | ||
| 267 | } | 269 | } | ||
| 268 | state := &scratch.state | 270 | state := &scratch.state | ||
| 271 | + | view := ac.viewSnapshot() | |||
| 272 | + | window := domain.Size{} | |||
| 273 | + | if view.windowSet { | |||
| 274 | + | window = ac.size | |||
| 275 | + | } | |||
| 269 | *state = capturedRenderState{ | 276 | *state = capturedRenderState{ | ||
| 270 | - | attachment: ac, lease: lease, reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme, | 277 | + | attachment: ac, lease: lease, view: view, window: window, |
| 278 | + | reset: reset, contentOnly: ac.proxied, bars: bars, theme: bars.theme, | |||
| 271 | styles: request.styles, styleGeneration: request.styleGeneration, | 279 | styles: request.styles, styleGeneration: request.styleGeneration, | ||
| 272 | overlays: overlays, preview: preview, | 280 | 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}, | 281 | 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_pipeline.go +9 -2
| @@ -68,6 +68,10 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 68 | 68 | scratch = scratchIn[0] | |
| 69 | 69 | } | |
| 70 | 70 | width, rows := state.layout.area.Width, state.layout.area.Height | |
| 71 | + | if state.window.Valid() { | |
| 72 | + | window := contentSize(state.window, state.contentOnly) | |
| 73 | + | width, rows = window.Cols, window.Rows | |
| 74 | + | } | |
| 71 | 75 | if width <= 0 || rows < 0 { | |
| 72 | 76 | return composedRenderFrame{frame: renderer.NewFrame(0, 0), cursor: cursorOut{hidden: true}, reset: state.reset} | |
| 73 | 77 | } | |
| @@ -197,14 +201,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||
| 197 | 201 | } | |
| 198 | 202 | ||
| 199 | 203 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | |
| 204 | + | if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height { | |
| 205 | + | return | |
| 206 | + | } | |
| 200 | 207 | style := styles.StatusBar | |
| 201 | 208 | if !focused { | |
| 202 | 209 | style = dimmer.Dim(neutralBorder) | |
| 203 | 210 | } | |
| 204 | - | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | |
| 211 | + | for x := max(pl.TitleBar.X, 0); x < min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width); x++ { | |
| 205 | 212 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | |
| 206 | 213 | } | |
| 207 | - | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | |
| 214 | + | ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style) | |
| 208 | 215 | } | |
| 209 | 216 | ||
| 210 | 217 | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | |
| @@ -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, state.contentOnly) | |||
| 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 | } | ||
| @@ -197,14 +201,17 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ... | |||||
| 197 | } | 201 | } | ||
| 198 | 202 | ||||
| 199 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | 203 | func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeui.Styles, neutralBorder renderer.Style, dimmer themeui.Dimmer) { | ||
| 204 | + | if pl.TitleBar.Y < 0 || pl.TitleBar.Y >= frame.Height { | |||
| 205 | + | return | |||
| 206 | + | } | |||
| 200 | style := styles.StatusBar | 207 | style := styles.StatusBar | ||
| 201 | if !focused { | 208 | if !focused { | ||
| 202 | style = dimmer.Dim(neutralBorder) | 209 | style = dimmer.Dim(neutralBorder) | ||
| 203 | } | 210 | } | ||
| 204 | - | for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ { | 211 | + | 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}) | 212 | frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style}) | ||
| 206 | } | 213 | } | ||
| 207 | - | ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style) | 214 | + | ui.DrawText(frame, max(pl.TitleBar.X, 0), pl.TitleBar.Y, min(pl.TitleBar.X+pl.TitleBar.Width, frame.Width), title, style) |
| 208 | } | 215 | } | ||
| 209 | 216 | ||||
| 210 | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | 217 | func desiredCapturedCursor(c capturedCursorInputs, contentY int) cursorOut { | ||