fix(client): prevent late terminal input after stop
- commit
- f5d748a777a867e99f9e3b677632d6d0896ffc25
- author
- brice · · 2026-07-18 11:08 UTC
- committer
- brice · · 2026-07-18 11:13 UTC
- parent
- a18957d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +189 -13
M internal/usecase/client/client.go +124 -13
| @@ -167,6 +167,9 @@ type Runner struct { | |||
| 167 | 167 | clipboard ports.ClipboardReader | |
| 168 | 168 | logger *slog.Logger | |
| 169 | 169 | runtimeObserver ports.SerializedRuntimeObserver | |
| 170 | + | ||
| 171 | + | inputMu sync.Mutex | |
| 172 | + | input *terminalInputPump | |
| 170 | 173 | } | |
| 171 | 174 | ||
| 172 | 175 | // NewRunner constructs a client runner. A nil logger uses the process default. | |
| @@ -185,6 +188,21 @@ func NewRunner(deps Dependencies) *Runner { | |||
| 185 | 188 | } | |
| 186 | 189 | } | |
| 187 | 190 | ||
| 191 | + | // terminalInput returns the Runner-owned sole reader. It survives sequential | |
| 192 | + | // Run calls because a caller-owned reader may remain blocked after an attach | |
| 193 | + | // exits and therefore cannot safely be replaced by another reader. | |
| 194 | + | func (r *Runner) terminalInput() *terminalInputPump { | |
| 195 | + | r.inputMu.Lock() | |
| 196 | + | defer r.inputMu.Unlock() | |
| 197 | + | ||
| 198 | + | if r.input == nil || r.input.hasExited() || r.input.isClosed() { | |
| 199 | + | r.input = newTerminalInputPump(r.term.In()) | |
| 200 | + | r.input.start() | |
| 201 | + | } | |
| 202 | + | r.input.resume() | |
| 203 | + | return r.input | |
| 204 | + | } | |
| 205 | + | ||
| 188 | 206 | // Run connects and runs the attach client. It owns the terminal lifecycle | |
| 189 | 207 | // above attach attempts so raw mode remains active while a live client process | |
| 190 | 208 | // redials a lost link. | |
| @@ -233,7 +251,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 233 | 251 | var input *terminalInputPump | |
| 234 | 252 | defer func() { | |
| 235 | 253 | if input != nil { | |
| 236 | - | input.stop() | |
| 254 | + | input.suspend() | |
| 237 | 255 | } | |
| 238 | 256 | }() | |
| 239 | 257 | originalEnterRaw := enterRaw | |
| @@ -242,8 +260,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||
| 242 | 260 | return err | |
| 243 | 261 | } | |
| 244 | 262 | if input == nil { | |
| 245 | - | input = newTerminalInputPump(r.term.In()) | |
| 246 | - | input.start() | |
| 263 | + | input = r.terminalInput() | |
| 247 | 264 | } | |
| 248 | 265 | return nil | |
| 249 | 266 | } | |
| @@ -956,9 +973,9 @@ type terminalReadResult struct { | |||
| 956 | 973 | ||
| 957 | 974 | // terminalInputPump is the sole consumer of the caller-owned terminal reader. | |
| 958 | 975 | // It is started once after raw mode is entered and is reused by each attach | |
| 959 | - | // scanner. A bare io.Reader cannot be interrupted, so one final Read may stay | |
| 960 | - | // blocked after Run exits; stop drops its result and never closes caller-owned | |
| 961 | - | // stdin. Reconnects never add another reader. | |
| 976 | + | // scanner. A bare io.Reader cannot be interrupted, so a Read may stay blocked | |
| 977 | + | // after Run exits; suspend drops any result received while inactive and never | |
| 978 | + | // closes caller-owned stdin. Reconnects and later runs never add another reader. | |
| 962 | 979 | type terminalInputPump struct { | |
| 963 | 980 | in io.Reader | |
| 964 | 981 | done chan struct{} | |
| @@ -969,8 +986,11 @@ type terminalInputPump struct { | |||
| 969 | 986 | delivering uint64 | |
| 970 | 987 | nextID uint64 | |
| 971 | 988 | closed bool | |
| 989 | + | active bool | |
| 972 | 990 | ready chan struct{} | |
| 973 | 991 | space chan struct{} | |
| 992 | + | state chan struct{} | |
| 993 | + | exited chan struct{} | |
| 974 | 994 | startMu sync.Once | |
| 975 | 995 | stopMu sync.Once | |
| 976 | 996 | } | |
| @@ -979,10 +999,13 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||
| 979 | 999 | space := make(chan struct{}, 1) | |
| 980 | 1000 | space <- struct{}{} | |
| 981 | 1001 | return &terminalInputPump{ | |
| 982 | - | in: in, | |
| 983 | - | done: make(chan struct{}), | |
| 984 | - | ready: make(chan struct{}, 1), | |
| 985 | - | space: space, | |
| 1002 | + | in: in, | |
| 1003 | + | done: make(chan struct{}), | |
| 1004 | + | active: true, | |
| 1005 | + | ready: make(chan struct{}, 1), | |
| 1006 | + | space: space, | |
| 1007 | + | state: make(chan struct{}, 1), | |
| 1008 | + | exited: make(chan struct{}), | |
| 986 | 1009 | } | |
| 987 | 1010 | } | |
| 988 | 1011 | ||
| @@ -1031,7 +1054,7 @@ func (p *terminalInputPump) enqueue(result terminalReadResult) bool { | |||
| 1031 | 1054 | case <-p.space: | |
| 1032 | 1055 | } | |
| 1033 | 1056 | p.mu.Lock() | |
| 1034 | - | if p.closed { | |
| 1057 | + | if p.closed || !p.active { | |
| 1035 | 1058 | p.mu.Unlock() | |
| 1036 | 1059 | p.space <- struct{}{} | |
| 1037 | 1060 | return false | |
| @@ -1080,20 +1103,54 @@ func (p *terminalInputPump) finish() { | |||
| 1080 | 1103 | p.closed = true | |
| 1081 | 1104 | p.mu.Unlock() | |
| 1082 | 1105 | p.signalReady() | |
| 1106 | + | p.signalState() | |
| 1107 | + | } | |
| 1108 | + | ||
| 1109 | + | func (p *terminalInputPump) signalState() { | |
| 1110 | + | select { | |
| 1111 | + | case p.state <- struct{}{}: | |
| 1112 | + | default: | |
| 1113 | + | } | |
| 1114 | + | } | |
| 1115 | + | ||
| 1116 | + | func (p *terminalInputPump) waitForActive() bool { | |
| 1117 | + | for { | |
| 1118 | + | p.mu.Lock() | |
| 1119 | + | active, closed := p.active, p.closed | |
| 1120 | + | p.mu.Unlock() | |
| 1121 | + | if closed { | |
| 1122 | + | return false | |
| 1123 | + | } | |
| 1124 | + | if active { | |
| 1125 | + | return true | |
| 1126 | + | } | |
| 1127 | + | select { | |
| 1128 | + | case <-p.done: | |
| 1129 | + | return false | |
| 1130 | + | case <-p.state: | |
| 1131 | + | } | |
| 1132 | + | } | |
| 1083 | 1133 | } | |
| 1084 | 1134 | ||
| 1085 | 1135 | func (p *terminalInputPump) start() { | |
| 1086 | 1136 | p.startMu.Do(func() { | |
| 1087 | 1137 | go func() { | |
| 1138 | + | defer close(p.exited) | |
| 1088 | 1139 | buf := make([]byte, stdinBufSize) | |
| 1089 | 1140 | for { | |
| 1141 | + | if !p.waitForActive() { | |
| 1142 | + | return | |
| 1143 | + | } | |
| 1090 | 1144 | n, err := p.in.Read(buf) | |
| 1091 | 1145 | result := terminalReadResult{err: err} | |
| 1092 | 1146 | if n > 0 { | |
| 1093 | 1147 | result.data = append([]byte(nil), buf[:n]...) | |
| 1094 | 1148 | } | |
| 1095 | 1149 | if !p.enqueue(result) { | |
| 1096 | - | return | |
| 1150 | + | if p.isClosed() { | |
| 1151 | + | return | |
| 1152 | + | } | |
| 1153 | + | continue | |
| 1097 | 1154 | } | |
| 1098 | 1155 | if err != nil { | |
| 1099 | 1156 | p.finish() | |
| @@ -1104,8 +1161,62 @@ func (p *terminalInputPump) start() { | |||
| 1104 | 1161 | }) | |
| 1105 | 1162 | } | |
| 1106 | 1163 | ||
| 1164 | + | func (p *terminalInputPump) hasExited() bool { | |
| 1165 | + | select { | |
| 1166 | + | case <-p.exited: | |
| 1167 | + | return true | |
| 1168 | + | default: | |
| 1169 | + | return false | |
| 1170 | + | } | |
| 1171 | + | } | |
| 1172 | + | ||
| 1173 | + | func (p *terminalInputPump) isClosed() bool { | |
| 1174 | + | p.mu.Lock() | |
| 1175 | + | defer p.mu.Unlock() | |
| 1176 | + | return p.closed | |
| 1177 | + | } | |
| 1178 | + | ||
| 1179 | + | // suspend drops input completed after an attach ends and parks the lifecycle | |
| 1180 | + | // reader before its next Read. A later Run resumes this same reader, avoiding | |
| 1181 | + | // both input replay between runs and a competing read on caller-owned stdin. | |
| 1182 | + | func (p *terminalInputPump) suspend() { | |
| 1183 | + | p.mu.Lock() | |
| 1184 | + | p.active = false | |
| 1185 | + | discarded := p.pending != nil | |
| 1186 | + | p.pending = nil | |
| 1187 | + | p.delivering = 0 | |
| 1188 | + | p.mu.Unlock() | |
| 1189 | + | if discarded { | |
| 1190 | + | select { | |
| 1191 | + | case p.space <- struct{}{}: | |
| 1192 | + | default: | |
| 1193 | + | } | |
| 1194 | + | } | |
| 1195 | + | p.signalState() | |
| 1196 | + | p.signalReady() | |
| 1197 | + | } | |
| 1198 | + | ||
| 1199 | + | func (p *terminalInputPump) resume() { | |
| 1200 | + | p.mu.Lock() | |
| 1201 | + | if !p.closed { | |
| 1202 | + | p.active = true | |
| 1203 | + | } | |
| 1204 | + | p.mu.Unlock() | |
| 1205 | + | p.signalState() | |
| 1206 | + | } | |
| 1207 | + | ||
| 1107 | 1208 | func (p *terminalInputPump) stop() { | |
| 1108 | - | p.stopMu.Do(func() { close(p.done) }) | |
| 1209 | + | p.stopMu.Do(func() { | |
| 1210 | + | // Mark closure while holding the same mutex that publishes a completed | |
| 1211 | + | // Read. A Read which returns after stop therefore cannot publish even | |
| 1212 | + | // when both done and space are selectable in enqueue. | |
| 1213 | + | p.mu.Lock() | |
| 1214 | + | p.closed = true | |
| 1215 | + | p.mu.Unlock() | |
| 1216 | + | close(p.done) | |
| 1217 | + | p.signalReady() | |
| 1218 | + | p.signalState() | |
| 1219 | + | }) | |
| 1109 | 1220 | } | |
| 1110 | 1221 | ||
| 1111 | 1222 | // stdinPump parses terminal color reports into generation-tagged events while | |
| @@ -167,6 +167,9 @@ type Runner struct { | |||||
| 167 | clipboard ports.ClipboardReader | 167 | clipboard ports.ClipboardReader | ||
| 168 | logger *slog.Logger | 168 | logger *slog.Logger | ||
| 169 | runtimeObserver ports.SerializedRuntimeObserver | 169 | runtimeObserver ports.SerializedRuntimeObserver | ||
| 170 | + | ||||
| 171 | + | inputMu sync.Mutex | |||
| 172 | + | input *terminalInputPump | |||
| 170 | } | 173 | } | ||
| 171 | 174 | ||||
| 172 | // NewRunner constructs a client runner. A nil logger uses the process default. | 175 | // NewRunner constructs a client runner. A nil logger uses the process default. | ||
| @@ -185,6 +188,21 @@ func NewRunner(deps Dependencies) *Runner { | |||||
| 185 | } | 188 | } | ||
| 186 | } | 189 | } | ||
| 187 | 190 | ||||
| 191 | + | // terminalInput returns the Runner-owned sole reader. It survives sequential | |||
| 192 | + | // Run calls because a caller-owned reader may remain blocked after an attach | |||
| 193 | + | // exits and therefore cannot safely be replaced by another reader. | |||
| 194 | + | func (r *Runner) terminalInput() *terminalInputPump { | |||
| 195 | + | r.inputMu.Lock() | |||
| 196 | + | defer r.inputMu.Unlock() | |||
| 197 | + | ||||
| 198 | + | if r.input == nil || r.input.hasExited() || r.input.isClosed() { | |||
| 199 | + | r.input = newTerminalInputPump(r.term.In()) | |||
| 200 | + | r.input.start() | |||
| 201 | + | } | |||
| 202 | + | r.input.resume() | |||
| 203 | + | return r.input | |||
| 204 | + | } | |||
| 205 | + | ||||
| 188 | // Run connects and runs the attach client. It owns the terminal lifecycle | 206 | // Run connects and runs the attach client. It owns the terminal lifecycle | ||
| 189 | // above attach attempts so raw mode remains active while a live client process | 207 | // above attach attempts so raw mode remains active while a live client process | ||
| 190 | // redials a lost link. | 208 | // redials a lost link. | ||
| @@ -233,7 +251,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 233 | var input *terminalInputPump | 251 | var input *terminalInputPump | ||
| 234 | defer func() { | 252 | defer func() { | ||
| 235 | if input != nil { | 253 | if input != nil { | ||
| 236 | - | input.stop() | 254 | + | input.suspend() |
| 237 | } | 255 | } | ||
| 238 | }() | 256 | }() | ||
| 239 | originalEnterRaw := enterRaw | 257 | originalEnterRaw := enterRaw | ||
| @@ -242,8 +260,7 @@ func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) | |||||
| 242 | return err | 260 | return err | ||
| 243 | } | 261 | } | ||
| 244 | if input == nil { | 262 | if input == nil { | ||
| 245 | - | input = newTerminalInputPump(r.term.In()) | 263 | + | input = r.terminalInput() |
| 246 | - | input.start() | |||
| 247 | } | 264 | } | ||
| 248 | return nil | 265 | return nil | ||
| 249 | } | 266 | } | ||
| @@ -956,9 +973,9 @@ type terminalReadResult struct { | |||||
| 956 | 973 | ||||
| 957 | // terminalInputPump is the sole consumer of the caller-owned terminal reader. | 974 | // terminalInputPump is the sole consumer of the caller-owned terminal reader. | ||
| 958 | // It is started once after raw mode is entered and is reused by each attach | 975 | // It is started once after raw mode is entered and is reused by each attach | ||
| 959 | - | // scanner. A bare io.Reader cannot be interrupted, so one final Read may stay | 976 | + | // scanner. A bare io.Reader cannot be interrupted, so a Read may stay blocked |
| 960 | - | // blocked after Run exits; stop drops its result and never closes caller-owned | 977 | + | // after Run exits; suspend drops any result received while inactive and never |
| 961 | - | // stdin. Reconnects never add another reader. | 978 | + | // closes caller-owned stdin. Reconnects and later runs never add another reader. |
| 962 | type terminalInputPump struct { | 979 | type terminalInputPump struct { | ||
| 963 | in io.Reader | 980 | in io.Reader | ||
| 964 | done chan struct{} | 981 | done chan struct{} | ||
| @@ -969,8 +986,11 @@ type terminalInputPump struct { | |||||
| 969 | delivering uint64 | 986 | delivering uint64 | ||
| 970 | nextID uint64 | 987 | nextID uint64 | ||
| 971 | closed bool | 988 | closed bool | ||
| 989 | + | active bool | |||
| 972 | ready chan struct{} | 990 | ready chan struct{} | ||
| 973 | space chan struct{} | 991 | space chan struct{} | ||
| 992 | + | state chan struct{} | |||
| 993 | + | exited chan struct{} | |||
| 974 | startMu sync.Once | 994 | startMu sync.Once | ||
| 975 | stopMu sync.Once | 995 | stopMu sync.Once | ||
| 976 | } | 996 | } | ||
| @@ -979,10 +999,13 @@ func newTerminalInputPump(in io.Reader) *terminalInputPump { | |||||
| 979 | space := make(chan struct{}, 1) | 999 | space := make(chan struct{}, 1) | ||
| 980 | space <- struct{}{} | 1000 | space <- struct{}{} | ||
| 981 | return &terminalInputPump{ | 1001 | return &terminalInputPump{ | ||
| 982 | - | in: in, | 1002 | + | in: in, |
| 983 | - | done: make(chan struct{}), | 1003 | + | done: make(chan struct{}), |
| 984 | - | ready: make(chan struct{}, 1), | 1004 | + | active: true, |
| 985 | - | space: space, | 1005 | + | ready: make(chan struct{}, 1), |
| 1006 | + | space: space, | |||
| 1007 | + | state: make(chan struct{}, 1), | |||
| 1008 | + | exited: make(chan struct{}), | |||
| 986 | } | 1009 | } | ||
| 987 | } | 1010 | } | ||
| 988 | 1011 | ||||
| @@ -1031,7 +1054,7 @@ func (p *terminalInputPump) enqueue(result terminalReadResult) bool { | |||||
| 1031 | case <-p.space: | 1054 | case <-p.space: | ||
| 1032 | } | 1055 | } | ||
| 1033 | p.mu.Lock() | 1056 | p.mu.Lock() | ||
| 1034 | - | if p.closed { | 1057 | + | if p.closed || !p.active { |
| 1035 | p.mu.Unlock() | 1058 | p.mu.Unlock() | ||
| 1036 | p.space <- struct{}{} | 1059 | p.space <- struct{}{} | ||
| 1037 | return false | 1060 | return false | ||
| @@ -1080,20 +1103,54 @@ func (p *terminalInputPump) finish() { | |||||
| 1080 | p.closed = true | 1103 | p.closed = true | ||
| 1081 | p.mu.Unlock() | 1104 | p.mu.Unlock() | ||
| 1082 | p.signalReady() | 1105 | p.signalReady() | ||
| 1106 | + | p.signalState() | |||
| 1107 | + | } | |||
| 1108 | + | ||||
| 1109 | + | func (p *terminalInputPump) signalState() { | |||
| 1110 | + | select { | |||
| 1111 | + | case p.state <- struct{}{}: | |||
| 1112 | + | default: | |||
| 1113 | + | } | |||
| 1114 | + | } | |||
| 1115 | + | ||||
| 1116 | + | func (p *terminalInputPump) waitForActive() bool { | |||
| 1117 | + | for { | |||
| 1118 | + | p.mu.Lock() | |||
| 1119 | + | active, closed := p.active, p.closed | |||
| 1120 | + | p.mu.Unlock() | |||
| 1121 | + | if closed { | |||
| 1122 | + | return false | |||
| 1123 | + | } | |||
| 1124 | + | if active { | |||
| 1125 | + | return true | |||
| 1126 | + | } | |||
| 1127 | + | select { | |||
| 1128 | + | case <-p.done: | |||
| 1129 | + | return false | |||
| 1130 | + | case <-p.state: | |||
| 1131 | + | } | |||
| 1132 | + | } | |||
| 1083 | } | 1133 | } | ||
| 1084 | 1134 | ||||
| 1085 | func (p *terminalInputPump) start() { | 1135 | func (p *terminalInputPump) start() { | ||
| 1086 | p.startMu.Do(func() { | 1136 | p.startMu.Do(func() { | ||
| 1087 | go func() { | 1137 | go func() { | ||
| 1138 | + | defer close(p.exited) | |||
| 1088 | buf := make([]byte, stdinBufSize) | 1139 | buf := make([]byte, stdinBufSize) | ||
| 1089 | for { | 1140 | for { | ||
| 1141 | + | if !p.waitForActive() { | |||
| 1142 | + | return | |||
| 1143 | + | } | |||
| 1090 | n, err := p.in.Read(buf) | 1144 | n, err := p.in.Read(buf) | ||
| 1091 | result := terminalReadResult{err: err} | 1145 | result := terminalReadResult{err: err} | ||
| 1092 | if n > 0 { | 1146 | if n > 0 { | ||
| 1093 | result.data = append([]byte(nil), buf[:n]...) | 1147 | result.data = append([]byte(nil), buf[:n]...) | ||
| 1094 | } | 1148 | } | ||
| 1095 | if !p.enqueue(result) { | 1149 | if !p.enqueue(result) { | ||
| 1096 | - | return | 1150 | + | if p.isClosed() { |
| 1151 | + | return | |||
| 1152 | + | } | |||
| 1153 | + | continue | |||
| 1097 | } | 1154 | } | ||
| 1098 | if err != nil { | 1155 | if err != nil { | ||
| 1099 | p.finish() | 1156 | p.finish() | ||
| @@ -1104,8 +1161,62 @@ func (p *terminalInputPump) start() { | |||||
| 1104 | }) | 1161 | }) | ||
| 1105 | } | 1162 | } | ||
| 1106 | 1163 | ||||
| 1164 | + | func (p *terminalInputPump) hasExited() bool { | |||
| 1165 | + | select { | |||
| 1166 | + | case <-p.exited: | |||
| 1167 | + | return true | |||
| 1168 | + | default: | |||
| 1169 | + | return false | |||
| 1170 | + | } | |||
| 1171 | + | } | |||
| 1172 | + | ||||
| 1173 | + | func (p *terminalInputPump) isClosed() bool { | |||
| 1174 | + | p.mu.Lock() | |||
| 1175 | + | defer p.mu.Unlock() | |||
| 1176 | + | return p.closed | |||
| 1177 | + | } | |||
| 1178 | + | ||||
| 1179 | + | // suspend drops input completed after an attach ends and parks the lifecycle | |||
| 1180 | + | // reader before its next Read. A later Run resumes this same reader, avoiding | |||
| 1181 | + | // both input replay between runs and a competing read on caller-owned stdin. | |||
| 1182 | + | func (p *terminalInputPump) suspend() { | |||
| 1183 | + | p.mu.Lock() | |||
| 1184 | + | p.active = false | |||
| 1185 | + | discarded := p.pending != nil | |||
| 1186 | + | p.pending = nil | |||
| 1187 | + | p.delivering = 0 | |||
| 1188 | + | p.mu.Unlock() | |||
| 1189 | + | if discarded { | |||
| 1190 | + | select { | |||
| 1191 | + | case p.space <- struct{}{}: | |||
| 1192 | + | default: | |||
| 1193 | + | } | |||
| 1194 | + | } | |||
| 1195 | + | p.signalState() | |||
| 1196 | + | p.signalReady() | |||
| 1197 | + | } | |||
| 1198 | + | ||||
| 1199 | + | func (p *terminalInputPump) resume() { | |||
| 1200 | + | p.mu.Lock() | |||
| 1201 | + | if !p.closed { | |||
| 1202 | + | p.active = true | |||
| 1203 | + | } | |||
| 1204 | + | p.mu.Unlock() | |||
| 1205 | + | p.signalState() | |||
| 1206 | + | } | |||
| 1207 | + | ||||
| 1107 | func (p *terminalInputPump) stop() { | 1208 | func (p *terminalInputPump) stop() { | ||
| 1108 | - | p.stopMu.Do(func() { close(p.done) }) | 1209 | + | p.stopMu.Do(func() { |
| 1210 | + | // Mark closure while holding the same mutex that publishes a completed | |||
| 1211 | + | // Read. A Read which returns after stop therefore cannot publish even | |||
| 1212 | + | // when both done and space are selectable in enqueue. | |||
| 1213 | + | p.mu.Lock() | |||
| 1214 | + | p.closed = true | |||
| 1215 | + | p.mu.Unlock() | |||
| 1216 | + | close(p.done) | |||
| 1217 | + | p.signalReady() | |||
| 1218 | + | p.signalState() | |||
| 1219 | + | }) | |||
| 1109 | } | 1220 | } | ||
| 1110 | 1221 | ||||
| 1111 | // stdinPump parses terminal color reports into generation-tagged events while | 1222 | // stdinPump parses terminal color reports into generation-tagged events while | ||
M internal/usecase/client/terminal_input_pump_test.go +65 -0
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | 14 | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| @@ -50,6 +51,70 @@ func (r *lifecycleReader) maxActive() int { | |||
| 50 | 51 | return r.max | |
| 51 | 52 | } | |
| 52 | 53 | ||
| 54 | + | type lateStopReader struct { | |
| 55 | + | started chan struct{} | |
| 56 | + | release chan terminalReadResult | |
| 57 | + | reads atomic.Int32 | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func newLateStopReader() *lateStopReader { | |
| 61 | + | return &lateStopReader{ | |
| 62 | + | started: make(chan struct{}, 2), | |
| 63 | + | release: make(chan terminalReadResult, 1), | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func (r *lateStopReader) Read(p []byte) (int, error) { | |
| 68 | + | r.reads.Add(1) | |
| 69 | + | r.started <- struct{}{} | |
| 70 | + | result := <-r.release | |
| 71 | + | return copy(p, result.data), result.err | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestTerminalInputPumpStopDropsLateReadWithoutStartingAnother(t *testing.T) { | |
| 75 | + | reader := newLateStopReader() | |
| 76 | + | input := newTerminalInputPump(reader) | |
| 77 | + | input.start() | |
| 78 | + | <-reader.started // The sole lifecycle read is blocked in the caller-owned reader. | |
| 79 | + | ||
| 80 | + | input.stop() | |
| 81 | + | reader.release <- terminalReadResult{data: []byte("late")} | |
| 82 | + | ||
| 83 | + | // The late read must be discarded rather than published. Both possible | |
| 84 | + | // outcomes are channel-synchronized: a correct pump exits; the historical | |
| 85 | + | // race published and immediately issued a second Read. | |
| 86 | + | select { | |
| 87 | + | case <-input.exited: | |
| 88 | + | case <-reader.started: | |
| 89 | + | require.Fail(t, "pump issued another Read after stop") | |
| 90 | + | } | |
| 91 | + | require.Equal(t, int32(1), reader.reads.Load()) | |
| 92 | + | ||
| 93 | + | consumer := input.claim() | |
| 94 | + | _, ok := input.take(context.Background(), consumer) | |
| 95 | + | require.False(t, ok, "late read must not be available after stop") | |
| 96 | + | input.revoke(consumer) | |
| 97 | + | input.stop() // stop is deliberately idempotent. | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func TestRunnerReusesBlockedTerminalInputPumpAcrossRuns(t *testing.T) { | |
| 101 | + | reader := newLateStopReader() | |
| 102 | + | runner := &Runner{term: &attachPaletteTerminalHarness{ | |
| 103 | + | in: reader, out: newAttachPaletteWriter(), resize: make(chan domain.Size), | |
| 104 | + | }} | |
| 105 | + | ||
| 106 | + | first := runner.terminalInput() | |
| 107 | + | <-reader.started | |
| 108 | + | first.suspend() | |
| 109 | + | second := runner.terminalInput() | |
| 110 | + | require.Same(t, first, second, "a later Run must reuse a blocked caller-owned reader") | |
| 111 | + | require.Equal(t, int32(1), reader.reads.Load()) | |
| 112 | + | ||
| 113 | + | first.stop() | |
| 114 | + | reader.release <- terminalReadResult{} | |
| 115 | + | <-first.exited | |
| 116 | + | } | |
| 117 | + | ||
| 53 | 118 | func TestTerminalInputPumpCancellationHandoffPreservesQueuedBytes(t *testing.T) { | |
| 54 | 119 | input := newTerminalInputPump(nil) | |
| 55 | 120 | // Both cancellation and a terminal read are ready before the old scanner | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 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 | ||||
| @@ -50,6 +51,70 @@ func (r *lifecycleReader) maxActive() int { | |||||
| 50 | return r.max | 51 | return r.max | ||
| 51 | } | 52 | } | ||
| 52 | 53 | ||||
| 54 | + | type lateStopReader struct { | |||
| 55 | + | started chan struct{} | |||
| 56 | + | release chan terminalReadResult | |||
| 57 | + | reads atomic.Int32 | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func newLateStopReader() *lateStopReader { | |||
| 61 | + | return &lateStopReader{ | |||
| 62 | + | started: make(chan struct{}, 2), | |||
| 63 | + | release: make(chan terminalReadResult, 1), | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (r *lateStopReader) Read(p []byte) (int, error) { | |||
| 68 | + | r.reads.Add(1) | |||
| 69 | + | r.started <- struct{}{} | |||
| 70 | + | result := <-r.release | |||
| 71 | + | return copy(p, result.data), result.err | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestTerminalInputPumpStopDropsLateReadWithoutStartingAnother(t *testing.T) { | |||
| 75 | + | reader := newLateStopReader() | |||
| 76 | + | input := newTerminalInputPump(reader) | |||
| 77 | + | input.start() | |||
| 78 | + | <-reader.started // The sole lifecycle read is blocked in the caller-owned reader. | |||
| 79 | + | ||||
| 80 | + | input.stop() | |||
| 81 | + | reader.release <- terminalReadResult{data: []byte("late")} | |||
| 82 | + | ||||
| 83 | + | // The late read must be discarded rather than published. Both possible | |||
| 84 | + | // outcomes are channel-synchronized: a correct pump exits; the historical | |||
| 85 | + | // race published and immediately issued a second Read. | |||
| 86 | + | select { | |||
| 87 | + | case <-input.exited: | |||
| 88 | + | case <-reader.started: | |||
| 89 | + | require.Fail(t, "pump issued another Read after stop") | |||
| 90 | + | } | |||
| 91 | + | require.Equal(t, int32(1), reader.reads.Load()) | |||
| 92 | + | ||||
| 93 | + | consumer := input.claim() | |||
| 94 | + | _, ok := input.take(context.Background(), consumer) | |||
| 95 | + | require.False(t, ok, "late read must not be available after stop") | |||
| 96 | + | input.revoke(consumer) | |||
| 97 | + | input.stop() // stop is deliberately idempotent. | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func TestRunnerReusesBlockedTerminalInputPumpAcrossRuns(t *testing.T) { | |||
| 101 | + | reader := newLateStopReader() | |||
| 102 | + | runner := &Runner{term: &attachPaletteTerminalHarness{ | |||
| 103 | + | in: reader, out: newAttachPaletteWriter(), resize: make(chan domain.Size), | |||
| 104 | + | }} | |||
| 105 | + | ||||
| 106 | + | first := runner.terminalInput() | |||
| 107 | + | <-reader.started | |||
| 108 | + | first.suspend() | |||
| 109 | + | second := runner.terminalInput() | |||
| 110 | + | require.Same(t, first, second, "a later Run must reuse a blocked caller-owned reader") | |||
| 111 | + | require.Equal(t, int32(1), reader.reads.Load()) | |||
| 112 | + | ||||
| 113 | + | first.stop() | |||
| 114 | + | reader.release <- terminalReadResult{} | |||
| 115 | + | <-first.exited | |||
| 116 | + | } | |||
| 117 | + | ||||
| 53 | func TestTerminalInputPumpCancellationHandoffPreservesQueuedBytes(t *testing.T) { | 118 | func TestTerminalInputPumpCancellationHandoffPreservesQueuedBytes(t *testing.T) { | ||
| 54 | input := newTerminalInputPump(nil) | 119 | input := newTerminalInputPump(nil) | ||
| 55 | // Both cancellation and a terminal read are ready before the old scanner | 120 | // Both cancellation and a terminal read are ready before the old scanner | ||