fix(remote): preserve pipelined output state
- commit
- 4480b59d4c46037fec29e449996e454fa53d8615
- author
- brice · · 2026-07-09 21:48 UTC
- committer
- brice · · 2026-07-09 22:40 UTC
- parent
- a75e98b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
29 files · +1579 -633
M internal/adapters/dgram/output_pacer.go +37 -23
| @@ -7,19 +7,10 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | type queuedSend struct { | |
| 10 | - | seq uint64 | |
| 11 | - | reliable bool | |
| 12 | - | frame ports.Frame | |
| 13 | - | outputSkipThrough uint64 | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | func (t *Transport) flushQueuedOutputLocked() []queuedSend { | |
| 17 | - | if len(t.outputQueue) == 0 { | |
| 18 | - | return nil | |
| 19 | - | } | |
| 20 | - | queued := append([]queuedSend(nil), t.outputQueue...) | |
| 21 | - | t.outputQueue = nil | |
| 22 | - | return queued | |
| 10 | + | seq uint64 | |
| 11 | + | reliable bool | |
| 12 | + | frame ports.Frame | |
| 13 | + | done chan error | |
| 23 | 14 | } | |
| 24 | 15 | ||
| 25 | 16 | func (t *Transport) outputPaceLoop() { | |
| @@ -59,7 +50,16 @@ func (t *Transport) outputPaceLoop() { | |||
| 59 | 50 | timer.Stop() | |
| 60 | 51 | continue | |
| 61 | 52 | } | |
| 62 | - | limit := min(defaultOutputPaceBatch, len(t.outputQueue)) | |
| 53 | + | limit := 0 | |
| 54 | + | budget := t.mtu | |
| 55 | + | for limit < len(t.outputQueue) && limit < defaultOutputPaceBatch { | |
| 56 | + | cost := len(t.outputQueue[limit].frame.Payload) + dataRecordHeaderSize | |
| 57 | + | if limit > 0 && cost > budget { | |
| 58 | + | break | |
| 59 | + | } | |
| 60 | + | limit++ | |
| 61 | + | budget -= min(cost, budget) | |
| 62 | + | } | |
| 63 | 63 | batch := append([]queuedSend(nil), t.outputQueue[:limit]...) | |
| 64 | 64 | copy(t.outputQueue, t.outputQueue[limit:]) | |
| 65 | 65 | t.outputQueue = t.outputQueue[:len(t.outputQueue)-limit] | |
| @@ -67,7 +67,7 @@ func (t *Transport) outputPaceLoop() { | |||
| 67 | 67 | t.mu.Unlock() | |
| 68 | 68 | for i, q := range batch { | |
| 69 | 69 | if err := t.sendQueuedData(q); err != nil { | |
| 70 | - | t.removeQueuedPending(batch[i+1:]) | |
| 70 | + | t.removeQueuedPending(batch[i+1:], err) | |
| 71 | 71 | t.closeWithError(err) | |
| 72 | 72 | t.outboundMu.Unlock() | |
| 73 | 73 | return | |
| @@ -105,23 +105,25 @@ func shouldPaceOutput(f ports.Frame) bool { | |||
| 105 | 105 | ||
| 106 | 106 | func (t *Transport) sendQueuedData(q queuedSend) error { | |
| 107 | 107 | t.markPendingSent(q.seq, q.reliable) | |
| 108 | - | if err := t.sendData(q.seq, q.reliable, q.frame, q.outputSkipThrough); err != nil { | |
| 108 | + | err := t.sendData(q.seq, q.reliable, q.frame) | |
| 109 | + | if err != nil { | |
| 109 | 110 | t.removePending(q.seq, q.reliable) | |
| 110 | - | return err | |
| 111 | + | } else { | |
| 112 | + | t.markPendingReady(q.seq, q.reliable) | |
| 111 | 113 | } | |
| 112 | - | t.markPendingReady(q.seq, q.reliable) | |
| 113 | - | return nil | |
| 114 | + | if q.done != nil { | |
| 115 | + | q.done <- err | |
| 116 | + | close(q.done) | |
| 117 | + | } | |
| 118 | + | return err | |
| 114 | 119 | } | |
| 115 | 120 | ||
| 116 | 121 | func (t *Transport) markPendingSent(seq uint64, reliable bool) { | |
| 117 | 122 | if !reliable { | |
| 118 | 123 | return | |
| 119 | 124 | } | |
| 120 | - | now := t.clock.Now() | |
| 121 | 125 | t.mu.Lock() | |
| 122 | - | if p := t.pending[seq]; p != nil && p.first.IsZero() { | |
| 123 | - | p.first = now | |
| 124 | - | p.last = now | |
| 126 | + | if p := t.pending[seq]; p != nil { | |
| 125 | 127 | p.initialInFlight = true | |
| 126 | 128 | } | |
| 127 | 129 | t.mu.Unlock() | |
| @@ -137,3 +139,15 @@ func (t *Transport) markPendingReady(seq uint64, reliable bool) { | |||
| 137 | 139 | } | |
| 138 | 140 | t.mu.Unlock() | |
| 139 | 141 | } | |
| 142 | + | ||
| 143 | + | func (t *Transport) markPendingFinalWrite(seq uint64) { | |
| 144 | + | now := t.clock.Now() | |
| 145 | + | t.mu.Lock() | |
| 146 | + | if p := t.pending[seq]; p != nil { | |
| 147 | + | if p.first.IsZero() { | |
| 148 | + | p.first = now | |
| 149 | + | } | |
| 150 | + | p.last = now | |
| 151 | + | } | |
| 152 | + | t.mu.Unlock() | |
| 153 | + | } | |
| @@ -7,19 +7,10 @@ import ( | |||||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | type queuedSend struct { | 9 | type queuedSend struct { | ||
| 10 | - | seq uint64 | 10 | + | seq uint64 |
| 11 | - | reliable bool | 11 | + | reliable bool |
| 12 | - | frame ports.Frame | 12 | + | frame ports.Frame |
| 13 | - | outputSkipThrough uint64 | 13 | + | done chan error |
| 14 | - | } | |||
| 15 | - | ||||
| 16 | - | func (t *Transport) flushQueuedOutputLocked() []queuedSend { | |||
| 17 | - | if len(t.outputQueue) == 0 { | |||
| 18 | - | return nil | |||
| 19 | - | } | |||
| 20 | - | queued := append([]queuedSend(nil), t.outputQueue...) | |||
| 21 | - | t.outputQueue = nil | |||
| 22 | - | return queued | |||
| 23 | } | 14 | } | ||
| 24 | 15 | ||||
| 25 | func (t *Transport) outputPaceLoop() { | 16 | func (t *Transport) outputPaceLoop() { | ||
| @@ -59,7 +50,16 @@ func (t *Transport) outputPaceLoop() { | |||||
| 59 | timer.Stop() | 50 | timer.Stop() | ||
| 60 | continue | 51 | continue | ||
| 61 | } | 52 | } | ||
| 62 | - | limit := min(defaultOutputPaceBatch, len(t.outputQueue)) | 53 | + | limit := 0 |
| 54 | + | budget := t.mtu | |||
| 55 | + | for limit < len(t.outputQueue) && limit < defaultOutputPaceBatch { | |||
| 56 | + | cost := len(t.outputQueue[limit].frame.Payload) + dataRecordHeaderSize | |||
| 57 | + | if limit > 0 && cost > budget { | |||
| 58 | + | break | |||
| 59 | + | } | |||
| 60 | + | limit++ | |||
| 61 | + | budget -= min(cost, budget) | |||
| 62 | + | } | |||
| 63 | batch := append([]queuedSend(nil), t.outputQueue[:limit]...) | 63 | batch := append([]queuedSend(nil), t.outputQueue[:limit]...) | ||
| 64 | copy(t.outputQueue, t.outputQueue[limit:]) | 64 | copy(t.outputQueue, t.outputQueue[limit:]) | ||
| 65 | t.outputQueue = t.outputQueue[:len(t.outputQueue)-limit] | 65 | t.outputQueue = t.outputQueue[:len(t.outputQueue)-limit] | ||
| @@ -67,7 +67,7 @@ func (t *Transport) outputPaceLoop() { | |||||
| 67 | t.mu.Unlock() | 67 | t.mu.Unlock() | ||
| 68 | for i, q := range batch { | 68 | for i, q := range batch { | ||
| 69 | if err := t.sendQueuedData(q); err != nil { | 69 | if err := t.sendQueuedData(q); err != nil { | ||
| 70 | - | t.removeQueuedPending(batch[i+1:]) | 70 | + | t.removeQueuedPending(batch[i+1:], err) |
| 71 | t.closeWithError(err) | 71 | t.closeWithError(err) | ||
| 72 | t.outboundMu.Unlock() | 72 | t.outboundMu.Unlock() | ||
| 73 | return | 73 | return | ||
| @@ -105,23 +105,25 @@ func shouldPaceOutput(f ports.Frame) bool { | |||||
| 105 | 105 | ||||
| 106 | func (t *Transport) sendQueuedData(q queuedSend) error { | 106 | func (t *Transport) sendQueuedData(q queuedSend) error { | ||
| 107 | t.markPendingSent(q.seq, q.reliable) | 107 | t.markPendingSent(q.seq, q.reliable) | ||
| 108 | - | if err := t.sendData(q.seq, q.reliable, q.frame, q.outputSkipThrough); err != nil { | 108 | + | err := t.sendData(q.seq, q.reliable, q.frame) |
| 109 | + | if err != nil { | |||
| 109 | t.removePending(q.seq, q.reliable) | 110 | t.removePending(q.seq, q.reliable) | ||
| 110 | - | return err | 111 | + | } else { |
| 112 | + | t.markPendingReady(q.seq, q.reliable) | |||
| 111 | } | 113 | } | ||
| 112 | - | t.markPendingReady(q.seq, q.reliable) | 114 | + | if q.done != nil { |
| 113 | - | return nil | 115 | + | q.done <- err |
| 116 | + | close(q.done) | |||
| 117 | + | } | |||
| 118 | + | return err | |||
| 114 | } | 119 | } | ||
| 115 | 120 | ||||
| 116 | func (t *Transport) markPendingSent(seq uint64, reliable bool) { | 121 | func (t *Transport) markPendingSent(seq uint64, reliable bool) { | ||
| 117 | if !reliable { | 122 | if !reliable { | ||
| 118 | return | 123 | return | ||
| 119 | } | 124 | } | ||
| 120 | - | now := t.clock.Now() | |||
| 121 | t.mu.Lock() | 125 | t.mu.Lock() | ||
| 122 | - | if p := t.pending[seq]; p != nil && p.first.IsZero() { | 126 | + | if p := t.pending[seq]; p != nil { |
| 123 | - | p.first = now | |||
| 124 | - | p.last = now | |||
| 125 | p.initialInFlight = true | 127 | p.initialInFlight = true | ||
| 126 | } | 128 | } | ||
| 127 | t.mu.Unlock() | 129 | t.mu.Unlock() | ||
| @@ -137,3 +139,15 @@ func (t *Transport) markPendingReady(seq uint64, reliable bool) { | |||||
| 137 | } | 139 | } | ||
| 138 | t.mu.Unlock() | 140 | t.mu.Unlock() | ||
| 139 | } | 141 | } | ||
| 142 | + | ||||
| 143 | + | func (t *Transport) markPendingFinalWrite(seq uint64) { | |||
| 144 | + | now := t.clock.Now() | |||
| 145 | + | t.mu.Lock() | |||
| 146 | + | if p := t.pending[seq]; p != nil { | |||
| 147 | + | if p.first.IsZero() { | |||
| 148 | + | p.first = now | |||
| 149 | + | } | |||
| 150 | + | p.last = now | |||
| 151 | + | } | |||
| 152 | + | t.mu.Unlock() | |||
| 153 | + | } | |||
M internal/adapters/dgram/transport.go +191 -160
| @@ -36,7 +36,9 @@ const ( | |||
| 36 | 36 | defaultOutputPaceMinDelay = 8 * time.Millisecond | |
| 37 | 37 | defaultOutputPaceMinCadence = 20 * time.Millisecond | |
| 38 | 38 | defaultOutputPaceMaxDelay = 250 * time.Millisecond | |
| 39 | - | defaultOutputPaceBatch = 16 | |
| 39 | + | defaultOutputPaceBatch = 2 | |
| 40 | + | defaultPacketPaceBudget = 8 | |
| 41 | + | dataRecordHeaderSize = 12 | |
| 40 | 42 | maxRecvBuffer = defaultMaxRecvBuf | |
| 41 | 43 | linkEventBufferSize = 16 | |
| 42 | 44 | probeReplyBufferSize = 16 | |
| @@ -177,26 +179,28 @@ type Transport struct { | |||
| 177 | 179 | in chan ports.Frame | |
| 178 | 180 | done chan struct{} | |
| 179 | 181 | ||
| 180 | - | writeMu sync.Mutex | |
| 182 | + | writeMu sync.Mutex | |
| 183 | + | dataPaceMu sync.Mutex | |
| 184 | + | dataPaceRemaining int | |
| 185 | + | dataPaceNext time.Time | |
| 181 | 186 | // outboundMu orders first transmissions and paced batches. Retransmits use | |
| 182 | 187 | // writeMu directly because they do not establish new sequence order. | |
| 183 | 188 | outboundMu sync.Mutex | |
| 184 | 189 | ||
| 185 | - | deliverMu sync.Mutex | |
| 186 | - | deliverCond *sync.Cond | |
| 187 | - | nextRecvSeq uint64 | |
| 188 | - | recvBuf map[uint64]ports.Frame | |
| 189 | - | recvSkipThrough uint64 | |
| 190 | + | deliverMu sync.Mutex | |
| 191 | + | deliverCond *sync.Cond | |
| 192 | + | nextRecvSeq uint64 | |
| 193 | + | recvBuf map[uint64]ports.Frame | |
| 190 | 194 | } | |
| 191 | 195 | ||
| 192 | 196 | type pending struct { | |
| 193 | - | frame ports.Frame | |
| 194 | - | first time.Time | |
| 195 | - | last time.Time | |
| 196 | - | initialInFlight bool | |
| 197 | - | attempts int | |
| 198 | - | retransmitted bool | |
| 199 | - | outputSkipThrough uint64 | |
| 197 | + | frame ports.Frame | |
| 198 | + | enqueued time.Time | |
| 199 | + | first time.Time | |
| 200 | + | last time.Time | |
| 201 | + | initialInFlight bool | |
| 202 | + | attempts int | |
| 203 | + | retransmitted bool | |
| 200 | 204 | } | |
| 201 | 205 | ||
| 202 | 206 | func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) { | |
| @@ -255,53 +259,79 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||
| 255 | 259 | return t, nil | |
| 256 | 260 | } | |
| 257 | 261 | ||
| 258 | - | // DatagramTransport marks this transport as UDP/datagram-backed for daemon | |
| 259 | - | // output-state policy selection. | |
| 260 | - | func (t *Transport) DatagramTransport() {} | |
| 262 | + | func (t *Transport) Send(f ports.Frame) error { return t.send(f, false) } | |
| 261 | 263 | ||
| 262 | - | func (t *Transport) Send(f ports.Frame) error { | |
| 264 | + | func (t *Transport) SendAsync(f ports.Frame) error { return t.send(f, true) } | |
| 265 | + | ||
| 266 | + | // SendSynchronous owns queue reservation, fragment pacing, per-write deadlines, | |
| 267 | + | // and close cancellation. Callers must not layer a shorter preflight timer over it. | |
| 268 | + | func (t *Transport) SendSynchronous(f ports.Frame) error { return t.Send(f) } | |
| 269 | + | ||
| 270 | + | func (t *Transport) send(f ports.Frame, async bool) error { | |
| 263 | 271 | reliable := true | |
| 264 | - | outputSkipThrough, err := t.lockOutboundSlot(f, reliable) | |
| 265 | - | if err != nil { | |
| 272 | + | if err := t.lockOutboundSlot(reliable); err != nil { | |
| 266 | 273 | return err | |
| 267 | 274 | } | |
| 268 | - | defer t.outboundMu.Unlock() | |
| 275 | + | unlockOutbound := true | |
| 276 | + | defer func() { | |
| 277 | + | if unlockOutbound { | |
| 278 | + | t.outboundMu.Unlock() | |
| 279 | + | } | |
| 280 | + | }() | |
| 269 | 281 | // lockOutboundSlot returns with both outboundMu and mu held. | |
| 270 | 282 | ||
| 271 | 283 | t.seq++ | |
| 272 | 284 | seq := t.seq | |
| 273 | 285 | if reliable { | |
| 274 | - | t.pending[seq] = &pending{frame: f, outputSkipThrough: outputSkipThrough} | |
| 286 | + | t.pending[seq] = &pending{frame: f, enqueued: t.clock.Now()} | |
| 275 | 287 | } | |
| 276 | 288 | if shouldPaceOutput(f) { | |
| 277 | 289 | now := t.clock.Now() | |
| 278 | 290 | if len(t.outputQueue) > 0 || (!t.outputNext.IsZero() && now.Before(t.outputNext)) { | |
| 279 | - | t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f, outputSkipThrough: outputSkipThrough}) | |
| 280 | - | t.notifyOutputPacerLocked() | |
| 291 | + | wasEmpty := len(t.outputQueue) == 0 | |
| 292 | + | var done chan error | |
| 293 | + | if !async { | |
| 294 | + | done = make(chan error, 1) | |
| 295 | + | } | |
| 296 | + | t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f, done: done}) | |
| 297 | + | if wasEmpty { | |
| 298 | + | t.notifyOutputPacerLocked() | |
| 299 | + | } | |
| 281 | 300 | t.mu.Unlock() | |
| 282 | - | return nil | |
| 301 | + | if done == nil { | |
| 302 | + | return nil | |
| 303 | + | } | |
| 304 | + | t.outboundMu.Unlock() | |
| 305 | + | unlockOutbound = false | |
| 306 | + | return t.waitQueuedSend(done) | |
| 283 | 307 | } | |
| 284 | 308 | t.outputNext = now.Add(t.outputPaceDelayLocked()) | |
| 285 | 309 | t.mu.Unlock() | |
| 286 | 310 | t.markPendingSent(seq, reliable) | |
| 287 | - | if err := t.sendData(seq, reliable, f, outputSkipThrough); err != nil { | |
| 311 | + | if err := t.sendData(seq, reliable, f); err != nil { | |
| 288 | 312 | t.removePending(seq, reliable) | |
| 289 | 313 | return err | |
| 290 | 314 | } | |
| 291 | 315 | t.markPendingReady(seq, reliable) | |
| 292 | 316 | return nil | |
| 293 | 317 | } | |
| 294 | - | queued := t.flushQueuedOutputLocked() | |
| 295 | - | t.mu.Unlock() | |
| 296 | - | for i, q := range queued { | |
| 297 | - | if err := t.sendQueuedData(q); err != nil { | |
| 298 | - | t.removePending(seq, reliable) | |
| 299 | - | t.removeQueuedPending(queued[i+1:]) | |
| 300 | - | return err | |
| 318 | + | if len(t.outputQueue) > 0 { | |
| 319 | + | var done chan error | |
| 320 | + | if !async { | |
| 321 | + | done = make(chan error, 1) | |
| 301 | 322 | } | |
| 323 | + | t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f, done: done}) | |
| 324 | + | t.mu.Unlock() | |
| 325 | + | if done == nil { | |
| 326 | + | return nil | |
| 327 | + | } | |
| 328 | + | t.outboundMu.Unlock() | |
| 329 | + | unlockOutbound = false | |
| 330 | + | return t.waitQueuedSend(done) | |
| 302 | 331 | } | |
| 332 | + | t.mu.Unlock() | |
| 303 | 333 | t.markPendingSent(seq, reliable) | |
| 304 | - | if err := t.sendData(seq, reliable, f, outputSkipThrough); err != nil { | |
| 334 | + | if err := t.sendData(seq, reliable, f); err != nil { | |
| 305 | 335 | t.removePending(seq, reliable) | |
| 306 | 336 | return err | |
| 307 | 337 | } | |
| @@ -309,9 +339,32 @@ func (t *Transport) Send(f ports.Frame) error { | |||
| 309 | 339 | return nil | |
| 310 | 340 | } | |
| 311 | 341 | ||
| 312 | - | func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, error) { | |
| 342 | + | func (t *Transport) waitQueuedSend(done <-chan error) error { | |
| 343 | + | select { | |
| 344 | + | case err := <-done: | |
| 345 | + | return err | |
| 346 | + | case <-t.done: | |
| 347 | + | t.mu.Lock() | |
| 348 | + | err := t.closeErr | |
| 349 | + | t.mu.Unlock() | |
| 350 | + | if err != nil { | |
| 351 | + | return err | |
| 352 | + | } | |
| 353 | + | return errors.New("dgram: closed") | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func (t *Transport) closedError() error { | |
| 358 | + | t.mu.Lock() | |
| 359 | + | defer t.mu.Unlock() | |
| 360 | + | if t.closeErr != nil { | |
| 361 | + | return t.closeErr | |
| 362 | + | } | |
| 363 | + | return errors.New("dgram: closed") | |
| 364 | + | } | |
| 365 | + | ||
| 366 | + | func (t *Transport) lockOutboundSlot(reliable bool) error { | |
| 313 | 367 | var pendingWaitStarted time.Time | |
| 314 | - | var outputSkipThrough uint64 | |
| 315 | 368 | for { | |
| 316 | 369 | t.outboundMu.Lock() | |
| 317 | 370 | t.mu.Lock() | |
| @@ -320,22 +373,17 @@ func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, erro | |||
| 320 | 373 | t.mu.Unlock() | |
| 321 | 374 | t.outboundMu.Unlock() | |
| 322 | 375 | if err != nil { | |
| 323 | - | return 0, err | |
| 324 | - | } | |
| 325 | - | return 0, errors.New("dgram: closed") | |
| 326 | - | } | |
| 327 | - | if f.Type == ports.MsgOutput { | |
| 328 | - | if skipped := t.supersedePendingOutputLocked(); skipped > outputSkipThrough { | |
| 329 | - | outputSkipThrough = skipped | |
| 376 | + | return err | |
| 330 | 377 | } | |
| 378 | + | return errors.New("dgram: closed") | |
| 331 | 379 | } | |
| 332 | 380 | if !reliable || len(t.pending) < t.maxPending { | |
| 333 | - | return outputSkipThrough, nil | |
| 381 | + | return nil | |
| 334 | 382 | } | |
| 335 | 383 | if t.linkState != ports.LinkStateConnected { | |
| 336 | 384 | t.mu.Unlock() | |
| 337 | 385 | t.outboundMu.Unlock() | |
| 338 | - | return 0, ErrPendingFull | |
| 386 | + | return ErrPendingFull | |
| 339 | 387 | } | |
| 340 | 388 | if pendingWaitStarted.IsZero() { | |
| 341 | 389 | pendingWaitStarted = t.clock.Now() | |
| @@ -344,7 +392,7 @@ func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, erro | |||
| 344 | 392 | if remaining <= 0 { | |
| 345 | 393 | t.mu.Unlock() | |
| 346 | 394 | t.outboundMu.Unlock() | |
| 347 | - | return 0, ErrPendingFull | |
| 395 | + | return ErrPendingFull | |
| 348 | 396 | } | |
| 349 | 397 | wake := t.sendWake | |
| 350 | 398 | timer := t.clock.NewTimer(remaining) | |
| @@ -371,13 +419,17 @@ func (t *Transport) removePending(seq uint64, reliable bool) { | |||
| 371 | 419 | t.mu.Unlock() | |
| 372 | 420 | } | |
| 373 | 421 | ||
| 374 | - | func (t *Transport) removeQueuedPending(queued []queuedSend) { | |
| 422 | + | func (t *Transport) removeQueuedPending(queued []queuedSend, err error) { | |
| 375 | 423 | t.mu.Lock() | |
| 376 | 424 | removed := false | |
| 377 | 425 | for _, q := range queued { | |
| 378 | 426 | if q.reliable { | |
| 379 | 427 | removed = t.removePendingLocked(q.seq) || removed | |
| 380 | 428 | } | |
| 429 | + | if q.done != nil { | |
| 430 | + | q.done <- err | |
| 431 | + | close(q.done) | |
| 432 | + | } | |
| 381 | 433 | } | |
| 382 | 434 | if removed { | |
| 383 | 435 | t.notifySendWaitersLocked() | |
| @@ -393,41 +445,6 @@ func (t *Transport) removePendingLocked(seq uint64) bool { | |||
| 393 | 445 | return true | |
| 394 | 446 | } | |
| 395 | 447 | ||
| 396 | - | func (t *Transport) supersedePendingOutputLocked() uint64 { | |
| 397 | - | var barrier uint64 | |
| 398 | - | for seq, p := range t.pending { | |
| 399 | - | if p != nil && p.frame.Type != ports.MsgOutput && seq > barrier { | |
| 400 | - | barrier = seq | |
| 401 | - | } | |
| 402 | - | } | |
| 403 | - | var newest uint64 | |
| 404 | - | for seq, p := range t.pending { | |
| 405 | - | if p == nil || p.frame.Type != ports.MsgOutput || seq <= barrier { | |
| 406 | - | continue | |
| 407 | - | } | |
| 408 | - | if seq > newest { | |
| 409 | - | newest = seq | |
| 410 | - | } | |
| 411 | - | } | |
| 412 | - | if newest == 0 { | |
| 413 | - | return 0 | |
| 414 | - | } | |
| 415 | - | for seq, p := range t.pending { | |
| 416 | - | if p != nil && p.frame.Type == ports.MsgOutput && seq > barrier && seq <= newest { | |
| 417 | - | delete(t.pending, seq) | |
| 418 | - | } | |
| 419 | - | } | |
| 420 | - | kept := t.outputQueue[:0] | |
| 421 | - | for _, q := range t.outputQueue { | |
| 422 | - | if q.seq > barrier && q.seq <= newest { | |
| 423 | - | continue | |
| 424 | - | } | |
| 425 | - | kept = append(kept, q) | |
| 426 | - | } | |
| 427 | - | t.outputQueue = kept | |
| 428 | - | t.notifySendWaitersLocked() | |
| 429 | - | return newest | |
| 430 | - | } | |
| 431 | 448 | func (t *Transport) Recv() (ports.Frame, error) { | |
| 432 | 449 | select { | |
| 433 | 450 | case f := <-t.in: | |
| @@ -509,28 +526,40 @@ func (t *Transport) unregisterProbe(id uint64) { | |||
| 509 | 526 | t.mu.Unlock() | |
| 510 | 527 | } | |
| 511 | 528 | ||
| 512 | - | func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame, outputSkipThrough uint64) error { | |
| 513 | - | p := encodeData(seq, reliable, f, outputSkipThrough) | |
| 514 | - | return t.sendPayload(p) | |
| 529 | + | func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error { | |
| 530 | + | p := encodeData(seq, reliable, f) | |
| 531 | + | return t.sendPayload(p, true, seq) | |
| 515 | 532 | } | |
| 516 | 533 | func (t *Transport) sendAck(seq uint64) { | |
| 517 | 534 | var b [9]byte | |
| 518 | 535 | b[0] = recAck | |
| 519 | 536 | binary.BigEndian.PutUint64(b[1:], seq) | |
| 520 | - | _ = t.sendPayload(b[:]) | |
| 537 | + | _ = t.sendPayload(b[:], false, 0) | |
| 521 | 538 | } | |
| 522 | 539 | func (t *Transport) sendProbe(kind byte, id uint64) error { | |
| 523 | 540 | var b [9]byte | |
| 524 | 541 | b[0] = kind | |
| 525 | 542 | binary.BigEndian.PutUint64(b[1:], id) | |
| 526 | - | return t.sendPayload(b[:]) | |
| 543 | + | return t.sendPayload(b[:], false, 0) | |
| 527 | 544 | } | |
| 528 | - | func (t *Transport) sendPayload(p []byte) error { | |
| 545 | + | func (t *Transport) sendPayload(p []byte, paced bool, pendingSeq uint64) error { | |
| 546 | + | if paced { | |
| 547 | + | t.dataPaceMu.Lock() | |
| 548 | + | defer t.dataPaceMu.Unlock() | |
| 549 | + | } | |
| 529 | 550 | frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead()) | |
| 530 | 551 | if err != nil { | |
| 531 | 552 | return err | |
| 532 | 553 | } | |
| 533 | - | for _, f := range frags { | |
| 554 | + | for i, f := range frags { | |
| 555 | + | if pendingSeq != 0 && !t.pendingExists(pendingSeq) { | |
| 556 | + | return nil | |
| 557 | + | } | |
| 558 | + | if paced { | |
| 559 | + | if err := t.takeDataPaceSlot(); err != nil { | |
| 560 | + | return err | |
| 561 | + | } | |
| 562 | + | } | |
| 534 | 563 | raw, err := pdgram.MarshalFragment(f) | |
| 535 | 564 | if err != nil { | |
| 536 | 565 | return err | |
| @@ -550,6 +579,9 @@ func (t *Transport) sendPayload(p []byte) error { | |||
| 550 | 579 | if peer == nil { | |
| 551 | 580 | return errors.New("dgram: no peer") | |
| 552 | 581 | } | |
| 582 | + | if pendingSeq != 0 && i == len(frags)-1 { | |
| 583 | + | t.markPendingFinalWrite(pendingSeq) | |
| 584 | + | } | |
| 553 | 585 | if err := t.writePacket(pkt, peer); err != nil { | |
| 554 | 586 | return err | |
| 555 | 587 | } | |
| @@ -557,6 +589,37 @@ func (t *Transport) sendPayload(p []byte) error { | |||
| 557 | 589 | return nil | |
| 558 | 590 | } | |
| 559 | 591 | ||
| 592 | + | func (t *Transport) pendingExists(seq uint64) bool { | |
| 593 | + | t.mu.Lock() | |
| 594 | + | defer t.mu.Unlock() | |
| 595 | + | _, ok := t.pending[seq] | |
| 596 | + | return ok | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | func (t *Transport) takeDataPaceSlot() error { | |
| 600 | + | now := t.clock.Now() | |
| 601 | + | if t.dataPaceRemaining > 0 && now.Before(t.dataPaceNext) { | |
| 602 | + | t.dataPaceRemaining-- | |
| 603 | + | return nil | |
| 604 | + | } | |
| 605 | + | if t.dataPaceNext.After(now) { | |
| 606 | + | timer := t.clock.NewTimer(t.dataPaceNext.Sub(now)) | |
| 607 | + | select { | |
| 608 | + | case <-timer.C(): | |
| 609 | + | case <-t.done: | |
| 610 | + | timer.Stop() | |
| 611 | + | return t.closedError() | |
| 612 | + | } | |
| 613 | + | timer.Stop() | |
| 614 | + | } | |
| 615 | + | t.mu.Lock() | |
| 616 | + | delay := t.outputPaceDelayLocked() | |
| 617 | + | t.mu.Unlock() | |
| 618 | + | t.dataPaceRemaining = defaultPacketPaceBudget - 1 | |
| 619 | + | t.dataPaceNext = t.clock.Now().Add(delay) | |
| 620 | + | return nil | |
| 621 | + | } | |
| 622 | + | ||
| 560 | 623 | func (t *Transport) writePacket(pkt []byte, peer net.Addr) error { | |
| 561 | 624 | t.writeMu.Lock() | |
| 562 | 625 | defer t.writeMu.Unlock() | |
| @@ -669,12 +732,12 @@ func (t *Transport) handleRecord(p []byte) { | |||
| 669 | 732 | } | |
| 670 | 733 | t.mu.Unlock() | |
| 671 | 734 | case recData: | |
| 672 | - | seq, reliable, f, outputSkipThrough, ok := decodeData(p) | |
| 735 | + | seq, reliable, f, ok := decodeData(p) | |
| 673 | 736 | if !ok { | |
| 674 | 737 | return | |
| 675 | 738 | } | |
| 676 | 739 | if reliable { | |
| 677 | - | ackSeq, ack, queued := t.enqueueReliable(seq, f, outputSkipThrough) | |
| 740 | + | ackSeq, ack, queued := t.enqueueReliable(seq, f) | |
| 678 | 741 | if ack { | |
| 679 | 742 | t.sendAck(ackSeq) | |
| 680 | 743 | } | |
| @@ -687,18 +750,9 @@ func (t *Transport) handleRecord(p []byte) { | |||
| 687 | 750 | } | |
| 688 | 751 | } | |
| 689 | 752 | ||
| 690 | - | func (t *Transport) enqueueReliable(seq uint64, f ports.Frame, outputSkipThrough uint64) (ackSeq uint64, ack bool, queued bool) { | |
| 753 | + | func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, ack bool, queued bool) { | |
| 691 | 754 | t.deliverMu.Lock() | |
| 692 | 755 | defer t.deliverMu.Unlock() | |
| 693 | - | if f.Type == ports.MsgOutput && outputSkipThrough > t.recvSkipThrough { | |
| 694 | - | t.recvSkipThrough = outputSkipThrough | |
| 695 | - | for bufferedSeq, buffered := range t.recvBuf { | |
| 696 | - | if bufferedSeq <= outputSkipThrough && buffered.Type == ports.MsgOutput { | |
| 697 | - | delete(t.recvBuf, bufferedSeq) | |
| 698 | - | } | |
| 699 | - | } | |
| 700 | - | t.deliverCond.Signal() | |
| 701 | - | } | |
| 702 | 756 | if seq < t.nextRecvSeq { | |
| 703 | 757 | return t.highestContiguousRecvLocked(), true, false | |
| 704 | 758 | } | |
| @@ -719,10 +773,6 @@ func (t *Transport) highestContiguousRecvLocked() uint64 { | |||
| 719 | 773 | seq++ | |
| 720 | 774 | continue | |
| 721 | 775 | } | |
| 722 | - | if seq <= t.recvSkipThrough { | |
| 723 | - | seq++ | |
| 724 | - | continue | |
| 725 | - | } | |
| 726 | 776 | break | |
| 727 | 777 | } | |
| 728 | 778 | return seq - 1 | |
| @@ -738,10 +788,6 @@ func (t *Transport) deliveryLoop() { | |||
| 738 | 788 | for { | |
| 739 | 789 | t.deliverMu.Lock() | |
| 740 | 790 | f, ok := t.recvBuf[t.nextRecvSeq] | |
| 741 | - | for !ok && t.nextRecvSeq <= t.recvSkipThrough { | |
| 742 | - | t.nextRecvSeq++ | |
| 743 | - | f, ok = t.recvBuf[t.nextRecvSeq] | |
| 744 | - | } | |
| 745 | 791 | for !ok { | |
| 746 | 792 | if t.isClosed() { | |
| 747 | 793 | t.deliverMu.Unlock() | |
| @@ -749,16 +795,6 @@ func (t *Transport) deliveryLoop() { | |||
| 749 | 795 | } | |
| 750 | 796 | t.deliverCond.Wait() | |
| 751 | 797 | f, ok = t.recvBuf[t.nextRecvSeq] | |
| 752 | - | for !ok && t.nextRecvSeq <= t.recvSkipThrough { | |
| 753 | - | t.nextRecvSeq++ | |
| 754 | - | f, ok = t.recvBuf[t.nextRecvSeq] | |
| 755 | - | } | |
| 756 | - | } | |
| 757 | - | if t.nextRecvSeq <= t.recvSkipThrough && f.Type == ports.MsgOutput { | |
| 758 | - | delete(t.recvBuf, t.nextRecvSeq) | |
| 759 | - | t.nextRecvSeq++ | |
| 760 | - | t.deliverMu.Unlock() | |
| 761 | - | continue | |
| 762 | 798 | } | |
| 763 | 799 | delete(t.recvBuf, t.nextRecvSeq) | |
| 764 | 800 | t.nextRecvSeq++ | |
| @@ -825,9 +861,8 @@ func (t *Transport) resendLoop() { | |||
| 825 | 861 | func (t *Transport) resendPending() { | |
| 826 | 862 | now := t.clock.Now() | |
| 827 | 863 | var resend []struct { | |
| 828 | - | seq uint64 | |
| 829 | - | f ports.Frame | |
| 830 | - | outputSkipThrough uint64 | |
| 864 | + | seq uint64 | |
| 865 | + | f ports.Frame | |
| 831 | 866 | } | |
| 832 | 867 | t.mu.Lock() | |
| 833 | 868 | seqs := make([]uint64, 0, len(t.pending)) | |
| @@ -844,19 +879,26 @@ func (t *Transport) resendPending() { | |||
| 844 | 879 | if p == nil || p.last.IsZero() || p.initialInFlight || now.Sub(p.last) < t.resendDelayLocked(p) { | |
| 845 | 880 | continue | |
| 846 | 881 | } | |
| 847 | - | p.last = now | |
| 882 | + | p.initialInFlight = true | |
| 848 | 883 | p.attempts++ | |
| 849 | 884 | p.retransmitted = true | |
| 850 | 885 | resend = append(resend, struct { | |
| 851 | - | seq uint64 | |
| 852 | - | f ports.Frame | |
| 853 | - | outputSkipThrough uint64 | |
| 854 | - | }{seq, p.frame, p.outputSkipThrough}) | |
| 886 | + | seq uint64 | |
| 887 | + | f ports.Frame | |
| 888 | + | }{seq, p.frame}) | |
| 855 | 889 | limit-- | |
| 856 | 890 | } | |
| 857 | 891 | t.mu.Unlock() | |
| 858 | 892 | for _, r := range resend { | |
| 859 | - | _ = t.sendData(r.seq, true, r.f, r.outputSkipThrough) | |
| 893 | + | if err := t.sendData(r.seq, true, r.f); err == nil { | |
| 894 | + | t.markPendingReady(r.seq, true) | |
| 895 | + | } else { | |
| 896 | + | t.mu.Lock() | |
| 897 | + | if p := t.pending[r.seq]; p != nil { | |
| 898 | + | p.initialInFlight = false | |
| 899 | + | } | |
| 900 | + | t.mu.Unlock() | |
| 901 | + | } | |
| 860 | 902 | } | |
| 861 | 903 | } | |
| 862 | 904 | ||
| @@ -966,7 +1008,7 @@ func (t *Transport) checkSilence() { | |||
| 966 | 1008 | // has gone unacknowledged for deadAfter despite at least one retransmit. The | |
| 967 | 1009 | // receive-silence check in checkSilence misses one-way-dead links whose receive | |
| 968 | 1010 | // side stays fresh from peer heartbeats; this catches them at the layer that | |
| 969 | - | // owns death. first is set once when a frame is enqueued and never refreshed, | |
| 1011 | + | // owns death. enqueued is set once when a frame is accepted and never refreshed, | |
| 970 | 1012 | // so acks (which delete pending entries) keep the oldest age bounded on a | |
| 971 | 1013 | // progressing link — only a frame that is never acked crosses the threshold. | |
| 972 | 1014 | func (t *Transport) checkSendStall() { | |
| @@ -981,11 +1023,11 @@ func (t *Transport) checkSendStall() { | |||
| 981 | 1023 | var attempts int | |
| 982 | 1024 | found := false | |
| 983 | 1025 | for _, p := range t.pending { | |
| 984 | - | if p == nil || p.first.IsZero() { | |
| 1026 | + | if p == nil || p.enqueued.IsZero() { | |
| 985 | 1027 | continue | |
| 986 | 1028 | } | |
| 987 | - | if !found || p.first.Before(oldest) { | |
| 988 | - | oldest = p.first | |
| 1029 | + | if !found || p.enqueued.Before(oldest) { | |
| 1030 | + | oldest = p.enqueued | |
| 989 | 1031 | attempts = p.attempts | |
| 990 | 1032 | found = true | |
| 991 | 1033 | } | |
| @@ -1030,6 +1072,13 @@ func (t *Transport) closeWithError(err error) { | |||
| 1030 | 1072 | } | |
| 1031 | 1073 | t.closed = true | |
| 1032 | 1074 | t.closeErr = err | |
| 1075 | + | for _, q := range t.outputQueue { | |
| 1076 | + | if q.done != nil { | |
| 1077 | + | q.done <- err | |
| 1078 | + | close(q.done) | |
| 1079 | + | } | |
| 1080 | + | } | |
| 1081 | + | t.outputQueue = nil | |
| 1033 | 1082 | t.probeWait = make(map[uint64]chan struct{}) | |
| 1034 | 1083 | close(t.done) | |
| 1035 | 1084 | t.notifySendWaitersLocked() | |
| @@ -1039,41 +1088,23 @@ func (t *Transport) closeWithError(err error) { | |||
| 1039 | 1088 | t.deliverMu.Unlock() | |
| 1040 | 1089 | } | |
| 1041 | 1090 | ||
| 1042 | - | func encodeData(seq uint64, reliable bool, f ports.Frame, outputSkipThrough uint64) []byte { | |
| 1043 | - | headerLen := 12 | |
| 1044 | - | if outputSkipThrough > 0 { | |
| 1045 | - | headerLen = 20 | |
| 1046 | - | } | |
| 1047 | - | b := make([]byte, headerLen+len(f.Payload)) | |
| 1091 | + | func encodeData(seq uint64, reliable bool, f ports.Frame) []byte { | |
| 1092 | + | b := make([]byte, dataRecordHeaderSize+len(f.Payload)) | |
| 1048 | 1093 | b[0] = recData | |
| 1049 | 1094 | binary.BigEndian.PutUint64(b[1:9], seq) | |
| 1050 | 1095 | if reliable { | |
| 1051 | 1096 | b[9] = 1 | |
| 1052 | 1097 | } | |
| 1053 | 1098 | b[10] = byte(f.Type) | |
| 1054 | - | if outputSkipThrough > 0 { | |
| 1055 | - | b[11] = 1 | |
| 1056 | - | binary.BigEndian.PutUint64(b[12:20], outputSkipThrough) | |
| 1057 | - | } | |
| 1058 | - | copy(b[headerLen:], f.Payload) | |
| 1099 | + | copy(b[dataRecordHeaderSize:], f.Payload) | |
| 1059 | 1100 | return b | |
| 1060 | 1101 | } | |
| 1061 | - | func decodeData(b []byte) (uint64, bool, ports.Frame, uint64, bool) { | |
| 1062 | - | if len(b) < 12 || b[0] != recData { | |
| 1063 | - | return 0, false, ports.Frame{}, 0, false | |
| 1064 | - | } | |
| 1065 | - | flags := b[11] | |
| 1066 | - | if flags&^byte(1) != 0 { | |
| 1067 | - | return 0, false, ports.Frame{}, 0, false | |
| 1068 | - | } | |
| 1069 | - | headerLen := 12 | |
| 1070 | - | outputSkipThrough := uint64(0) | |
| 1071 | - | if flags&1 != 0 { | |
| 1072 | - | if len(b) < 20 { | |
| 1073 | - | return 0, false, ports.Frame{}, 0, false | |
| 1074 | - | } | |
| 1075 | - | headerLen = 20 | |
| 1076 | - | outputSkipThrough = binary.BigEndian.Uint64(b[12:20]) | |
| 1102 | + | func decodeData(b []byte) (uint64, bool, ports.Frame, bool) { | |
| 1103 | + | if len(b) < dataRecordHeaderSize || b[0] != recData { | |
| 1104 | + | return 0, false, ports.Frame{}, false | |
| 1105 | + | } | |
| 1106 | + | if b[11] != 0 { | |
| 1107 | + | return 0, false, ports.Frame{}, false | |
| 1077 | 1108 | } | |
| 1078 | - | return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[headerLen:]...)}, outputSkipThrough, true | |
| 1109 | + | return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[dataRecordHeaderSize:]...)}, true | |
| 1079 | 1110 | } | |
| @@ -36,7 +36,9 @@ const ( | |||||
| 36 | defaultOutputPaceMinDelay = 8 * time.Millisecond | 36 | defaultOutputPaceMinDelay = 8 * time.Millisecond | ||
| 37 | defaultOutputPaceMinCadence = 20 * time.Millisecond | 37 | defaultOutputPaceMinCadence = 20 * time.Millisecond | ||
| 38 | defaultOutputPaceMaxDelay = 250 * time.Millisecond | 38 | defaultOutputPaceMaxDelay = 250 * time.Millisecond | ||
| 39 | - | defaultOutputPaceBatch = 16 | 39 | + | defaultOutputPaceBatch = 2 |
| 40 | + | defaultPacketPaceBudget = 8 | |||
| 41 | + | dataRecordHeaderSize = 12 | |||
| 40 | maxRecvBuffer = defaultMaxRecvBuf | 42 | maxRecvBuffer = defaultMaxRecvBuf | ||
| 41 | linkEventBufferSize = 16 | 43 | linkEventBufferSize = 16 | ||
| 42 | probeReplyBufferSize = 16 | 44 | probeReplyBufferSize = 16 | ||
| @@ -177,26 +179,28 @@ type Transport struct { | |||||
| 177 | in chan ports.Frame | 179 | in chan ports.Frame | ||
| 178 | done chan struct{} | 180 | done chan struct{} | ||
| 179 | 181 | ||||
| 180 | - | writeMu sync.Mutex | 182 | + | writeMu sync.Mutex |
| 183 | + | dataPaceMu sync.Mutex | |||
| 184 | + | dataPaceRemaining int | |||
| 185 | + | dataPaceNext time.Time | |||
| 181 | // outboundMu orders first transmissions and paced batches. Retransmits use | 186 | // outboundMu orders first transmissions and paced batches. Retransmits use | ||
| 182 | // writeMu directly because they do not establish new sequence order. | 187 | // writeMu directly because they do not establish new sequence order. | ||
| 183 | outboundMu sync.Mutex | 188 | outboundMu sync.Mutex | ||
| 184 | 189 | ||||
| 185 | - | deliverMu sync.Mutex | 190 | + | deliverMu sync.Mutex |
| 186 | - | deliverCond *sync.Cond | 191 | + | deliverCond *sync.Cond |
| 187 | - | nextRecvSeq uint64 | 192 | + | nextRecvSeq uint64 |
| 188 | - | recvBuf map[uint64]ports.Frame | 193 | + | recvBuf map[uint64]ports.Frame |
| 189 | - | recvSkipThrough uint64 | |||
| 190 | } | 194 | } | ||
| 191 | 195 | ||||
| 192 | type pending struct { | 196 | type pending struct { | ||
| 193 | - | frame ports.Frame | 197 | + | frame ports.Frame |
| 194 | - | first time.Time | 198 | + | enqueued time.Time |
| 195 | - | last time.Time | 199 | + | first time.Time |
| 196 | - | initialInFlight bool | 200 | + | last time.Time |
| 197 | - | attempts int | 201 | + | initialInFlight bool |
| 198 | - | retransmitted bool | 202 | + | attempts int |
| 199 | - | outputSkipThrough uint64 | 203 | + | retransmitted bool |
| 200 | } | 204 | } | ||
| 201 | 205 | ||||
| 202 | func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) { | 206 | func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) { | ||
| @@ -255,53 +259,79 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD | |||||
| 255 | return t, nil | 259 | return t, nil | ||
| 256 | } | 260 | } | ||
| 257 | 261 | ||||
| 258 | - | // DatagramTransport marks this transport as UDP/datagram-backed for daemon | 262 | + | func (t *Transport) Send(f ports.Frame) error { return t.send(f, false) } |
| 259 | - | // output-state policy selection. | |||
| 260 | - | func (t *Transport) DatagramTransport() {} | |||
| 261 | 263 | ||||
| 262 | - | func (t *Transport) Send(f ports.Frame) error { | 264 | + | func (t *Transport) SendAsync(f ports.Frame) error { return t.send(f, true) } |
| 265 | + | ||||
| 266 | + | // SendSynchronous owns queue reservation, fragment pacing, per-write deadlines, | |||
| 267 | + | // and close cancellation. Callers must not layer a shorter preflight timer over it. | |||
| 268 | + | func (t *Transport) SendSynchronous(f ports.Frame) error { return t.Send(f) } | |||
| 269 | + | ||||
| 270 | + | func (t *Transport) send(f ports.Frame, async bool) error { | |||
| 263 | reliable := true | 271 | reliable := true | ||
| 264 | - | outputSkipThrough, err := t.lockOutboundSlot(f, reliable) | 272 | + | if err := t.lockOutboundSlot(reliable); err != nil { |
| 265 | - | if err != nil { | |||
| 266 | return err | 273 | return err | ||
| 267 | } | 274 | } | ||
| 268 | - | defer t.outboundMu.Unlock() | 275 | + | unlockOutbound := true |
| 276 | + | defer func() { | |||
| 277 | + | if unlockOutbound { | |||
| 278 | + | t.outboundMu.Unlock() | |||
| 279 | + | } | |||
| 280 | + | }() | |||
| 269 | // lockOutboundSlot returns with both outboundMu and mu held. | 281 | // lockOutboundSlot returns with both outboundMu and mu held. | ||
| 270 | 282 | ||||
| 271 | t.seq++ | 283 | t.seq++ | ||
| 272 | seq := t.seq | 284 | seq := t.seq | ||
| 273 | if reliable { | 285 | if reliable { | ||
| 274 | - | t.pending[seq] = &pending{frame: f, outputSkipThrough: outputSkipThrough} | 286 | + | t.pending[seq] = &pending{frame: f, enqueued: t.clock.Now()} |
| 275 | } | 287 | } | ||
| 276 | if shouldPaceOutput(f) { | 288 | if shouldPaceOutput(f) { | ||
| 277 | now := t.clock.Now() | 289 | now := t.clock.Now() | ||
| 278 | if len(t.outputQueue) > 0 || (!t.outputNext.IsZero() && now.Before(t.outputNext)) { | 290 | if len(t.outputQueue) > 0 || (!t.outputNext.IsZero() && now.Before(t.outputNext)) { | ||
| 279 | - | t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f, outputSkipThrough: outputSkipThrough}) | 291 | + | wasEmpty := len(t.outputQueue) == 0 |
| 280 | - | t.notifyOutputPacerLocked() | 292 | + | var done chan error |
| 293 | + | if !async { | |||
| 294 | + | done = make(chan error, 1) | |||
| 295 | + | } | |||
| 296 | + | t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f, done: done}) | |||
| 297 | + | if wasEmpty { | |||
| 298 | + | t.notifyOutputPacerLocked() | |||
| 299 | + | } | |||
| 281 | t.mu.Unlock() | 300 | t.mu.Unlock() | ||
| 282 | - | return nil | 301 | + | if done == nil { |
| 302 | + | return nil | |||
| 303 | + | } | |||
| 304 | + | t.outboundMu.Unlock() | |||
| 305 | + | unlockOutbound = false | |||
| 306 | + | return t.waitQueuedSend(done) | |||
| 283 | } | 307 | } | ||
| 284 | t.outputNext = now.Add(t.outputPaceDelayLocked()) | 308 | t.outputNext = now.Add(t.outputPaceDelayLocked()) | ||
| 285 | t.mu.Unlock() | 309 | t.mu.Unlock() | ||
| 286 | t.markPendingSent(seq, reliable) | 310 | t.markPendingSent(seq, reliable) | ||
| 287 | - | if err := t.sendData(seq, reliable, f, outputSkipThrough); err != nil { | 311 | + | if err := t.sendData(seq, reliable, f); err != nil { |
| 288 | t.removePending(seq, reliable) | 312 | t.removePending(seq, reliable) | ||
| 289 | return err | 313 | return err | ||
| 290 | } | 314 | } | ||
| 291 | t.markPendingReady(seq, reliable) | 315 | t.markPendingReady(seq, reliable) | ||
| 292 | return nil | 316 | return nil | ||
| 293 | } | 317 | } | ||
| 294 | - | queued := t.flushQueuedOutputLocked() | 318 | + | if len(t.outputQueue) > 0 { |
| 295 | - | t.mu.Unlock() | 319 | + | var done chan error |
| 296 | - | for i, q := range queued { | 320 | + | if !async { |
| 297 | - | if err := t.sendQueuedData(q); err != nil { | 321 | + | done = make(chan error, 1) |
| 298 | - | t.removePending(seq, reliable) | |||
| 299 | - | t.removeQueuedPending(queued[i+1:]) | |||
| 300 | - | return err | |||
| 301 | } | 322 | } | ||
| 323 | + | t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f, done: done}) | |||
| 324 | + | t.mu.Unlock() | |||
| 325 | + | if done == nil { | |||
| 326 | + | return nil | |||
| 327 | + | } | |||
| 328 | + | t.outboundMu.Unlock() | |||
| 329 | + | unlockOutbound = false | |||
| 330 | + | return t.waitQueuedSend(done) | |||
| 302 | } | 331 | } | ||
| 332 | + | t.mu.Unlock() | |||
| 303 | t.markPendingSent(seq, reliable) | 333 | t.markPendingSent(seq, reliable) | ||
| 304 | - | if err := t.sendData(seq, reliable, f, outputSkipThrough); err != nil { | 334 | + | if err := t.sendData(seq, reliable, f); err != nil { |
| 305 | t.removePending(seq, reliable) | 335 | t.removePending(seq, reliable) | ||
| 306 | return err | 336 | return err | ||
| 307 | } | 337 | } | ||
| @@ -309,9 +339,32 @@ func (t *Transport) Send(f ports.Frame) error { | |||||
| 309 | return nil | 339 | return nil | ||
| 310 | } | 340 | } | ||
| 311 | 341 | ||||
| 312 | - | func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, error) { | 342 | + | func (t *Transport) waitQueuedSend(done <-chan error) error { |
| 343 | + | select { | |||
| 344 | + | case err := <-done: | |||
| 345 | + | return err | |||
| 346 | + | case <-t.done: | |||
| 347 | + | t.mu.Lock() | |||
| 348 | + | err := t.closeErr | |||
| 349 | + | t.mu.Unlock() | |||
| 350 | + | if err != nil { | |||
| 351 | + | return err | |||
| 352 | + | } | |||
| 353 | + | return errors.New("dgram: closed") | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | func (t *Transport) closedError() error { | |||
| 358 | + | t.mu.Lock() | |||
| 359 | + | defer t.mu.Unlock() | |||
| 360 | + | if t.closeErr != nil { | |||
| 361 | + | return t.closeErr | |||
| 362 | + | } | |||
| 363 | + | return errors.New("dgram: closed") | |||
| 364 | + | } | |||
| 365 | + | ||||
| 366 | + | func (t *Transport) lockOutboundSlot(reliable bool) error { | |||
| 313 | var pendingWaitStarted time.Time | 367 | var pendingWaitStarted time.Time | ||
| 314 | - | var outputSkipThrough uint64 | |||
| 315 | for { | 368 | for { | ||
| 316 | t.outboundMu.Lock() | 369 | t.outboundMu.Lock() | ||
| 317 | t.mu.Lock() | 370 | t.mu.Lock() | ||
| @@ -320,22 +373,17 @@ func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, erro | |||||
| 320 | t.mu.Unlock() | 373 | t.mu.Unlock() | ||
| 321 | t.outboundMu.Unlock() | 374 | t.outboundMu.Unlock() | ||
| 322 | if err != nil { | 375 | if err != nil { | ||
| 323 | - | return 0, err | 376 | + | return err |
| 324 | - | } | |||
| 325 | - | return 0, errors.New("dgram: closed") | |||
| 326 | - | } | |||
| 327 | - | if f.Type == ports.MsgOutput { | |||
| 328 | - | if skipped := t.supersedePendingOutputLocked(); skipped > outputSkipThrough { | |||
| 329 | - | outputSkipThrough = skipped | |||
| 330 | } | 377 | } | ||
| 378 | + | return errors.New("dgram: closed") | |||
| 331 | } | 379 | } | ||
| 332 | if !reliable || len(t.pending) < t.maxPending { | 380 | if !reliable || len(t.pending) < t.maxPending { | ||
| 333 | - | return outputSkipThrough, nil | 381 | + | return nil |
| 334 | } | 382 | } | ||
| 335 | if t.linkState != ports.LinkStateConnected { | 383 | if t.linkState != ports.LinkStateConnected { | ||
| 336 | t.mu.Unlock() | 384 | t.mu.Unlock() | ||
| 337 | t.outboundMu.Unlock() | 385 | t.outboundMu.Unlock() | ||
| 338 | - | return 0, ErrPendingFull | 386 | + | return ErrPendingFull |
| 339 | } | 387 | } | ||
| 340 | if pendingWaitStarted.IsZero() { | 388 | if pendingWaitStarted.IsZero() { | ||
| 341 | pendingWaitStarted = t.clock.Now() | 389 | pendingWaitStarted = t.clock.Now() | ||
| @@ -344,7 +392,7 @@ func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, erro | |||||
| 344 | if remaining <= 0 { | 392 | if remaining <= 0 { | ||
| 345 | t.mu.Unlock() | 393 | t.mu.Unlock() | ||
| 346 | t.outboundMu.Unlock() | 394 | t.outboundMu.Unlock() | ||
| 347 | - | return 0, ErrPendingFull | 395 | + | return ErrPendingFull |
| 348 | } | 396 | } | ||
| 349 | wake := t.sendWake | 397 | wake := t.sendWake | ||
| 350 | timer := t.clock.NewTimer(remaining) | 398 | timer := t.clock.NewTimer(remaining) | ||
| @@ -371,13 +419,17 @@ func (t *Transport) removePending(seq uint64, reliable bool) { | |||||
| 371 | t.mu.Unlock() | 419 | t.mu.Unlock() | ||
| 372 | } | 420 | } | ||
| 373 | 421 | ||||
| 374 | - | func (t *Transport) removeQueuedPending(queued []queuedSend) { | 422 | + | func (t *Transport) removeQueuedPending(queued []queuedSend, err error) { |
| 375 | t.mu.Lock() | 423 | t.mu.Lock() | ||
| 376 | removed := false | 424 | removed := false | ||
| 377 | for _, q := range queued { | 425 | for _, q := range queued { | ||
| 378 | if q.reliable { | 426 | if q.reliable { | ||
| 379 | removed = t.removePendingLocked(q.seq) || removed | 427 | removed = t.removePendingLocked(q.seq) || removed | ||
| 380 | } | 428 | } | ||
| 429 | + | if q.done != nil { | |||
| 430 | + | q.done <- err | |||
| 431 | + | close(q.done) | |||
| 432 | + | } | |||
| 381 | } | 433 | } | ||
| 382 | if removed { | 434 | if removed { | ||
| 383 | t.notifySendWaitersLocked() | 435 | t.notifySendWaitersLocked() | ||
| @@ -393,41 +445,6 @@ func (t *Transport) removePendingLocked(seq uint64) bool { | |||||
| 393 | return true | 445 | return true | ||
| 394 | } | 446 | } | ||
| 395 | 447 | ||||
| 396 | - | func (t *Transport) supersedePendingOutputLocked() uint64 { | |||
| 397 | - | var barrier uint64 | |||
| 398 | - | for seq, p := range t.pending { | |||
| 399 | - | if p != nil && p.frame.Type != ports.MsgOutput && seq > barrier { | |||
| 400 | - | barrier = seq | |||
| 401 | - | } | |||
| 402 | - | } | |||
| 403 | - | var newest uint64 | |||
| 404 | - | for seq, p := range t.pending { | |||
| 405 | - | if p == nil || p.frame.Type != ports.MsgOutput || seq <= barrier { | |||
| 406 | - | continue | |||
| 407 | - | } | |||
| 408 | - | if seq > newest { | |||
| 409 | - | newest = seq | |||
| 410 | - | } | |||
| 411 | - | } | |||
| 412 | - | if newest == 0 { | |||
| 413 | - | return 0 | |||
| 414 | - | } | |||
| 415 | - | for seq, p := range t.pending { | |||
| 416 | - | if p != nil && p.frame.Type == ports.MsgOutput && seq > barrier && seq <= newest { | |||
| 417 | - | delete(t.pending, seq) | |||
| 418 | - | } | |||
| 419 | - | } | |||
| 420 | - | kept := t.outputQueue[:0] | |||
| 421 | - | for _, q := range t.outputQueue { | |||
| 422 | - | if q.seq > barrier && q.seq <= newest { | |||
| 423 | - | continue | |||
| 424 | - | } | |||
| 425 | - | kept = append(kept, q) | |||
| 426 | - | } | |||
| 427 | - | t.outputQueue = kept | |||
| 428 | - | t.notifySendWaitersLocked() | |||
| 429 | - | return newest | |||
| 430 | - | } | |||
| 431 | func (t *Transport) Recv() (ports.Frame, error) { | 448 | func (t *Transport) Recv() (ports.Frame, error) { | ||
| 432 | select { | 449 | select { | ||
| 433 | case f := <-t.in: | 450 | case f := <-t.in: | ||
| @@ -509,28 +526,40 @@ func (t *Transport) unregisterProbe(id uint64) { | |||||
| 509 | t.mu.Unlock() | 526 | t.mu.Unlock() | ||
| 510 | } | 527 | } | ||
| 511 | 528 | ||||
| 512 | - | func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame, outputSkipThrough uint64) error { | 529 | + | func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error { |
| 513 | - | p := encodeData(seq, reliable, f, outputSkipThrough) | 530 | + | p := encodeData(seq, reliable, f) |
| 514 | - | return t.sendPayload(p) | 531 | + | return t.sendPayload(p, true, seq) |
| 515 | } | 532 | } | ||
| 516 | func (t *Transport) sendAck(seq uint64) { | 533 | func (t *Transport) sendAck(seq uint64) { | ||
| 517 | var b [9]byte | 534 | var b [9]byte | ||
| 518 | b[0] = recAck | 535 | b[0] = recAck | ||
| 519 | binary.BigEndian.PutUint64(b[1:], seq) | 536 | binary.BigEndian.PutUint64(b[1:], seq) | ||
| 520 | - | _ = t.sendPayload(b[:]) | 537 | + | _ = t.sendPayload(b[:], false, 0) |
| 521 | } | 538 | } | ||
| 522 | func (t *Transport) sendProbe(kind byte, id uint64) error { | 539 | func (t *Transport) sendProbe(kind byte, id uint64) error { | ||
| 523 | var b [9]byte | 540 | var b [9]byte | ||
| 524 | b[0] = kind | 541 | b[0] = kind | ||
| 525 | binary.BigEndian.PutUint64(b[1:], id) | 542 | binary.BigEndian.PutUint64(b[1:], id) | ||
| 526 | - | return t.sendPayload(b[:]) | 543 | + | return t.sendPayload(b[:], false, 0) |
| 527 | } | 544 | } | ||
| 528 | - | func (t *Transport) sendPayload(p []byte) error { | 545 | + | func (t *Transport) sendPayload(p []byte, paced bool, pendingSeq uint64) error { |
| 546 | + | if paced { | |||
| 547 | + | t.dataPaceMu.Lock() | |||
| 548 | + | defer t.dataPaceMu.Unlock() | |||
| 549 | + | } | |||
| 529 | frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead()) | 550 | frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead()) | ||
| 530 | if err != nil { | 551 | if err != nil { | ||
| 531 | return err | 552 | return err | ||
| 532 | } | 553 | } | ||
| 533 | - | for _, f := range frags { | 554 | + | for i, f := range frags { |
| 555 | + | if pendingSeq != 0 && !t.pendingExists(pendingSeq) { | |||
| 556 | + | return nil | |||
| 557 | + | } | |||
| 558 | + | if paced { | |||
| 559 | + | if err := t.takeDataPaceSlot(); err != nil { | |||
| 560 | + | return err | |||
| 561 | + | } | |||
| 562 | + | } | |||
| 534 | raw, err := pdgram.MarshalFragment(f) | 563 | raw, err := pdgram.MarshalFragment(f) | ||
| 535 | if err != nil { | 564 | if err != nil { | ||
| 536 | return err | 565 | return err | ||
| @@ -550,6 +579,9 @@ func (t *Transport) sendPayload(p []byte) error { | |||||
| 550 | if peer == nil { | 579 | if peer == nil { | ||
| 551 | return errors.New("dgram: no peer") | 580 | return errors.New("dgram: no peer") | ||
| 552 | } | 581 | } | ||
| 582 | + | if pendingSeq != 0 && i == len(frags)-1 { | |||
| 583 | + | t.markPendingFinalWrite(pendingSeq) | |||
| 584 | + | } | |||
| 553 | if err := t.writePacket(pkt, peer); err != nil { | 585 | if err := t.writePacket(pkt, peer); err != nil { | ||
| 554 | return err | 586 | return err | ||
| 555 | } | 587 | } | ||
| @@ -557,6 +589,37 @@ func (t *Transport) sendPayload(p []byte) error { | |||||
| 557 | return nil | 589 | return nil | ||
| 558 | } | 590 | } | ||
| 559 | 591 | ||||
| 592 | + | func (t *Transport) pendingExists(seq uint64) bool { | |||
| 593 | + | t.mu.Lock() | |||
| 594 | + | defer t.mu.Unlock() | |||
| 595 | + | _, ok := t.pending[seq] | |||
| 596 | + | return ok | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | func (t *Transport) takeDataPaceSlot() error { | |||
| 600 | + | now := t.clock.Now() | |||
| 601 | + | if t.dataPaceRemaining > 0 && now.Before(t.dataPaceNext) { | |||
| 602 | + | t.dataPaceRemaining-- | |||
| 603 | + | return nil | |||
| 604 | + | } | |||
| 605 | + | if t.dataPaceNext.After(now) { | |||
| 606 | + | timer := t.clock.NewTimer(t.dataPaceNext.Sub(now)) | |||
| 607 | + | select { | |||
| 608 | + | case <-timer.C(): | |||
| 609 | + | case <-t.done: | |||
| 610 | + | timer.Stop() | |||
| 611 | + | return t.closedError() | |||
| 612 | + | } | |||
| 613 | + | timer.Stop() | |||
| 614 | + | } | |||
| 615 | + | t.mu.Lock() | |||
| 616 | + | delay := t.outputPaceDelayLocked() | |||
| 617 | + | t.mu.Unlock() | |||
| 618 | + | t.dataPaceRemaining = defaultPacketPaceBudget - 1 | |||
| 619 | + | t.dataPaceNext = t.clock.Now().Add(delay) | |||
| 620 | + | return nil | |||
| 621 | + | } | |||
| 622 | + | ||||
| 560 | func (t *Transport) writePacket(pkt []byte, peer net.Addr) error { | 623 | func (t *Transport) writePacket(pkt []byte, peer net.Addr) error { | ||
| 561 | t.writeMu.Lock() | 624 | t.writeMu.Lock() | ||
| 562 | defer t.writeMu.Unlock() | 625 | defer t.writeMu.Unlock() | ||
| @@ -669,12 +732,12 @@ func (t *Transport) handleRecord(p []byte) { | |||||
| 669 | } | 732 | } | ||
| 670 | t.mu.Unlock() | 733 | t.mu.Unlock() | ||
| 671 | case recData: | 734 | case recData: | ||
| 672 | - | seq, reliable, f, outputSkipThrough, ok := decodeData(p) | 735 | + | seq, reliable, f, ok := decodeData(p) |
| 673 | if !ok { | 736 | if !ok { | ||
| 674 | return | 737 | return | ||
| 675 | } | 738 | } | ||
| 676 | if reliable { | 739 | if reliable { | ||
| 677 | - | ackSeq, ack, queued := t.enqueueReliable(seq, f, outputSkipThrough) | 740 | + | ackSeq, ack, queued := t.enqueueReliable(seq, f) |
| 678 | if ack { | 741 | if ack { | ||
| 679 | t.sendAck(ackSeq) | 742 | t.sendAck(ackSeq) | ||
| 680 | } | 743 | } | ||
| @@ -687,18 +750,9 @@ func (t *Transport) handleRecord(p []byte) { | |||||
| 687 | } | 750 | } | ||
| 688 | } | 751 | } | ||
| 689 | 752 | ||||
| 690 | - | func (t *Transport) enqueueReliable(seq uint64, f ports.Frame, outputSkipThrough uint64) (ackSeq uint64, ack bool, queued bool) { | 753 | + | func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, ack bool, queued bool) { |
| 691 | t.deliverMu.Lock() | 754 | t.deliverMu.Lock() | ||
| 692 | defer t.deliverMu.Unlock() | 755 | defer t.deliverMu.Unlock() | ||
| 693 | - | if f.Type == ports.MsgOutput && outputSkipThrough > t.recvSkipThrough { | |||
| 694 | - | t.recvSkipThrough = outputSkipThrough | |||
| 695 | - | for bufferedSeq, buffered := range t.recvBuf { | |||
| 696 | - | if bufferedSeq <= outputSkipThrough && buffered.Type == ports.MsgOutput { | |||
| 697 | - | delete(t.recvBuf, bufferedSeq) | |||
| 698 | - | } | |||
| 699 | - | } | |||
| 700 | - | t.deliverCond.Signal() | |||
| 701 | - | } | |||
| 702 | if seq < t.nextRecvSeq { | 756 | if seq < t.nextRecvSeq { | ||
| 703 | return t.highestContiguousRecvLocked(), true, false | 757 | return t.highestContiguousRecvLocked(), true, false | ||
| 704 | } | 758 | } | ||
| @@ -719,10 +773,6 @@ func (t *Transport) highestContiguousRecvLocked() uint64 { | |||||
| 719 | seq++ | 773 | seq++ | ||
| 720 | continue | 774 | continue | ||
| 721 | } | 775 | } | ||
| 722 | - | if seq <= t.recvSkipThrough { | |||
| 723 | - | seq++ | |||
| 724 | - | continue | |||
| 725 | - | } | |||
| 726 | break | 776 | break | ||
| 727 | } | 777 | } | ||
| 728 | return seq - 1 | 778 | return seq - 1 | ||
| @@ -738,10 +788,6 @@ func (t *Transport) deliveryLoop() { | |||||
| 738 | for { | 788 | for { | ||
| 739 | t.deliverMu.Lock() | 789 | t.deliverMu.Lock() | ||
| 740 | f, ok := t.recvBuf[t.nextRecvSeq] | 790 | f, ok := t.recvBuf[t.nextRecvSeq] | ||
| 741 | - | for !ok && t.nextRecvSeq <= t.recvSkipThrough { | |||
| 742 | - | t.nextRecvSeq++ | |||
| 743 | - | f, ok = t.recvBuf[t.nextRecvSeq] | |||
| 744 | - | } | |||
| 745 | for !ok { | 791 | for !ok { | ||
| 746 | if t.isClosed() { | 792 | if t.isClosed() { | ||
| 747 | t.deliverMu.Unlock() | 793 | t.deliverMu.Unlock() | ||
| @@ -749,16 +795,6 @@ func (t *Transport) deliveryLoop() { | |||||
| 749 | } | 795 | } | ||
| 750 | t.deliverCond.Wait() | 796 | t.deliverCond.Wait() | ||
| 751 | f, ok = t.recvBuf[t.nextRecvSeq] | 797 | f, ok = t.recvBuf[t.nextRecvSeq] | ||
| 752 | - | for !ok && t.nextRecvSeq <= t.recvSkipThrough { | |||
| 753 | - | t.nextRecvSeq++ | |||
| 754 | - | f, ok = t.recvBuf[t.nextRecvSeq] | |||
| 755 | - | } | |||
| 756 | - | } | |||
| 757 | - | if t.nextRecvSeq <= t.recvSkipThrough && f.Type == ports.MsgOutput { | |||
| 758 | - | delete(t.recvBuf, t.nextRecvSeq) | |||
| 759 | - | t.nextRecvSeq++ | |||
| 760 | - | t.deliverMu.Unlock() | |||
| 761 | - | continue | |||
| 762 | } | 798 | } | ||
| 763 | delete(t.recvBuf, t.nextRecvSeq) | 799 | delete(t.recvBuf, t.nextRecvSeq) | ||
| 764 | t.nextRecvSeq++ | 800 | t.nextRecvSeq++ | ||
| @@ -825,9 +861,8 @@ func (t *Transport) resendLoop() { | |||||
| 825 | func (t *Transport) resendPending() { | 861 | func (t *Transport) resendPending() { | ||
| 826 | now := t.clock.Now() | 862 | now := t.clock.Now() | ||
| 827 | var resend []struct { | 863 | var resend []struct { | ||
| 828 | - | seq uint64 | 864 | + | seq uint64 |
| 829 | - | f ports.Frame | 865 | + | f ports.Frame |
| 830 | - | outputSkipThrough uint64 | |||
| 831 | } | 866 | } | ||
| 832 | t.mu.Lock() | 867 | t.mu.Lock() | ||
| 833 | seqs := make([]uint64, 0, len(t.pending)) | 868 | seqs := make([]uint64, 0, len(t.pending)) | ||
| @@ -844,19 +879,26 @@ func (t *Transport) resendPending() { | |||||
| 844 | if p == nil || p.last.IsZero() || p.initialInFlight || now.Sub(p.last) < t.resendDelayLocked(p) { | 879 | if p == nil || p.last.IsZero() || p.initialInFlight || now.Sub(p.last) < t.resendDelayLocked(p) { | ||
| 845 | continue | 880 | continue | ||
| 846 | } | 881 | } | ||
| 847 | - | p.last = now | 882 | + | p.initialInFlight = true |
| 848 | p.attempts++ | 883 | p.attempts++ | ||
| 849 | p.retransmitted = true | 884 | p.retransmitted = true | ||
| 850 | resend = append(resend, struct { | 885 | resend = append(resend, struct { | ||
| 851 | - | seq uint64 | 886 | + | seq uint64 |
| 852 | - | f ports.Frame | 887 | + | f ports.Frame |
| 853 | - | outputSkipThrough uint64 | 888 | + | }{seq, p.frame}) |
| 854 | - | }{seq, p.frame, p.outputSkipThrough}) | |||
| 855 | limit-- | 889 | limit-- | ||
| 856 | } | 890 | } | ||
| 857 | t.mu.Unlock() | 891 | t.mu.Unlock() | ||
| 858 | for _, r := range resend { | 892 | for _, r := range resend { | ||
| 859 | - | _ = t.sendData(r.seq, true, r.f, r.outputSkipThrough) | 893 | + | if err := t.sendData(r.seq, true, r.f); err == nil { |
| 894 | + | t.markPendingReady(r.seq, true) | |||
| 895 | + | } else { | |||
| 896 | + | t.mu.Lock() | |||
| 897 | + | if p := t.pending[r.seq]; p != nil { | |||
| 898 | + | p.initialInFlight = false | |||
| 899 | + | } | |||
| 900 | + | t.mu.Unlock() | |||
| 901 | + | } | |||
| 860 | } | 902 | } | ||
| 861 | } | 903 | } | ||
| 862 | 904 | ||||
| @@ -966,7 +1008,7 @@ func (t *Transport) checkSilence() { | |||||
| 966 | // has gone unacknowledged for deadAfter despite at least one retransmit. The | 1008 | // has gone unacknowledged for deadAfter despite at least one retransmit. The | ||
| 967 | // receive-silence check in checkSilence misses one-way-dead links whose receive | 1009 | // receive-silence check in checkSilence misses one-way-dead links whose receive | ||
| 968 | // side stays fresh from peer heartbeats; this catches them at the layer that | 1010 | // side stays fresh from peer heartbeats; this catches them at the layer that | ||
| 969 | - | // owns death. first is set once when a frame is enqueued and never refreshed, | 1011 | + | // owns death. enqueued is set once when a frame is accepted and never refreshed, |
| 970 | // so acks (which delete pending entries) keep the oldest age bounded on a | 1012 | // so acks (which delete pending entries) keep the oldest age bounded on a | ||
| 971 | // progressing link — only a frame that is never acked crosses the threshold. | 1013 | // progressing link — only a frame that is never acked crosses the threshold. | ||
| 972 | func (t *Transport) checkSendStall() { | 1014 | func (t *Transport) checkSendStall() { | ||
| @@ -981,11 +1023,11 @@ func (t *Transport) checkSendStall() { | |||||
| 981 | var attempts int | 1023 | var attempts int | ||
| 982 | found := false | 1024 | found := false | ||
| 983 | for _, p := range t.pending { | 1025 | for _, p := range t.pending { | ||
| 984 | - | if p == nil || p.first.IsZero() { | 1026 | + | if p == nil || p.enqueued.IsZero() { |
| 985 | continue | 1027 | continue | ||
| 986 | } | 1028 | } | ||
| 987 | - | if !found || p.first.Before(oldest) { | 1029 | + | if !found || p.enqueued.Before(oldest) { |
| 988 | - | oldest = p.first | 1030 | + | oldest = p.enqueued |
| 989 | attempts = p.attempts | 1031 | attempts = p.attempts | ||
| 990 | found = true | 1032 | found = true | ||
| 991 | } | 1033 | } | ||
| @@ -1030,6 +1072,13 @@ func (t *Transport) closeWithError(err error) { | |||||
| 1030 | } | 1072 | } | ||
| 1031 | t.closed = true | 1073 | t.closed = true | ||
| 1032 | t.closeErr = err | 1074 | t.closeErr = err | ||
| 1075 | + | for _, q := range t.outputQueue { | |||
| 1076 | + | if q.done != nil { | |||
| 1077 | + | q.done <- err | |||
| 1078 | + | close(q.done) | |||
| 1079 | + | } | |||
| 1080 | + | } | |||
| 1081 | + | t.outputQueue = nil | |||
| 1033 | t.probeWait = make(map[uint64]chan struct{}) | 1082 | t.probeWait = make(map[uint64]chan struct{}) | ||
| 1034 | close(t.done) | 1083 | close(t.done) | ||
| 1035 | t.notifySendWaitersLocked() | 1084 | t.notifySendWaitersLocked() | ||
| @@ -1039,41 +1088,23 @@ func (t *Transport) closeWithError(err error) { | |||||
| 1039 | t.deliverMu.Unlock() | 1088 | t.deliverMu.Unlock() | ||
| 1040 | } | 1089 | } | ||
| 1041 | 1090 | ||||
| 1042 | - | func encodeData(seq uint64, reliable bool, f ports.Frame, outputSkipThrough uint64) []byte { | 1091 | + | func encodeData(seq uint64, reliable bool, f ports.Frame) []byte { |
| 1043 | - | headerLen := 12 | 1092 | + | b := make([]byte, dataRecordHeaderSize+len(f.Payload)) |
| 1044 | - | if outputSkipThrough > 0 { | |||
| 1045 | - | headerLen = 20 | |||
| 1046 | - | } | |||
| 1047 | - | b := make([]byte, headerLen+len(f.Payload)) | |||
| 1048 | b[0] = recData | 1093 | b[0] = recData | ||
| 1049 | binary.BigEndian.PutUint64(b[1:9], seq) | 1094 | binary.BigEndian.PutUint64(b[1:9], seq) | ||
| 1050 | if reliable { | 1095 | if reliable { | ||
| 1051 | b[9] = 1 | 1096 | b[9] = 1 | ||
| 1052 | } | 1097 | } | ||
| 1053 | b[10] = byte(f.Type) | 1098 | b[10] = byte(f.Type) | ||
| 1054 | - | if outputSkipThrough > 0 { | 1099 | + | copy(b[dataRecordHeaderSize:], f.Payload) |
| 1055 | - | b[11] = 1 | |||
| 1056 | - | binary.BigEndian.PutUint64(b[12:20], outputSkipThrough) | |||
| 1057 | - | } | |||
| 1058 | - | copy(b[headerLen:], f.Payload) | |||
| 1059 | return b | 1100 | return b | ||
| 1060 | } | 1101 | } | ||
| 1061 | - | func decodeData(b []byte) (uint64, bool, ports.Frame, uint64, bool) { | 1102 | + | func decodeData(b []byte) (uint64, bool, ports.Frame, bool) { |
| 1062 | - | if len(b) < 12 || b[0] != recData { | 1103 | + | if len(b) < dataRecordHeaderSize || b[0] != recData { |
| 1063 | - | return 0, false, ports.Frame{}, 0, false | 1104 | + | return 0, false, ports.Frame{}, false |
| 1064 | - | } | 1105 | + | } |
| 1065 | - | flags := b[11] | 1106 | + | if b[11] != 0 { |
| 1066 | - | if flags&^byte(1) != 0 { | 1107 | + | return 0, false, ports.Frame{}, false |
| 1067 | - | return 0, false, ports.Frame{}, 0, false | |||
| 1068 | - | } | |||
| 1069 | - | headerLen := 12 | |||
| 1070 | - | outputSkipThrough := uint64(0) | |||
| 1071 | - | if flags&1 != 0 { | |||
| 1072 | - | if len(b) < 20 { | |||
| 1073 | - | return 0, false, ports.Frame{}, 0, false | |||
| 1074 | - | } | |||
| 1075 | - | headerLen = 20 | |||
| 1076 | - | outputSkipThrough = binary.BigEndian.Uint64(b[12:20]) | |||
| 1077 | } | 1108 | } | ||
| 1078 | - | return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[headerLen:]...)}, outputSkipThrough, true | 1109 | + | return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[dataRecordHeaderSize:]...)}, true |
| 1079 | } | 1110 | } | ||
M internal/adapters/dgram/transport_test.go +376 -89
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/ports" | |
| 15 | 15 | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 16 | + | "github.com/stretchr/testify/require" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| 18 | 19 | type testAddr string | |
| @@ -25,12 +26,13 @@ type packet struct { | |||
| 25 | 26 | addr net.Addr | |
| 26 | 27 | } | |
| 27 | 28 | type fakePC struct { | |
| 28 | - | addr net.Addr | |
| 29 | - | mu sync.Mutex | |
| 30 | - | closed bool | |
| 31 | - | in chan packet | |
| 32 | - | peers map[string]*fakePC | |
| 33 | - | drop func([]byte, net.Addr) bool | |
| 29 | + | addr net.Addr | |
| 30 | + | mu sync.Mutex | |
| 31 | + | closed bool | |
| 32 | + | in chan packet | |
| 33 | + | peers map[string]*fakePC | |
| 34 | + | drop func([]byte, net.Addr) bool | |
| 35 | + | afterWrite func() | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | func newPair() (*fakePC, *fakePC) { | |
| @@ -60,11 +62,15 @@ func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) { | |||
| 60 | 62 | } | |
| 61 | 63 | peer := p.peers[addr.String()] | |
| 62 | 64 | peer.mu.Lock() | |
| 63 | - | defer peer.mu.Unlock() | |
| 64 | 65 | if peer.closed { | |
| 66 | + | peer.mu.Unlock() | |
| 65 | 67 | return 0, errors.New("peer closed") | |
| 66 | 68 | } | |
| 67 | 69 | peer.in <- packet{append([]byte(nil), b...), p.addr} | |
| 70 | + | peer.mu.Unlock() | |
| 71 | + | if p.afterWrite != nil { | |
| 72 | + | p.afterWrite() | |
| 73 | + | } | |
| 68 | 74 | return len(b), nil | |
| 69 | 75 | } | |
| 70 | 76 | func (p *fakePC) Close() error { | |
| @@ -299,7 +305,7 @@ func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error { | |||
| 299 | 305 | return nil | |
| 300 | 306 | } | |
| 301 | 307 | ||
| 302 | - | func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) { | |
| 308 | + | func TestOutputFirstSendIsPacedAndQueuedOutputsAreBatched(t *testing.T) { | |
| 303 | 309 | aPC, bPC := newPair() | |
| 304 | 310 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 305 | 311 | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | |
| @@ -309,7 +315,7 @@ func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) { | |||
| 309 | 315 | defer func() { _ = a.Close() }() | |
| 310 | 316 | ||
| 311 | 317 | for i := range 3 { | |
| 312 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |
| 318 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |
| 313 | 319 | t.Fatal(err) | |
| 314 | 320 | } | |
| 315 | 321 | } | |
| @@ -318,15 +324,156 @@ func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) { | |||
| 318 | 324 | } | |
| 319 | 325 | waitForManualTimers(t, clk, 3) | |
| 320 | 326 | clk.advance(defaultOutputPaceMinDelay) | |
| 321 | - | eventuallyPacketCount(t, bPC, 2) | |
| 322 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil { | |
| 327 | + | eventuallyPacketCount(t, bPC, 3) | |
| 328 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil { | |
| 323 | 329 | t.Fatal(err) | |
| 324 | 330 | } | |
| 325 | - | if got := len(bPC.in); got != 2 { | |
| 326 | - | t.Fatalf("datagrams before second pace tick=%d, want 2", got) | |
| 331 | + | if got := len(bPC.in); got != 3 { | |
| 332 | + | t.Fatalf("datagrams before second pace tick=%d, want 3", got) | |
| 327 | 333 | } | |
| 328 | 334 | clk.advance(defaultOutputPaceMinDelay) | |
| 329 | - | eventuallyPacketCount(t, bPC, 3) | |
| 335 | + | eventuallyPacketCount(t, bPC, 4) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) { | |
| 339 | + | aPC, _ := newPair() | |
| 340 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 341 | + | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | |
| 342 | + | if err != nil { | |
| 343 | + | t.Fatal(err) | |
| 344 | + | } | |
| 345 | + | defer func() { _ = a.Close() }() | |
| 346 | + | ||
| 347 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil { | |
| 348 | + | t.Fatal(err) | |
| 349 | + | } | |
| 350 | + | large := ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: make([]byte, 24*a.mtu)}) | |
| 351 | + | for range 2 { | |
| 352 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil { | |
| 353 | + | t.Fatal(err) | |
| 354 | + | } | |
| 355 | + | } | |
| 356 | + | waitForManualTimers(t, clk, 3) | |
| 357 | + | previousPackets := len(aPC.in) | |
| 358 | + | for tick := 0; tick < 128; tick++ { | |
| 359 | + | clk.advance(defaultOutputPaceMaxDelay) | |
| 360 | + | time.Sleep(time.Millisecond) | |
| 361 | + | packets := len(aPC.in) | |
| 362 | + | if delta := packets - previousPackets; delta > defaultPacketPaceBudget { | |
| 363 | + | t.Fatalf("tick %d emitted %d packets, budget %d", tick, delta, defaultPacketPaceBudget) | |
| 364 | + | } | |
| 365 | + | previousPackets = packets | |
| 366 | + | a.mu.Lock() | |
| 367 | + | last := a.pending[3] | |
| 368 | + | done := len(a.outputQueue) == 0 && last != nil && !last.initialInFlight | |
| 369 | + | a.mu.Unlock() | |
| 370 | + | if done { | |
| 371 | + | return | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | t.Fatal("oversized queued frames did not complete within pacing ticks") | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) { | |
| 378 | + | aPC, _ := newPair() | |
| 379 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 380 | + | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ | |
| 381 | + | Clock: clk, ResendAfter: 500 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour, | |
| 382 | + | }) | |
| 383 | + | if err != nil { | |
| 384 | + | t.Fatal(err) | |
| 385 | + | } | |
| 386 | + | defer func() { _ = a.Close() }() | |
| 387 | + | large := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*a.mtu)} | |
| 388 | + | done := make(chan error, 1) | |
| 389 | + | go func() { done <- a.SendAsync(large) }() | |
| 390 | + | start := clk.Now() | |
| 391 | + | for tick := 0; tick < 128; tick++ { | |
| 392 | + | a.mu.Lock() | |
| 393 | + | p := a.pending[1] | |
| 394 | + | if p != nil && p.initialInFlight && !p.last.IsZero() { | |
| 395 | + | a.mu.Unlock() | |
| 396 | + | t.Fatal("pending last timestamp set before final fragment") | |
| 397 | + | } | |
| 398 | + | a.mu.Unlock() | |
| 399 | + | clk.advance(defaultOutputPaceMaxDelay) | |
| 400 | + | time.Sleep(time.Millisecond) | |
| 401 | + | select { | |
| 402 | + | case err := <-done: | |
| 403 | + | if err != nil { | |
| 404 | + | t.Fatal(err) | |
| 405 | + | } | |
| 406 | + | a.mu.Lock() | |
| 407 | + | completed := a.pending[1].last | |
| 408 | + | a.mu.Unlock() | |
| 409 | + | if elapsed := completed.Sub(start); elapsed <= defaultOutputPaceMaxDelay { | |
| 410 | + | t.Fatalf("large send completed too early after %v", elapsed) | |
| 411 | + | } | |
| 412 | + | before := len(aPC.in) | |
| 413 | + | clk.advance(499 * time.Millisecond) | |
| 414 | + | a.resendPending() | |
| 415 | + | if got := len(aPC.in); got != before { | |
| 416 | + | t.Fatalf("resend emitted before one RTO after final fragment: %d -> %d", before, got) | |
| 417 | + | } | |
| 418 | + | a.handleRecord(ackRecord(1)) | |
| 419 | + | a.mu.Lock() | |
| 420 | + | srtt := a.srtt | |
| 421 | + | a.mu.Unlock() | |
| 422 | + | require.Equal(t, 499*time.Millisecond, srtt, "RTT sample must start at final-fragment completion") | |
| 423 | + | return | |
| 424 | + | default: | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | t.Fatal("large initial send did not complete") | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *testing.T) { | |
| 431 | + | aPC, bPC := newPair() | |
| 432 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 433 | + | a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ | |
| 434 | + | Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour, | |
| 435 | + | }) | |
| 436 | + | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |
| 437 | + | defer func() { _ = a.Close() }() | |
| 438 | + | defer func() { _ = b.Close() }() | |
| 439 | + | a.mu.Lock() | |
| 440 | + | a.srtt = 500 * time.Millisecond | |
| 441 | + | a.mu.Unlock() | |
| 442 | + | for i := range 8 { | |
| 443 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |
| 444 | + | t.Fatal(err) | |
| 445 | + | } | |
| 446 | + | } | |
| 447 | + | now := clk.Now() | |
| 448 | + | a.mu.Lock() | |
| 449 | + | for _, seq := range []uint64{100, 101} { | |
| 450 | + | a.pending[seq] = &pending{ | |
| 451 | + | frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 20*a.mtu)}, | |
| 452 | + | enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second), | |
| 453 | + | } | |
| 454 | + | } | |
| 455 | + | a.mu.Unlock() | |
| 456 | + | go a.resendPending() | |
| 457 | + | sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: make([]byte, 100*1024)})} | |
| 458 | + | done := make(chan error, 1) | |
| 459 | + | go func() { done <- a.SendSynchronous(sideEffect) }() | |
| 460 | + | ||
| 461 | + | for tick := 0; tick < 512; tick++ { | |
| 462 | + | clk.advance(defaultOutputPaceMaxDelay) | |
| 463 | + | time.Sleep(time.Millisecond) | |
| 464 | + | select { | |
| 465 | + | case err := <-done: | |
| 466 | + | if err != nil { | |
| 467 | + | t.Fatal(err) | |
| 468 | + | } | |
| 469 | + | if a.isClosed() { | |
| 470 | + | t.Fatal("healthy owned synchronous send force-closed transport") | |
| 471 | + | } | |
| 472 | + | return | |
| 473 | + | default: | |
| 474 | + | } | |
| 475 | + | } | |
| 476 | + | t.Fatal("maximum side effect did not complete through queued/retransmit pacing") | |
| 330 | 477 | } | |
| 331 | 478 | ||
| 332 | 479 | func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) { | |
| @@ -399,7 +546,7 @@ func TestInputWaitsForDequeuedOutputBatch(t *testing.T) { | |||
| 399 | 546 | defer func() { _ = a.Close() }() | |
| 400 | 547 | ||
| 401 | 548 | for _, payload := range []string{"first", "queued"} { | |
| 402 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil { | |
| 549 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil { | |
| 403 | 550 | t.Fatal(err) | |
| 404 | 551 | } | |
| 405 | 552 | } | |
| @@ -443,7 +590,7 @@ func TestInputWaitsForDequeuedOutputBatch(t *testing.T) { | |||
| 443 | 590 | } | |
| 444 | 591 | } | |
| 445 | 592 | ||
| 446 | - | func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) { | |
| 593 | + | func TestPacedControlQueueFailureClosesTransport(t *testing.T) { | |
| 447 | 594 | aPC, bPC := newPair() | |
| 448 | 595 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 449 | 596 | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | |
| @@ -453,7 +600,7 @@ func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) { | |||
| 453 | 600 | defer func() { _ = a.Close() }() | |
| 454 | 601 | ||
| 455 | 602 | for i := range 3 { | |
| 456 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |
| 603 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |
| 457 | 604 | t.Fatal(err) | |
| 458 | 605 | } | |
| 459 | 606 | } | |
| @@ -464,16 +611,78 @@ func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) { | |||
| 464 | 611 | t.Fatal(err) | |
| 465 | 612 | } | |
| 466 | 613 | ||
| 467 | - | if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err == nil { | |
| 468 | - | t.Fatal("input send after peer close succeeded, want write error") | |
| 614 | + | inputDone := make(chan error, 1) | |
| 615 | + | go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }() | |
| 616 | + | eventually(t, time.Second, func() bool { | |
| 617 | + | a.mu.Lock() | |
| 618 | + | defer a.mu.Unlock() | |
| 619 | + | return len(a.outputQueue) == 2 | |
| 620 | + | }) | |
| 621 | + | waitForManualTimers(t, clk, 3) | |
| 622 | + | clk.advance(defaultOutputPaceMinDelay) | |
| 623 | + | if err := <-inputDone; err == nil { | |
| 624 | + | t.Fatal("queued control send succeeded after paced wire failure") | |
| 625 | + | } | |
| 626 | + | select { | |
| 627 | + | case <-a.done: | |
| 628 | + | case <-time.After(time.Second): | |
| 629 | + | t.Fatal("transport remained open after paced control queue write failed") | |
| 469 | 630 | } | |
| 631 | + | } | |
| 470 | 632 | ||
| 471 | - | a.mu.Lock() | |
| 472 | - | defer a.mu.Unlock() | |
| 473 | - | for _, seq := range []uint64{1, 2, 3, 4} { | |
| 474 | - | if _, ok := a.pending[seq]; ok { | |
| 475 | - | t.Fatalf("pending[%d] stranded after queued flush failure", seq) | |
| 476 | - | } | |
| 633 | + | func TestRemoveQueuedPendingResolvesSynchronousWaiters(t *testing.T) { | |
| 634 | + | tr, _ := newResendTestTransport(t, 10) | |
| 635 | + | wantErr := errors.New("paced send failed") | |
| 636 | + | done := make(chan error, 1) | |
| 637 | + | ||
| 638 | + | tr.mu.Lock() | |
| 639 | + | tr.pending[7] = &pending{} | |
| 640 | + | tr.mu.Unlock() | |
| 641 | + | tr.removeQueuedPending([]queuedSend{{seq: 7, reliable: true, done: done}}, wantErr) | |
| 642 | + | ||
| 643 | + | require.ErrorIs(t, <-done, wantErr) | |
| 644 | + | _, ok := <-done | |
| 645 | + | require.False(t, ok, "completion channel must be closed") | |
| 646 | + | tr.mu.Lock() | |
| 647 | + | _, pending := tr.pending[7] | |
| 648 | + | tr.mu.Unlock() | |
| 649 | + | require.False(t, pending) | |
| 650 | + | } | |
| 651 | + | ||
| 652 | + | func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) { | |
| 653 | + | frames := []struct { | |
| 654 | + | name string | |
| 655 | + | frame ports.Frame | |
| 656 | + | }{ | |
| 657 | + | {name: "control", frame: ports.Frame{Type: ports.MsgPing}}, | |
| 658 | + | {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: []byte("osc")})}}, | |
| 659 | + | } | |
| 660 | + | for _, tt := range frames { | |
| 661 | + | t.Run(tt.name, func(t *testing.T) { | |
| 662 | + | aPC, _ := newPair() | |
| 663 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 664 | + | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | |
| 665 | + | if err != nil { | |
| 666 | + | t.Fatal(err) | |
| 667 | + | } | |
| 668 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil { | |
| 669 | + | t.Fatal(err) | |
| 670 | + | } | |
| 671 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil { | |
| 672 | + | t.Fatal(err) | |
| 673 | + | } | |
| 674 | + | done := make(chan error, 1) | |
| 675 | + | go func() { done <- a.Send(tt.frame) }() | |
| 676 | + | eventually(t, time.Second, func() bool { | |
| 677 | + | a.mu.Lock() | |
| 678 | + | defer a.mu.Unlock() | |
| 679 | + | return len(a.outputQueue) == 2 | |
| 680 | + | }) | |
| 681 | + | _ = a.Close() | |
| 682 | + | if err := <-done; err == nil { | |
| 683 | + | t.Fatal("queued synchronous Send returned nil after close") | |
| 684 | + | } | |
| 685 | + | }) | |
| 477 | 686 | } | |
| 478 | 687 | } | |
| 479 | 688 | ||
| @@ -487,7 +696,7 @@ func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) { | |||
| 487 | 696 | defer func() { _ = a.Close() }() | |
| 488 | 697 | ||
| 489 | 698 | for i := range 2 { | |
| 490 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |
| 699 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |
| 491 | 700 | t.Fatal(err) | |
| 492 | 701 | } | |
| 493 | 702 | } | |
| @@ -507,7 +716,7 @@ func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) { | |||
| 507 | 716 | } | |
| 508 | 717 | } | |
| 509 | 718 | ||
| 510 | - | func TestInputAndControlSendsBypassOutputPacing(t *testing.T) { | |
| 719 | + | func TestInputAndControlRemainOrderedInBoundedPacedQueue(t *testing.T) { | |
| 511 | 720 | aPC, bPC := newPair() | |
| 512 | 721 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 513 | 722 | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | |
| @@ -516,32 +725,46 @@ func TestInputAndControlSendsBypassOutputPacing(t *testing.T) { | |||
| 516 | 725 | } | |
| 517 | 726 | defer func() { _ = a.Close() }() | |
| 518 | 727 | ||
| 519 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("output")}); err != nil { | |
| 728 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("output")}); err != nil { | |
| 520 | 729 | t.Fatal(err) | |
| 521 | 730 | } | |
| 522 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil { | |
| 731 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil { | |
| 523 | 732 | t.Fatal(err) | |
| 524 | 733 | } | |
| 525 | 734 | if got := len(bPC.in); got != 1 { | |
| 526 | 735 | t.Fatalf("immediate output datagrams=%d, want 1", got) | |
| 527 | 736 | } | |
| 528 | - | if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil { | |
| 529 | - | t.Fatal(err) | |
| 737 | + | inputDone := make(chan error, 1) | |
| 738 | + | go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }() | |
| 739 | + | if got := len(bPC.in); got != 1 { | |
| 740 | + | t.Fatalf("datagrams after input=%d, want prerequisites and input still paced", got) | |
| 530 | 741 | } | |
| 531 | - | if got := len(bPC.in); got != 3 { | |
| 532 | - | t.Fatalf("datagrams after input=%d, want queued output flushed plus input immediately", got) | |
| 742 | + | waitForManualTimers(t, clk, 3) | |
| 743 | + | clk.advance(defaultOutputPaceMinDelay) | |
| 744 | + | eventuallyPacketCount(t, bPC, 3) | |
| 745 | + | if err := <-inputDone; err != nil { | |
| 746 | + | t.Fatal(err) | |
| 533 | 747 | } | |
| 534 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued-before-control")}); err != nil { | |
| 748 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued-before-control")}); err != nil { | |
| 535 | 749 | t.Fatal(err) | |
| 536 | 750 | } | |
| 537 | 751 | if got := len(bPC.in); got != 3 { | |
| 538 | 752 | t.Fatalf("datagrams before control=%d, want queued output still paced", got) | |
| 539 | 753 | } | |
| 540 | - | if err := a.Send(ports.Frame{Type: ports.MsgPing}); err != nil { | |
| 541 | - | t.Fatal(err) | |
| 754 | + | controlDone := make(chan error, 1) | |
| 755 | + | go func() { controlDone <- a.Send(ports.Frame{Type: ports.MsgPing}) }() | |
| 756 | + | eventually(t, time.Second, func() bool { | |
| 757 | + | a.mu.Lock() | |
| 758 | + | defer a.mu.Unlock() | |
| 759 | + | return len(a.outputQueue) == 2 | |
| 760 | + | }) | |
| 761 | + | if got := len(bPC.in); got != 3 { | |
| 762 | + | t.Fatalf("datagrams after control=%d, want ordered paced queue", got) | |
| 542 | 763 | } | |
| 543 | - | if got := len(bPC.in); got != 5 { | |
| 544 | - | t.Fatalf("datagrams after control=%d, want queued output flushed plus control sent immediately", got) | |
| 764 | + | clk.advance(defaultOutputPaceMinDelay) | |
| 765 | + | eventuallyPacketCount(t, bPC, 5) | |
| 766 | + | if err := <-controlDone; err != nil { | |
| 767 | + | t.Fatal(err) | |
| 545 | 768 | } | |
| 546 | 769 | } | |
| 547 | 770 | ||
| @@ -557,6 +780,18 @@ func eventuallyPacketCount(t *testing.T, pc *fakePC, want int) { | |||
| 557 | 780 | t.Fatalf("datagrams=%d, want at least %d", len(pc.in), want) | |
| 558 | 781 | } | |
| 559 | 782 | ||
| 783 | + | func eventually(t *testing.T, timeout time.Duration, condition func() bool) { | |
| 784 | + | t.Helper() | |
| 785 | + | deadline := time.Now().Add(timeout) | |
| 786 | + | for time.Now().Before(deadline) { | |
| 787 | + | if condition() { | |
| 788 | + | return | |
| 789 | + | } | |
| 790 | + | time.Sleep(time.Millisecond) | |
| 791 | + | } | |
| 792 | + | t.Fatal("condition was not met before timeout") | |
| 793 | + | } | |
| 794 | + | ||
| 560 | 795 | func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) { | |
| 561 | 796 | pc := newBlockingWritePC() | |
| 562 | 797 | tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{ | |
| @@ -682,7 +917,7 @@ func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) { | |||
| 682 | 917 | t.Fatalf("unauthenticated packet changed peer to %v", a.Peer()) | |
| 683 | 918 | } | |
| 684 | 919 | c, _ := pdgram.NewCodec(key()) | |
| 685 | - | rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing}, 0) | |
| 920 | + | rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing}) | |
| 686 | 921 | frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU) | |
| 687 | 922 | raw, _ := pdgram.MarshalFragment(frags[0]) | |
| 688 | 923 | aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")} | |
| @@ -804,11 +1039,11 @@ func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) { | |||
| 804 | 1039 | ||
| 805 | 1040 | func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) { | |
| 806 | 1041 | tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)} | |
| 807 | - | _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")}, 0) | |
| 1042 | + | _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")}) | |
| 808 | 1043 | if !ack || !queued { | |
| 809 | 1044 | t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued) | |
| 810 | 1045 | } | |
| 811 | - | _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0) | |
| 1046 | + | _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}) | |
| 812 | 1047 | if !ack || !queued { | |
| 813 | 1048 | t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued) | |
| 814 | 1049 | } | |
| @@ -817,28 +1052,21 @@ func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) { | |||
| 817 | 1052 | } | |
| 818 | 1053 | } | |
| 819 | 1054 | ||
| 820 | - | func TestDataRecordWireEncodingOutputSkip(t *testing.T) { | |
| 821 | - | got := encodeData(5, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("x")}, 3) | |
| 822 | - | want := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 0, 0, 0, 0, 0, 0, 0, 3, 'x'} | |
| 1055 | + | func TestDataRecordWireEncoding(t *testing.T) { | |
| 1056 | + | got := encodeData(5, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("x")}) | |
| 1057 | + | want := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 0, 'x'} | |
| 823 | 1058 | if !bytes.Equal(got, want) { | |
| 824 | 1059 | t.Fatalf("encoded data=%v, want %v", got, want) | |
| 825 | 1060 | } | |
| 826 | - | seq, reliable, frame, skip, ok := decodeData(got) | |
| 827 | - | if !ok || seq != 5 || !reliable || frame.Type != ports.MsgOutput || string(frame.Payload) != "x" || skip != 3 { | |
| 828 | - | t.Fatalf("decoded seq=%d reliable=%v frame=%+v skip=%d ok=%v", seq, reliable, frame, skip, ok) | |
| 829 | - | } | |
| 830 | - | } | |
| 831 | - | ||
| 832 | - | func TestDataRecordWireDecodeRejectsTruncatedOutputSkip(t *testing.T) { | |
| 833 | - | truncated := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 0} | |
| 834 | - | if _, _, _, _, ok := decodeData(truncated); ok { | |
| 835 | - | t.Fatal("decodeData accepted truncated output skip header") | |
| 1061 | + | seq, reliable, frame, ok := decodeData(got) | |
| 1062 | + | if !ok || seq != 5 || !reliable || frame.Type != ports.MsgOutput || string(frame.Payload) != "x" { | |
| 1063 | + | t.Fatalf("decoded seq=%d reliable=%v frame=%+v ok=%v", seq, reliable, frame, ok) | |
| 836 | 1064 | } | |
| 837 | 1065 | } | |
| 838 | 1066 | ||
| 839 | 1067 | func TestDataRecordWireDecodeRejectsUnknownFlags(t *testing.T) { | |
| 840 | - | withTrailingGarbage := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 0x80, 'x'} | |
| 841 | - | if _, _, _, _, ok := decodeData(withTrailingGarbage); ok { | |
| 1068 | + | withTrailingGarbage := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 'x'} | |
| 1069 | + | if _, _, _, ok := decodeData(withTrailingGarbage); ok { | |
| 842 | 1070 | t.Fatal("decodeData accepted unknown data-record flags with trailing garbage") | |
| 843 | 1071 | } | |
| 844 | 1072 | } | |
| @@ -861,7 +1089,7 @@ func TestOutputRetransmitsUntilAck(t *testing.T) { | |||
| 861 | 1089 | t.Errorf("close b: %v", err) | |
| 862 | 1090 | } | |
| 863 | 1091 | }() | |
| 864 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil { | |
| 1092 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil { | |
| 865 | 1093 | t.Fatal(err) | |
| 866 | 1094 | } | |
| 867 | 1095 | got := recvWithin(t, b, time.Second) | |
| @@ -870,6 +1098,40 @@ func TestOutputRetransmitsUntilAck(t *testing.T) { | |||
| 870 | 1098 | } | |
| 871 | 1099 | } | |
| 872 | 1100 | ||
| 1101 | + | func TestImmediateLoopbackAckSamplesStampedFinalWrite(t *testing.T) { | |
| 1102 | + | aPC, bPC := newPair() | |
| 1103 | + | a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |
| 1104 | + | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |
| 1105 | + | defer func() { _ = a.Close() }() | |
| 1106 | + | defer func() { _ = b.Close() }() | |
| 1107 | + | aPC.afterWrite = func() { | |
| 1108 | + | deadline := time.Now().Add(time.Second) | |
| 1109 | + | for time.Now().Before(deadline) { | |
| 1110 | + | a.mu.Lock() | |
| 1111 | + | _, pending := a.pending[1] | |
| 1112 | + | a.mu.Unlock() | |
| 1113 | + | if !pending { | |
| 1114 | + | return | |
| 1115 | + | } | |
| 1116 | + | time.Sleep(time.Microsecond) | |
| 1117 | + | } | |
| 1118 | + | t.Error("ACK did not retire pending frame before WriteTo returned") | |
| 1119 | + | } | |
| 1120 | + | ||
| 1121 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state")}); err != nil { | |
| 1122 | + | t.Fatal(err) | |
| 1123 | + | } | |
| 1124 | + | a.mu.Lock() | |
| 1125 | + | srtt, rto := a.srtt, a.rto | |
| 1126 | + | a.mu.Unlock() | |
| 1127 | + | if srtt <= 0 || srtt > time.Second { | |
| 1128 | + | t.Fatalf("srtt=%v, want finite immediate-loopback sample", srtt) | |
| 1129 | + | } | |
| 1130 | + | if rto < a.resendAfter || rto > a.maxResendAfter { | |
| 1131 | + | t.Fatalf("rto=%v outside [%v,%v]", rto, a.resendAfter, a.maxResendAfter) | |
| 1132 | + | } | |
| 1133 | + | } | |
| 1134 | + | ||
| 873 | 1135 | func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) { | |
| 874 | 1136 | aPC, bPC := newPair() | |
| 875 | 1137 | a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |
| @@ -890,7 +1152,7 @@ func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) { | |||
| 890 | 1152 | } | |
| 891 | 1153 | } | |
| 892 | 1154 | ||
| 893 | - | func TestOutputSupersessionSkipsDroppedOlderOutput(t *testing.T) { | |
| 1155 | + | func TestOutputDependencyChainRetransmitsDroppedPredecessors(t *testing.T) { | |
| 894 | 1156 | aPC, bPC := newPair() | |
| 895 | 1157 | var sendAttempts atomic.Int32 | |
| 896 | 1158 | aPC.drop = func(_ []byte, addr net.Addr) bool { | |
| @@ -902,21 +1164,16 @@ func TestOutputSupersessionSkipsDroppedOlderOutput(t *testing.T) { | |||
| 902 | 1164 | defer func() { _ = b.Close() }() | |
| 903 | 1165 | ||
| 904 | 1166 | for _, payload := range []string{"state1", "state2", "state3"} { | |
| 905 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil { | |
| 1167 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil { | |
| 906 | 1168 | t.Fatal(err) | |
| 907 | 1169 | } | |
| 908 | 1170 | } | |
| 909 | 1171 | ||
| 910 | - | a.mu.Lock() | |
| 911 | - | pending := len(a.pending) | |
| 912 | - | a.mu.Unlock() | |
| 913 | - | if pending > 1 { | |
| 914 | - | t.Fatalf("pending outputs=%d, want collapsed to newest", pending) | |
| 915 | - | } | |
| 916 | - | ||
| 917 | - | got := recvWithin(t, b, time.Second) | |
| 918 | - | if got.Type != ports.MsgOutput || string(got.Payload) != "state3" { | |
| 919 | - | t.Fatalf("got=%+v, want newest output after skipped predecessors", got) | |
| 1172 | + | for _, want := range []string{"state1", "state2", "state3"} { | |
| 1173 | + | got := recvWithin(t, b, time.Second) | |
| 1174 | + | if got.Type != ports.MsgOutput || string(got.Payload) != want { | |
| 1175 | + | t.Fatalf("got=%+v, want ordered prerequisite output %q", got, want) | |
| 1176 | + | } | |
| 920 | 1177 | } | |
| 921 | 1178 | if sendAttempts.Load() < 3 { | |
| 922 | 1179 | t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load()) | |
| @@ -931,7 +1188,7 @@ func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) { | |||
| 931 | 1188 | defer func() { _ = b.Close() }() | |
| 932 | 1189 | ||
| 933 | 1190 | for i := range cap(b.in) + 20 { | |
| 934 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |
| 1191 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |
| 935 | 1192 | t.Fatal(err) | |
| 936 | 1193 | } | |
| 937 | 1194 | } | |
| @@ -994,6 +1251,39 @@ func TestResendPendingCapsBurst(t *testing.T) { | |||
| 994 | 1251 | } | |
| 995 | 1252 | } | |
| 996 | 1253 | ||
| 1254 | + | func TestOversizedRetransmitHonorsPacketBudget(t *testing.T) { | |
| 1255 | + | tr, writes := newResendTestTransport(t, 1) | |
| 1256 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 1257 | + | tr.clock = clk | |
| 1258 | + | tr.done = make(chan struct{}) | |
| 1259 | + | tr.pending[1] = &pending{ | |
| 1260 | + | frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*tr.mtu)}, | |
| 1261 | + | last: clk.now.Add(-time.Second), | |
| 1262 | + | } | |
| 1263 | + | done := make(chan struct{}) | |
| 1264 | + | go func() { | |
| 1265 | + | tr.resendPending() | |
| 1266 | + | close(done) | |
| 1267 | + | }() | |
| 1268 | + | ||
| 1269 | + | previous := int32(0) | |
| 1270 | + | for tick := 0; tick < 128; tick++ { | |
| 1271 | + | clk.advance(defaultOutputPaceMaxDelay) | |
| 1272 | + | time.Sleep(time.Millisecond) | |
| 1273 | + | current := writes.Load() | |
| 1274 | + | if delta := current - previous; delta > defaultPacketPaceBudget { | |
| 1275 | + | t.Fatalf("tick %d retransmitted %d packets, budget %d", tick, delta, defaultPacketPaceBudget) | |
| 1276 | + | } | |
| 1277 | + | previous = current | |
| 1278 | + | select { | |
| 1279 | + | case <-done: | |
| 1280 | + | return | |
| 1281 | + | default: | |
| 1282 | + | } | |
| 1283 | + | } | |
| 1284 | + | t.Fatal("oversized retransmit did not complete within pacing ticks") | |
| 1285 | + | } | |
| 1286 | + | ||
| 997 | 1287 | func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) { | |
| 998 | 1288 | tr, writes := newResendTestTransport(t, 10) | |
| 999 | 1289 | now := tr.clock.Now() | |
| @@ -1071,7 +1361,7 @@ func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) { | |||
| 1071 | 1361 | func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) { | |
| 1072 | 1362 | tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)} | |
| 1073 | 1363 | ||
| 1074 | - | ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")}, 0) | |
| 1364 | + | ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")}) | |
| 1075 | 1365 | if !ack || !queued { | |
| 1076 | 1366 | t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued) | |
| 1077 | 1367 | } | |
| @@ -1079,7 +1369,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) { | |||
| 1079 | 1369 | t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want) | |
| 1080 | 1370 | } | |
| 1081 | 1371 | ||
| 1082 | - | ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")}, 0) | |
| 1372 | + | ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")}) | |
| 1083 | 1373 | if !ack || !queued { | |
| 1084 | 1374 | t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued) | |
| 1085 | 1375 | } | |
| @@ -1087,7 +1377,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) { | |||
| 1087 | 1377 | t.Fatalf("first contiguous ack=%d, want %d", got, want) | |
| 1088 | 1378 | } | |
| 1089 | 1379 | ||
| 1090 | - | ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}, 0) | |
| 1380 | + | ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}) | |
| 1091 | 1381 | if !ack || !queued { | |
| 1092 | 1382 | t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued) | |
| 1093 | 1383 | } | |
| @@ -1318,12 +1608,12 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) { | |||
| 1318 | 1608 | tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})} | |
| 1319 | 1609 | tr.deliverCond = sync.NewCond(&tr.deliverMu) | |
| 1320 | 1610 | for i := range maxRecvBuffer + 100 { | |
| 1321 | - | tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, 0) | |
| 1611 | + | tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}) | |
| 1322 | 1612 | } | |
| 1323 | 1613 | if got := len(tr.recvBuf); got != maxRecvBuffer { | |
| 1324 | 1614 | t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer) | |
| 1325 | 1615 | } | |
| 1326 | - | tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0) | |
| 1616 | + | tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}) | |
| 1327 | 1617 | if _, ok := tr.recvBuf[1]; !ok { | |
| 1328 | 1618 | t.Fatalf("next expected sequence was dropped when far-future buffer was full") | |
| 1329 | 1619 | } | |
| @@ -1332,7 +1622,7 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) { | |||
| 1332 | 1622 | } | |
| 1333 | 1623 | } | |
| 1334 | 1624 | ||
| 1335 | - | func TestOutputSupersessionPrunesFullReceiveBuffer(t *testing.T) { | |
| 1625 | + | func TestOutputPrerequisitesRemainInFullReceiveBuffer(t *testing.T) { | |
| 1336 | 1626 | tr := &Transport{ | |
| 1337 | 1627 | nextRecvSeq: 1, | |
| 1338 | 1628 | recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}}, | |
| @@ -1341,18 +1631,15 @@ func TestOutputSupersessionPrunesFullReceiveBuffer(t *testing.T) { | |||
| 1341 | 1631 | } | |
| 1342 | 1632 | tr.deliverCond = sync.NewCond(&tr.deliverMu) | |
| 1343 | 1633 | ||
| 1344 | - | ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("replacement")}, 2) | |
| 1345 | - | if !ack || !queued { | |
| 1346 | - | t.Fatalf("replacement ack=%v queued=%v, want true/true", ack, queued) | |
| 1634 | + | ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("replacement")}) | |
| 1635 | + | if ack || queued || ackSeq != 0 { | |
| 1636 | + | t.Fatalf("replacement ackSeq=%d ack=%v queued=%v, want output rejected while prerequisite buffer is full", ackSeq, ack, queued) | |
| 1347 | 1637 | } | |
| 1348 | - | if ackSeq != 3 { | |
| 1349 | - | t.Fatalf("ack seq=%d, want 3 after skipped output", ackSeq) | |
| 1350 | - | } | |
| 1351 | - | if _, ok := tr.recvBuf[2]; ok { | |
| 1352 | - | t.Fatal("superseded output remained in full receive buffer") | |
| 1638 | + | if _, ok := tr.recvBuf[2]; !ok { | |
| 1639 | + | t.Fatal("prerequisite output was discarded from full receive buffer") | |
| 1353 | 1640 | } | |
| 1354 | - | if got := string(tr.recvBuf[3].Payload); got != "replacement" { | |
| 1355 | - | t.Fatalf("queued payload=%q, want replacement", got) | |
| 1641 | + | if _, ok := tr.recvBuf[3]; ok { | |
| 1642 | + | t.Fatal("far-future replacement bypassed full receive buffer") | |
| 1356 | 1643 | } | |
| 1357 | 1644 | } | |
| 1358 | 1645 | ||
| @@ -1370,14 +1657,14 @@ func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) { | |||
| 1370 | 1657 | for i := range maxRecvBuffer { | |
| 1371 | 1658 | tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}} | |
| 1372 | 1659 | } | |
| 1373 | - | tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}, 0)) | |
| 1660 | + | tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")})) | |
| 1374 | 1661 | select { | |
| 1375 | 1662 | case pkt := <-bPC.in: | |
| 1376 | 1663 | t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b) | |
| 1377 | 1664 | case <-time.After(25 * time.Millisecond): | |
| 1378 | 1665 | } | |
| 1379 | 1666 | ||
| 1380 | - | tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0)) | |
| 1667 | + | tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})) | |
| 1381 | 1668 | select { | |
| 1382 | 1669 | case <-bPC.in: | |
| 1383 | 1670 | case <-time.After(time.Second): | |
| @@ -1507,7 +1794,7 @@ func TestSendStallDeathRequiresOldRetransmittedFrame(t *testing.T) { | |||
| 1507 | 1794 | a.lastHeard = now | |
| 1508 | 1795 | a.pending[1] = &pending{ | |
| 1509 | 1796 | frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stuck")}, | |
| 1510 | - | first: now.Add(-tt.age), | |
| 1797 | + | enqueued: now.Add(-tt.age), | |
| 1511 | 1798 | last: now, | |
| 1512 | 1799 | attempts: tt.attempts, | |
| 1513 | 1800 | } | |
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | 13 | ||||
| 14 | "github.com/bnema/vev/internal/ports" | 14 | "github.com/bnema/vev/internal/ports" | ||
| 15 | pdgram "github.com/bnema/vev/pkg/dgram" | 15 | pdgram "github.com/bnema/vev/pkg/dgram" | ||
| 16 | + | "github.com/stretchr/testify/require" | |||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| 18 | type testAddr string | 19 | type testAddr string | ||
| @@ -25,12 +26,13 @@ type packet struct { | |||||
| 25 | addr net.Addr | 26 | addr net.Addr | ||
| 26 | } | 27 | } | ||
| 27 | type fakePC struct { | 28 | type fakePC struct { | ||
| 28 | - | addr net.Addr | 29 | + | addr net.Addr |
| 29 | - | mu sync.Mutex | 30 | + | mu sync.Mutex |
| 30 | - | closed bool | 31 | + | closed bool |
| 31 | - | in chan packet | 32 | + | in chan packet |
| 32 | - | peers map[string]*fakePC | 33 | + | peers map[string]*fakePC |
| 33 | - | drop func([]byte, net.Addr) bool | 34 | + | drop func([]byte, net.Addr) bool |
| 35 | + | afterWrite func() | |||
| 34 | } | 36 | } | ||
| 35 | 37 | ||||
| 36 | func newPair() (*fakePC, *fakePC) { | 38 | func newPair() (*fakePC, *fakePC) { | ||
| @@ -60,11 +62,15 @@ func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) { | |||||
| 60 | } | 62 | } | ||
| 61 | peer := p.peers[addr.String()] | 63 | peer := p.peers[addr.String()] | ||
| 62 | peer.mu.Lock() | 64 | peer.mu.Lock() | ||
| 63 | - | defer peer.mu.Unlock() | |||
| 64 | if peer.closed { | 65 | if peer.closed { | ||
| 66 | + | peer.mu.Unlock() | |||
| 65 | return 0, errors.New("peer closed") | 67 | return 0, errors.New("peer closed") | ||
| 66 | } | 68 | } | ||
| 67 | peer.in <- packet{append([]byte(nil), b...), p.addr} | 69 | peer.in <- packet{append([]byte(nil), b...), p.addr} | ||
| 70 | + | peer.mu.Unlock() | |||
| 71 | + | if p.afterWrite != nil { | |||
| 72 | + | p.afterWrite() | |||
| 73 | + | } | |||
| 68 | return len(b), nil | 74 | return len(b), nil | ||
| 69 | } | 75 | } | ||
| 70 | func (p *fakePC) Close() error { | 76 | func (p *fakePC) Close() error { | ||
| @@ -299,7 +305,7 @@ func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error { | |||||
| 299 | return nil | 305 | return nil | ||
| 300 | } | 306 | } | ||
| 301 | 307 | ||||
| 302 | - | func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) { | 308 | + | func TestOutputFirstSendIsPacedAndQueuedOutputsAreBatched(t *testing.T) { |
| 303 | aPC, bPC := newPair() | 309 | aPC, bPC := newPair() | ||
| 304 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | 310 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | ||
| 305 | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | 311 | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | ||
| @@ -309,7 +315,7 @@ func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) { | |||||
| 309 | defer func() { _ = a.Close() }() | 315 | defer func() { _ = a.Close() }() | ||
| 310 | 316 | ||||
| 311 | for i := range 3 { | 317 | for i := range 3 { | ||
| 312 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | 318 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { |
| 313 | t.Fatal(err) | 319 | t.Fatal(err) | ||
| 314 | } | 320 | } | ||
| 315 | } | 321 | } | ||
| @@ -318,15 +324,156 @@ func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) { | |||||
| 318 | } | 324 | } | ||
| 319 | waitForManualTimers(t, clk, 3) | 325 | waitForManualTimers(t, clk, 3) | ||
| 320 | clk.advance(defaultOutputPaceMinDelay) | 326 | clk.advance(defaultOutputPaceMinDelay) | ||
| 321 | - | eventuallyPacketCount(t, bPC, 2) | 327 | + | eventuallyPacketCount(t, bPC, 3) |
| 322 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil { | 328 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil { |
| 323 | t.Fatal(err) | 329 | t.Fatal(err) | ||
| 324 | } | 330 | } | ||
| 325 | - | if got := len(bPC.in); got != 2 { | 331 | + | if got := len(bPC.in); got != 3 { |
| 326 | - | t.Fatalf("datagrams before second pace tick=%d, want 2", got) | 332 | + | t.Fatalf("datagrams before second pace tick=%d, want 3", got) |
| 327 | } | 333 | } | ||
| 328 | clk.advance(defaultOutputPaceMinDelay) | 334 | clk.advance(defaultOutputPaceMinDelay) | ||
| 329 | - | eventuallyPacketCount(t, bPC, 3) | 335 | + | eventuallyPacketCount(t, bPC, 4) |
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) { | |||
| 339 | + | aPC, _ := newPair() | |||
| 340 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |||
| 341 | + | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | |||
| 342 | + | if err != nil { | |||
| 343 | + | t.Fatal(err) | |||
| 344 | + | } | |||
| 345 | + | defer func() { _ = a.Close() }() | |||
| 346 | + | ||||
| 347 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil { | |||
| 348 | + | t.Fatal(err) | |||
| 349 | + | } | |||
| 350 | + | large := ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: make([]byte, 24*a.mtu)}) | |||
| 351 | + | for range 2 { | |||
| 352 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil { | |||
| 353 | + | t.Fatal(err) | |||
| 354 | + | } | |||
| 355 | + | } | |||
| 356 | + | waitForManualTimers(t, clk, 3) | |||
| 357 | + | previousPackets := len(aPC.in) | |||
| 358 | + | for tick := 0; tick < 128; tick++ { | |||
| 359 | + | clk.advance(defaultOutputPaceMaxDelay) | |||
| 360 | + | time.Sleep(time.Millisecond) | |||
| 361 | + | packets := len(aPC.in) | |||
| 362 | + | if delta := packets - previousPackets; delta > defaultPacketPaceBudget { | |||
| 363 | + | t.Fatalf("tick %d emitted %d packets, budget %d", tick, delta, defaultPacketPaceBudget) | |||
| 364 | + | } | |||
| 365 | + | previousPackets = packets | |||
| 366 | + | a.mu.Lock() | |||
| 367 | + | last := a.pending[3] | |||
| 368 | + | done := len(a.outputQueue) == 0 && last != nil && !last.initialInFlight | |||
| 369 | + | a.mu.Unlock() | |||
| 370 | + | if done { | |||
| 371 | + | return | |||
| 372 | + | } | |||
| 373 | + | } | |||
| 374 | + | t.Fatal("oversized queued frames did not complete within pacing ticks") | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) { | |||
| 378 | + | aPC, _ := newPair() | |||
| 379 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |||
| 380 | + | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ | |||
| 381 | + | Clock: clk, ResendAfter: 500 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour, | |||
| 382 | + | }) | |||
| 383 | + | if err != nil { | |||
| 384 | + | t.Fatal(err) | |||
| 385 | + | } | |||
| 386 | + | defer func() { _ = a.Close() }() | |||
| 387 | + | large := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*a.mtu)} | |||
| 388 | + | done := make(chan error, 1) | |||
| 389 | + | go func() { done <- a.SendAsync(large) }() | |||
| 390 | + | start := clk.Now() | |||
| 391 | + | for tick := 0; tick < 128; tick++ { | |||
| 392 | + | a.mu.Lock() | |||
| 393 | + | p := a.pending[1] | |||
| 394 | + | if p != nil && p.initialInFlight && !p.last.IsZero() { | |||
| 395 | + | a.mu.Unlock() | |||
| 396 | + | t.Fatal("pending last timestamp set before final fragment") | |||
| 397 | + | } | |||
| 398 | + | a.mu.Unlock() | |||
| 399 | + | clk.advance(defaultOutputPaceMaxDelay) | |||
| 400 | + | time.Sleep(time.Millisecond) | |||
| 401 | + | select { | |||
| 402 | + | case err := <-done: | |||
| 403 | + | if err != nil { | |||
| 404 | + | t.Fatal(err) | |||
| 405 | + | } | |||
| 406 | + | a.mu.Lock() | |||
| 407 | + | completed := a.pending[1].last | |||
| 408 | + | a.mu.Unlock() | |||
| 409 | + | if elapsed := completed.Sub(start); elapsed <= defaultOutputPaceMaxDelay { | |||
| 410 | + | t.Fatalf("large send completed too early after %v", elapsed) | |||
| 411 | + | } | |||
| 412 | + | before := len(aPC.in) | |||
| 413 | + | clk.advance(499 * time.Millisecond) | |||
| 414 | + | a.resendPending() | |||
| 415 | + | if got := len(aPC.in); got != before { | |||
| 416 | + | t.Fatalf("resend emitted before one RTO after final fragment: %d -> %d", before, got) | |||
| 417 | + | } | |||
| 418 | + | a.handleRecord(ackRecord(1)) | |||
| 419 | + | a.mu.Lock() | |||
| 420 | + | srtt := a.srtt | |||
| 421 | + | a.mu.Unlock() | |||
| 422 | + | require.Equal(t, 499*time.Millisecond, srtt, "RTT sample must start at final-fragment completion") | |||
| 423 | + | return | |||
| 424 | + | default: | |||
| 425 | + | } | |||
| 426 | + | } | |||
| 427 | + | t.Fatal("large initial send did not complete") | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *testing.T) { | |||
| 431 | + | aPC, bPC := newPair() | |||
| 432 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |||
| 433 | + | a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ | |||
| 434 | + | Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour, | |||
| 435 | + | }) | |||
| 436 | + | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |||
| 437 | + | defer func() { _ = a.Close() }() | |||
| 438 | + | defer func() { _ = b.Close() }() | |||
| 439 | + | a.mu.Lock() | |||
| 440 | + | a.srtt = 500 * time.Millisecond | |||
| 441 | + | a.mu.Unlock() | |||
| 442 | + | for i := range 8 { | |||
| 443 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | |||
| 444 | + | t.Fatal(err) | |||
| 445 | + | } | |||
| 446 | + | } | |||
| 447 | + | now := clk.Now() | |||
| 448 | + | a.mu.Lock() | |||
| 449 | + | for _, seq := range []uint64{100, 101} { | |||
| 450 | + | a.pending[seq] = &pending{ | |||
| 451 | + | frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 20*a.mtu)}, | |||
| 452 | + | enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second), | |||
| 453 | + | } | |||
| 454 | + | } | |||
| 455 | + | a.mu.Unlock() | |||
| 456 | + | go a.resendPending() | |||
| 457 | + | sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: make([]byte, 100*1024)})} | |||
| 458 | + | done := make(chan error, 1) | |||
| 459 | + | go func() { done <- a.SendSynchronous(sideEffect) }() | |||
| 460 | + | ||||
| 461 | + | for tick := 0; tick < 512; tick++ { | |||
| 462 | + | clk.advance(defaultOutputPaceMaxDelay) | |||
| 463 | + | time.Sleep(time.Millisecond) | |||
| 464 | + | select { | |||
| 465 | + | case err := <-done: | |||
| 466 | + | if err != nil { | |||
| 467 | + | t.Fatal(err) | |||
| 468 | + | } | |||
| 469 | + | if a.isClosed() { | |||
| 470 | + | t.Fatal("healthy owned synchronous send force-closed transport") | |||
| 471 | + | } | |||
| 472 | + | return | |||
| 473 | + | default: | |||
| 474 | + | } | |||
| 475 | + | } | |||
| 476 | + | t.Fatal("maximum side effect did not complete through queued/retransmit pacing") | |||
| 330 | } | 477 | } | ||
| 331 | 478 | ||||
| 332 | func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) { | 479 | func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) { | ||
| @@ -399,7 +546,7 @@ func TestInputWaitsForDequeuedOutputBatch(t *testing.T) { | |||||
| 399 | defer func() { _ = a.Close() }() | 546 | defer func() { _ = a.Close() }() | ||
| 400 | 547 | ||||
| 401 | for _, payload := range []string{"first", "queued"} { | 548 | for _, payload := range []string{"first", "queued"} { | ||
| 402 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil { | 549 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil { |
| 403 | t.Fatal(err) | 550 | t.Fatal(err) | ||
| 404 | } | 551 | } | ||
| 405 | } | 552 | } | ||
| @@ -443,7 +590,7 @@ func TestInputWaitsForDequeuedOutputBatch(t *testing.T) { | |||||
| 443 | } | 590 | } | ||
| 444 | } | 591 | } | ||
| 445 | 592 | ||||
| 446 | - | func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) { | 593 | + | func TestPacedControlQueueFailureClosesTransport(t *testing.T) { |
| 447 | aPC, bPC := newPair() | 594 | aPC, bPC := newPair() | ||
| 448 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | 595 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | ||
| 449 | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | 596 | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | ||
| @@ -453,7 +600,7 @@ func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) { | |||||
| 453 | defer func() { _ = a.Close() }() | 600 | defer func() { _ = a.Close() }() | ||
| 454 | 601 | ||||
| 455 | for i := range 3 { | 602 | for i := range 3 { | ||
| 456 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | 603 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { |
| 457 | t.Fatal(err) | 604 | t.Fatal(err) | ||
| 458 | } | 605 | } | ||
| 459 | } | 606 | } | ||
| @@ -464,16 +611,78 @@ func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) { | |||||
| 464 | t.Fatal(err) | 611 | t.Fatal(err) | ||
| 465 | } | 612 | } | ||
| 466 | 613 | ||||
| 467 | - | if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err == nil { | 614 | + | inputDone := make(chan error, 1) |
| 468 | - | t.Fatal("input send after peer close succeeded, want write error") | 615 | + | go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }() |
| 616 | + | eventually(t, time.Second, func() bool { | |||
| 617 | + | a.mu.Lock() | |||
| 618 | + | defer a.mu.Unlock() | |||
| 619 | + | return len(a.outputQueue) == 2 | |||
| 620 | + | }) | |||
| 621 | + | waitForManualTimers(t, clk, 3) | |||
| 622 | + | clk.advance(defaultOutputPaceMinDelay) | |||
| 623 | + | if err := <-inputDone; err == nil { | |||
| 624 | + | t.Fatal("queued control send succeeded after paced wire failure") | |||
| 625 | + | } | |||
| 626 | + | select { | |||
| 627 | + | case <-a.done: | |||
| 628 | + | case <-time.After(time.Second): | |||
| 629 | + | t.Fatal("transport remained open after paced control queue write failed") | |||
| 469 | } | 630 | } | ||
| 631 | + | } | |||
| 470 | 632 | ||||
| 471 | - | a.mu.Lock() | 633 | + | func TestRemoveQueuedPendingResolvesSynchronousWaiters(t *testing.T) { |
| 472 | - | defer a.mu.Unlock() | 634 | + | tr, _ := newResendTestTransport(t, 10) |
| 473 | - | for _, seq := range []uint64{1, 2, 3, 4} { | 635 | + | wantErr := errors.New("paced send failed") |
| 474 | - | if _, ok := a.pending[seq]; ok { | 636 | + | done := make(chan error, 1) |
| 475 | - | t.Fatalf("pending[%d] stranded after queued flush failure", seq) | 637 | + | |
| 476 | - | } | 638 | + | tr.mu.Lock() |
| 639 | + | tr.pending[7] = &pending{} | |||
| 640 | + | tr.mu.Unlock() | |||
| 641 | + | tr.removeQueuedPending([]queuedSend{{seq: 7, reliable: true, done: done}}, wantErr) | |||
| 642 | + | ||||
| 643 | + | require.ErrorIs(t, <-done, wantErr) | |||
| 644 | + | _, ok := <-done | |||
| 645 | + | require.False(t, ok, "completion channel must be closed") | |||
| 646 | + | tr.mu.Lock() | |||
| 647 | + | _, pending := tr.pending[7] | |||
| 648 | + | tr.mu.Unlock() | |||
| 649 | + | require.False(t, pending) | |||
| 650 | + | } | |||
| 651 | + | ||||
| 652 | + | func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) { | |||
| 653 | + | frames := []struct { | |||
| 654 | + | name string | |||
| 655 | + | frame ports.Frame | |||
| 656 | + | }{ | |||
| 657 | + | {name: "control", frame: ports.Frame{Type: ports.MsgPing}}, | |||
| 658 | + | {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: []byte("osc")})}}, | |||
| 659 | + | } | |||
| 660 | + | for _, tt := range frames { | |||
| 661 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 662 | + | aPC, _ := newPair() | |||
| 663 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |||
| 664 | + | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | |||
| 665 | + | if err != nil { | |||
| 666 | + | t.Fatal(err) | |||
| 667 | + | } | |||
| 668 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil { | |||
| 669 | + | t.Fatal(err) | |||
| 670 | + | } | |||
| 671 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil { | |||
| 672 | + | t.Fatal(err) | |||
| 673 | + | } | |||
| 674 | + | done := make(chan error, 1) | |||
| 675 | + | go func() { done <- a.Send(tt.frame) }() | |||
| 676 | + | eventually(t, time.Second, func() bool { | |||
| 677 | + | a.mu.Lock() | |||
| 678 | + | defer a.mu.Unlock() | |||
| 679 | + | return len(a.outputQueue) == 2 | |||
| 680 | + | }) | |||
| 681 | + | _ = a.Close() | |||
| 682 | + | if err := <-done; err == nil { | |||
| 683 | + | t.Fatal("queued synchronous Send returned nil after close") | |||
| 684 | + | } | |||
| 685 | + | }) | |||
| 477 | } | 686 | } | ||
| 478 | } | 687 | } | ||
| 479 | 688 | ||||
| @@ -487,7 +696,7 @@ func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) { | |||||
| 487 | defer func() { _ = a.Close() }() | 696 | defer func() { _ = a.Close() }() | ||
| 488 | 697 | ||||
| 489 | for i := range 2 { | 698 | for i := range 2 { | ||
| 490 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | 699 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { |
| 491 | t.Fatal(err) | 700 | t.Fatal(err) | ||
| 492 | } | 701 | } | ||
| 493 | } | 702 | } | ||
| @@ -507,7 +716,7 @@ func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) { | |||||
| 507 | } | 716 | } | ||
| 508 | } | 717 | } | ||
| 509 | 718 | ||||
| 510 | - | func TestInputAndControlSendsBypassOutputPacing(t *testing.T) { | 719 | + | func TestInputAndControlRemainOrderedInBoundedPacedQueue(t *testing.T) { |
| 511 | aPC, bPC := newPair() | 720 | aPC, bPC := newPair() | ||
| 512 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | 721 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | ||
| 513 | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | 722 | a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour}) | ||
| @@ -516,32 +725,46 @@ func TestInputAndControlSendsBypassOutputPacing(t *testing.T) { | |||||
| 516 | } | 725 | } | ||
| 517 | defer func() { _ = a.Close() }() | 726 | defer func() { _ = a.Close() }() | ||
| 518 | 727 | ||||
| 519 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("output")}); err != nil { | 728 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("output")}); err != nil { |
| 520 | t.Fatal(err) | 729 | t.Fatal(err) | ||
| 521 | } | 730 | } | ||
| 522 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil { | 731 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil { |
| 523 | t.Fatal(err) | 732 | t.Fatal(err) | ||
| 524 | } | 733 | } | ||
| 525 | if got := len(bPC.in); got != 1 { | 734 | if got := len(bPC.in); got != 1 { | ||
| 526 | t.Fatalf("immediate output datagrams=%d, want 1", got) | 735 | t.Fatalf("immediate output datagrams=%d, want 1", got) | ||
| 527 | } | 736 | } | ||
| 528 | - | if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil { | 737 | + | inputDone := make(chan error, 1) |
| 529 | - | t.Fatal(err) | 738 | + | go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }() |
| 739 | + | if got := len(bPC.in); got != 1 { | |||
| 740 | + | t.Fatalf("datagrams after input=%d, want prerequisites and input still paced", got) | |||
| 530 | } | 741 | } | ||
| 531 | - | if got := len(bPC.in); got != 3 { | 742 | + | waitForManualTimers(t, clk, 3) |
| 532 | - | t.Fatalf("datagrams after input=%d, want queued output flushed plus input immediately", got) | 743 | + | clk.advance(defaultOutputPaceMinDelay) |
| 744 | + | eventuallyPacketCount(t, bPC, 3) | |||
| 745 | + | if err := <-inputDone; err != nil { | |||
| 746 | + | t.Fatal(err) | |||
| 533 | } | 747 | } | ||
| 534 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued-before-control")}); err != nil { | 748 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued-before-control")}); err != nil { |
| 535 | t.Fatal(err) | 749 | t.Fatal(err) | ||
| 536 | } | 750 | } | ||
| 537 | if got := len(bPC.in); got != 3 { | 751 | if got := len(bPC.in); got != 3 { | ||
| 538 | t.Fatalf("datagrams before control=%d, want queued output still paced", got) | 752 | t.Fatalf("datagrams before control=%d, want queued output still paced", got) | ||
| 539 | } | 753 | } | ||
| 540 | - | if err := a.Send(ports.Frame{Type: ports.MsgPing}); err != nil { | 754 | + | controlDone := make(chan error, 1) |
| 541 | - | t.Fatal(err) | 755 | + | go func() { controlDone <- a.Send(ports.Frame{Type: ports.MsgPing}) }() |
| 756 | + | eventually(t, time.Second, func() bool { | |||
| 757 | + | a.mu.Lock() | |||
| 758 | + | defer a.mu.Unlock() | |||
| 759 | + | return len(a.outputQueue) == 2 | |||
| 760 | + | }) | |||
| 761 | + | if got := len(bPC.in); got != 3 { | |||
| 762 | + | t.Fatalf("datagrams after control=%d, want ordered paced queue", got) | |||
| 542 | } | 763 | } | ||
| 543 | - | if got := len(bPC.in); got != 5 { | 764 | + | clk.advance(defaultOutputPaceMinDelay) |
| 544 | - | t.Fatalf("datagrams after control=%d, want queued output flushed plus control sent immediately", got) | 765 | + | eventuallyPacketCount(t, bPC, 5) |
| 766 | + | if err := <-controlDone; err != nil { | |||
| 767 | + | t.Fatal(err) | |||
| 545 | } | 768 | } | ||
| 546 | } | 769 | } | ||
| 547 | 770 | ||||
| @@ -557,6 +780,18 @@ func eventuallyPacketCount(t *testing.T, pc *fakePC, want int) { | |||||
| 557 | t.Fatalf("datagrams=%d, want at least %d", len(pc.in), want) | 780 | t.Fatalf("datagrams=%d, want at least %d", len(pc.in), want) | ||
| 558 | } | 781 | } | ||
| 559 | 782 | ||||
| 783 | + | func eventually(t *testing.T, timeout time.Duration, condition func() bool) { | |||
| 784 | + | t.Helper() | |||
| 785 | + | deadline := time.Now().Add(timeout) | |||
| 786 | + | for time.Now().Before(deadline) { | |||
| 787 | + | if condition() { | |||
| 788 | + | return | |||
| 789 | + | } | |||
| 790 | + | time.Sleep(time.Millisecond) | |||
| 791 | + | } | |||
| 792 | + | t.Fatal("condition was not met before timeout") | |||
| 793 | + | } | |||
| 794 | + | ||||
| 560 | func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) { | 795 | func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) { | ||
| 561 | pc := newBlockingWritePC() | 796 | pc := newBlockingWritePC() | ||
| 562 | tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{ | 797 | tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{ | ||
| @@ -682,7 +917,7 @@ func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) { | |||||
| 682 | t.Fatalf("unauthenticated packet changed peer to %v", a.Peer()) | 917 | t.Fatalf("unauthenticated packet changed peer to %v", a.Peer()) | ||
| 683 | } | 918 | } | ||
| 684 | c, _ := pdgram.NewCodec(key()) | 919 | c, _ := pdgram.NewCodec(key()) | ||
| 685 | - | rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing}, 0) | 920 | + | rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing}) |
| 686 | frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU) | 921 | frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU) | ||
| 687 | raw, _ := pdgram.MarshalFragment(frags[0]) | 922 | raw, _ := pdgram.MarshalFragment(frags[0]) | ||
| 688 | aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")} | 923 | aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")} | ||
| @@ -804,11 +1039,11 @@ func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) { | |||||
| 804 | 1039 | ||||
| 805 | func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) { | 1040 | func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) { | ||
| 806 | tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)} | 1041 | tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)} | ||
| 807 | - | _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")}, 0) | 1042 | + | _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")}) |
| 808 | if !ack || !queued { | 1043 | if !ack || !queued { | ||
| 809 | t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued) | 1044 | t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued) | ||
| 810 | } | 1045 | } | ||
| 811 | - | _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0) | 1046 | + | _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}) |
| 812 | if !ack || !queued { | 1047 | if !ack || !queued { | ||
| 813 | t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued) | 1048 | t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued) | ||
| 814 | } | 1049 | } | ||
| @@ -817,28 +1052,21 @@ func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) { | |||||
| 817 | } | 1052 | } | ||
| 818 | } | 1053 | } | ||
| 819 | 1054 | ||||
| 820 | - | func TestDataRecordWireEncodingOutputSkip(t *testing.T) { | 1055 | + | func TestDataRecordWireEncoding(t *testing.T) { |
| 821 | - | got := encodeData(5, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("x")}, 3) | 1056 | + | got := encodeData(5, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("x")}) |
| 822 | - | want := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 0, 0, 0, 0, 0, 0, 0, 3, 'x'} | 1057 | + | want := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 0, 'x'} |
| 823 | if !bytes.Equal(got, want) { | 1058 | if !bytes.Equal(got, want) { | ||
| 824 | t.Fatalf("encoded data=%v, want %v", got, want) | 1059 | t.Fatalf("encoded data=%v, want %v", got, want) | ||
| 825 | } | 1060 | } | ||
| 826 | - | seq, reliable, frame, skip, ok := decodeData(got) | 1061 | + | seq, reliable, frame, ok := decodeData(got) |
| 827 | - | if !ok || seq != 5 || !reliable || frame.Type != ports.MsgOutput || string(frame.Payload) != "x" || skip != 3 { | 1062 | + | if !ok || seq != 5 || !reliable || frame.Type != ports.MsgOutput || string(frame.Payload) != "x" { |
| 828 | - | t.Fatalf("decoded seq=%d reliable=%v frame=%+v skip=%d ok=%v", seq, reliable, frame, skip, ok) | 1063 | + | t.Fatalf("decoded seq=%d reliable=%v frame=%+v ok=%v", seq, reliable, frame, ok) |
| 829 | - | } | |||
| 830 | - | } | |||
| 831 | - | ||||
| 832 | - | func TestDataRecordWireDecodeRejectsTruncatedOutputSkip(t *testing.T) { | |||
| 833 | - | truncated := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 0} | |||
| 834 | - | if _, _, _, _, ok := decodeData(truncated); ok { | |||
| 835 | - | t.Fatal("decodeData accepted truncated output skip header") | |||
| 836 | } | 1064 | } | ||
| 837 | } | 1065 | } | ||
| 838 | 1066 | ||||
| 839 | func TestDataRecordWireDecodeRejectsUnknownFlags(t *testing.T) { | 1067 | func TestDataRecordWireDecodeRejectsUnknownFlags(t *testing.T) { | ||
| 840 | - | withTrailingGarbage := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 0x80, 'x'} | 1068 | + | withTrailingGarbage := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 'x'} |
| 841 | - | if _, _, _, _, ok := decodeData(withTrailingGarbage); ok { | 1069 | + | if _, _, _, ok := decodeData(withTrailingGarbage); ok { |
| 842 | t.Fatal("decodeData accepted unknown data-record flags with trailing garbage") | 1070 | t.Fatal("decodeData accepted unknown data-record flags with trailing garbage") | ||
| 843 | } | 1071 | } | ||
| 844 | } | 1072 | } | ||
| @@ -861,7 +1089,7 @@ func TestOutputRetransmitsUntilAck(t *testing.T) { | |||||
| 861 | t.Errorf("close b: %v", err) | 1089 | t.Errorf("close b: %v", err) | ||
| 862 | } | 1090 | } | ||
| 863 | }() | 1091 | }() | ||
| 864 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil { | 1092 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil { |
| 865 | t.Fatal(err) | 1093 | t.Fatal(err) | ||
| 866 | } | 1094 | } | ||
| 867 | got := recvWithin(t, b, time.Second) | 1095 | got := recvWithin(t, b, time.Second) | ||
| @@ -870,6 +1098,40 @@ func TestOutputRetransmitsUntilAck(t *testing.T) { | |||||
| 870 | } | 1098 | } | ||
| 871 | } | 1099 | } | ||
| 872 | 1100 | ||||
| 1101 | + | func TestImmediateLoopbackAckSamplesStampedFinalWrite(t *testing.T) { | |||
| 1102 | + | aPC, bPC := newPair() | |||
| 1103 | + | a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |||
| 1104 | + | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |||
| 1105 | + | defer func() { _ = a.Close() }() | |||
| 1106 | + | defer func() { _ = b.Close() }() | |||
| 1107 | + | aPC.afterWrite = func() { | |||
| 1108 | + | deadline := time.Now().Add(time.Second) | |||
| 1109 | + | for time.Now().Before(deadline) { | |||
| 1110 | + | a.mu.Lock() | |||
| 1111 | + | _, pending := a.pending[1] | |||
| 1112 | + | a.mu.Unlock() | |||
| 1113 | + | if !pending { | |||
| 1114 | + | return | |||
| 1115 | + | } | |||
| 1116 | + | time.Sleep(time.Microsecond) | |||
| 1117 | + | } | |||
| 1118 | + | t.Error("ACK did not retire pending frame before WriteTo returned") | |||
| 1119 | + | } | |||
| 1120 | + | ||||
| 1121 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state")}); err != nil { | |||
| 1122 | + | t.Fatal(err) | |||
| 1123 | + | } | |||
| 1124 | + | a.mu.Lock() | |||
| 1125 | + | srtt, rto := a.srtt, a.rto | |||
| 1126 | + | a.mu.Unlock() | |||
| 1127 | + | if srtt <= 0 || srtt > time.Second { | |||
| 1128 | + | t.Fatalf("srtt=%v, want finite immediate-loopback sample", srtt) | |||
| 1129 | + | } | |||
| 1130 | + | if rto < a.resendAfter || rto > a.maxResendAfter { | |||
| 1131 | + | t.Fatalf("rto=%v outside [%v,%v]", rto, a.resendAfter, a.maxResendAfter) | |||
| 1132 | + | } | |||
| 1133 | + | } | |||
| 1134 | + | ||||
| 873 | func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) { | 1135 | func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) { | ||
| 874 | aPC, bPC := newPair() | 1136 | aPC, bPC := newPair() | ||
| 875 | a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2) | 1137 | a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2) | ||
| @@ -890,7 +1152,7 @@ func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) { | |||||
| 890 | } | 1152 | } | ||
| 891 | } | 1153 | } | ||
| 892 | 1154 | ||||
| 893 | - | func TestOutputSupersessionSkipsDroppedOlderOutput(t *testing.T) { | 1155 | + | func TestOutputDependencyChainRetransmitsDroppedPredecessors(t *testing.T) { |
| 894 | aPC, bPC := newPair() | 1156 | aPC, bPC := newPair() | ||
| 895 | var sendAttempts atomic.Int32 | 1157 | var sendAttempts atomic.Int32 | ||
| 896 | aPC.drop = func(_ []byte, addr net.Addr) bool { | 1158 | aPC.drop = func(_ []byte, addr net.Addr) bool { | ||
| @@ -902,21 +1164,16 @@ func TestOutputSupersessionSkipsDroppedOlderOutput(t *testing.T) { | |||||
| 902 | defer func() { _ = b.Close() }() | 1164 | defer func() { _ = b.Close() }() | ||
| 903 | 1165 | ||||
| 904 | for _, payload := range []string{"state1", "state2", "state3"} { | 1166 | for _, payload := range []string{"state1", "state2", "state3"} { | ||
| 905 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil { | 1167 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil { |
| 906 | t.Fatal(err) | 1168 | t.Fatal(err) | ||
| 907 | } | 1169 | } | ||
| 908 | } | 1170 | } | ||
| 909 | 1171 | ||||
| 910 | - | a.mu.Lock() | 1172 | + | for _, want := range []string{"state1", "state2", "state3"} { |
| 911 | - | pending := len(a.pending) | 1173 | + | got := recvWithin(t, b, time.Second) |
| 912 | - | a.mu.Unlock() | 1174 | + | if got.Type != ports.MsgOutput || string(got.Payload) != want { |
| 913 | - | if pending > 1 { | 1175 | + | t.Fatalf("got=%+v, want ordered prerequisite output %q", got, want) |
| 914 | - | t.Fatalf("pending outputs=%d, want collapsed to newest", pending) | 1176 | + | } |
| 915 | - | } | |||
| 916 | - | ||||
| 917 | - | got := recvWithin(t, b, time.Second) | |||
| 918 | - | if got.Type != ports.MsgOutput || string(got.Payload) != "state3" { | |||
| 919 | - | t.Fatalf("got=%+v, want newest output after skipped predecessors", got) | |||
| 920 | } | 1177 | } | ||
| 921 | if sendAttempts.Load() < 3 { | 1178 | if sendAttempts.Load() < 3 { | ||
| 922 | t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load()) | 1179 | t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load()) | ||
| @@ -931,7 +1188,7 @@ func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) { | |||||
| 931 | defer func() { _ = b.Close() }() | 1188 | defer func() { _ = b.Close() }() | ||
| 932 | 1189 | ||||
| 933 | for i := range cap(b.in) + 20 { | 1190 | for i := range cap(b.in) + 20 { | ||
| 934 | - | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { | 1191 | + | if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil { |
| 935 | t.Fatal(err) | 1192 | t.Fatal(err) | ||
| 936 | } | 1193 | } | ||
| 937 | } | 1194 | } | ||
| @@ -994,6 +1251,39 @@ func TestResendPendingCapsBurst(t *testing.T) { | |||||
| 994 | } | 1251 | } | ||
| 995 | } | 1252 | } | ||
| 996 | 1253 | ||||
| 1254 | + | func TestOversizedRetransmitHonorsPacketBudget(t *testing.T) { | |||
| 1255 | + | tr, writes := newResendTestTransport(t, 1) | |||
| 1256 | + | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |||
| 1257 | + | tr.clock = clk | |||
| 1258 | + | tr.done = make(chan struct{}) | |||
| 1259 | + | tr.pending[1] = &pending{ | |||
| 1260 | + | frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*tr.mtu)}, | |||
| 1261 | + | last: clk.now.Add(-time.Second), | |||
| 1262 | + | } | |||
| 1263 | + | done := make(chan struct{}) | |||
| 1264 | + | go func() { | |||
| 1265 | + | tr.resendPending() | |||
| 1266 | + | close(done) | |||
| 1267 | + | }() | |||
| 1268 | + | ||||
| 1269 | + | previous := int32(0) | |||
| 1270 | + | for tick := 0; tick < 128; tick++ { | |||
| 1271 | + | clk.advance(defaultOutputPaceMaxDelay) | |||
| 1272 | + | time.Sleep(time.Millisecond) | |||
| 1273 | + | current := writes.Load() | |||
| 1274 | + | if delta := current - previous; delta > defaultPacketPaceBudget { | |||
| 1275 | + | t.Fatalf("tick %d retransmitted %d packets, budget %d", tick, delta, defaultPacketPaceBudget) | |||
| 1276 | + | } | |||
| 1277 | + | previous = current | |||
| 1278 | + | select { | |||
| 1279 | + | case <-done: | |||
| 1280 | + | return | |||
| 1281 | + | default: | |||
| 1282 | + | } | |||
| 1283 | + | } | |||
| 1284 | + | t.Fatal("oversized retransmit did not complete within pacing ticks") | |||
| 1285 | + | } | |||
| 1286 | + | ||||
| 997 | func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) { | 1287 | func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) { | ||
| 998 | tr, writes := newResendTestTransport(t, 10) | 1288 | tr, writes := newResendTestTransport(t, 10) | ||
| 999 | now := tr.clock.Now() | 1289 | now := tr.clock.Now() | ||
| @@ -1071,7 +1361,7 @@ func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) { | |||||
| 1071 | func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) { | 1361 | func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) { | ||
| 1072 | tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)} | 1362 | tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)} | ||
| 1073 | 1363 | ||||
| 1074 | - | ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")}, 0) | 1364 | + | ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")}) |
| 1075 | if !ack || !queued { | 1365 | if !ack || !queued { | ||
| 1076 | t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued) | 1366 | t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued) | ||
| 1077 | } | 1367 | } | ||
| @@ -1079,7 +1369,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) { | |||||
| 1079 | t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want) | 1369 | t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want) | ||
| 1080 | } | 1370 | } | ||
| 1081 | 1371 | ||||
| 1082 | - | ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")}, 0) | 1372 | + | ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")}) |
| 1083 | if !ack || !queued { | 1373 | if !ack || !queued { | ||
| 1084 | t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued) | 1374 | t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued) | ||
| 1085 | } | 1375 | } | ||
| @@ -1087,7 +1377,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) { | |||||
| 1087 | t.Fatalf("first contiguous ack=%d, want %d", got, want) | 1377 | t.Fatalf("first contiguous ack=%d, want %d", got, want) | ||
| 1088 | } | 1378 | } | ||
| 1089 | 1379 | ||||
| 1090 | - | ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}, 0) | 1380 | + | ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}) |
| 1091 | if !ack || !queued { | 1381 | if !ack || !queued { | ||
| 1092 | t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued) | 1382 | t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued) | ||
| 1093 | } | 1383 | } | ||
| @@ -1318,12 +1608,12 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) { | |||||
| 1318 | tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})} | 1608 | tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})} | ||
| 1319 | tr.deliverCond = sync.NewCond(&tr.deliverMu) | 1609 | tr.deliverCond = sync.NewCond(&tr.deliverMu) | ||
| 1320 | for i := range maxRecvBuffer + 100 { | 1610 | for i := range maxRecvBuffer + 100 { | ||
| 1321 | - | tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, 0) | 1611 | + | tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}) |
| 1322 | } | 1612 | } | ||
| 1323 | if got := len(tr.recvBuf); got != maxRecvBuffer { | 1613 | if got := len(tr.recvBuf); got != maxRecvBuffer { | ||
| 1324 | t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer) | 1614 | t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer) | ||
| 1325 | } | 1615 | } | ||
| 1326 | - | tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0) | 1616 | + | tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}) |
| 1327 | if _, ok := tr.recvBuf[1]; !ok { | 1617 | if _, ok := tr.recvBuf[1]; !ok { | ||
| 1328 | t.Fatalf("next expected sequence was dropped when far-future buffer was full") | 1618 | t.Fatalf("next expected sequence was dropped when far-future buffer was full") | ||
| 1329 | } | 1619 | } | ||
| @@ -1332,7 +1622,7 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) { | |||||
| 1332 | } | 1622 | } | ||
| 1333 | } | 1623 | } | ||
| 1334 | 1624 | ||||
| 1335 | - | func TestOutputSupersessionPrunesFullReceiveBuffer(t *testing.T) { | 1625 | + | func TestOutputPrerequisitesRemainInFullReceiveBuffer(t *testing.T) { |
| 1336 | tr := &Transport{ | 1626 | tr := &Transport{ | ||
| 1337 | nextRecvSeq: 1, | 1627 | nextRecvSeq: 1, | ||
| 1338 | recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}}, | 1628 | recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}}, | ||
| @@ -1341,18 +1631,15 @@ func TestOutputSupersessionPrunesFullReceiveBuffer(t *testing.T) { | |||||
| 1341 | } | 1631 | } | ||
| 1342 | tr.deliverCond = sync.NewCond(&tr.deliverMu) | 1632 | tr.deliverCond = sync.NewCond(&tr.deliverMu) | ||
| 1343 | 1633 | ||||
| 1344 | - | ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("replacement")}, 2) | 1634 | + | ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("replacement")}) |
| 1345 | - | if !ack || !queued { | 1635 | + | if ack || queued || ackSeq != 0 { |
| 1346 | - | t.Fatalf("replacement ack=%v queued=%v, want true/true", ack, queued) | 1636 | + | t.Fatalf("replacement ackSeq=%d ack=%v queued=%v, want output rejected while prerequisite buffer is full", ackSeq, ack, queued) |
| 1347 | } | 1637 | } | ||
| 1348 | - | if ackSeq != 3 { | 1638 | + | if _, ok := tr.recvBuf[2]; !ok { |
| 1349 | - | t.Fatalf("ack seq=%d, want 3 after skipped output", ackSeq) | 1639 | + | t.Fatal("prerequisite output was discarded from full receive buffer") |
| 1350 | - | } | |||
| 1351 | - | if _, ok := tr.recvBuf[2]; ok { | |||
| 1352 | - | t.Fatal("superseded output remained in full receive buffer") | |||
| 1353 | } | 1640 | } | ||
| 1354 | - | if got := string(tr.recvBuf[3].Payload); got != "replacement" { | 1641 | + | if _, ok := tr.recvBuf[3]; ok { |
| 1355 | - | t.Fatalf("queued payload=%q, want replacement", got) | 1642 | + | t.Fatal("far-future replacement bypassed full receive buffer") |
| 1356 | } | 1643 | } | ||
| 1357 | } | 1644 | } | ||
| 1358 | 1645 | ||||
| @@ -1370,14 +1657,14 @@ func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) { | |||||
| 1370 | for i := range maxRecvBuffer { | 1657 | for i := range maxRecvBuffer { | ||
| 1371 | tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}} | 1658 | tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}} | ||
| 1372 | } | 1659 | } | ||
| 1373 | - | tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}, 0)) | 1660 | + | tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")})) |
| 1374 | select { | 1661 | select { | ||
| 1375 | case pkt := <-bPC.in: | 1662 | case pkt := <-bPC.in: | ||
| 1376 | t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b) | 1663 | t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b) | ||
| 1377 | case <-time.After(25 * time.Millisecond): | 1664 | case <-time.After(25 * time.Millisecond): | ||
| 1378 | } | 1665 | } | ||
| 1379 | 1666 | ||||
| 1380 | - | tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0)) | 1667 | + | tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})) |
| 1381 | select { | 1668 | select { | ||
| 1382 | case <-bPC.in: | 1669 | case <-bPC.in: | ||
| 1383 | case <-time.After(time.Second): | 1670 | case <-time.After(time.Second): | ||
| @@ -1507,7 +1794,7 @@ func TestSendStallDeathRequiresOldRetransmittedFrame(t *testing.T) { | |||||
| 1507 | a.lastHeard = now | 1794 | a.lastHeard = now | ||
| 1508 | a.pending[1] = &pending{ | 1795 | a.pending[1] = &pending{ | ||
| 1509 | frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stuck")}, | 1796 | frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stuck")}, | ||
| 1510 | - | first: now.Add(-tt.age), | 1797 | + | enqueued: now.Add(-tt.age), |
| 1511 | last: now, | 1798 | last: now, | ||
| 1512 | attempts: tt.attempts, | 1799 | attempts: tt.attempts, | ||
| 1513 | } | 1800 | } | ||
M internal/ports/frame.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 12 | |
| 4 | + | const ProtocolVersion uint16 = 13 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 12 | 4 | + | const ProtocolVersion uint16 = 13 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 12 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 12", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 13 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 13", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 12 { | 6 | + | if ProtocolVersion != 13 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 12", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 13", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
M internal/ports/mocks/mocks.go +156 -0
| @@ -1120,6 +1120,162 @@ func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) erro | |||
| 1120 | 1120 | return _c | |
| 1121 | 1121 | } | |
| 1122 | 1122 | ||
| 1123 | + | // NewMockAsyncTransport creates a new instance of MockAsyncTransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1124 | + | // The first argument is typically a *testing.T value. | |
| 1125 | + | func NewMockAsyncTransport(t interface { | |
| 1126 | + | mock.TestingT | |
| 1127 | + | Cleanup(func()) | |
| 1128 | + | }) *MockAsyncTransport { | |
| 1129 | + | mock := &MockAsyncTransport{} | |
| 1130 | + | mock.Mock.Test(t) | |
| 1131 | + | ||
| 1132 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1133 | + | ||
| 1134 | + | return mock | |
| 1135 | + | } | |
| 1136 | + | ||
| 1137 | + | // MockAsyncTransport is an autogenerated mock type for the AsyncTransport type | |
| 1138 | + | type MockAsyncTransport struct { | |
| 1139 | + | mock.Mock | |
| 1140 | + | } | |
| 1141 | + | ||
| 1142 | + | type MockAsyncTransport_Expecter struct { | |
| 1143 | + | mock *mock.Mock | |
| 1144 | + | } | |
| 1145 | + | ||
| 1146 | + | func (_m *MockAsyncTransport) EXPECT() *MockAsyncTransport_Expecter { | |
| 1147 | + | return &MockAsyncTransport_Expecter{mock: &_m.Mock} | |
| 1148 | + | } | |
| 1149 | + | ||
| 1150 | + | // SendAsync provides a mock function for the type MockAsyncTransport | |
| 1151 | + | func (_mock *MockAsyncTransport) SendAsync(frame ports.Frame) error { | |
| 1152 | + | ret := _mock.Called(frame) | |
| 1153 | + | ||
| 1154 | + | if len(ret) == 0 { | |
| 1155 | + | panic("no return value specified for SendAsync") | |
| 1156 | + | } | |
| 1157 | + | ||
| 1158 | + | var r0 error | |
| 1159 | + | if returnFunc, ok := ret.Get(0).(func(ports.Frame) error); ok { | |
| 1160 | + | r0 = returnFunc(frame) | |
| 1161 | + | } else { | |
| 1162 | + | r0 = ret.Error(0) | |
| 1163 | + | } | |
| 1164 | + | return r0 | |
| 1165 | + | } | |
| 1166 | + | ||
| 1167 | + | // MockAsyncTransport_SendAsync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendAsync' | |
| 1168 | + | type MockAsyncTransport_SendAsync_Call struct { | |
| 1169 | + | *mock.Call | |
| 1170 | + | } | |
| 1171 | + | ||
| 1172 | + | // SendAsync is a helper method to define mock.On call | |
| 1173 | + | // - frame ports.Frame | |
| 1174 | + | func (_e *MockAsyncTransport_Expecter) SendAsync(frame any) *MockAsyncTransport_SendAsync_Call { | |
| 1175 | + | return &MockAsyncTransport_SendAsync_Call{Call: _e.mock.On("SendAsync", frame)} | |
| 1176 | + | } | |
| 1177 | + | ||
| 1178 | + | func (_c *MockAsyncTransport_SendAsync_Call) Run(run func(frame ports.Frame)) *MockAsyncTransport_SendAsync_Call { | |
| 1179 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1180 | + | var arg0 ports.Frame | |
| 1181 | + | if args[0] != nil { | |
| 1182 | + | arg0 = args[0].(ports.Frame) | |
| 1183 | + | } | |
| 1184 | + | run( | |
| 1185 | + | arg0, | |
| 1186 | + | ) | |
| 1187 | + | }) | |
| 1188 | + | return _c | |
| 1189 | + | } | |
| 1190 | + | ||
| 1191 | + | func (_c *MockAsyncTransport_SendAsync_Call) Return(err error) *MockAsyncTransport_SendAsync_Call { | |
| 1192 | + | _c.Call.Return(err) | |
| 1193 | + | return _c | |
| 1194 | + | } | |
| 1195 | + | ||
| 1196 | + | func (_c *MockAsyncTransport_SendAsync_Call) RunAndReturn(run func(frame ports.Frame) error) *MockAsyncTransport_SendAsync_Call { | |
| 1197 | + | _c.Call.Return(run) | |
| 1198 | + | return _c | |
| 1199 | + | } | |
| 1200 | + | ||
| 1201 | + | // NewMockOwnedSynchronousTransport creates a new instance of MockOwnedSynchronousTransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1202 | + | // The first argument is typically a *testing.T value. | |
| 1203 | + | func NewMockOwnedSynchronousTransport(t interface { | |
| 1204 | + | mock.TestingT | |
| 1205 | + | Cleanup(func()) | |
| 1206 | + | }) *MockOwnedSynchronousTransport { | |
| 1207 | + | mock := &MockOwnedSynchronousTransport{} | |
| 1208 | + | mock.Mock.Test(t) | |
| 1209 | + | ||
| 1210 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| 1211 | + | ||
| 1212 | + | return mock | |
| 1213 | + | } | |
| 1214 | + | ||
| 1215 | + | // MockOwnedSynchronousTransport is an autogenerated mock type for the OwnedSynchronousTransport type | |
| 1216 | + | type MockOwnedSynchronousTransport struct { | |
| 1217 | + | mock.Mock | |
| 1218 | + | } | |
| 1219 | + | ||
| 1220 | + | type MockOwnedSynchronousTransport_Expecter struct { | |
| 1221 | + | mock *mock.Mock | |
| 1222 | + | } | |
| 1223 | + | ||
| 1224 | + | func (_m *MockOwnedSynchronousTransport) EXPECT() *MockOwnedSynchronousTransport_Expecter { | |
| 1225 | + | return &MockOwnedSynchronousTransport_Expecter{mock: &_m.Mock} | |
| 1226 | + | } | |
| 1227 | + | ||
| 1228 | + | // SendSynchronous provides a mock function for the type MockOwnedSynchronousTransport | |
| 1229 | + | func (_mock *MockOwnedSynchronousTransport) SendSynchronous(frame ports.Frame) error { | |
| 1230 | + | ret := _mock.Called(frame) | |
| 1231 | + | ||
| 1232 | + | if len(ret) == 0 { | |
| 1233 | + | panic("no return value specified for SendSynchronous") | |
| 1234 | + | } | |
| 1235 | + | ||
| 1236 | + | var r0 error | |
| 1237 | + | if returnFunc, ok := ret.Get(0).(func(ports.Frame) error); ok { | |
| 1238 | + | r0 = returnFunc(frame) | |
| 1239 | + | } else { | |
| 1240 | + | r0 = ret.Error(0) | |
| 1241 | + | } | |
| 1242 | + | return r0 | |
| 1243 | + | } | |
| 1244 | + | ||
| 1245 | + | // MockOwnedSynchronousTransport_SendSynchronous_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendSynchronous' | |
| 1246 | + | type MockOwnedSynchronousTransport_SendSynchronous_Call struct { | |
| 1247 | + | *mock.Call | |
| 1248 | + | } | |
| 1249 | + | ||
| 1250 | + | // SendSynchronous is a helper method to define mock.On call | |
| 1251 | + | // - frame ports.Frame | |
| 1252 | + | func (_e *MockOwnedSynchronousTransport_Expecter) SendSynchronous(frame any) *MockOwnedSynchronousTransport_SendSynchronous_Call { | |
| 1253 | + | return &MockOwnedSynchronousTransport_SendSynchronous_Call{Call: _e.mock.On("SendSynchronous", frame)} | |
| 1254 | + | } | |
| 1255 | + | ||
| 1256 | + | func (_c *MockOwnedSynchronousTransport_SendSynchronous_Call) Run(run func(frame ports.Frame)) *MockOwnedSynchronousTransport_SendSynchronous_Call { | |
| 1257 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 1258 | + | var arg0 ports.Frame | |
| 1259 | + | if args[0] != nil { | |
| 1260 | + | arg0 = args[0].(ports.Frame) | |
| 1261 | + | } | |
| 1262 | + | run( | |
| 1263 | + | arg0, | |
| 1264 | + | ) | |
| 1265 | + | }) | |
| 1266 | + | return _c | |
| 1267 | + | } | |
| 1268 | + | ||
| 1269 | + | func (_c *MockOwnedSynchronousTransport_SendSynchronous_Call) Return(err error) *MockOwnedSynchronousTransport_SendSynchronous_Call { | |
| 1270 | + | _c.Call.Return(err) | |
| 1271 | + | return _c | |
| 1272 | + | } | |
| 1273 | + | ||
| 1274 | + | func (_c *MockOwnedSynchronousTransport_SendSynchronous_Call) RunAndReturn(run func(frame ports.Frame) error) *MockOwnedSynchronousTransport_SendSynchronous_Call { | |
| 1275 | + | _c.Call.Return(run) | |
| 1276 | + | return _c | |
| 1277 | + | } | |
| 1278 | + | ||
| 1123 | 1279 | // NewMockDialer creates a new instance of MockDialer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 1124 | 1280 | // The first argument is typically a *testing.T value. | |
| 1125 | 1281 | func NewMockDialer(t interface { | |
| @@ -1120,6 +1120,162 @@ func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) erro | |||||
| 1120 | return _c | 1120 | return _c | ||
| 1121 | } | 1121 | } | ||
| 1122 | 1122 | ||||
| 1123 | + | // NewMockAsyncTransport creates a new instance of MockAsyncTransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1124 | + | // The first argument is typically a *testing.T value. | |||
| 1125 | + | func NewMockAsyncTransport(t interface { | |||
| 1126 | + | mock.TestingT | |||
| 1127 | + | Cleanup(func()) | |||
| 1128 | + | }) *MockAsyncTransport { | |||
| 1129 | + | mock := &MockAsyncTransport{} | |||
| 1130 | + | mock.Mock.Test(t) | |||
| 1131 | + | ||||
| 1132 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1133 | + | ||||
| 1134 | + | return mock | |||
| 1135 | + | } | |||
| 1136 | + | ||||
| 1137 | + | // MockAsyncTransport is an autogenerated mock type for the AsyncTransport type | |||
| 1138 | + | type MockAsyncTransport struct { | |||
| 1139 | + | mock.Mock | |||
| 1140 | + | } | |||
| 1141 | + | ||||
| 1142 | + | type MockAsyncTransport_Expecter struct { | |||
| 1143 | + | mock *mock.Mock | |||
| 1144 | + | } | |||
| 1145 | + | ||||
| 1146 | + | func (_m *MockAsyncTransport) EXPECT() *MockAsyncTransport_Expecter { | |||
| 1147 | + | return &MockAsyncTransport_Expecter{mock: &_m.Mock} | |||
| 1148 | + | } | |||
| 1149 | + | ||||
| 1150 | + | // SendAsync provides a mock function for the type MockAsyncTransport | |||
| 1151 | + | func (_mock *MockAsyncTransport) SendAsync(frame ports.Frame) error { | |||
| 1152 | + | ret := _mock.Called(frame) | |||
| 1153 | + | ||||
| 1154 | + | if len(ret) == 0 { | |||
| 1155 | + | panic("no return value specified for SendAsync") | |||
| 1156 | + | } | |||
| 1157 | + | ||||
| 1158 | + | var r0 error | |||
| 1159 | + | if returnFunc, ok := ret.Get(0).(func(ports.Frame) error); ok { | |||
| 1160 | + | r0 = returnFunc(frame) | |||
| 1161 | + | } else { | |||
| 1162 | + | r0 = ret.Error(0) | |||
| 1163 | + | } | |||
| 1164 | + | return r0 | |||
| 1165 | + | } | |||
| 1166 | + | ||||
| 1167 | + | // MockAsyncTransport_SendAsync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendAsync' | |||
| 1168 | + | type MockAsyncTransport_SendAsync_Call struct { | |||
| 1169 | + | *mock.Call | |||
| 1170 | + | } | |||
| 1171 | + | ||||
| 1172 | + | // SendAsync is a helper method to define mock.On call | |||
| 1173 | + | // - frame ports.Frame | |||
| 1174 | + | func (_e *MockAsyncTransport_Expecter) SendAsync(frame any) *MockAsyncTransport_SendAsync_Call { | |||
| 1175 | + | return &MockAsyncTransport_SendAsync_Call{Call: _e.mock.On("SendAsync", frame)} | |||
| 1176 | + | } | |||
| 1177 | + | ||||
| 1178 | + | func (_c *MockAsyncTransport_SendAsync_Call) Run(run func(frame ports.Frame)) *MockAsyncTransport_SendAsync_Call { | |||
| 1179 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1180 | + | var arg0 ports.Frame | |||
| 1181 | + | if args[0] != nil { | |||
| 1182 | + | arg0 = args[0].(ports.Frame) | |||
| 1183 | + | } | |||
| 1184 | + | run( | |||
| 1185 | + | arg0, | |||
| 1186 | + | ) | |||
| 1187 | + | }) | |||
| 1188 | + | return _c | |||
| 1189 | + | } | |||
| 1190 | + | ||||
| 1191 | + | func (_c *MockAsyncTransport_SendAsync_Call) Return(err error) *MockAsyncTransport_SendAsync_Call { | |||
| 1192 | + | _c.Call.Return(err) | |||
| 1193 | + | return _c | |||
| 1194 | + | } | |||
| 1195 | + | ||||
| 1196 | + | func (_c *MockAsyncTransport_SendAsync_Call) RunAndReturn(run func(frame ports.Frame) error) *MockAsyncTransport_SendAsync_Call { | |||
| 1197 | + | _c.Call.Return(run) | |||
| 1198 | + | return _c | |||
| 1199 | + | } | |||
| 1200 | + | ||||
| 1201 | + | // NewMockOwnedSynchronousTransport creates a new instance of MockOwnedSynchronousTransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |||
| 1202 | + | // The first argument is typically a *testing.T value. | |||
| 1203 | + | func NewMockOwnedSynchronousTransport(t interface { | |||
| 1204 | + | mock.TestingT | |||
| 1205 | + | Cleanup(func()) | |||
| 1206 | + | }) *MockOwnedSynchronousTransport { | |||
| 1207 | + | mock := &MockOwnedSynchronousTransport{} | |||
| 1208 | + | mock.Mock.Test(t) | |||
| 1209 | + | ||||
| 1210 | + | t.Cleanup(func() { mock.AssertExpectations(t) }) | |||
| 1211 | + | ||||
| 1212 | + | return mock | |||
| 1213 | + | } | |||
| 1214 | + | ||||
| 1215 | + | // MockOwnedSynchronousTransport is an autogenerated mock type for the OwnedSynchronousTransport type | |||
| 1216 | + | type MockOwnedSynchronousTransport struct { | |||
| 1217 | + | mock.Mock | |||
| 1218 | + | } | |||
| 1219 | + | ||||
| 1220 | + | type MockOwnedSynchronousTransport_Expecter struct { | |||
| 1221 | + | mock *mock.Mock | |||
| 1222 | + | } | |||
| 1223 | + | ||||
| 1224 | + | func (_m *MockOwnedSynchronousTransport) EXPECT() *MockOwnedSynchronousTransport_Expecter { | |||
| 1225 | + | return &MockOwnedSynchronousTransport_Expecter{mock: &_m.Mock} | |||
| 1226 | + | } | |||
| 1227 | + | ||||
| 1228 | + | // SendSynchronous provides a mock function for the type MockOwnedSynchronousTransport | |||
| 1229 | + | func (_mock *MockOwnedSynchronousTransport) SendSynchronous(frame ports.Frame) error { | |||
| 1230 | + | ret := _mock.Called(frame) | |||
| 1231 | + | ||||
| 1232 | + | if len(ret) == 0 { | |||
| 1233 | + | panic("no return value specified for SendSynchronous") | |||
| 1234 | + | } | |||
| 1235 | + | ||||
| 1236 | + | var r0 error | |||
| 1237 | + | if returnFunc, ok := ret.Get(0).(func(ports.Frame) error); ok { | |||
| 1238 | + | r0 = returnFunc(frame) | |||
| 1239 | + | } else { | |||
| 1240 | + | r0 = ret.Error(0) | |||
| 1241 | + | } | |||
| 1242 | + | return r0 | |||
| 1243 | + | } | |||
| 1244 | + | ||||
| 1245 | + | // MockOwnedSynchronousTransport_SendSynchronous_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendSynchronous' | |||
| 1246 | + | type MockOwnedSynchronousTransport_SendSynchronous_Call struct { | |||
| 1247 | + | *mock.Call | |||
| 1248 | + | } | |||
| 1249 | + | ||||
| 1250 | + | // SendSynchronous is a helper method to define mock.On call | |||
| 1251 | + | // - frame ports.Frame | |||
| 1252 | + | func (_e *MockOwnedSynchronousTransport_Expecter) SendSynchronous(frame any) *MockOwnedSynchronousTransport_SendSynchronous_Call { | |||
| 1253 | + | return &MockOwnedSynchronousTransport_SendSynchronous_Call{Call: _e.mock.On("SendSynchronous", frame)} | |||
| 1254 | + | } | |||
| 1255 | + | ||||
| 1256 | + | func (_c *MockOwnedSynchronousTransport_SendSynchronous_Call) Run(run func(frame ports.Frame)) *MockOwnedSynchronousTransport_SendSynchronous_Call { | |||
| 1257 | + | _c.Call.Run(func(args mock.Arguments) { | |||
| 1258 | + | var arg0 ports.Frame | |||
| 1259 | + | if args[0] != nil { | |||
| 1260 | + | arg0 = args[0].(ports.Frame) | |||
| 1261 | + | } | |||
| 1262 | + | run( | |||
| 1263 | + | arg0, | |||
| 1264 | + | ) | |||
| 1265 | + | }) | |||
| 1266 | + | return _c | |||
| 1267 | + | } | |||
| 1268 | + | ||||
| 1269 | + | func (_c *MockOwnedSynchronousTransport_SendSynchronous_Call) Return(err error) *MockOwnedSynchronousTransport_SendSynchronous_Call { | |||
| 1270 | + | _c.Call.Return(err) | |||
| 1271 | + | return _c | |||
| 1272 | + | } | |||
| 1273 | + | ||||
| 1274 | + | func (_c *MockOwnedSynchronousTransport_SendSynchronous_Call) RunAndReturn(run func(frame ports.Frame) error) *MockOwnedSynchronousTransport_SendSynchronous_Call { | |||
| 1275 | + | _c.Call.Return(run) | |||
| 1276 | + | return _c | |||
| 1277 | + | } | |||
| 1278 | + | ||||
| 1123 | // NewMockDialer creates a new instance of MockDialer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 1279 | // NewMockDialer creates a new instance of MockDialer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 1124 | // The first argument is typically a *testing.T value. | 1280 | // The first argument is typically a *testing.T value. | ||
| 1125 | func NewMockDialer(t interface { | 1281 | func NewMockDialer(t interface { | ||
M internal/ports/ports.go +11 -4
| @@ -45,10 +45,17 @@ type Transport interface { | |||
| 45 | 45 | Close() error | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | - | // DatagramTransport marks transports with datagram/reordered/lossy delivery | |
| 49 | - | // semantics behind the framed Transport interface. | |
| 50 | - | type DatagramTransport interface { | |
| 51 | - | DatagramTransport() | |
| 48 | + | // AsyncTransport accepts frames for ordered background transmission. SendAsync | |
| 49 | + | // returns once the adapter owns the frame; Send retains its synchronous wire- | |
| 50 | + | // attempt contract. Daemon paint output may use this capability to pipeline. | |
| 51 | + | type AsyncTransport interface { | |
| 52 | + | SendAsync(Frame) error | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // OwnedSynchronousTransport owns the complete bounded synchronous operation, | |
| 56 | + | // including adapter queues, pacing, write deadlines, and close cancellation. | |
| 57 | + | type OwnedSynchronousTransport interface { | |
| 58 | + | SendSynchronous(Frame) error | |
| 52 | 59 | } | |
| 53 | 60 | ||
| 54 | 61 | // Dialer establishes outbound Transport connections. | |
| @@ -45,10 +45,17 @@ type Transport interface { | |||||
| 45 | Close() error | 45 | Close() error | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | - | // DatagramTransport marks transports with datagram/reordered/lossy delivery | 48 | + | // AsyncTransport accepts frames for ordered background transmission. SendAsync |
| 49 | - | // semantics behind the framed Transport interface. | 49 | + | // returns once the adapter owns the frame; Send retains its synchronous wire- |
| 50 | - | type DatagramTransport interface { | 50 | + | // attempt contract. Daemon paint output may use this capability to pipeline. |
| 51 | - | DatagramTransport() | 51 | + | type AsyncTransport interface { |
| 52 | + | SendAsync(Frame) error | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // OwnedSynchronousTransport owns the complete bounded synchronous operation, | |||
| 56 | + | // including adapter queues, pacing, write deadlines, and close cancellation. | |||
| 57 | + | type OwnedSynchronousTransport interface { | |||
| 58 | + | SendSynchronous(Frame) error | |||
| 52 | } | 59 | } | ||
| 53 | 60 | ||||
| 54 | // Dialer establishes outbound Transport connections. | 61 | // Dialer establishes outbound Transport connections. | ||
M internal/ports/wire.go +3 -1
| @@ -66,7 +66,9 @@ type Hello struct { | |||
| 66 | 66 | TermEnv string | |
| 67 | 67 | Cwd string | |
| 68 | 68 | TrueColor bool | |
| 69 | - | AckOutput bool | |
| 69 | + | // AckOutput is reserved for wire compatibility in protocol v13. The daemon | |
| 70 | + | // ignores it because v13 clients cumulatively ACK output on every transport. | |
| 71 | + | AckOutput bool | |
| 70 | 72 | } | |
| 71 | 73 | ||
| 72 | 74 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | |
| @@ -66,7 +66,9 @@ type Hello struct { | |||||
| 66 | TermEnv string | 66 | TermEnv string | ||
| 67 | Cwd string | 67 | Cwd string | ||
| 68 | TrueColor bool | 68 | TrueColor bool | ||
| 69 | - | AckOutput bool | 69 | + | // AckOutput is reserved for wire compatibility in protocol v13. The daemon |
| 70 | + | // ignores it because v13 clients cumulatively ACK output on every transport. | |||
| 71 | + | AckOutput bool | |||
| 70 | } | 72 | } | ||
| 71 | 73 | ||||
| 72 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | 74 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | ||
A internal/usecase/client/ack_queue_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "io" | |
| 6 | + | "log/slog" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | type ackRecordingTransport struct{ sent chan ports.Frame } | |
| 14 | + | ||
| 15 | + | func (t *ackRecordingTransport) Send(f ports.Frame) error { t.sent <- f; return nil } | |
| 16 | + | func (t *ackRecordingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 17 | + | func (t *ackRecordingTransport) Close() error { return nil } | |
| 18 | + | ||
| 19 | + | func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) { | |
| 20 | + | normal := make(chan ports.Frame, sendQueueDepth) | |
| 21 | + | for range cap(normal) { | |
| 22 | + | normal <- ports.Frame{Type: ports.MsgInput} | |
| 23 | + | } | |
| 24 | + | acks := newCumulativeAckQueue() | |
| 25 | + | for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ { | |
| 26 | + | acks.offer(state) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 30 | + | defer cancel() | |
| 31 | + | tr := &ackRecordingTransport{sent: make(chan ports.Frame, sendQueueDepth+1)} | |
| 32 | + | errCh := make(chan error, 1) | |
| 33 | + | go runSender(ctx, cancel, tr, normal, acks, errCh, slog.Default()) | |
| 34 | + | ||
| 35 | + | first := <-tr.sent | |
| 36 | + | require.Equal(t, ports.MsgAck, first.Type) | |
| 37 | + | ack, err := ports.UnmarshalAck(first.Payload) | |
| 38 | + | require.NoError(t, err) | |
| 39 | + | require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.AckedStateNum) | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | const maxUnackedOutputStatesForTest = 8 | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "io" | |||
| 6 | + | "log/slog" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type ackRecordingTransport struct{ sent chan ports.Frame } | |||
| 14 | + | ||||
| 15 | + | func (t *ackRecordingTransport) Send(f ports.Frame) error { t.sent <- f; return nil } | |||
| 16 | + | func (t *ackRecordingTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 17 | + | func (t *ackRecordingTransport) Close() error { return nil } | |||
| 18 | + | ||||
| 19 | + | func TestCumulativeAckBypassesFullNormalSendQueue(t *testing.T) { | |||
| 20 | + | normal := make(chan ports.Frame, sendQueueDepth) | |||
| 21 | + | for range cap(normal) { | |||
| 22 | + | normal <- ports.Frame{Type: ports.MsgInput} | |||
| 23 | + | } | |||
| 24 | + | acks := newCumulativeAckQueue() | |||
| 25 | + | for state := uint64(1); state <= maxUnackedOutputStatesForTest; state++ { | |||
| 26 | + | acks.offer(state) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 30 | + | defer cancel() | |||
| 31 | + | tr := &ackRecordingTransport{sent: make(chan ports.Frame, sendQueueDepth+1)} | |||
| 32 | + | errCh := make(chan error, 1) | |||
| 33 | + | go runSender(ctx, cancel, tr, normal, acks, errCh, slog.Default()) | |||
| 34 | + | ||||
| 35 | + | first := <-tr.sent | |||
| 36 | + | require.Equal(t, ports.MsgAck, first.Type) | |||
| 37 | + | ack, err := ports.UnmarshalAck(first.Payload) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | require.Equal(t, uint64(maxUnackedOutputStatesForTest), ack.AckedStateNum) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | const maxUnackedOutputStatesForTest = 8 | |||
M internal/usecase/client/client.go +57 -18
| @@ -352,7 +352,6 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 352 | 352 | termEnv := os.Getenv("TERM") | |
| 353 | 353 | colorTerm := os.Getenv("COLORTERM") | |
| 354 | 354 | trueColor := DetectTrueColor(termEnv, colorTerm) | |
| 355 | - | _, ackOutput := transport.(ports.DatagramTransport) | |
| 356 | 355 | hello := ports.Hello{ | |
| 357 | 356 | Version: ports.ProtocolVersion, | |
| 358 | 357 | Intent: intent, | |
| @@ -363,7 +362,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 363 | 362 | TermEnv: termEnv, | |
| 364 | 363 | Cwd: cwd, | |
| 365 | 364 | TrueColor: trueColor, | |
| 366 | - | AckOutput: ackOutput, | |
| 365 | + | // Protocol v13 requires cumulative output ACKs on every transport. Keep | |
| 366 | + | // the field asserted while it remains in the wire layout. | |
| 367 | + | AckOutput: true, | |
| 367 | 368 | } | |
| 368 | 369 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 369 | 370 | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} | |
| @@ -412,6 +413,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 412 | 413 | defer cancel() | |
| 413 | 414 | ||
| 414 | 415 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 416 | + | ackQueue := newCumulativeAckQueue() | |
| 415 | 417 | sendErrCh := make(chan error, 1) | |
| 416 | 418 | ||
| 417 | 419 | // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to | |
| @@ -431,7 +433,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 431 | 433 | if remote { | |
| 432 | 434 | clip = clipboard | |
| 433 | 435 | } | |
| 434 | - | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | |
| 436 | + | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) | |
| 435 | 437 | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log) | |
| 436 | 438 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | |
| 437 | 439 | ||
| @@ -490,14 +492,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 490 | 492 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | |
| 491 | 493 | } | |
| 492 | 494 | if o.NewStateNum != 0 { | |
| 493 | - | ack := ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: o.NewStateNum})} | |
| 494 | - | select { | |
| 495 | - | case sendCh <- ack: | |
| 496 | - | case <-loopCtx.Done(): | |
| 497 | - | return welcomedResult(nil) | |
| 498 | - | default: | |
| 499 | - | log.Debug("dropping output ack because send queue is full", "state_num", o.NewStateNum) | |
| 500 | - | } | |
| 495 | + | ackQueue.offer(o.NewStateNum) | |
| 501 | 496 | } | |
| 502 | 497 | if !ms.firstOutput { | |
| 503 | 498 | ms.firstOutput = true | |
| @@ -552,22 +547,66 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu | |||
| 552 | 547 | // runSender is the sole caller of transport.Send: serialising the input and | |
| 553 | 548 | // resize pumps through one goroutine keeps the transport's single-writer | |
| 554 | 549 | // contract intact. A send failure cancels the loop and is surfaced once. | |
| 555 | - | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error, log *slog.Logger) { | |
| 550 | + | type cumulativeAckQueue struct { | |
| 551 | + | latest atomic.Uint64 | |
| 552 | + | wake chan struct{} | |
| 553 | + | } | |
| 554 | + | ||
| 555 | + | func newCumulativeAckQueue() *cumulativeAckQueue { | |
| 556 | + | return &cumulativeAckQueue{wake: make(chan struct{}, 1)} | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | func (q *cumulativeAckQueue) offer(state uint64) { | |
| 560 | + | for { | |
| 561 | + | current := q.latest.Load() | |
| 562 | + | if state <= current || q.latest.CompareAndSwap(current, state) { | |
| 563 | + | break | |
| 564 | + | } | |
| 565 | + | } | |
| 566 | + | select { | |
| 567 | + | case q.wake <- struct{}{}: | |
| 568 | + | default: | |
| 569 | + | } | |
| 570 | + | } | |
| 571 | + | ||
| 572 | + | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) { | |
| 556 | 573 | defer log.Debug("sender pump exited") | |
| 574 | + | send := func(f ports.Frame) bool { | |
| 575 | + | if err := transport.Send(f); err != nil { | |
| 576 | + | select { | |
| 577 | + | case errCh <- err: | |
| 578 | + | default: | |
| 579 | + | } | |
| 580 | + | cancel() | |
| 581 | + | return false | |
| 582 | + | } | |
| 583 | + | return true | |
| 584 | + | } | |
| 585 | + | sendAck := func() bool { | |
| 586 | + | state := acks.latest.Swap(0) | |
| 587 | + | return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: state})}) | |
| 588 | + | } | |
| 557 | 589 | for { | |
| 558 | 590 | select { | |
| 591 | + | case <-acks.wake: | |
| 592 | + | if !sendAck() { | |
| 593 | + | return | |
| 594 | + | } | |
| 595 | + | continue | |
| 596 | + | default: | |
| 597 | + | } | |
| 598 | + | select { | |
| 599 | + | case <-acks.wake: | |
| 600 | + | if !sendAck() { | |
| 601 | + | return | |
| 602 | + | } | |
| 559 | 603 | case f := <-in: | |
| 560 | 604 | select { | |
| 561 | 605 | case <-ctx.Done(): | |
| 562 | 606 | return | |
| 563 | 607 | default: | |
| 564 | 608 | } | |
| 565 | - | if err := transport.Send(f); err != nil { | |
| 566 | - | select { | |
| 567 | - | case errCh <- err: | |
| 568 | - | default: | |
| 569 | - | } | |
| 570 | - | cancel() | |
| 609 | + | if !send(f) { | |
| 571 | 610 | return | |
| 572 | 611 | } | |
| 573 | 612 | case <-ctx.Done(): | |
| @@ -352,7 +352,6 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 352 | termEnv := os.Getenv("TERM") | 352 | termEnv := os.Getenv("TERM") | ||
| 353 | colorTerm := os.Getenv("COLORTERM") | 353 | colorTerm := os.Getenv("COLORTERM") | ||
| 354 | trueColor := DetectTrueColor(termEnv, colorTerm) | 354 | trueColor := DetectTrueColor(termEnv, colorTerm) | ||
| 355 | - | _, ackOutput := transport.(ports.DatagramTransport) | |||
| 356 | hello := ports.Hello{ | 355 | hello := ports.Hello{ | ||
| 357 | Version: ports.ProtocolVersion, | 356 | Version: ports.ProtocolVersion, | ||
| 358 | Intent: intent, | 357 | Intent: intent, | ||
| @@ -363,7 +362,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 363 | TermEnv: termEnv, | 362 | TermEnv: termEnv, | ||
| 364 | Cwd: cwd, | 363 | Cwd: cwd, | ||
| 365 | TrueColor: trueColor, | 364 | TrueColor: trueColor, | ||
| 366 | - | AckOutput: ackOutput, | 365 | + | // Protocol v13 requires cumulative output ACKs on every transport. Keep |
| 366 | + | // the field asserted while it remains in the wire layout. | |||
| 367 | + | AckOutput: true, | |||
| 367 | } | 368 | } | ||
| 368 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | 369 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | ||
| 369 | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} | 370 | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} | ||
| @@ -412,6 +413,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 412 | defer cancel() | 413 | defer cancel() | ||
| 413 | 414 | ||||
| 414 | sendCh := make(chan ports.Frame, sendQueueDepth) | 415 | sendCh := make(chan ports.Frame, sendQueueDepth) | ||
| 416 | + | ackQueue := newCumulativeAckQueue() | |||
| 415 | sendErrCh := make(chan error, 1) | 417 | sendErrCh := make(chan error, 1) | ||
| 416 | 418 | ||||
| 417 | // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to | 419 | // colorQueryCh hands an OSC 10/11 re-query request from the stdin pump to | ||
| @@ -431,7 +433,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 431 | if remote { | 433 | if remote { | ||
| 432 | clip = clipboard | 434 | clip = clipboard | ||
| 433 | } | 435 | } | ||
| 434 | - | go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log) | 436 | + | go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log) |
| 435 | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log) | 437 | go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log) | ||
| 436 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | 438 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | ||
| 437 | 439 | ||||
| @@ -490,14 +492,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||||
| 490 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | 492 | return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr)) | ||
| 491 | } | 493 | } | ||
| 492 | if o.NewStateNum != 0 { | 494 | if o.NewStateNum != 0 { | ||
| 493 | - | ack := ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: o.NewStateNum})} | 495 | + | ackQueue.offer(o.NewStateNum) |
| 494 | - | select { | |||
| 495 | - | case sendCh <- ack: | |||
| 496 | - | case <-loopCtx.Done(): | |||
| 497 | - | return welcomedResult(nil) | |||
| 498 | - | default: | |||
| 499 | - | log.Debug("dropping output ack because send queue is full", "state_num", o.NewStateNum) | |||
| 500 | - | } | |||
| 501 | } | 496 | } | ||
| 502 | if !ms.firstOutput { | 497 | if !ms.firstOutput { | ||
| 503 | ms.firstOutput = true | 498 | ms.firstOutput = true | ||
| @@ -552,22 +547,66 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu | |||||
| 552 | // runSender is the sole caller of transport.Send: serialising the input and | 547 | // runSender is the sole caller of transport.Send: serialising the input and | ||
| 553 | // resize pumps through one goroutine keeps the transport's single-writer | 548 | // resize pumps through one goroutine keeps the transport's single-writer | ||
| 554 | // contract intact. A send failure cancels the loop and is surfaced once. | 549 | // contract intact. A send failure cancels the loop and is surfaced once. | ||
| 555 | - | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, errCh chan<- error, log *slog.Logger) { | 550 | + | type cumulativeAckQueue struct { |
| 551 | + | latest atomic.Uint64 | |||
| 552 | + | wake chan struct{} | |||
| 553 | + | } | |||
| 554 | + | ||||
| 555 | + | func newCumulativeAckQueue() *cumulativeAckQueue { | |||
| 556 | + | return &cumulativeAckQueue{wake: make(chan struct{}, 1)} | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | func (q *cumulativeAckQueue) offer(state uint64) { | |||
| 560 | + | for { | |||
| 561 | + | current := q.latest.Load() | |||
| 562 | + | if state <= current || q.latest.CompareAndSwap(current, state) { | |||
| 563 | + | break | |||
| 564 | + | } | |||
| 565 | + | } | |||
| 566 | + | select { | |||
| 567 | + | case q.wake <- struct{}{}: | |||
| 568 | + | default: | |||
| 569 | + | } | |||
| 570 | + | } | |||
| 571 | + | ||||
| 572 | + | func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.Transport, in <-chan ports.Frame, acks *cumulativeAckQueue, errCh chan<- error, log *slog.Logger) { | |||
| 556 | defer log.Debug("sender pump exited") | 573 | defer log.Debug("sender pump exited") | ||
| 574 | + | send := func(f ports.Frame) bool { | |||
| 575 | + | if err := transport.Send(f); err != nil { | |||
| 576 | + | select { | |||
| 577 | + | case errCh <- err: | |||
| 578 | + | default: | |||
| 579 | + | } | |||
| 580 | + | cancel() | |||
| 581 | + | return false | |||
| 582 | + | } | |||
| 583 | + | return true | |||
| 584 | + | } | |||
| 585 | + | sendAck := func() bool { | |||
| 586 | + | state := acks.latest.Swap(0) | |||
| 587 | + | return state == 0 || send(ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: state})}) | |||
| 588 | + | } | |||
| 557 | for { | 589 | for { | ||
| 558 | select { | 590 | select { | ||
| 591 | + | case <-acks.wake: | |||
| 592 | + | if !sendAck() { | |||
| 593 | + | return | |||
| 594 | + | } | |||
| 595 | + | continue | |||
| 596 | + | default: | |||
| 597 | + | } | |||
| 598 | + | select { | |||
| 599 | + | case <-acks.wake: | |||
| 600 | + | if !sendAck() { | |||
| 601 | + | return | |||
| 602 | + | } | |||
| 559 | case f := <-in: | 603 | case f := <-in: | ||
| 560 | select { | 604 | select { | ||
| 561 | case <-ctx.Done(): | 605 | case <-ctx.Done(): | ||
| 562 | return | 606 | return | ||
| 563 | default: | 607 | default: | ||
| 564 | } | 608 | } | ||
| 565 | - | if err := transport.Send(f); err != nil { | 609 | + | if !send(f) { |
| 566 | - | select { | |||
| 567 | - | case errCh <- err: | |||
| 568 | - | default: | |||
| 569 | - | } | |||
| 570 | - | cancel() | |||
| 571 | return | 610 | return | ||
| 572 | } | 611 | } | ||
| 573 | case <-ctx.Done(): | 612 | case <-ctx.Done(): | ||
M internal/usecase/client/client_test.go +1 -5
| @@ -151,10 +151,6 @@ func isType(typ ports.MsgType) any { | |||
| 151 | 151 | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | |
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | - | type datagramMarkedTransport struct{ ports.Transport } | |
| 155 | - | ||
| 156 | - | func (datagramMarkedTransport) DatagramTransport() {} | |
| 157 | - | ||
| 158 | 154 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | |
| 159 | 155 | t.Setenv("TERM", "xterm-256color") | |
| 160 | 156 | t.Setenv("COLORTERM", "truecolor") | |
| @@ -180,7 +176,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) { | |||
| 180 | 176 | defer unblock() | |
| 181 | 177 | tr.EXPECT().Close().Return(nil).Once() | |
| 182 | 178 | ||
| 183 | - | err := client.Attach(context.Background(), datagramMarkedTransport{Transport: tr}, tm, realClock{}, ports.IntentEphemeral, "") | |
| 179 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") | |
| 184 | 180 | require.NoError(t, err) | |
| 185 | 181 | ||
| 186 | 182 | select { | |
| @@ -151,10 +151,6 @@ func isType(typ ports.MsgType) any { | |||||
| 151 | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | 151 | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | ||
| 152 | } | 152 | } | ||
| 153 | 153 | ||||
| 154 | - | type datagramMarkedTransport struct{ ports.Transport } | |||
| 155 | - | ||||
| 156 | - | func (datagramMarkedTransport) DatagramTransport() {} | |||
| 157 | - | ||||
| 158 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | 154 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | ||
| 159 | t.Setenv("TERM", "xterm-256color") | 155 | t.Setenv("TERM", "xterm-256color") | ||
| 160 | t.Setenv("COLORTERM", "truecolor") | 156 | t.Setenv("COLORTERM", "truecolor") | ||
| @@ -180,7 +176,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) { | |||||
| 180 | defer unblock() | 176 | defer unblock() | ||
| 181 | tr.EXPECT().Close().Return(nil).Once() | 177 | tr.EXPECT().Close().Return(nil).Once() | ||
| 182 | 178 | ||||
| 183 | - | err := client.Attach(context.Background(), datagramMarkedTransport{Transport: tr}, tm, realClock{}, ports.IntentEphemeral, "") | 179 | + | err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "") |
| 184 | require.NoError(t, err) | 180 | require.NoError(t, err) | ||
| 185 | 181 | ||||
| 186 | select { | 182 | select { | ||
M internal/usecase/daemon/attention_test.go +2 -2
| @@ -169,7 +169,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||
| 169 | 169 | return nil | |
| 170 | 170 | }).Maybe() | |
| 171 | 171 | trW.EXPECT().Close().Return(nil).Maybe() | |
| 172 | - | acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 172 | + | acW := &attachedClient{tr: trW, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 173 | 173 | acW.initOverlays() | |
| 174 | 174 | sctxW, cancelW := context.WithCancel(d.serveCtx) | |
| 175 | 175 | t.Cleanup(cancelW) | |
| @@ -417,7 +417,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||
| 417 | 417 | d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank | |
| 418 | 418 | ||
| 419 | 419 | trB, sendsB := newCapturingTransport(t) | |
| 420 | - | acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 420 | + | acB := &attachedClient{tr: trB, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 421 | 421 | acB.initOverlays() | |
| 422 | 422 | sctxB, cancelB := context.WithCancel(d.serveCtx) | |
| 423 | 423 | t.Cleanup(cancelB) | |
| @@ -169,7 +169,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) { | |||||
| 169 | return nil | 169 | return nil | ||
| 170 | }).Maybe() | 170 | }).Maybe() | ||
| 171 | trW.EXPECT().Close().Return(nil).Maybe() | 171 | trW.EXPECT().Close().Return(nil).Maybe() | ||
| 172 | - | acW := &attachedClient{tr: trW, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 172 | + | acW := &attachedClient{tr: trW, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 173 | acW.initOverlays() | 173 | acW.initOverlays() | ||
| 174 | sctxW, cancelW := context.WithCancel(d.serveCtx) | 174 | sctxW, cancelW := context.WithCancel(d.serveCtx) | ||
| 175 | t.Cleanup(cancelW) | 175 | t.Cleanup(cancelW) | ||
| @@ -417,7 +417,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) { | |||||
| 417 | d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank | 417 | d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank | ||
| 418 | 418 | ||||
| 419 | trB, sendsB := newCapturingTransport(t) | 419 | trB, sendsB := newCapturingTransport(t) | ||
| 420 | - | acB := &attachedClient{tr: trB, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 420 | + | acB := &attachedClient{tr: trB, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 421 | acB.initOverlays() | 421 | acB.initOverlays() | ||
| 422 | sctxB, cancelB := context.WithCancel(d.serveCtx) | 422 | sctxB, cancelB := context.WithCancel(d.serveCtx) | ||
| 423 | t.Cleanup(cancelB) | 423 | t.Cleanup(cancelB) | ||
M internal/usecase/daemon/client.go +52 -115
| @@ -35,34 +35,28 @@ import ( | |||
| 35 | 35 | "github.com/bnema/vev/internal/usecase/keys" | |
| 36 | 36 | "github.com/bnema/vev/internal/usecase/mouse" | |
| 37 | 37 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| 38 | - | "github.com/bnema/vev/pkg/renderer" | |
| 39 | 38 | ) | |
| 40 | 39 | ||
| 41 | 40 | type attachedClient struct { | |
| 42 | - | tr ports.Transport | |
| 43 | - | rend *renderer.Renderer | |
| 44 | - | overlays *overlayRuntime | |
| 45 | - | clientID [16]byte | |
| 46 | - | resumeCapable bool | |
| 47 | - | resumeToken uint64 | |
| 48 | - | parked bool | |
| 49 | - | nextStateNum uint64 | |
| 50 | - | echoAck atomic.Uint64 | |
| 51 | - | outputAck atomic.Uint64 | |
| 52 | - | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag | |
| 53 | - | bars barCache // only touched while sendMu is held | |
| 54 | - | composed composedFrameCache // only touched while sendMu is held | |
| 55 | - | advanceOutputOnAck bool // renderer shadow advances after client MsgAck instead of send | |
| 56 | - | outputFrames map[uint64]renderer.Frame // only touched while sendMu is held; frames sent by output state | |
| 57 | - | size domain.Size | |
| 58 | - | keys *keys.Router | |
| 59 | - | sess Guarded[*session] | |
| 60 | - | mouseScan mouse.Scanner | |
| 61 | - | themeMu sync.Mutex | |
| 62 | - | theme themeui.Theme | |
| 63 | - | clientTheme themeui.Theme | |
| 64 | - | lastCursor cursorOut | |
| 65 | - | recentNav recentSessionNavigator | |
| 41 | + | tr ports.Transport | |
| 42 | + | output *outputStateStream | |
| 43 | + | overlays *overlayRuntime | |
| 44 | + | clientID [16]byte | |
| 45 | + | resumeCapable bool | |
| 46 | + | resumeToken uint64 | |
| 47 | + | parked bool | |
| 48 | + | echoAck atomic.Uint64 | |
| 49 | + | bars barCache // only touched while sendMu is held | |
| 50 | + | composed composedFrameCache // only touched while sendMu is held | |
| 51 | + | size domain.Size | |
| 52 | + | keys *keys.Router | |
| 53 | + | sess Guarded[*session] | |
| 54 | + | mouseScan mouse.Scanner | |
| 55 | + | themeMu sync.Mutex | |
| 56 | + | theme themeui.Theme | |
| 57 | + | clientTheme themeui.Theme | |
| 58 | + | lastCursor cursorOut | |
| 59 | + | recentNav recentSessionNavigator | |
| 66 | 60 | // historyNavMu protects historyNav and historyNavTimer. When paint needs | |
| 67 | 61 | // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu. | |
| 68 | 62 | historyNavMu sync.Mutex | |
| @@ -197,90 +191,26 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool { | |||
| 197 | 191 | return tr != nil && ac.transportIs(tr) | |
| 198 | 192 | } | |
| 199 | 193 | ||
| 200 | - | func (ac *attachedClient) nextOutputFrameLocked(b []byte, reset bool) ports.Frame { | |
| 201 | - | ac.nextStateNum++ | |
| 202 | - | baseStateNum := ac.nextStateNum - 1 | |
| 203 | - | if reset { | |
| 204 | - | baseStateNum = 0 | |
| 205 | - | } | |
| 206 | - | return frameOutputState(b, baseStateNum, ac.nextStateNum, ac.echoAck.Load()) | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | // shouldResetOutputState honors explicit invalidations; reliable transport ack | |
| 210 | - | // lag alone must not force a full output-state repaint. | |
| 211 | - | func (ac *attachedClient) shouldResetOutputState(reset bool) bool { | |
| 212 | - | return reset | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | func (ac *attachedClient) advanceOutputAck(state uint64) { | |
| 216 | - | ac.sendMu.Lock() | |
| 217 | - | defer ac.sendMu.Unlock() | |
| 218 | - | ac.advanceOutputAckLocked(state) | |
| 219 | - | } | |
| 220 | - | ||
| 221 | - | func (ac *attachedClient) advanceOutputAckLocked(state uint64) { | |
| 222 | - | if state > ac.nextStateNum { | |
| 223 | - | return | |
| 224 | - | } | |
| 225 | - | for { | |
| 226 | - | cur := ac.outputAck.Load() | |
| 227 | - | if state <= cur { | |
| 228 | - | return | |
| 229 | - | } | |
| 230 | - | if ac.outputAck.CompareAndSwap(cur, state) { | |
| 231 | - | return | |
| 232 | - | } | |
| 233 | - | } | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | func (ac *attachedClient) ackOutputState(state uint64) error { | |
| 194 | + | func (ac *attachedClient) ackOutputState(state uint64) { | |
| 237 | 195 | ac.sendMu.Lock() | |
| 238 | 196 | defer ac.sendMu.Unlock() | |
| 239 | - | if state > ac.nextStateNum || state <= ac.outputAck.Load() { | |
| 240 | - | return nil | |
| 241 | - | } | |
| 242 | - | if ac.advanceOutputOnAck { | |
| 243 | - | frame, ok := ac.outputFrames[state] | |
| 244 | - | if ok { | |
| 245 | - | if err := ac.rend.Ack(frame); err != nil { | |
| 246 | - | return err | |
| 247 | - | } | |
| 248 | - | } | |
| 249 | - | for n := range ac.outputFrames { | |
| 250 | - | if n <= state { | |
| 251 | - | delete(ac.outputFrames, n) | |
| 252 | - | } | |
| 253 | - | } | |
| 254 | - | } | |
| 255 | - | ac.advanceOutputAckLocked(state) | |
| 256 | - | return nil | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | // outputLagAtCapLocked reports whether the client has at least | |
| 260 | - | // maxUnackedOutputStates output states in flight (sent but unacked). Caller | |
| 261 | - | // holds sendMu. | |
| 262 | - | func (ac *attachedClient) outputLagAtCapLocked() bool { | |
| 263 | - | return ac.nextStateNum >= ac.outputAck.Load()+maxUnackedOutputStates | |
| 197 | + | ac.output.ack(state) | |
| 264 | 198 | } | |
| 265 | 199 | ||
| 266 | 200 | // takeDeferredPaint reports whether a deferred diff paint is now flushable | |
| 267 | 201 | // because acks have caught up, clearing the flag when it is. The caller must | |
| 268 | 202 | // then paint outside sendMu (paint re-acquires it). | |
| 269 | - | func (ac *attachedClient) takeDeferredPaint() bool { | |
| 203 | + | func (ac *attachedClient) takeDeferredPaint() (reset bool, ok bool) { | |
| 270 | 204 | ac.sendMu.Lock() | |
| 271 | 205 | defer ac.sendMu.Unlock() | |
| 272 | - | if ac.paintDeferred && !ac.outputLagAtCapLocked() { | |
| 273 | - | ac.paintDeferred = false | |
| 274 | - | return true | |
| 275 | - | } | |
| 276 | - | return false | |
| 206 | + | return ac.output.takeDeferred() | |
| 277 | 207 | } | |
| 278 | 208 | ||
| 279 | 209 | // clearPaintDeferred drops any pending deferred-paint flag. Used on park so a | |
| 280 | 210 | // resumed client does not inherit a stale deferral. | |
| 281 | 211 | func (ac *attachedClient) clearPaintDeferred() { | |
| 282 | 212 | ac.sendMu.Lock() | |
| 283 | - | ac.paintDeferred = false | |
| 213 | + | ac.output.clearDeferred() | |
| 284 | 214 | ac.sendMu.Unlock() | |
| 285 | 215 | } | |
| 286 | 216 | ||
| @@ -327,6 +257,19 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error { | |||
| 327 | 257 | } | |
| 328 | 258 | ||
| 329 | 259 | func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) { | |
| 260 | + | frame := ac.output.sideEffect(b, ac.echoAck.Load()) | |
| 261 | + | ac.sendMu.Lock() | |
| 262 | + | tr := ac.transport() | |
| 263 | + | if tr == nil { | |
| 264 | + | ac.sendMu.Unlock() | |
| 265 | + | return nil, errors.New("client transport is nil") | |
| 266 | + | } | |
| 267 | + | if owned, ok := tr.(ports.OwnedSynchronousTransport); ok { | |
| 268 | + | err := owned.SendSynchronous(frame) | |
| 269 | + | ac.sendMu.Unlock() | |
| 270 | + | return tr, err | |
| 271 | + | } | |
| 272 | + | ac.sendMu.Unlock() | |
| 330 | 273 | return d.boundedSendWith(func(capture func(ports.Transport)) error { | |
| 331 | 274 | ac.sendMu.Lock() | |
| 332 | 275 | defer ac.sendMu.Unlock() | |
| @@ -335,14 +278,18 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po | |||
| 335 | 278 | if tr == nil { | |
| 336 | 279 | return errors.New("client transport is nil") | |
| 337 | 280 | } | |
| 338 | - | return tr.Send(ac.nextOutputFrameLocked(b, false)) | |
| 281 | + | return tr.Send(frame) | |
| 339 | 282 | }) | |
| 340 | 283 | } | |
| 341 | 284 | ||
| 342 | 285 | var errSendTimedOut = errors.New("send timed out") | |
| 343 | 286 | ||
| 344 | 287 | func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) { | |
| 345 | - | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 288 | + | return d.boundedSendWithTimeout(detachNotifyTimeout, send) | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, send func(capture func(ports.Transport)) error) (ports.Transport, error) { | |
| 292 | + | timer := d.clock.NewTimer(timeout) | |
| 346 | 293 | result := make(chan error, 1) | |
| 347 | 294 | var ( | |
| 348 | 295 | capturedMu sync.Mutex | |
| @@ -425,7 +372,6 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} { | |||
| 425 | 372 | type attachClientOptions struct { | |
| 426 | 373 | clientID [16]byte | |
| 427 | 374 | resumeCapable bool | |
| 428 | - | ackOutput bool | |
| 429 | 375 | } | |
| 430 | 376 | ||
| 431 | 377 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) { | |
| @@ -433,20 +379,13 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||
| 433 | 379 | if opts.resumeCapable { | |
| 434 | 380 | resumeToken = d.nextResumeTokenLocked() | |
| 435 | 381 | } | |
| 436 | - | caps := renderer.Capabilities{} | |
| 437 | - | advanceOutputOnAck := opts.ackOutput | |
| 438 | - | if advanceOutputOnAck { | |
| 439 | - | caps.AdvancePolicy = renderer.AdvanceOnAck | |
| 440 | - | } | |
| 441 | 382 | ac := &attachedClient{ | |
| 442 | - | tr: tr, | |
| 443 | - | rend: renderer.New(caps), | |
| 444 | - | advanceOutputOnAck: advanceOutputOnAck, | |
| 445 | - | outputFrames: make(map[uint64]renderer.Frame), | |
| 446 | - | size: sz, | |
| 447 | - | clientID: opts.clientID, | |
| 448 | - | resumeCapable: opts.resumeCapable, | |
| 449 | - | resumeToken: resumeToken, | |
| 383 | + | tr: tr, | |
| 384 | + | output: newOutputStateStream(), | |
| 385 | + | size: sz, | |
| 386 | + | clientID: opts.clientID, | |
| 387 | + | resumeCapable: opts.resumeCapable, | |
| 388 | + | resumeToken: resumeToken, | |
| 450 | 389 | } | |
| 451 | 390 | ac.initOverlays() | |
| 452 | 391 | ac.setSession(sess) | |
| @@ -558,11 +497,9 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 558 | 497 | return | |
| 559 | 498 | case ports.MsgAck: | |
| 560 | 499 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |
| 561 | - | if err := ac.ackOutputState(ack.AckedStateNum); err != nil { | |
| 562 | - | d.log.Warn("output ack rejected", "err", err, "state", ack.AckedStateNum) | |
| 563 | - | } | |
| 564 | - | if ac.takeDeferredPaint() { | |
| 565 | - | d.paint(sess, ac, false) | |
| 500 | + | ac.ackOutputState(ack.AckedStateNum) | |
| 501 | + | if reset, ok := ac.takeDeferredPaint(); ok { | |
| 502 | + | d.paint(sess, ac, reset) | |
| 566 | 503 | } | |
| 567 | 504 | } | |
| 568 | 505 | case ports.MsgPing: | |
| @@ -35,34 +35,28 @@ import ( | |||||
| 35 | "github.com/bnema/vev/internal/usecase/keys" | 35 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 36 | "github.com/bnema/vev/internal/usecase/mouse" | 36 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 37 | themeui "github.com/bnema/vev/internal/usecase/theme" | 37 | themeui "github.com/bnema/vev/internal/usecase/theme" | ||
| 38 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 39 | ) | 38 | ) | ||
| 40 | 39 | ||||
| 41 | type attachedClient struct { | 40 | type attachedClient struct { | ||
| 42 | - | tr ports.Transport | 41 | + | tr ports.Transport |
| 43 | - | rend *renderer.Renderer | 42 | + | output *outputStateStream |
| 44 | - | overlays *overlayRuntime | 43 | + | overlays *overlayRuntime |
| 45 | - | clientID [16]byte | 44 | + | clientID [16]byte |
| 46 | - | resumeCapable bool | 45 | + | resumeCapable bool |
| 47 | - | resumeToken uint64 | 46 | + | resumeToken uint64 |
| 48 | - | parked bool | 47 | + | parked bool |
| 49 | - | nextStateNum uint64 | 48 | + | echoAck atomic.Uint64 |
| 50 | - | echoAck atomic.Uint64 | 49 | + | bars barCache // only touched while sendMu is held |
| 51 | - | outputAck atomic.Uint64 | 50 | + | composed composedFrameCache // only touched while sendMu is held |
| 52 | - | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag | 51 | + | size domain.Size |
| 53 | - | bars barCache // only touched while sendMu is held | 52 | + | keys *keys.Router |
| 54 | - | composed composedFrameCache // only touched while sendMu is held | 53 | + | sess Guarded[*session] |
| 55 | - | advanceOutputOnAck bool // renderer shadow advances after client MsgAck instead of send | 54 | + | mouseScan mouse.Scanner |
| 56 | - | outputFrames map[uint64]renderer.Frame // only touched while sendMu is held; frames sent by output state | 55 | + | themeMu sync.Mutex |
| 57 | - | size domain.Size | 56 | + | theme themeui.Theme |
| 58 | - | keys *keys.Router | 57 | + | clientTheme themeui.Theme |
| 59 | - | sess Guarded[*session] | 58 | + | lastCursor cursorOut |
| 60 | - | mouseScan mouse.Scanner | 59 | + | recentNav recentSessionNavigator |
| 61 | - | themeMu sync.Mutex | |||
| 62 | - | theme themeui.Theme | |||
| 63 | - | clientTheme themeui.Theme | |||
| 64 | - | lastCursor cursorOut | |||
| 65 | - | recentNav recentSessionNavigator | |||
| 66 | // historyNavMu protects historyNav and historyNavTimer. When paint needs | 60 | // historyNavMu protects historyNav and historyNavTimer. When paint needs | ||
| 67 | // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu. | 61 | // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu. | ||
| 68 | historyNavMu sync.Mutex | 62 | historyNavMu sync.Mutex | ||
| @@ -197,90 +191,26 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool { | |||||
| 197 | return tr != nil && ac.transportIs(tr) | 191 | return tr != nil && ac.transportIs(tr) | ||
| 198 | } | 192 | } | ||
| 199 | 193 | ||||
| 200 | - | func (ac *attachedClient) nextOutputFrameLocked(b []byte, reset bool) ports.Frame { | 194 | + | func (ac *attachedClient) ackOutputState(state uint64) { |
| 201 | - | ac.nextStateNum++ | |||
| 202 | - | baseStateNum := ac.nextStateNum - 1 | |||
| 203 | - | if reset { | |||
| 204 | - | baseStateNum = 0 | |||
| 205 | - | } | |||
| 206 | - | return frameOutputState(b, baseStateNum, ac.nextStateNum, ac.echoAck.Load()) | |||
| 207 | - | } | |||
| 208 | - | ||||
| 209 | - | // shouldResetOutputState honors explicit invalidations; reliable transport ack | |||
| 210 | - | // lag alone must not force a full output-state repaint. | |||
| 211 | - | func (ac *attachedClient) shouldResetOutputState(reset bool) bool { | |||
| 212 | - | return reset | |||
| 213 | - | } | |||
| 214 | - | ||||
| 215 | - | func (ac *attachedClient) advanceOutputAck(state uint64) { | |||
| 216 | - | ac.sendMu.Lock() | |||
| 217 | - | defer ac.sendMu.Unlock() | |||
| 218 | - | ac.advanceOutputAckLocked(state) | |||
| 219 | - | } | |||
| 220 | - | ||||
| 221 | - | func (ac *attachedClient) advanceOutputAckLocked(state uint64) { | |||
| 222 | - | if state > ac.nextStateNum { | |||
| 223 | - | return | |||
| 224 | - | } | |||
| 225 | - | for { | |||
| 226 | - | cur := ac.outputAck.Load() | |||
| 227 | - | if state <= cur { | |||
| 228 | - | return | |||
| 229 | - | } | |||
| 230 | - | if ac.outputAck.CompareAndSwap(cur, state) { | |||
| 231 | - | return | |||
| 232 | - | } | |||
| 233 | - | } | |||
| 234 | - | } | |||
| 235 | - | ||||
| 236 | - | func (ac *attachedClient) ackOutputState(state uint64) error { | |||
| 237 | ac.sendMu.Lock() | 195 | ac.sendMu.Lock() | ||
| 238 | defer ac.sendMu.Unlock() | 196 | defer ac.sendMu.Unlock() | ||
| 239 | - | if state > ac.nextStateNum || state <= ac.outputAck.Load() { | 197 | + | ac.output.ack(state) |
| 240 | - | return nil | |||
| 241 | - | } | |||
| 242 | - | if ac.advanceOutputOnAck { | |||
| 243 | - | frame, ok := ac.outputFrames[state] | |||
| 244 | - | if ok { | |||
| 245 | - | if err := ac.rend.Ack(frame); err != nil { | |||
| 246 | - | return err | |||
| 247 | - | } | |||
| 248 | - | } | |||
| 249 | - | for n := range ac.outputFrames { | |||
| 250 | - | if n <= state { | |||
| 251 | - | delete(ac.outputFrames, n) | |||
| 252 | - | } | |||
| 253 | - | } | |||
| 254 | - | } | |||
| 255 | - | ac.advanceOutputAckLocked(state) | |||
| 256 | - | return nil | |||
| 257 | - | } | |||
| 258 | - | ||||
| 259 | - | // outputLagAtCapLocked reports whether the client has at least | |||
| 260 | - | // maxUnackedOutputStates output states in flight (sent but unacked). Caller | |||
| 261 | - | // holds sendMu. | |||
| 262 | - | func (ac *attachedClient) outputLagAtCapLocked() bool { | |||
| 263 | - | return ac.nextStateNum >= ac.outputAck.Load()+maxUnackedOutputStates | |||
| 264 | } | 198 | } | ||
| 265 | 199 | ||||
| 266 | // takeDeferredPaint reports whether a deferred diff paint is now flushable | 200 | // takeDeferredPaint reports whether a deferred diff paint is now flushable | ||
| 267 | // because acks have caught up, clearing the flag when it is. The caller must | 201 | // because acks have caught up, clearing the flag when it is. The caller must | ||
| 268 | // then paint outside sendMu (paint re-acquires it). | 202 | // then paint outside sendMu (paint re-acquires it). | ||
| 269 | - | func (ac *attachedClient) takeDeferredPaint() bool { | 203 | + | func (ac *attachedClient) takeDeferredPaint() (reset bool, ok bool) { |
| 270 | ac.sendMu.Lock() | 204 | ac.sendMu.Lock() | ||
| 271 | defer ac.sendMu.Unlock() | 205 | defer ac.sendMu.Unlock() | ||
| 272 | - | if ac.paintDeferred && !ac.outputLagAtCapLocked() { | 206 | + | return ac.output.takeDeferred() |
| 273 | - | ac.paintDeferred = false | |||
| 274 | - | return true | |||
| 275 | - | } | |||
| 276 | - | return false | |||
| 277 | } | 207 | } | ||
| 278 | 208 | ||||
| 279 | // clearPaintDeferred drops any pending deferred-paint flag. Used on park so a | 209 | // clearPaintDeferred drops any pending deferred-paint flag. Used on park so a | ||
| 280 | // resumed client does not inherit a stale deferral. | 210 | // resumed client does not inherit a stale deferral. | ||
| 281 | func (ac *attachedClient) clearPaintDeferred() { | 211 | func (ac *attachedClient) clearPaintDeferred() { | ||
| 282 | ac.sendMu.Lock() | 212 | ac.sendMu.Lock() | ||
| 283 | - | ac.paintDeferred = false | 213 | + | ac.output.clearDeferred() |
| 284 | ac.sendMu.Unlock() | 214 | ac.sendMu.Unlock() | ||
| 285 | } | 215 | } | ||
| 286 | 216 | ||||
| @@ -327,6 +257,19 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error { | |||||
| 327 | } | 257 | } | ||
| 328 | 258 | ||||
| 329 | func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) { | 259 | func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) { | ||
| 260 | + | frame := ac.output.sideEffect(b, ac.echoAck.Load()) | |||
| 261 | + | ac.sendMu.Lock() | |||
| 262 | + | tr := ac.transport() | |||
| 263 | + | if tr == nil { | |||
| 264 | + | ac.sendMu.Unlock() | |||
| 265 | + | return nil, errors.New("client transport is nil") | |||
| 266 | + | } | |||
| 267 | + | if owned, ok := tr.(ports.OwnedSynchronousTransport); ok { | |||
| 268 | + | err := owned.SendSynchronous(frame) | |||
| 269 | + | ac.sendMu.Unlock() | |||
| 270 | + | return tr, err | |||
| 271 | + | } | |||
| 272 | + | ac.sendMu.Unlock() | |||
| 330 | return d.boundedSendWith(func(capture func(ports.Transport)) error { | 273 | return d.boundedSendWith(func(capture func(ports.Transport)) error { | ||
| 331 | ac.sendMu.Lock() | 274 | ac.sendMu.Lock() | ||
| 332 | defer ac.sendMu.Unlock() | 275 | defer ac.sendMu.Unlock() | ||
| @@ -335,14 +278,18 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po | |||||
| 335 | if tr == nil { | 278 | if tr == nil { | ||
| 336 | return errors.New("client transport is nil") | 279 | return errors.New("client transport is nil") | ||
| 337 | } | 280 | } | ||
| 338 | - | return tr.Send(ac.nextOutputFrameLocked(b, false)) | 281 | + | return tr.Send(frame) |
| 339 | }) | 282 | }) | ||
| 340 | } | 283 | } | ||
| 341 | 284 | ||||
| 342 | var errSendTimedOut = errors.New("send timed out") | 285 | var errSendTimedOut = errors.New("send timed out") | ||
| 343 | 286 | ||||
| 344 | func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) { | 287 | func (d *Daemon) boundedSendWith(send func(capture func(ports.Transport)) error) (ports.Transport, error) { | ||
| 345 | - | timer := d.clock.NewTimer(detachNotifyTimeout) | 288 | + | return d.boundedSendWithTimeout(detachNotifyTimeout, send) |
| 289 | + | } | |||
| 290 | + | ||||
| 291 | + | func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, send func(capture func(ports.Transport)) error) (ports.Transport, error) { | |||
| 292 | + | timer := d.clock.NewTimer(timeout) | |||
| 346 | result := make(chan error, 1) | 293 | result := make(chan error, 1) | ||
| 347 | var ( | 294 | var ( | ||
| 348 | capturedMu sync.Mutex | 295 | capturedMu sync.Mutex | ||
| @@ -425,7 +372,6 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} { | |||||
| 425 | type attachClientOptions struct { | 372 | type attachClientOptions struct { | ||
| 426 | clientID [16]byte | 373 | clientID [16]byte | ||
| 427 | resumeCapable bool | 374 | resumeCapable bool | ||
| 428 | - | ackOutput bool | |||
| 429 | } | 375 | } | ||
| 430 | 376 | ||||
| 431 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) { | 377 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) { | ||
| @@ -433,20 +379,13 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, | |||||
| 433 | if opts.resumeCapable { | 379 | if opts.resumeCapable { | ||
| 434 | resumeToken = d.nextResumeTokenLocked() | 380 | resumeToken = d.nextResumeTokenLocked() | ||
| 435 | } | 381 | } | ||
| 436 | - | caps := renderer.Capabilities{} | |||
| 437 | - | advanceOutputOnAck := opts.ackOutput | |||
| 438 | - | if advanceOutputOnAck { | |||
| 439 | - | caps.AdvancePolicy = renderer.AdvanceOnAck | |||
| 440 | - | } | |||
| 441 | ac := &attachedClient{ | 382 | ac := &attachedClient{ | ||
| 442 | - | tr: tr, | 383 | + | tr: tr, |
| 443 | - | rend: renderer.New(caps), | 384 | + | output: newOutputStateStream(), |
| 444 | - | advanceOutputOnAck: advanceOutputOnAck, | 385 | + | size: sz, |
| 445 | - | outputFrames: make(map[uint64]renderer.Frame), | 386 | + | clientID: opts.clientID, |
| 446 | - | size: sz, | 387 | + | resumeCapable: opts.resumeCapable, |
| 447 | - | clientID: opts.clientID, | 388 | + | resumeToken: resumeToken, |
| 448 | - | resumeCapable: opts.resumeCapable, | |||
| 449 | - | resumeToken: resumeToken, | |||
| 450 | } | 389 | } | ||
| 451 | ac.initOverlays() | 390 | ac.initOverlays() | ||
| 452 | ac.setSession(sess) | 391 | ac.setSession(sess) | ||
| @@ -558,11 +497,9 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 558 | return | 497 | return | ||
| 559 | case ports.MsgAck: | 498 | case ports.MsgAck: | ||
| 560 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | 499 | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | ||
| 561 | - | if err := ac.ackOutputState(ack.AckedStateNum); err != nil { | 500 | + | ac.ackOutputState(ack.AckedStateNum) |
| 562 | - | d.log.Warn("output ack rejected", "err", err, "state", ack.AckedStateNum) | 501 | + | if reset, ok := ac.takeDeferredPaint(); ok { |
| 563 | - | } | 502 | + | d.paint(sess, ac, reset) |
| 564 | - | if ac.takeDeferredPaint() { | |||
| 565 | - | d.paint(sess, ac, false) | |||
| 566 | } | 503 | } | ||
| 567 | } | 504 | } | ||
| 568 | case ports.MsgPing: | 505 | case ports.MsgPing: | ||
M internal/usecase/daemon/clipboard_test.go +4 -5
| @@ -16,7 +16,6 @@ import ( | |||
| 16 | 16 | "github.com/bnema/vev/internal/ports" | |
| 17 | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | 18 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 19 | - | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 19 | ) | |
| 21 | 20 | ||
| 22 | 21 | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | |
| @@ -190,7 +189,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||
| 190 | 189 | sctx, cancel := context.WithCancel(context.Background()) | |
| 191 | 190 | defer cancel() | |
| 192 | 191 | win := newTestTabWithContext(p, sctx, cancel) | |
| 193 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 192 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 194 | 193 | ac.initOverlays() | |
| 195 | 194 | sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 196 | 195 | ac.setSession(sess) | |
| @@ -215,7 +214,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |||
| 215 | 214 | sctx, cancel := context.WithCancel(context.Background()) | |
| 216 | 215 | defer cancel() | |
| 217 | 216 | win := newTestTabWithContext(p, sctx, cancel) | |
| 218 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 217 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 219 | 218 | ac.initOverlays() | |
| 220 | 219 | sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 221 | 220 | ac.setSession(sess) | |
| @@ -239,7 +238,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |||
| 239 | 238 | sctx, cancel := context.WithCancel(context.Background()) | |
| 240 | 239 | defer cancel() | |
| 241 | 240 | win := newTestTabWithContext(p, sctx, cancel) | |
| 242 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 241 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 243 | 242 | ac.initOverlays() | |
| 244 | 243 | sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 245 | 244 | ac.setSession(sess) | |
| @@ -274,7 +273,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) { | |||
| 274 | 273 | func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | |
| 275 | 274 | d := newTestDaemon(t, nil, stubClock{}) | |
| 276 | 275 | tr := newBlockingClipboardTransport() | |
| 277 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 276 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 278 | 277 | ac.initOverlays() | |
| 279 | 278 | sctx, cancel := context.WithCancel(context.Background()) | |
| 280 | 279 | defer cancel() | |
| @@ -16,7 +16,6 @@ import ( | |||||
| 16 | "github.com/bnema/vev/internal/ports" | 16 | "github.com/bnema/vev/internal/ports" | ||
| 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 17 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 18 | scopy "github.com/bnema/vev/internal/usecase/copy" | 18 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 19 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 20 | ) | 19 | ) | ||
| 21 | 20 | ||||
| 22 | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | 21 | func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) { | ||
| @@ -190,7 +189,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) { | |||||
| 190 | sctx, cancel := context.WithCancel(context.Background()) | 189 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 191 | defer cancel() | 190 | defer cancel() | ||
| 192 | win := newTestTabWithContext(p, sctx, cancel) | 191 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 193 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 192 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 194 | ac.initOverlays() | 193 | ac.initOverlays() | ||
| 195 | sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 194 | sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 196 | ac.setSession(sess) | 195 | ac.setSession(sess) | ||
| @@ -215,7 +214,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) { | |||||
| 215 | sctx, cancel := context.WithCancel(context.Background()) | 214 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 216 | defer cancel() | 215 | defer cancel() | ||
| 217 | win := newTestTabWithContext(p, sctx, cancel) | 216 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 218 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 217 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 219 | ac.initOverlays() | 218 | ac.initOverlays() | ||
| 220 | sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 219 | sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 221 | ac.setSession(sess) | 220 | ac.setSession(sess) | ||
| @@ -239,7 +238,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) { | |||||
| 239 | sctx, cancel := context.WithCancel(context.Background()) | 238 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 240 | defer cancel() | 239 | defer cancel() | ||
| 241 | win := newTestTabWithContext(p, sctx, cancel) | 240 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 242 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 241 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 243 | ac.initOverlays() | 242 | ac.initOverlays() | ||
| 244 | sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 243 | sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 245 | ac.setSession(sess) | 244 | ac.setSession(sess) | ||
| @@ -274,7 +273,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) { | |||||
| 274 | func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | 273 | func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) { | ||
| 275 | d := newTestDaemon(t, nil, stubClock{}) | 274 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 276 | tr := newBlockingClipboardTransport() | 275 | tr := newBlockingClipboardTransport() | ||
| 277 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 276 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 278 | ac.initOverlays() | 277 | ac.initOverlays() | ||
| 279 | sctx, cancel := context.WithCancel(context.Background()) | 278 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 280 | defer cancel() | 279 | defer cancel() | ||
M internal/usecase/daemon/copymode_test.go +40 -1
| @@ -25,7 +25,7 @@ import ( | |||
| 25 | 25 | ||
| 26 | 26 | func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) { | |
| 27 | 27 | d := newTestDaemon(t, nil, stubClock{}) | |
| 28 | - | ac := &attachedClient{} | |
| 28 | + | ac := &attachedClient{output: newOutputStateStream()} | |
| 29 | 29 | ac.initOverlays() | |
| 30 | 30 | replacement := &closeTrackingTransport{} | |
| 31 | 31 | sendErr := errors.New("send failed") | |
| @@ -39,6 +39,29 @@ func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) { | |||
| 39 | 39 | require.Same(t, replacement, ac.transport()) | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | + | func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testing.T) { | |
| 43 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 44 | + | replacement := &closeTrackingTransport{} | |
| 45 | + | sendErr := errors.New("owned send failed") | |
| 46 | + | ac := &attachedClient{output: newOutputStateStream()} | |
| 47 | + | failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)} | |
| 48 | + | ac.replaceTransport(failed) | |
| 49 | + | sess := &session{name: "work", client: ac} | |
| 50 | + | ac.setSession(sess) | |
| 51 | + | ||
| 52 | + | used, err := d.boundedSendOutputErrTransport(ac, []byte("copy")) | |
| 53 | + | ||
| 54 | + | require.ErrorIs(t, err, sendErr) | |
| 55 | + | require.Same(t, failed, used) | |
| 56 | + | require.Same(t, replacement, ac.transport()) | |
| 57 | + | out, decodeErr := ports.UnmarshalOutput((<-failed.sent).Payload) | |
| 58 | + | require.NoError(t, decodeErr) | |
| 59 | + | require.Equal(t, []byte("copy"), out.Data) | |
| 60 | + | d.detachOnSendError(sess, ac, used) | |
| 61 | + | require.Same(t, ac, sess.client) | |
| 62 | + | require.False(t, replacement.Closed()) | |
| 63 | + | } | |
| 64 | + | ||
| 42 | 65 | type swapErrorTransport struct { | |
| 43 | 66 | ac *attachedClient | |
| 44 | 67 | replacement ports.Transport | |
| @@ -53,6 +76,22 @@ func (t *swapErrorTransport) Send(ports.Frame) error { | |||
| 53 | 76 | func (t *swapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 54 | 77 | func (t *swapErrorTransport) Close() error { return nil } | |
| 55 | 78 | ||
| 79 | + | type ownedSwapErrorTransport struct { | |
| 80 | + | ac *attachedClient | |
| 81 | + | replacement ports.Transport | |
| 82 | + | err error | |
| 83 | + | sent chan ports.Frame | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (t *ownedSwapErrorTransport) Send(f ports.Frame) error { return t.SendSynchronous(f) } | |
| 87 | + | func (t *ownedSwapErrorTransport) SendSynchronous(f ports.Frame) error { | |
| 88 | + | t.sent <- f | |
| 89 | + | t.ac.replaceTransport(t.replacement) | |
| 90 | + | return t.err | |
| 91 | + | } | |
| 92 | + | func (t *ownedSwapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 93 | + | func (t *ownedSwapErrorTransport) Close() error { return nil } | |
| 94 | + | ||
| 56 | 95 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 57 | 96 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 58 | 97 | ||
| @@ -25,7 +25,7 @@ import ( | |||||
| 25 | 25 | ||||
| 26 | func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) { | 26 | func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) { | ||
| 27 | d := newTestDaemon(t, nil, stubClock{}) | 27 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 28 | - | ac := &attachedClient{} | 28 | + | ac := &attachedClient{output: newOutputStateStream()} |
| 29 | ac.initOverlays() | 29 | ac.initOverlays() | ||
| 30 | replacement := &closeTrackingTransport{} | 30 | replacement := &closeTrackingTransport{} | ||
| 31 | sendErr := errors.New("send failed") | 31 | sendErr := errors.New("send failed") | ||
| @@ -39,6 +39,29 @@ func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) { | |||||
| 39 | require.Same(t, replacement, ac.transport()) | 39 | require.Same(t, replacement, ac.transport()) | ||
| 40 | } | 40 | } | ||
| 41 | 41 | ||||
| 42 | + | func TestOwnedSynchronousSendReturnsCapturedTransportAcrossReplacement(t *testing.T) { | |||
| 43 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 44 | + | replacement := &closeTrackingTransport{} | |||
| 45 | + | sendErr := errors.New("owned send failed") | |||
| 46 | + | ac := &attachedClient{output: newOutputStateStream()} | |||
| 47 | + | failed := &ownedSwapErrorTransport{ac: ac, replacement: replacement, err: sendErr, sent: make(chan ports.Frame, 1)} | |||
| 48 | + | ac.replaceTransport(failed) | |||
| 49 | + | sess := &session{name: "work", client: ac} | |||
| 50 | + | ac.setSession(sess) | |||
| 51 | + | ||||
| 52 | + | used, err := d.boundedSendOutputErrTransport(ac, []byte("copy")) | |||
| 53 | + | ||||
| 54 | + | require.ErrorIs(t, err, sendErr) | |||
| 55 | + | require.Same(t, failed, used) | |||
| 56 | + | require.Same(t, replacement, ac.transport()) | |||
| 57 | + | out, decodeErr := ports.UnmarshalOutput((<-failed.sent).Payload) | |||
| 58 | + | require.NoError(t, decodeErr) | |||
| 59 | + | require.Equal(t, []byte("copy"), out.Data) | |||
| 60 | + | d.detachOnSendError(sess, ac, used) | |||
| 61 | + | require.Same(t, ac, sess.client) | |||
| 62 | + | require.False(t, replacement.Closed()) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 42 | type swapErrorTransport struct { | 65 | type swapErrorTransport struct { | ||
| 43 | ac *attachedClient | 66 | ac *attachedClient | ||
| 44 | replacement ports.Transport | 67 | replacement ports.Transport | ||
| @@ -53,6 +76,22 @@ func (t *swapErrorTransport) Send(ports.Frame) error { | |||||
| 53 | func (t *swapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | 76 | func (t *swapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | ||
| 54 | func (t *swapErrorTransport) Close() error { return nil } | 77 | func (t *swapErrorTransport) Close() error { return nil } | ||
| 55 | 78 | ||||
| 79 | + | type ownedSwapErrorTransport struct { | |||
| 80 | + | ac *attachedClient | |||
| 81 | + | replacement ports.Transport | |||
| 82 | + | err error | |||
| 83 | + | sent chan ports.Frame | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (t *ownedSwapErrorTransport) Send(f ports.Frame) error { return t.SendSynchronous(f) } | |||
| 87 | + | func (t *ownedSwapErrorTransport) SendSynchronous(f ports.Frame) error { | |||
| 88 | + | t.sent <- f | |||
| 89 | + | t.ac.replaceTransport(t.replacement) | |||
| 90 | + | return t.err | |||
| 91 | + | } | |||
| 92 | + | func (t *ownedSwapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 93 | + | func (t *ownedSwapErrorTransport) Close() error { return nil } | |||
| 94 | + | ||||
| 56 | // stubClock returns timers whose channel never fires, so a scheduler under it | 95 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 57 | // blocks in its debounce loop until the session context is cancelled. Used by | 96 | // blocks in its debounce loop until the session context is cancelled. Used by | ||
| 58 | 97 | ||||
M internal/usecase/daemon/daemon.go +10 -8
| @@ -633,6 +633,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 633 | 633 | } | |
| 634 | 634 | term := terminalEnv{TrueColor: h.TrueColor} | |
| 635 | 635 | ||
| 636 | + | if h.Intent == ports.IntentResume { | |
| 637 | + | if sess, ac, ok, err := d.resumeParked(h, tr, sz); ok || err != nil { | |
| 638 | + | return sess, ac, err | |
| 639 | + | } | |
| 640 | + | } | |
| 641 | + | ||
| 636 | 642 | d.mu.Lock() | |
| 637 | 643 | // Shutdown/create interlock: once shutdown has begun (last session removed, | |
| 638 | 644 | // or shutdownAll started) no new session may be created and no attach may | |
| @@ -644,10 +650,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 644 | 650 | } | |
| 645 | 651 | switch h.Intent { | |
| 646 | 652 | case ports.IntentResume: | |
| 647 | - | if sess, ac, ok, err := d.resumeParkedLocked(h, tr, sz); ok || err != nil { | |
| 648 | - | d.mu.Unlock() | |
| 649 | - | return sess, ac, err | |
| 650 | - | } | |
| 651 | 653 | // Resume miss/expiry falls back to normal attach semantics. | |
| 652 | 654 | sess := d.findByNameLocked(h.Name) | |
| 653 | 655 | if sess == nil { | |
| @@ -665,7 +667,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 665 | 667 | } | |
| 666 | 668 | } | |
| 667 | 669 | d.purgeParkedForSessionLocked(sess) | |
| 668 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput}) | |
| 670 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |
| 669 | 671 | sess.mu.Lock() | |
| 670 | 672 | sess.terminal = term | |
| 671 | 673 | sess.mu.Unlock() | |
| @@ -681,7 +683,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 681 | 683 | return nil, nil, err | |
| 682 | 684 | } | |
| 683 | 685 | d.purgeParkedForSessionLocked(sess) | |
| 684 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput}) | |
| 686 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |
| 685 | 687 | sess.mu.Lock() | |
| 686 | 688 | sess.terminal = term | |
| 687 | 689 | sess.mu.Unlock() | |
| @@ -708,7 +710,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 708 | 710 | return nil, nil, err | |
| 709 | 711 | } | |
| 710 | 712 | d.purgeParkedForSessionLocked(sess) | |
| 711 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput}) | |
| 713 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |
| 712 | 714 | sess.mu.Lock() | |
| 713 | 715 | sess.terminal = term | |
| 714 | 716 | sess.mu.Unlock() | |
| @@ -733,7 +735,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 733 | 735 | } | |
| 734 | 736 | } | |
| 735 | 737 | d.purgeParkedForSessionLocked(sess) | |
| 736 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput}) | |
| 738 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |
| 737 | 739 | sess.mu.Lock() | |
| 738 | 740 | sess.terminal = term | |
| 739 | 741 | sess.mu.Unlock() | |
| @@ -633,6 +633,12 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 633 | } | 633 | } | ||
| 634 | term := terminalEnv{TrueColor: h.TrueColor} | 634 | term := terminalEnv{TrueColor: h.TrueColor} | ||
| 635 | 635 | ||||
| 636 | + | if h.Intent == ports.IntentResume { | |||
| 637 | + | if sess, ac, ok, err := d.resumeParked(h, tr, sz); ok || err != nil { | |||
| 638 | + | return sess, ac, err | |||
| 639 | + | } | |||
| 640 | + | } | |||
| 641 | + | ||||
| 636 | d.mu.Lock() | 642 | d.mu.Lock() | ||
| 637 | // Shutdown/create interlock: once shutdown has begun (last session removed, | 643 | // Shutdown/create interlock: once shutdown has begun (last session removed, | ||
| 638 | // or shutdownAll started) no new session may be created and no attach may | 644 | // or shutdownAll started) no new session may be created and no attach may | ||
| @@ -644,10 +650,6 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 644 | } | 650 | } | ||
| 645 | switch h.Intent { | 651 | switch h.Intent { | ||
| 646 | case ports.IntentResume: | 652 | case ports.IntentResume: | ||
| 647 | - | if sess, ac, ok, err := d.resumeParkedLocked(h, tr, sz); ok || err != nil { | |||
| 648 | - | d.mu.Unlock() | |||
| 649 | - | return sess, ac, err | |||
| 650 | - | } | |||
| 651 | // Resume miss/expiry falls back to normal attach semantics. | 653 | // Resume miss/expiry falls back to normal attach semantics. | ||
| 652 | sess := d.findByNameLocked(h.Name) | 654 | sess := d.findByNameLocked(h.Name) | ||
| 653 | if sess == nil { | 655 | if sess == nil { | ||
| @@ -665,7 +667,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 665 | } | 667 | } | ||
| 666 | } | 668 | } | ||
| 667 | d.purgeParkedForSessionLocked(sess) | 669 | d.purgeParkedForSessionLocked(sess) | ||
| 668 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput}) | 670 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) |
| 669 | sess.mu.Lock() | 671 | sess.mu.Lock() | ||
| 670 | sess.terminal = term | 672 | sess.terminal = term | ||
| 671 | sess.mu.Unlock() | 673 | sess.mu.Unlock() | ||
| @@ -681,7 +683,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 681 | return nil, nil, err | 683 | return nil, nil, err | ||
| 682 | } | 684 | } | ||
| 683 | d.purgeParkedForSessionLocked(sess) | 685 | d.purgeParkedForSessionLocked(sess) | ||
| 684 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput}) | 686 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) |
| 685 | sess.mu.Lock() | 687 | sess.mu.Lock() | ||
| 686 | sess.terminal = term | 688 | sess.terminal = term | ||
| 687 | sess.mu.Unlock() | 689 | sess.mu.Unlock() | ||
| @@ -708,7 +710,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 708 | return nil, nil, err | 710 | return nil, nil, err | ||
| 709 | } | 711 | } | ||
| 710 | d.purgeParkedForSessionLocked(sess) | 712 | d.purgeParkedForSessionLocked(sess) | ||
| 711 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput}) | 713 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) |
| 712 | sess.mu.Lock() | 714 | sess.mu.Lock() | ||
| 713 | sess.terminal = term | 715 | sess.terminal = term | ||
| 714 | sess.mu.Unlock() | 716 | sess.mu.Unlock() | ||
| @@ -733,7 +735,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 733 | } | 735 | } | ||
| 734 | } | 736 | } | ||
| 735 | d.purgeParkedForSessionLocked(sess) | 737 | d.purgeParkedForSessionLocked(sess) | ||
| 736 | - | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput}) | 738 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) |
| 737 | sess.mu.Lock() | 739 | sess.mu.Lock() | ||
| 738 | sess.terminal = term | 740 | sess.terminal = term | ||
| 739 | sess.mu.Unlock() | 741 | sess.mu.Unlock() | ||
M internal/usecase/daemon/daemon_test.go +1 -1
| @@ -239,7 +239,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||
| 239 | 239 | t.Helper() | |
| 240 | 240 | d := newTestDaemon(t, nil, stubClock{}) | |
| 241 | 241 | tr, sends := newCapturingTransport(t) | |
| 242 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 242 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 243 | 243 | ac.initOverlays() | |
| 244 | 244 | sctx, cancel := context.WithCancel(d.serveCtx) | |
| 245 | 245 | tabs := make([]*tab, 0, len(ptys)) | |
| @@ -239,7 +239,7 @@ func newManualSessionWithPTYs(t *testing.T, ptys ...ports.PTY) (*Daemon, *sessio | |||||
| 239 | t.Helper() | 239 | t.Helper() | ||
| 240 | d := newTestDaemon(t, nil, stubClock{}) | 240 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 241 | tr, sends := newCapturingTransport(t) | 241 | tr, sends := newCapturingTransport(t) | ||
| 242 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 242 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 243 | ac.initOverlays() | 243 | ac.initOverlays() | ||
| 244 | sctx, cancel := context.WithCancel(d.serveCtx) | 244 | sctx, cancel := context.WithCancel(d.serveCtx) | ||
| 245 | tabs := make([]*tab, 0, len(ptys)) | 245 | tabs := make([]*tab, 0, len(ptys)) | ||
A internal/usecase/daemon/output_state_stream.go +119 -0
| @@ -0,0 +1,119 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/bnema/vev/internal/ports" | |
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // outputStateStream owns the terminal-output dependency chain for one | |
| 9 | + | // attached client. Each incremental output is rendered from the preceding | |
| 10 | + | // emitted frame and therefore remains pipelineable without waiting for an | |
| 11 | + | // ACK. Cumulative ACKs advance the bounded paint window. | |
| 12 | + | // A reset frame is the only dependency-free output and advertises state 0 as | |
| 13 | + | // its base. | |
| 14 | + | // | |
| 15 | + | // Callers serialize access with attachedClient.sendMu. | |
| 16 | + | type outputStateStream struct { | |
| 17 | + | renderer *renderer.Renderer | |
| 18 | + | next uint64 | |
| 19 | + | acked uint64 | |
| 20 | + | deferred bool | |
| 21 | + | deferredReset bool | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func newOutputStateStream() *outputStateStream { | |
| 25 | + | return &outputStateStream{ | |
| 26 | + | renderer: renderer.New(renderer.Capabilities{}), | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) { | |
| 31 | + | if reset { | |
| 32 | + | s.renderer.Reset() | |
| 33 | + | } | |
| 34 | + | return s.renderer.Draw(frame, damage) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | type preparedOutput struct { | |
| 38 | + | stream *outputStateStream | |
| 39 | + | next uint64 | |
| 40 | + | data []byte | |
| 41 | + | reset bool | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) { | |
| 45 | + | data, err := s.render(frame, damage, reset) | |
| 46 | + | if err != nil { | |
| 47 | + | return nil, err | |
| 48 | + | } | |
| 49 | + | return &preparedOutput{stream: s, next: s.next, data: data, reset: reset}, nil | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error { | |
| 53 | + | out := p.stream.frame(data, p.reset, echoAck) | |
| 54 | + | if err := send(out); err != nil { | |
| 55 | + | p.stream.renderer.Reset() | |
| 56 | + | p.stream.next = p.next | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | return nil | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (s *outputStateStream) frame(data []byte, reset bool, echoAck uint64) ports.Frame { | |
| 63 | + | s.next++ | |
| 64 | + | base := s.next - 1 | |
| 65 | + | if reset { | |
| 66 | + | base = 0 | |
| 67 | + | } | |
| 68 | + | frame := frameOutputState(data, base, s.next, echoAck) | |
| 69 | + | return frame | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame { | |
| 73 | + | return frameOutputState(data, 0, 0, echoAck) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (s *outputStateStream) ack(state uint64) { | |
| 77 | + | if state > s.next || state <= s.acked { | |
| 78 | + | return | |
| 79 | + | } | |
| 80 | + | s.acked = state | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func (s *outputStateStream) rebase() { | |
| 84 | + | s.acked = s.next | |
| 85 | + | s.renderer.Reset() | |
| 86 | + | s.clearDeferred() | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func (s *outputStateStream) atCapacity() bool { | |
| 90 | + | return s.outstanding() >= maxUnackedOutputStates | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (s *outputStateStream) deferIfAtCapacity(reset bool) bool { | |
| 94 | + | if !s.atCapacity() { | |
| 95 | + | if reset { | |
| 96 | + | s.clearDeferred() | |
| 97 | + | } | |
| 98 | + | return false | |
| 99 | + | } | |
| 100 | + | s.deferred = true | |
| 101 | + | s.deferredReset = s.deferredReset || reset | |
| 102 | + | return true | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func (s *outputStateStream) takeDeferred() (reset bool, ok bool) { | |
| 106 | + | if !s.deferred || s.atCapacity() { | |
| 107 | + | return false, false | |
| 108 | + | } | |
| 109 | + | reset = s.deferredReset | |
| 110 | + | s.clearDeferred() | |
| 111 | + | return reset, true | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (s *outputStateStream) clearDeferred() { | |
| 115 | + | s.deferred = false | |
| 116 | + | s.deferredReset = false | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | |
| @@ -0,0 +1,119 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "github.com/bnema/vev/internal/ports" | |||
| 5 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // outputStateStream owns the terminal-output dependency chain for one | |||
| 9 | + | // attached client. Each incremental output is rendered from the preceding | |||
| 10 | + | // emitted frame and therefore remains pipelineable without waiting for an | |||
| 11 | + | // ACK. Cumulative ACKs advance the bounded paint window. | |||
| 12 | + | // A reset frame is the only dependency-free output and advertises state 0 as | |||
| 13 | + | // its base. | |||
| 14 | + | // | |||
| 15 | + | // Callers serialize access with attachedClient.sendMu. | |||
| 16 | + | type outputStateStream struct { | |||
| 17 | + | renderer *renderer.Renderer | |||
| 18 | + | next uint64 | |||
| 19 | + | acked uint64 | |||
| 20 | + | deferred bool | |||
| 21 | + | deferredReset bool | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func newOutputStateStream() *outputStateStream { | |||
| 25 | + | return &outputStateStream{ | |||
| 26 | + | renderer: renderer.New(renderer.Capabilities{}), | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) { | |||
| 31 | + | if reset { | |||
| 32 | + | s.renderer.Reset() | |||
| 33 | + | } | |||
| 34 | + | return s.renderer.Draw(frame, damage) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | type preparedOutput struct { | |||
| 38 | + | stream *outputStateStream | |||
| 39 | + | next uint64 | |||
| 40 | + | data []byte | |||
| 41 | + | reset bool | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) { | |||
| 45 | + | data, err := s.render(frame, damage, reset) | |||
| 46 | + | if err != nil { | |||
| 47 | + | return nil, err | |||
| 48 | + | } | |||
| 49 | + | return &preparedOutput{stream: s, next: s.next, data: data, reset: reset}, nil | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error { | |||
| 53 | + | out := p.stream.frame(data, p.reset, echoAck) | |||
| 54 | + | if err := send(out); err != nil { | |||
| 55 | + | p.stream.renderer.Reset() | |||
| 56 | + | p.stream.next = p.next | |||
| 57 | + | return err | |||
| 58 | + | } | |||
| 59 | + | return nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func (s *outputStateStream) frame(data []byte, reset bool, echoAck uint64) ports.Frame { | |||
| 63 | + | s.next++ | |||
| 64 | + | base := s.next - 1 | |||
| 65 | + | if reset { | |||
| 66 | + | base = 0 | |||
| 67 | + | } | |||
| 68 | + | frame := frameOutputState(data, base, s.next, echoAck) | |||
| 69 | + | return frame | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame { | |||
| 73 | + | return frameOutputState(data, 0, 0, echoAck) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (s *outputStateStream) ack(state uint64) { | |||
| 77 | + | if state > s.next || state <= s.acked { | |||
| 78 | + | return | |||
| 79 | + | } | |||
| 80 | + | s.acked = state | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func (s *outputStateStream) rebase() { | |||
| 84 | + | s.acked = s.next | |||
| 85 | + | s.renderer.Reset() | |||
| 86 | + | s.clearDeferred() | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func (s *outputStateStream) atCapacity() bool { | |||
| 90 | + | return s.outstanding() >= maxUnackedOutputStates | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (s *outputStateStream) deferIfAtCapacity(reset bool) bool { | |||
| 94 | + | if !s.atCapacity() { | |||
| 95 | + | if reset { | |||
| 96 | + | s.clearDeferred() | |||
| 97 | + | } | |||
| 98 | + | return false | |||
| 99 | + | } | |||
| 100 | + | s.deferred = true | |||
| 101 | + | s.deferredReset = s.deferredReset || reset | |||
| 102 | + | return true | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func (s *outputStateStream) takeDeferred() (reset bool, ok bool) { | |||
| 106 | + | if !s.deferred || s.atCapacity() { | |||
| 107 | + | return false, false | |||
| 108 | + | } | |||
| 109 | + | reset = s.deferredReset | |||
| 110 | + | s.clearDeferred() | |||
| 111 | + | return reset, true | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (s *outputStateStream) clearDeferred() { | |||
| 115 | + | s.deferred = false | |||
| 116 | + | s.deferredReset = false | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func (s *outputStateStream) outstanding() uint64 { return s.next - s.acked } | |||
A internal/usecase/daemon/output_state_stream_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/ports" | |
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |
| 8 | + | "github.com/bnema/vev/pkg/vt" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) { | |
| 13 | + | stream := newOutputStateStream() | |
| 14 | + | firstFrame := renderer.NewFrame(3, 1) | |
| 15 | + | fillOutputStateRows(firstFrame, []string{"abc"}) | |
| 16 | + | first, ok, err := drawOutputState(t, stream, firstFrame, nil, true, 7) | |
| 17 | + | require.NoError(t, err) | |
| 18 | + | require.True(t, ok) | |
| 19 | + | firstOut, err := ports.UnmarshalOutput(first.Payload) | |
| 20 | + | require.NoError(t, err) | |
| 21 | + | require.Equal(t, uint64(0), firstOut.BaseStateNum) | |
| 22 | + | require.Equal(t, uint64(1), firstOut.NewStateNum) | |
| 23 | + | require.Equal(t, uint64(7), firstOut.EchoAck) | |
| 24 | + | ||
| 25 | + | secondFrame := firstFrame.Clone() | |
| 26 | + | secondFrame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |
| 27 | + | second, ok, err := drawOutputState(t, stream, secondFrame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 8) | |
| 28 | + | require.NoError(t, err) | |
| 29 | + | require.True(t, ok) | |
| 30 | + | secondOut, err := ports.UnmarshalOutput(second.Payload) | |
| 31 | + | require.NoError(t, err) | |
| 32 | + | require.Equal(t, firstOut.NewStateNum, secondOut.BaseStateNum) | |
| 33 | + | require.Equal(t, uint64(2), secondOut.NewStateNum) | |
| 34 | + | require.Equal(t, uint64(2), stream.outstanding()) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) { | |
| 38 | + | stream := newOutputStateStream() | |
| 39 | + | client := vt.NewScreen(4, 3) | |
| 40 | + | initial := renderer.NewFrame(4, 3) | |
| 41 | + | fillOutputStateRows(initial, []string{"AAAA", "BBBB", "CCCC"}) | |
| 42 | + | first, ok, err := drawOutputState(t, stream, initial, nil, true, 0) | |
| 43 | + | require.NoError(t, err) | |
| 44 | + | require.True(t, ok) | |
| 45 | + | mustApplyOutput(t, client, first) | |
| 46 | + | stream.ack(1) | |
| 47 | + | ||
| 48 | + | scrolled := renderer.NewFrame(4, 3) | |
| 49 | + | fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "}) | |
| 50 | + | damage := []renderer.Damage{ | |
| 51 | + | {Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 52 | + | {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 53 | + | } | |
| 54 | + | second, ok, err := drawOutputState(t, stream, scrolled, damage, false, 0) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | require.True(t, ok) | |
| 57 | + | mustApplyOutput(t, client, second) | |
| 58 | + | ||
| 59 | + | // The renderer baseline is the preceding emitted frame, so repeating stale | |
| 60 | + | // scroll damage falls back to an overwrite instead of applying a second | |
| 61 | + | // incompatible scroll program. | |
| 62 | + | third, ok, err := drawOutputState(t, stream, scrolled, damage, false, 0) | |
| 63 | + | require.NoError(t, err) | |
| 64 | + | require.True(t, ok) | |
| 65 | + | mustApplyOutput(t, client, third) | |
| 66 | + | for y, want := range []string{"BBBB", "CCCC", "N "} { | |
| 67 | + | require.Equal(t, want, outputStateRow(client.Frame.Row(y))) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func TestOutputStateStreamAckProgressionReleasesDeferredPaint(t *testing.T) { | |
| 72 | + | stream := newOutputStateStream() | |
| 73 | + | for range maxUnackedOutputStates { | |
| 74 | + | stream.frame([]byte("output"), false, 0) | |
| 75 | + | } | |
| 76 | + | require.True(t, stream.deferIfAtCapacity(false)) | |
| 77 | + | _, ok := stream.takeDeferred() | |
| 78 | + | require.False(t, ok) | |
| 79 | + | stream.ack(maxUnackedOutputStates - 1) | |
| 80 | + | reset, ok := stream.takeDeferred() | |
| 81 | + | require.True(t, ok) | |
| 82 | + | require.False(t, reset) | |
| 83 | + | require.Equal(t, uint64(1), stream.outstanding()) | |
| 84 | + | ||
| 85 | + | // Stale and future ACKs do not move the cumulative acknowledgement. | |
| 86 | + | stream.ack(maxUnackedOutputStates - 2) | |
| 87 | + | stream.ack(maxUnackedOutputStates + 1) | |
| 88 | + | require.Equal(t, uint64(maxUnackedOutputStates-1), stream.acked) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func TestOutputStateStreamResetIsDependencyFreeAndClearsDeferral(t *testing.T) { | |
| 92 | + | stream := newOutputStateStream() | |
| 93 | + | for range maxUnackedOutputStates { | |
| 94 | + | stream.frame([]byte("output"), false, 0) | |
| 95 | + | } | |
| 96 | + | require.True(t, stream.deferIfAtCapacity(false)) | |
| 97 | + | for range 32 { | |
| 98 | + | require.True(t, stream.deferIfAtCapacity(true), "reset paints must coalesce while the stream is at capacity") | |
| 99 | + | } | |
| 100 | + | require.Equal(t, uint64(maxUnackedOutputStates), stream.outstanding()) | |
| 101 | + | stream.ack(maxUnackedOutputStates) | |
| 102 | + | reset, ok := stream.takeDeferred() | |
| 103 | + | require.True(t, ok) | |
| 104 | + | require.True(t, reset, "a coalesced reset must flush as a dependency-free repaint") | |
| 105 | + | ||
| 106 | + | frame := renderer.NewFrame(2, 1) | |
| 107 | + | fillOutputStateRows(frame, []string{"ok"}) | |
| 108 | + | outFrame, ok, err := drawOutputState(t, stream, frame, nil, true, 0) | |
| 109 | + | require.NoError(t, err) | |
| 110 | + | require.True(t, ok) | |
| 111 | + | out, err := ports.UnmarshalOutput(outFrame.Payload) | |
| 112 | + | require.NoError(t, err) | |
| 113 | + | require.Zero(t, out.BaseStateNum) | |
| 114 | + | _, ok = stream.takeDeferred() | |
| 115 | + | require.False(t, ok) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | |
| 119 | + | t.Helper() | |
| 120 | + | data, err := stream.render(frame, damage, reset) | |
| 121 | + | if err != nil || len(data) == 0 { | |
| 122 | + | return ports.Frame{}, false, err | |
| 123 | + | } | |
| 124 | + | return stream.frame(data, reset, echoAck), true, nil | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/ports" | |||
| 7 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 8 | + | "github.com/bnema/vev/pkg/vt" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) { | |||
| 13 | + | stream := newOutputStateStream() | |||
| 14 | + | firstFrame := renderer.NewFrame(3, 1) | |||
| 15 | + | fillOutputStateRows(firstFrame, []string{"abc"}) | |||
| 16 | + | first, ok, err := drawOutputState(t, stream, firstFrame, nil, true, 7) | |||
| 17 | + | require.NoError(t, err) | |||
| 18 | + | require.True(t, ok) | |||
| 19 | + | firstOut, err := ports.UnmarshalOutput(first.Payload) | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | require.Equal(t, uint64(0), firstOut.BaseStateNum) | |||
| 22 | + | require.Equal(t, uint64(1), firstOut.NewStateNum) | |||
| 23 | + | require.Equal(t, uint64(7), firstOut.EchoAck) | |||
| 24 | + | ||||
| 25 | + | secondFrame := firstFrame.Clone() | |||
| 26 | + | secondFrame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}) | |||
| 27 | + | second, ok, err := drawOutputState(t, stream, secondFrame, []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}, false, 8) | |||
| 28 | + | require.NoError(t, err) | |||
| 29 | + | require.True(t, ok) | |||
| 30 | + | secondOut, err := ports.UnmarshalOutput(second.Payload) | |||
| 31 | + | require.NoError(t, err) | |||
| 32 | + | require.Equal(t, firstOut.NewStateNum, secondOut.BaseStateNum) | |||
| 33 | + | require.Equal(t, uint64(2), secondOut.NewStateNum) | |||
| 34 | + | require.Equal(t, uint64(2), stream.outstanding()) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) { | |||
| 38 | + | stream := newOutputStateStream() | |||
| 39 | + | client := vt.NewScreen(4, 3) | |||
| 40 | + | initial := renderer.NewFrame(4, 3) | |||
| 41 | + | fillOutputStateRows(initial, []string{"AAAA", "BBBB", "CCCC"}) | |||
| 42 | + | first, ok, err := drawOutputState(t, stream, initial, nil, true, 0) | |||
| 43 | + | require.NoError(t, err) | |||
| 44 | + | require.True(t, ok) | |||
| 45 | + | mustApplyOutput(t, client, first) | |||
| 46 | + | stream.ack(1) | |||
| 47 | + | ||||
| 48 | + | scrolled := renderer.NewFrame(4, 3) | |||
| 49 | + | fillOutputStateRows(scrolled, []string{"BBBB", "CCCC", "N "}) | |||
| 50 | + | damage := []renderer.Damage{ | |||
| 51 | + | {Kind: renderer.DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 52 | + | {Kind: renderer.DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 53 | + | } | |||
| 54 | + | second, ok, err := drawOutputState(t, stream, scrolled, damage, false, 0) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | require.True(t, ok) | |||
| 57 | + | mustApplyOutput(t, client, second) | |||
| 58 | + | ||||
| 59 | + | // The renderer baseline is the preceding emitted frame, so repeating stale | |||
| 60 | + | // scroll damage falls back to an overwrite instead of applying a second | |||
| 61 | + | // incompatible scroll program. | |||
| 62 | + | third, ok, err := drawOutputState(t, stream, scrolled, damage, false, 0) | |||
| 63 | + | require.NoError(t, err) | |||
| 64 | + | require.True(t, ok) | |||
| 65 | + | mustApplyOutput(t, client, third) | |||
| 66 | + | for y, want := range []string{"BBBB", "CCCC", "N "} { | |||
| 67 | + | require.Equal(t, want, outputStateRow(client.Frame.Row(y))) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func TestOutputStateStreamAckProgressionReleasesDeferredPaint(t *testing.T) { | |||
| 72 | + | stream := newOutputStateStream() | |||
| 73 | + | for range maxUnackedOutputStates { | |||
| 74 | + | stream.frame([]byte("output"), false, 0) | |||
| 75 | + | } | |||
| 76 | + | require.True(t, stream.deferIfAtCapacity(false)) | |||
| 77 | + | _, ok := stream.takeDeferred() | |||
| 78 | + | require.False(t, ok) | |||
| 79 | + | stream.ack(maxUnackedOutputStates - 1) | |||
| 80 | + | reset, ok := stream.takeDeferred() | |||
| 81 | + | require.True(t, ok) | |||
| 82 | + | require.False(t, reset) | |||
| 83 | + | require.Equal(t, uint64(1), stream.outstanding()) | |||
| 84 | + | ||||
| 85 | + | // Stale and future ACKs do not move the cumulative acknowledgement. | |||
| 86 | + | stream.ack(maxUnackedOutputStates - 2) | |||
| 87 | + | stream.ack(maxUnackedOutputStates + 1) | |||
| 88 | + | require.Equal(t, uint64(maxUnackedOutputStates-1), stream.acked) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func TestOutputStateStreamResetIsDependencyFreeAndClearsDeferral(t *testing.T) { | |||
| 92 | + | stream := newOutputStateStream() | |||
| 93 | + | for range maxUnackedOutputStates { | |||
| 94 | + | stream.frame([]byte("output"), false, 0) | |||
| 95 | + | } | |||
| 96 | + | require.True(t, stream.deferIfAtCapacity(false)) | |||
| 97 | + | for range 32 { | |||
| 98 | + | require.True(t, stream.deferIfAtCapacity(true), "reset paints must coalesce while the stream is at capacity") | |||
| 99 | + | } | |||
| 100 | + | require.Equal(t, uint64(maxUnackedOutputStates), stream.outstanding()) | |||
| 101 | + | stream.ack(maxUnackedOutputStates) | |||
| 102 | + | reset, ok := stream.takeDeferred() | |||
| 103 | + | require.True(t, ok) | |||
| 104 | + | require.True(t, reset, "a coalesced reset must flush as a dependency-free repaint") | |||
| 105 | + | ||||
| 106 | + | frame := renderer.NewFrame(2, 1) | |||
| 107 | + | fillOutputStateRows(frame, []string{"ok"}) | |||
| 108 | + | outFrame, ok, err := drawOutputState(t, stream, frame, nil, true, 0) | |||
| 109 | + | require.NoError(t, err) | |||
| 110 | + | require.True(t, ok) | |||
| 111 | + | out, err := ports.UnmarshalOutput(outFrame.Payload) | |||
| 112 | + | require.NoError(t, err) | |||
| 113 | + | require.Zero(t, out.BaseStateNum) | |||
| 114 | + | _, ok = stream.takeDeferred() | |||
| 115 | + | require.False(t, ok) | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) { | |||
| 119 | + | t.Helper() | |||
| 120 | + | data, err := stream.render(frame, damage, reset) | |||
| 121 | + | if err != nil || len(data) == 0 { | |||
| 122 | + | return ports.Frame{}, false, err | |||
| 123 | + | } | |||
| 124 | + | return stream.frame(data, reset, echoAck), true, nil | |||
| 125 | + | } | |||
M internal/usecase/daemon/output_state_sync_test.go +175 -9
| @@ -1,12 +1,16 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "errors" | |
| 4 | 5 | "io" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/vev/internal/domain" | |
| 9 | 11 | "github.com/bnema/vev/internal/ports" | |
| 12 | + | "github.com/bnema/vev/pkg/renderer" | |
| 13 | + | "github.com/bnema/vev/pkg/vt" | |
| 10 | 14 | "github.com/stretchr/testify/require" | |
| 11 | 15 | ) | |
| 12 | 16 | ||
| @@ -16,6 +20,37 @@ type datagramTestTransport struct { | |||
| 16 | 20 | once sync.Once | |
| 17 | 21 | } | |
| 18 | 22 | ||
| 23 | + | type failingOutputTransport struct{} | |
| 24 | + | ||
| 25 | + | func (failingOutputTransport) Send(ports.Frame) error { return errors.New("send failed") } | |
| 26 | + | func (failingOutputTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 27 | + | func (failingOutputTransport) Close() error { return nil } | |
| 28 | + | ||
| 29 | + | type asyncPaintTransport struct { | |
| 30 | + | syncSends chan ports.Frame | |
| 31 | + | asyncSends chan ports.Frame | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (t *asyncPaintTransport) Send(f ports.Frame) error { t.syncSends <- f; return nil } | |
| 35 | + | func (t *asyncPaintTransport) SendAsync(f ports.Frame) error { t.asyncSends <- f; return nil } | |
| 36 | + | func (t *asyncPaintTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |
| 37 | + | func (t *asyncPaintTransport) Close() error { return nil } | |
| 38 | + | ||
| 39 | + | type timedSideEffectTransport struct { | |
| 40 | + | closeTrackingTransport | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (t *timedSideEffectTransport) SendSynchronous(f ports.Frame) error { | |
| 44 | + | return t.Send(f) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | type noWatchdogClock struct{} | |
| 48 | + | ||
| 49 | + | func (noWatchdogClock) Now() time.Time { return time.Time{} } | |
| 50 | + | func (noWatchdogClock) NewTimer(time.Duration) ports.Timer { | |
| 51 | + | panic("owned synchronous send must not install daemon watchdog") | |
| 52 | + | } | |
| 53 | + | ||
| 19 | 54 | func newDatagramTestTransport() *datagramTestTransport { | |
| 20 | 55 | return &datagramTestTransport{sends: make(chan ports.Frame, 64), recv: make(chan ports.Frame, 64)} | |
| 21 | 56 | } | |
| @@ -30,7 +65,7 @@ func (t *datagramTestTransport) Recv() (ports.Frame, error) { | |||
| 30 | 65 | } | |
| 31 | 66 | func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil } | |
| 32 | 67 | ||
| 33 | - | func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) { | |
| 68 | + | func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) { | |
| 34 | 69 | p, releasePTY := newBlockingPTY(t) | |
| 35 | 70 | defer releasePTY() | |
| 36 | 71 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| @@ -52,21 +87,114 @@ func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) { | |||
| 52 | 87 | require.NoError(t, err) | |
| 53 | 88 | require.Equal(t, uint64(1), out.NewStateNum) | |
| 54 | 89 | ||
| 55 | - | // Before the MsgAck, AdvanceOnAck must keep rediffing from the unacked | |
| 56 | - | // shadow even if the composed screen is unchanged. | |
| 90 | + | // Before the MsgAck, the renderer has already advanced along the ordered | |
| 91 | + | // output dependency chain, so an unchanged repaint is a no-op. | |
| 57 | 92 | d.paint(sess, ac, false) | |
| 58 | - | awaitFrame(t, tr.sends, ports.MsgOutput) | |
| 93 | + | requireNoOutputFrame(t, tr.sends) | |
| 59 | 94 | ||
| 60 | 95 | tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})} | |
| 61 | 96 | require.NoError(t, tr.Close()) | |
| 62 | 97 | d.runConnLoop(ac) | |
| 63 | 98 | ||
| 64 | - | // After the production MsgAck path advances the renderer shadow to state 1, | |
| 65 | - | // an unchanged repaint is a no-op. | |
| 99 | + | // The production MsgAck path retires retained states without moving the | |
| 100 | + | // renderer baseline backward. | |
| 66 | 101 | d.paint(sess, ac, false) | |
| 67 | 102 | requireNoOutputFrame(t, tr.sends) | |
| 68 | 103 | } | |
| 69 | 104 | ||
| 105 | + | func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) { | |
| 106 | + | p, releasePTY := newBlockingPTY(t) | |
| 107 | + | defer releasePTY() | |
| 108 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 109 | + | tr := &asyncPaintTransport{syncSends: make(chan ports.Frame, 1), asyncSends: make(chan ports.Frame, 1)} | |
| 110 | + | ac.replaceTransport(tr) | |
| 111 | + | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 112 | + | ||
| 113 | + | d.paint(sess, ac, false) | |
| 114 | + | ||
| 115 | + | awaitFrame(t, tr.asyncSends, ports.MsgOutput) | |
| 116 | + | select { | |
| 117 | + | case frame := <-tr.syncSends: | |
| 118 | + | t.Fatalf("paint used synchronous Send: %+v", frame) | |
| 119 | + | default: | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |
| 124 | + | p, releasePTY := newBlockingPTY(t) | |
| 125 | + | defer releasePTY() | |
| 126 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |
| 127 | + | tr := newDatagramTestTransport() | |
| 128 | + | _, ac, err := d.route(ports.Hello{ | |
| 129 | + | Version: ports.ProtocolVersion, | |
| 130 | + | Intent: ports.IntentAttach, | |
| 131 | + | Name: sess.name, | |
| 132 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 133 | + | AckOutput: true, | |
| 134 | + | }, tr) | |
| 135 | + | require.NoError(t, err) | |
| 136 | + | ||
| 137 | + | pane := sess.tabs[0].focusedPane() | |
| 138 | + | for y := range pane.screen.Frame.Height { | |
| 139 | + | cell := renderer.Cell{Rune: rune('A' + y), Style: renderer.DefaultStyle()} | |
| 140 | + | for x := range pane.screen.Frame.Width { | |
| 141 | + | pane.screen.Frame.Set(x, y, cell) | |
| 142 | + | } | |
| 143 | + | } | |
| 144 | + | pane.screen.ClearDamage() | |
| 145 | + | client := vt.NewScreen(80, 25) | |
| 146 | + | d.paint(sess, ac, true) | |
| 147 | + | first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | |
| 148 | + | ac.ackOutputState(first.NewStateNum) | |
| 149 | + | ||
| 150 | + | // Preserve the frame after one scroll while inducing a real VT scroll | |
| 151 | + | // damage event before each production paint. On main, both unacknowledged | |
| 152 | + | // paints are generated from the ACKed frame and apply the scroll twice to | |
| 153 | + | // the client. The ordered stream instead renders the second paint from the | |
| 154 | + | // preceding emitted frame and overwrites the incompatible repeated damage. | |
| 155 | + | desired := pane.screen.Frame.Clone() | |
| 156 | + | desired.ScrollUp(0, desired.Height-1, 1) | |
| 157 | + | for x := range desired.Width { | |
| 158 | + | desired.Set(x, desired.Height-1, renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()}) | |
| 159 | + | } | |
| 160 | + | for range 2 { | |
| 161 | + | pane.screen.Frame = desired.Clone() | |
| 162 | + | pane.screen.Row = pane.screen.Frame.Height - 1 | |
| 163 | + | pane.screen.Col = 0 | |
| 164 | + | pane.screen.Write([]byte("\nq")) | |
| 165 | + | pane.screen.Frame = desired.Clone() | |
| 166 | + | d.paint(sess, ac, false) | |
| 167 | + | mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | require.Equal(t, 'B', client.Frame.At(0, 1).Rune, | |
| 171 | + | "client-visible content must equal the latest daemon-composed frame after pipelined scroll paints") | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func fillOutputStateRows(frame renderer.Frame, rows []string) { | |
| 175 | + | for y, row := range rows { | |
| 176 | + | for x, r := range row { | |
| 177 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func outputStateRow(row []renderer.Cell) string { | |
| 183 | + | runes := make([]rune, len(row)) | |
| 184 | + | for i, cell := range row { | |
| 185 | + | runes[i] = cell.Rune | |
| 186 | + | } | |
| 187 | + | return string(runes) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func mustApplyOutput(t *testing.T, screen *vt.Screen, frame ports.Frame) ports.Output { | |
| 191 | + | t.Helper() | |
| 192 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |
| 193 | + | require.NoError(t, err) | |
| 194 | + | screen.Write(out.Data) | |
| 195 | + | return out | |
| 196 | + | } | |
| 197 | + | ||
| 70 | 198 | func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) { | |
| 71 | 199 | p, releasePTY := newBlockingPTY(t) | |
| 72 | 200 | defer releasePTY() | |
| @@ -76,8 +204,7 @@ func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) { | |||
| 76 | 204 | d.paint(sess, ac, false) | |
| 77 | 205 | awaitFrame(t, sends, ports.MsgOutput) | |
| 78 | 206 | ||
| 79 | - | // Local/default transports keep AdvanceOnSend: the first paint updates the | |
| 80 | - | // renderer shadow without waiting for MsgAck. | |
| 207 | + | // The first paint updates the renderer shadow without waiting for MsgAck. | |
| 81 | 208 | d.paint(sess, ac, false) | |
| 82 | 209 | requireNoOutputFrame(t, sends) | |
| 83 | 210 | } | |
| @@ -98,7 +225,46 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) { | |||
| 98 | 225 | d.paint(sess, ac, false) | |
| 99 | 226 | awaitFrame(t, sends, ports.MsgOutput) | |
| 100 | 227 | ||
| 101 | - | require.NoError(t, ac.ackOutputState(out.NewStateNum)) | |
| 228 | + | ac.ackOutputState(out.NewStateNum) | |
| 102 | 229 | d.paint(sess, ac, false) | |
| 103 | 230 | requireNoOutputFrame(t, sends) | |
| 104 | 231 | } | |
| 232 | + | ||
| 233 | + | func TestRawTerminalSideEffectDoesNotEnterFullOutputWindow(t *testing.T) { | |
| 234 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 235 | + | tr := &closeTrackingTransport{} | |
| 236 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 237 | + | ac.output.next = maxUnackedOutputStates | |
| 238 | + | ||
| 239 | + | require.NoError(t, d.boundedSendOutputErr(ac, []byte("\x1b]52;c;YQ==\x07"))) | |
| 240 | + | require.Equal(t, uint64(maxUnackedOutputStates), ac.output.next) | |
| 241 | + | require.Equal(t, uint64(maxUnackedOutputStates), ac.output.outstanding()) | |
| 242 | + | sends := tr.Sends() | |
| 243 | + | require.Len(t, sends, 1) | |
| 244 | + | out, err := ports.UnmarshalOutput(sends[0].Payload) | |
| 245 | + | require.NoError(t, err) | |
| 246 | + | require.Zero(t, out.BaseStateNum) | |
| 247 | + | require.Zero(t, out.NewStateNum) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | func TestOwnedSynchronousSideEffectSkipsOuterWatchdog(t *testing.T) { | |
| 251 | + | d := newTestDaemon(t, nil, noWatchdogClock{}) | |
| 252 | + | tr := &timedSideEffectTransport{} | |
| 253 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 254 | + | ||
| 255 | + | require.NoError(t, d.boundedSendOutputErr(ac, make([]byte, 100*1024))) | |
| 256 | + | require.Len(t, tr.Sends(), 1) | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | func TestFailedPaintSendRollsBackOutputState(t *testing.T) { | |
| 260 | + | p, releasePTY := newBlockingPTY(t) | |
| 261 | + | defer releasePTY() | |
| 262 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |
| 263 | + | ac.replaceTransport(failingOutputTransport{}) | |
| 264 | + | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 265 | + | ||
| 266 | + | d.paint(sess, ac, false) | |
| 267 | + | ||
| 268 | + | require.Zero(t, ac.output.next) | |
| 269 | + | require.Zero(t, ac.output.acked) | |
| 270 | + | } | |
| @@ -1,12 +1,16 @@ | |||||
| 1 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "errors" | |||
| 4 | "io" | 5 | "io" | ||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "testing" | 7 | "testing" | ||
| 8 | + | "time" | |||
| 7 | 9 | ||||
| 8 | "github.com/bnema/vev/internal/domain" | 10 | "github.com/bnema/vev/internal/domain" | ||
| 9 | "github.com/bnema/vev/internal/ports" | 11 | "github.com/bnema/vev/internal/ports" | ||
| 12 | + | "github.com/bnema/vev/pkg/renderer" | |||
| 13 | + | "github.com/bnema/vev/pkg/vt" | |||
| 10 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 15 | ) | ||
| 12 | 16 | ||||
| @@ -16,6 +20,37 @@ type datagramTestTransport struct { | |||||
| 16 | once sync.Once | 20 | once sync.Once | ||
| 17 | } | 21 | } | ||
| 18 | 22 | ||||
| 23 | + | type failingOutputTransport struct{} | |||
| 24 | + | ||||
| 25 | + | func (failingOutputTransport) Send(ports.Frame) error { return errors.New("send failed") } | |||
| 26 | + | func (failingOutputTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 27 | + | func (failingOutputTransport) Close() error { return nil } | |||
| 28 | + | ||||
| 29 | + | type asyncPaintTransport struct { | |||
| 30 | + | syncSends chan ports.Frame | |||
| 31 | + | asyncSends chan ports.Frame | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (t *asyncPaintTransport) Send(f ports.Frame) error { t.syncSends <- f; return nil } | |||
| 35 | + | func (t *asyncPaintTransport) SendAsync(f ports.Frame) error { t.asyncSends <- f; return nil } | |||
| 36 | + | func (t *asyncPaintTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF } | |||
| 37 | + | func (t *asyncPaintTransport) Close() error { return nil } | |||
| 38 | + | ||||
| 39 | + | type timedSideEffectTransport struct { | |||
| 40 | + | closeTrackingTransport | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (t *timedSideEffectTransport) SendSynchronous(f ports.Frame) error { | |||
| 44 | + | return t.Send(f) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | type noWatchdogClock struct{} | |||
| 48 | + | ||||
| 49 | + | func (noWatchdogClock) Now() time.Time { return time.Time{} } | |||
| 50 | + | func (noWatchdogClock) NewTimer(time.Duration) ports.Timer { | |||
| 51 | + | panic("owned synchronous send must not install daemon watchdog") | |||
| 52 | + | } | |||
| 53 | + | ||||
| 19 | func newDatagramTestTransport() *datagramTestTransport { | 54 | func newDatagramTestTransport() *datagramTestTransport { | ||
| 20 | return &datagramTestTransport{sends: make(chan ports.Frame, 64), recv: make(chan ports.Frame, 64)} | 55 | return &datagramTestTransport{sends: make(chan ports.Frame, 64), recv: make(chan ports.Frame, 64)} | ||
| 21 | } | 56 | } | ||
| @@ -30,7 +65,7 @@ func (t *datagramTestTransport) Recv() (ports.Frame, error) { | |||||
| 30 | } | 65 | } | ||
| 31 | func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil } | 66 | func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil } | ||
| 32 | 67 | ||||
| 33 | - | func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) { | 68 | + | func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) { |
| 34 | p, releasePTY := newBlockingPTY(t) | 69 | p, releasePTY := newBlockingPTY(t) | ||
| 35 | defer releasePTY() | 70 | defer releasePTY() | ||
| 36 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | 71 | d, sess, _, _ := newManualSessionWithPTYs(t, p) | ||
| @@ -52,21 +87,114 @@ func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) { | |||||
| 52 | require.NoError(t, err) | 87 | require.NoError(t, err) | ||
| 53 | require.Equal(t, uint64(1), out.NewStateNum) | 88 | require.Equal(t, uint64(1), out.NewStateNum) | ||
| 54 | 89 | ||||
| 55 | - | // Before the MsgAck, AdvanceOnAck must keep rediffing from the unacked | 90 | + | // Before the MsgAck, the renderer has already advanced along the ordered |
| 56 | - | // shadow even if the composed screen is unchanged. | 91 | + | // output dependency chain, so an unchanged repaint is a no-op. |
| 57 | d.paint(sess, ac, false) | 92 | d.paint(sess, ac, false) | ||
| 58 | - | awaitFrame(t, tr.sends, ports.MsgOutput) | 93 | + | requireNoOutputFrame(t, tr.sends) |
| 59 | 94 | ||||
| 60 | tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})} | 95 | tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})} | ||
| 61 | require.NoError(t, tr.Close()) | 96 | require.NoError(t, tr.Close()) | ||
| 62 | d.runConnLoop(ac) | 97 | d.runConnLoop(ac) | ||
| 63 | 98 | ||||
| 64 | - | // After the production MsgAck path advances the renderer shadow to state 1, | 99 | + | // The production MsgAck path retires retained states without moving the |
| 65 | - | // an unchanged repaint is a no-op. | 100 | + | // renderer baseline backward. |
| 66 | d.paint(sess, ac, false) | 101 | d.paint(sess, ac, false) | ||
| 67 | requireNoOutputFrame(t, tr.sends) | 102 | requireNoOutputFrame(t, tr.sends) | ||
| 68 | } | 103 | } | ||
| 69 | 104 | ||||
| 105 | + | func TestPaintExplicitlyUsesAsyncTransportCapability(t *testing.T) { | |||
| 106 | + | p, releasePTY := newBlockingPTY(t) | |||
| 107 | + | defer releasePTY() | |||
| 108 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 109 | + | tr := &asyncPaintTransport{syncSends: make(chan ports.Frame, 1), asyncSends: make(chan ports.Frame, 1)} | |||
| 110 | + | ac.replaceTransport(tr) | |||
| 111 | + | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |||
| 112 | + | ||||
| 113 | + | d.paint(sess, ac, false) | |||
| 114 | + | ||||
| 115 | + | awaitFrame(t, tr.asyncSends, ports.MsgOutput) | |||
| 116 | + | select { | |||
| 117 | + | case frame := <-tr.syncSends: | |||
| 118 | + | t.Fatalf("paint used synchronous Send: %+v", frame) | |||
| 119 | + | default: | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | func TestDatagramMultipleUnackedScrollPaintsMatchLatestFrame(t *testing.T) { | |||
| 124 | + | p, releasePTY := newBlockingPTY(t) | |||
| 125 | + | defer releasePTY() | |||
| 126 | + | d, sess, _, _ := newManualSessionWithPTYs(t, p) | |||
| 127 | + | tr := newDatagramTestTransport() | |||
| 128 | + | _, ac, err := d.route(ports.Hello{ | |||
| 129 | + | Version: ports.ProtocolVersion, | |||
| 130 | + | Intent: ports.IntentAttach, | |||
| 131 | + | Name: sess.name, | |||
| 132 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 133 | + | AckOutput: true, | |||
| 134 | + | }, tr) | |||
| 135 | + | require.NoError(t, err) | |||
| 136 | + | ||||
| 137 | + | pane := sess.tabs[0].focusedPane() | |||
| 138 | + | for y := range pane.screen.Frame.Height { | |||
| 139 | + | cell := renderer.Cell{Rune: rune('A' + y), Style: renderer.DefaultStyle()} | |||
| 140 | + | for x := range pane.screen.Frame.Width { | |||
| 141 | + | pane.screen.Frame.Set(x, y, cell) | |||
| 142 | + | } | |||
| 143 | + | } | |||
| 144 | + | pane.screen.ClearDamage() | |||
| 145 | + | client := vt.NewScreen(80, 25) | |||
| 146 | + | d.paint(sess, ac, true) | |||
| 147 | + | first := mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | |||
| 148 | + | ac.ackOutputState(first.NewStateNum) | |||
| 149 | + | ||||
| 150 | + | // Preserve the frame after one scroll while inducing a real VT scroll | |||
| 151 | + | // damage event before each production paint. On main, both unacknowledged | |||
| 152 | + | // paints are generated from the ACKed frame and apply the scroll twice to | |||
| 153 | + | // the client. The ordered stream instead renders the second paint from the | |||
| 154 | + | // preceding emitted frame and overwrites the incompatible repeated damage. | |||
| 155 | + | desired := pane.screen.Frame.Clone() | |||
| 156 | + | desired.ScrollUp(0, desired.Height-1, 1) | |||
| 157 | + | for x := range desired.Width { | |||
| 158 | + | desired.Set(x, desired.Height-1, renderer.Cell{Rune: 'z', Style: renderer.DefaultStyle()}) | |||
| 159 | + | } | |||
| 160 | + | for range 2 { | |||
| 161 | + | pane.screen.Frame = desired.Clone() | |||
| 162 | + | pane.screen.Row = pane.screen.Frame.Height - 1 | |||
| 163 | + | pane.screen.Col = 0 | |||
| 164 | + | pane.screen.Write([]byte("\nq")) | |||
| 165 | + | pane.screen.Frame = desired.Clone() | |||
| 166 | + | d.paint(sess, ac, false) | |||
| 167 | + | mustApplyOutput(t, client, awaitFrame(t, tr.sends, ports.MsgOutput)) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | require.Equal(t, 'B', client.Frame.At(0, 1).Rune, | |||
| 171 | + | "client-visible content must equal the latest daemon-composed frame after pipelined scroll paints") | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func fillOutputStateRows(frame renderer.Frame, rows []string) { | |||
| 175 | + | for y, row := range rows { | |||
| 176 | + | for x, r := range row { | |||
| 177 | + | frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 178 | + | } | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func outputStateRow(row []renderer.Cell) string { | |||
| 183 | + | runes := make([]rune, len(row)) | |||
| 184 | + | for i, cell := range row { | |||
| 185 | + | runes[i] = cell.Rune | |||
| 186 | + | } | |||
| 187 | + | return string(runes) | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | func mustApplyOutput(t *testing.T, screen *vt.Screen, frame ports.Frame) ports.Output { | |||
| 191 | + | t.Helper() | |||
| 192 | + | out, err := ports.UnmarshalOutput(frame.Payload) | |||
| 193 | + | require.NoError(t, err) | |||
| 194 | + | screen.Write(out.Data) | |||
| 195 | + | return out | |||
| 196 | + | } | |||
| 197 | + | ||||
| 70 | func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) { | 198 | func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) { | ||
| 71 | p, releasePTY := newBlockingPTY(t) | 199 | p, releasePTY := newBlockingPTY(t) | ||
| 72 | defer releasePTY() | 200 | defer releasePTY() | ||
| @@ -76,8 +204,7 @@ func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) { | |||||
| 76 | d.paint(sess, ac, false) | 204 | d.paint(sess, ac, false) | ||
| 77 | awaitFrame(t, sends, ports.MsgOutput) | 205 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 78 | 206 | ||||
| 79 | - | // Local/default transports keep AdvanceOnSend: the first paint updates the | 207 | + | // The first paint updates the renderer shadow without waiting for MsgAck. |
| 80 | - | // renderer shadow without waiting for MsgAck. | |||
| 81 | d.paint(sess, ac, false) | 208 | d.paint(sess, ac, false) | ||
| 82 | requireNoOutputFrame(t, sends) | 209 | requireNoOutputFrame(t, sends) | ||
| 83 | } | 210 | } | ||
| @@ -98,7 +225,46 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) { | |||||
| 98 | d.paint(sess, ac, false) | 225 | d.paint(sess, ac, false) | ||
| 99 | awaitFrame(t, sends, ports.MsgOutput) | 226 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 100 | 227 | ||||
| 101 | - | require.NoError(t, ac.ackOutputState(out.NewStateNum)) | 228 | + | ac.ackOutputState(out.NewStateNum) |
| 102 | d.paint(sess, ac, false) | 229 | d.paint(sess, ac, false) | ||
| 103 | requireNoOutputFrame(t, sends) | 230 | requireNoOutputFrame(t, sends) | ||
| 104 | } | 231 | } | ||
| 232 | + | ||||
| 233 | + | func TestRawTerminalSideEffectDoesNotEnterFullOutputWindow(t *testing.T) { | |||
| 234 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 235 | + | tr := &closeTrackingTransport{} | |||
| 236 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |||
| 237 | + | ac.output.next = maxUnackedOutputStates | |||
| 238 | + | ||||
| 239 | + | require.NoError(t, d.boundedSendOutputErr(ac, []byte("\x1b]52;c;YQ==\x07"))) | |||
| 240 | + | require.Equal(t, uint64(maxUnackedOutputStates), ac.output.next) | |||
| 241 | + | require.Equal(t, uint64(maxUnackedOutputStates), ac.output.outstanding()) | |||
| 242 | + | sends := tr.Sends() | |||
| 243 | + | require.Len(t, sends, 1) | |||
| 244 | + | out, err := ports.UnmarshalOutput(sends[0].Payload) | |||
| 245 | + | require.NoError(t, err) | |||
| 246 | + | require.Zero(t, out.BaseStateNum) | |||
| 247 | + | require.Zero(t, out.NewStateNum) | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | func TestOwnedSynchronousSideEffectSkipsOuterWatchdog(t *testing.T) { | |||
| 251 | + | d := newTestDaemon(t, nil, noWatchdogClock{}) | |||
| 252 | + | tr := &timedSideEffectTransport{} | |||
| 253 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |||
| 254 | + | ||||
| 255 | + | require.NoError(t, d.boundedSendOutputErr(ac, make([]byte, 100*1024))) | |||
| 256 | + | require.Len(t, tr.Sends(), 1) | |||
| 257 | + | } | |||
| 258 | + | ||||
| 259 | + | func TestFailedPaintSendRollsBackOutputState(t *testing.T) { | |||
| 260 | + | p, releasePTY := newBlockingPTY(t) | |||
| 261 | + | defer releasePTY() | |||
| 262 | + | d, sess, ac, _ := newManualSessionWithPTYs(t, p) | |||
| 263 | + | ac.replaceTransport(failingOutputTransport{}) | |||
| 264 | + | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |||
| 265 | + | ||||
| 266 | + | d.paint(sess, ac, false) | |||
| 267 | + | ||||
| 268 | + | require.Zero(t, ac.output.next) | |||
| 269 | + | require.Zero(t, ac.output.acked) | |||
| 270 | + | } | |||
M internal/usecase/daemon/paint_gate_test.go +50 -13
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/vev/internal/ports" | |
| 9 | 9 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 10 | + | "github.com/bnema/vev/pkg/vt" | |
| 10 | 11 | "github.com/stretchr/testify/require" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| @@ -31,12 +32,12 @@ func TestPaintDefersWhenOutputAckLagsAtCap(t *testing.T) { | |||
| 31 | 32 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 32 | 33 | ||
| 33 | 34 | // Client is maxUnackedOutputStates behind (outputAck stays 0). | |
| 34 | - | ac.nextStateNum = maxUnackedOutputStates | |
| 35 | + | ac.output.next = maxUnackedOutputStates | |
| 35 | 36 | d.paint(sess, ac, false) | |
| 36 | 37 | ||
| 37 | 38 | requireNoOutputFrame(t, sends) | |
| 38 | 39 | ac.sendMu.Lock() | |
| 39 | - | deferred := ac.paintDeferred | |
| 40 | + | deferred := ac.output.deferred | |
| 40 | 41 | ac.sendMu.Unlock() | |
| 41 | 42 | require.True(t, deferred, "diff paint at ack cap must defer") | |
| 42 | 43 | } | |
| @@ -46,7 +47,7 @@ func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) { | |||
| 46 | 47 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 47 | 48 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 48 | 49 | ||
| 49 | - | ac.nextStateNum = maxUnackedOutputStates | |
| 50 | + | ac.output.next = maxUnackedOutputStates | |
| 50 | 51 | d.paint(sess, ac, false) | |
| 51 | 52 | requireNoOutputFrame(t, sends) | |
| 52 | 53 | ||
| @@ -74,25 +75,61 @@ func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) { | |||
| 74 | 75 | awaitFrame(t, sends, ports.MsgOutput) | |
| 75 | 76 | requireNoOutputFrame(t, sends) | |
| 76 | 77 | ac.sendMu.Lock() | |
| 77 | - | deferred := ac.paintDeferred | |
| 78 | + | deferred := ac.output.deferred | |
| 78 | 79 | ac.sendMu.Unlock() | |
| 79 | 80 | require.False(t, deferred, "flag must clear after the flush paint") | |
| 80 | 81 | } | |
| 81 | 82 | ||
| 82 | - | func TestResetPaintBypassesAckGate(t *testing.T) { | |
| 83 | + | func TestResetPaintCoalescesAtAckGate(t *testing.T) { | |
| 83 | 84 | p, _ := newBlockingPTY(t) | |
| 84 | 85 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 85 | 86 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 86 | 87 | ||
| 87 | - | // At the cap and already deferred: a reset paint is a full invalidation that | |
| 88 | - | // must paint anyway and clear the deferral. | |
| 89 | - | ac.nextStateNum = maxUnackedOutputStates | |
| 90 | - | ac.paintDeferred = true | |
| 91 | - | d.paint(sess, ac, true) | |
| 88 | + | // At the cap, repeated reset paints coalesce instead of growing retained | |
| 89 | + | // state and transport queues without bound. | |
| 90 | + | ac.output.next = maxUnackedOutputStates | |
| 91 | + | for range 32 { | |
| 92 | + | d.paint(sess, ac, true) | |
| 93 | + | } | |
| 92 | 94 | ||
| 93 | - | awaitFrame(t, sends, ports.MsgOutput) | |
| 95 | + | requireNoOutputFrame(t, sends) | |
| 94 | 96 | ac.sendMu.Lock() | |
| 95 | - | deferred := ac.paintDeferred | |
| 97 | + | deferred := ac.output.deferred | |
| 98 | + | deferredReset := ac.output.deferredReset | |
| 99 | + | retained := ac.output.outstanding() | |
| 96 | 100 | ac.sendMu.Unlock() | |
| 97 | - | require.False(t, deferred, "reset paint must clear the deferral") | |
| 101 | + | require.True(t, deferred) | |
| 102 | + | require.True(t, deferredReset) | |
| 103 | + | require.LessOrEqual(t, retained, uint64(maxUnackedOutputStates)) | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestCoalescedResetFlushesLatestFrameAfterAck(t *testing.T) { | |
| 107 | + | p, _ := newBlockingPTY(t) | |
| 108 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |
| 109 | + | for range maxUnackedOutputStates { | |
| 110 | + | ac.output.frame([]byte("in flight"), false, 0) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | pane := sess.tabs[0].focusedPane() | |
| 114 | + | var latest byte | |
| 115 | + | for i := range 32 { | |
| 116 | + | latest = byte('a' + i%26) | |
| 117 | + | pane.screen.Write([]byte{'\r', latest}) | |
| 118 | + | d.paint(sess, ac, true) | |
| 119 | + | } | |
| 120 | + | requireNoOutputFrame(t, sends) | |
| 121 | + | require.Equal(t, uint64(maxUnackedOutputStates), ac.output.outstanding()) | |
| 122 | + | ||
| 123 | + | ac.ackOutputState(maxUnackedOutputStates) | |
| 124 | + | reset, ok := ac.takeDeferredPaint() | |
| 125 | + | require.True(t, ok) | |
| 126 | + | require.True(t, reset) | |
| 127 | + | d.paint(sess, ac, reset) | |
| 128 | + | ||
| 129 | + | client := vt.NewScreen(80, 25) | |
| 130 | + | out := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |
| 131 | + | require.Zero(t, out.BaseStateNum) | |
| 132 | + | require.Equal(t, rune(latest), client.Frame.At(0, 1).Rune) | |
| 133 | + | require.Equal(t, uint64(1), ac.output.outstanding()) | |
| 134 | + | requireNoOutputFrame(t, sends) | |
| 98 | 135 | } | |
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/bnema/vev/internal/ports" | 8 | "github.com/bnema/vev/internal/ports" | ||
| 9 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 9 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 10 | + | "github.com/bnema/vev/pkg/vt" | |||
| 10 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 11 | ) | 12 | ) | ||
| 12 | 13 | ||||
| @@ -31,12 +32,12 @@ func TestPaintDefersWhenOutputAckLagsAtCap(t *testing.T) { | |||||
| 31 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | 32 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 32 | 33 | ||||
| 33 | // Client is maxUnackedOutputStates behind (outputAck stays 0). | 34 | // Client is maxUnackedOutputStates behind (outputAck stays 0). | ||
| 34 | - | ac.nextStateNum = maxUnackedOutputStates | 35 | + | ac.output.next = maxUnackedOutputStates |
| 35 | d.paint(sess, ac, false) | 36 | d.paint(sess, ac, false) | ||
| 36 | 37 | ||||
| 37 | requireNoOutputFrame(t, sends) | 38 | requireNoOutputFrame(t, sends) | ||
| 38 | ac.sendMu.Lock() | 39 | ac.sendMu.Lock() | ||
| 39 | - | deferred := ac.paintDeferred | 40 | + | deferred := ac.output.deferred |
| 40 | ac.sendMu.Unlock() | 41 | ac.sendMu.Unlock() | ||
| 41 | require.True(t, deferred, "diff paint at ack cap must defer") | 42 | require.True(t, deferred, "diff paint at ack cap must defer") | ||
| 42 | } | 43 | } | ||
| @@ -46,7 +47,7 @@ func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) { | |||||
| 46 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 47 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 47 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | 48 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 48 | 49 | ||||
| 49 | - | ac.nextStateNum = maxUnackedOutputStates | 50 | + | ac.output.next = maxUnackedOutputStates |
| 50 | d.paint(sess, ac, false) | 51 | d.paint(sess, ac, false) | ||
| 51 | requireNoOutputFrame(t, sends) | 52 | requireNoOutputFrame(t, sends) | ||
| 52 | 53 | ||||
| @@ -74,25 +75,61 @@ func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) { | |||||
| 74 | awaitFrame(t, sends, ports.MsgOutput) | 75 | awaitFrame(t, sends, ports.MsgOutput) | ||
| 75 | requireNoOutputFrame(t, sends) | 76 | requireNoOutputFrame(t, sends) | ||
| 76 | ac.sendMu.Lock() | 77 | ac.sendMu.Lock() | ||
| 77 | - | deferred := ac.paintDeferred | 78 | + | deferred := ac.output.deferred |
| 78 | ac.sendMu.Unlock() | 79 | ac.sendMu.Unlock() | ||
| 79 | require.False(t, deferred, "flag must clear after the flush paint") | 80 | require.False(t, deferred, "flag must clear after the flush paint") | ||
| 80 | } | 81 | } | ||
| 81 | 82 | ||||
| 82 | - | func TestResetPaintBypassesAckGate(t *testing.T) { | 83 | + | func TestResetPaintCoalescesAtAckGate(t *testing.T) { |
| 83 | p, _ := newBlockingPTY(t) | 84 | p, _ := newBlockingPTY(t) | ||
| 84 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | 85 | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 85 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | 86 | sess.tabs[0].focusedPane().screen.Write([]byte("A")) | ||
| 86 | 87 | ||||
| 87 | - | // At the cap and already deferred: a reset paint is a full invalidation that | 88 | + | // At the cap, repeated reset paints coalesce instead of growing retained |
| 88 | - | // must paint anyway and clear the deferral. | 89 | + | // state and transport queues without bound. |
| 89 | - | ac.nextStateNum = maxUnackedOutputStates | 90 | + | ac.output.next = maxUnackedOutputStates |
| 90 | - | ac.paintDeferred = true | 91 | + | for range 32 { |
| 91 | - | d.paint(sess, ac, true) | 92 | + | d.paint(sess, ac, true) |
| 93 | + | } | |||
| 92 | 94 | ||||
| 93 | - | awaitFrame(t, sends, ports.MsgOutput) | 95 | + | requireNoOutputFrame(t, sends) |
| 94 | ac.sendMu.Lock() | 96 | ac.sendMu.Lock() | ||
| 95 | - | deferred := ac.paintDeferred | 97 | + | deferred := ac.output.deferred |
| 98 | + | deferredReset := ac.output.deferredReset | |||
| 99 | + | retained := ac.output.outstanding() | |||
| 96 | ac.sendMu.Unlock() | 100 | ac.sendMu.Unlock() | ||
| 97 | - | require.False(t, deferred, "reset paint must clear the deferral") | 101 | + | require.True(t, deferred) |
| 102 | + | require.True(t, deferredReset) | |||
| 103 | + | require.LessOrEqual(t, retained, uint64(maxUnackedOutputStates)) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func TestCoalescedResetFlushesLatestFrameAfterAck(t *testing.T) { | |||
| 107 | + | p, _ := newBlockingPTY(t) | |||
| 108 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, p) | |||
| 109 | + | for range maxUnackedOutputStates { | |||
| 110 | + | ac.output.frame([]byte("in flight"), false, 0) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | pane := sess.tabs[0].focusedPane() | |||
| 114 | + | var latest byte | |||
| 115 | + | for i := range 32 { | |||
| 116 | + | latest = byte('a' + i%26) | |||
| 117 | + | pane.screen.Write([]byte{'\r', latest}) | |||
| 118 | + | d.paint(sess, ac, true) | |||
| 119 | + | } | |||
| 120 | + | requireNoOutputFrame(t, sends) | |||
| 121 | + | require.Equal(t, uint64(maxUnackedOutputStates), ac.output.outstanding()) | |||
| 122 | + | ||||
| 123 | + | ac.ackOutputState(maxUnackedOutputStates) | |||
| 124 | + | reset, ok := ac.takeDeferredPaint() | |||
| 125 | + | require.True(t, ok) | |||
| 126 | + | require.True(t, reset) | |||
| 127 | + | d.paint(sess, ac, reset) | |||
| 128 | + | ||||
| 129 | + | client := vt.NewScreen(80, 25) | |||
| 130 | + | out := mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput)) | |||
| 131 | + | require.Zero(t, out.BaseStateNum) | |||
| 132 | + | require.Equal(t, rune(latest), client.Frame.At(0, 1).Rune) | |||
| 133 | + | require.Equal(t, uint64(1), ac.output.outstanding()) | |||
| 134 | + | requireNoOutputFrame(t, sends) | |||
| 98 | } | 135 | } | ||
M internal/usecase/daemon/pane_actions_test.go +1 -2
| @@ -16,7 +16,6 @@ import ( | |||
| 16 | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 17 | 17 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 18 | 18 | "github.com/bnema/vev/internal/usecase/layout" | |
| 19 | - | "github.com/bnema/vev/pkg/renderer" | |
| 20 | 19 | ) | |
| 21 | 20 | ||
| 22 | 21 | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | |
| @@ -151,7 +150,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |||
| 151 | 150 | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | |
| 152 | 151 | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | |
| 153 | 152 | tr, _ := newCapturingTransport(t) | |
| 154 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 41, Rows: 12}} | |
| 153 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}} | |
| 155 | 154 | ac.initOverlays() | |
| 156 | 155 | ac.overlays.copyMode = &scopy.Mode{} | |
| 157 | 156 | ||
| @@ -16,7 +16,6 @@ import ( | |||||
| 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 16 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 17 | scopy "github.com/bnema/vev/internal/usecase/copy" | 17 | scopy "github.com/bnema/vev/internal/usecase/copy" | ||
| 18 | "github.com/bnema/vev/internal/usecase/layout" | 18 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 19 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 20 | ) | 19 | ) | ||
| 21 | 20 | ||||
| 22 | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | 21 | func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) { | ||
| @@ -151,7 +150,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) { | |||||
| 151 | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | 150 | tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10} | ||
| 152 | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | 151 | tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10} | ||
| 153 | tr, _ := newCapturingTransport(t) | 152 | tr, _ := newCapturingTransport(t) | ||
| 154 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 41, Rows: 12}} | 153 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 41, Rows: 12}} |
| 155 | ac.initOverlays() | 154 | ac.initOverlays() | ||
| 156 | ac.overlays.copyMode = &scopy.Mode{} | 155 | ac.overlays.copyMode = &scopy.Mode{} | ||
| 157 | 156 | ||||
M internal/usecase/daemon/picker_test.go +9 -10
| @@ -15,7 +15,6 @@ import ( | |||
| 15 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| 16 | 16 | "github.com/bnema/vev/internal/usecase/layout" | |
| 17 | 17 | "github.com/bnema/vev/internal/usecase/picker" | |
| 18 | - | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 18 | ) | |
| 20 | 19 | ||
| 21 | 20 | // --- test doubles ----------------------------------------------------------- | |
| @@ -172,9 +171,9 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||
| 172 | 171 | d := newTestDaemon(t, nil, stubClock{}) | |
| 173 | 172 | tr1, sends1 := newCapturingTransport(t) | |
| 174 | 173 | tr2, sends2 := newCapturingTransport(t) | |
| 175 | - | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 174 | + | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 176 | 175 | ac1.initOverlays() | |
| 177 | - | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 176 | + | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 178 | 177 | ac2.initOverlays() | |
| 179 | 178 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 180 | 179 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| @@ -214,9 +213,9 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||
| 214 | 213 | d := newTestDaemon(t, nil, stubClock{}) | |
| 215 | 214 | tr1, sends1 := newCapturingTransport(t) | |
| 216 | 215 | tr2, _ := newCapturingTransport(t) | |
| 217 | - | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 216 | + | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 218 | 217 | ac1.initOverlays() | |
| 219 | - | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 218 | + | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 220 | 219 | ac2.initOverlays() | |
| 221 | 220 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 222 | 221 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| @@ -263,7 +262,7 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||
| 263 | 262 | return nil | |
| 264 | 263 | }).Once() | |
| 265 | 264 | tr.EXPECT().Close().Return(nil).Maybe() | |
| 266 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 265 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 267 | 266 | ac.initOverlays() | |
| 268 | 267 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 269 | 268 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| @@ -324,8 +323,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||
| 324 | 323 | d := newTestDaemon(t, f, stubClock{}) | |
| 325 | 324 | tr1, _ := newCapturingTransport(t) | |
| 326 | 325 | tr2, _ := newCapturingTransport(t) | |
| 327 | - | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 328 | - | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 326 | + | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 327 | + | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 329 | 328 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 330 | 329 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| 331 | 330 | defer cancel1() | |
| @@ -425,9 +424,9 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) { | |||
| 425 | 424 | d := newTestDaemon(t, nil, stubClock{}) | |
| 426 | 425 | tr1, sends1 := newCapturingTransport(t) | |
| 427 | 426 | tr2, _ := newCapturingTransport(t) | |
| 428 | - | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 427 | + | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 429 | 428 | ac1.initOverlays() | |
| 430 | - | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | |
| 429 | + | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} | |
| 431 | 430 | ac2.initOverlays() | |
| 432 | 431 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | |
| 433 | 432 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | |
| @@ -15,7 +15,6 @@ import ( | |||||
| 15 | "github.com/bnema/vev/internal/usecase/keys" | 15 | "github.com/bnema/vev/internal/usecase/keys" | ||
| 16 | "github.com/bnema/vev/internal/usecase/layout" | 16 | "github.com/bnema/vev/internal/usecase/layout" | ||
| 17 | "github.com/bnema/vev/internal/usecase/picker" | 17 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 18 | - | "github.com/bnema/vev/pkg/renderer" | |||
| 19 | ) | 18 | ) | ||
| 20 | 19 | ||||
| 21 | // --- test doubles ----------------------------------------------------------- | 20 | // --- test doubles ----------------------------------------------------------- | ||
| @@ -172,9 +171,9 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) { | |||||
| 172 | d := newTestDaemon(t, nil, stubClock{}) | 171 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 173 | tr1, sends1 := newCapturingTransport(t) | 172 | tr1, sends1 := newCapturingTransport(t) | ||
| 174 | tr2, sends2 := newCapturingTransport(t) | 173 | tr2, sends2 := newCapturingTransport(t) | ||
| 175 | - | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 174 | + | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 176 | ac1.initOverlays() | 175 | ac1.initOverlays() | ||
| 177 | - | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 176 | + | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 178 | ac2.initOverlays() | 177 | ac2.initOverlays() | ||
| 179 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | 178 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | ||
| 180 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 179 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| @@ -214,9 +213,9 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) { | |||||
| 214 | d := newTestDaemon(t, nil, stubClock{}) | 213 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 215 | tr1, sends1 := newCapturingTransport(t) | 214 | tr1, sends1 := newCapturingTransport(t) | ||
| 216 | tr2, _ := newCapturingTransport(t) | 215 | tr2, _ := newCapturingTransport(t) | ||
| 217 | - | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 216 | + | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 218 | ac1.initOverlays() | 217 | ac1.initOverlays() | ||
| 219 | - | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 218 | + | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 220 | ac2.initOverlays() | 219 | ac2.initOverlays() | ||
| 221 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | 220 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | ||
| 222 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 221 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| @@ -263,7 +262,7 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) { | |||||
| 263 | return nil | 262 | return nil | ||
| 264 | }).Once() | 263 | }).Once() | ||
| 265 | tr.EXPECT().Close().Return(nil).Maybe() | 264 | tr.EXPECT().Close().Return(nil).Maybe() | ||
| 266 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 265 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 267 | ac.initOverlays() | 266 | ac.initOverlays() | ||
| 268 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | 267 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | ||
| 269 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 268 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| @@ -324,8 +323,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) { | |||||
| 324 | d := newTestDaemon(t, f, stubClock{}) | 323 | d := newTestDaemon(t, f, stubClock{}) | ||
| 325 | tr1, _ := newCapturingTransport(t) | 324 | tr1, _ := newCapturingTransport(t) | ||
| 326 | tr2, _ := newCapturingTransport(t) | 325 | tr2, _ := newCapturingTransport(t) | ||
| 327 | - | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 326 | + | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 328 | - | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 327 | + | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 329 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | 328 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | ||
| 330 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 329 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
| 331 | defer cancel1() | 330 | defer cancel1() | ||
| @@ -425,9 +424,9 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) { | |||||
| 425 | d := newTestDaemon(t, nil, stubClock{}) | 424 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 426 | tr1, sends1 := newCapturingTransport(t) | 425 | tr1, sends1 := newCapturingTransport(t) | ||
| 427 | tr2, _ := newCapturingTransport(t) | 426 | tr2, _ := newCapturingTransport(t) | ||
| 428 | - | ac1 := &attachedClient{tr: tr1, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 427 | + | ac1 := &attachedClient{tr: tr1, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 429 | ac1.initOverlays() | 428 | ac1.initOverlays() | ||
| 430 | - | ac2 := &attachedClient{tr: tr2, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 80, Rows: 24}} | 429 | + | ac2 := &attachedClient{tr: tr2, output: newOutputStateStream(), size: domain.Size{Cols: 80, Rows: 24}} |
| 431 | ac2.initOverlays() | 430 | ac2.initOverlays() | ||
| 432 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | 431 | sctx1, cancel1 := context.WithCancel(d.serveCtx) | ||
| 433 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | 432 | sctx2, cancel2 := context.WithCancel(d.serveCtx) | ||
M internal/usecase/daemon/render.go +12 -16
| @@ -286,13 +286,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 286 | 286 | } | |
| 287 | 287 | // Ack-gated coalescing: while the client is at least maxUnackedOutputStates | |
| 288 | 288 | // behind, skip composing another diff and mark the paint deferred. The | |
| 289 | - | // MsgOutputAck handler flushes one cumulative paint once acks catch up. | |
| 290 | - | // A reset paint is an explicit full invalidation that supersedes any queued | |
| 291 | - | // diffs, so it bypasses the gate and clears the flag. | |
| 292 | - | if reset { | |
| 293 | - | ac.paintDeferred = false | |
| 294 | - | } else if ac.outputLagAtCapLocked() { | |
| 295 | - | ac.paintDeferred = true | |
| 289 | + | // MsgOutputAck handler flushes one cumulative paint once acks catch up. A | |
| 290 | + | // deferred reset stays sticky so the cumulative paint is dependency-free. | |
| 291 | + | if ac.output.deferIfAtCapacity(reset) { | |
| 296 | 292 | ac.sendMu.Unlock() | |
| 297 | 293 | return | |
| 298 | 294 | } | |
| @@ -332,10 +328,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 332 | 328 | ac.sendMu.Unlock() | |
| 333 | 329 | return | |
| 334 | 330 | } | |
| 335 | - | reset = ac.shouldResetOutputState(reset) | |
| 336 | 331 | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | |
| 337 | 332 | if reset || overlayActive { | |
| 338 | - | ac.rend.Reset() | |
| 339 | 333 | ac.bars.Reset() | |
| 340 | 334 | ac.composed.invalidate() | |
| 341 | 335 | } | |
| @@ -384,7 +378,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 384 | 378 | ac.sendMu.Unlock() | |
| 385 | 379 | return | |
| 386 | 380 | } | |
| 387 | - | data, err := ac.rend.Draw(frame, damage) | |
| 381 | + | prepared, err := ac.output.prepare(frame, damage, reset || overlayActive) | |
| 382 | + | var data []byte | |
| 383 | + | if err == nil { | |
| 384 | + | data = prepared.data | |
| 385 | + | } | |
| 388 | 386 | var cursorTail []byte | |
| 389 | 387 | if err == nil { | |
| 390 | 388 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | |
| @@ -400,13 +398,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 400 | 398 | if sendTr == nil { | |
| 401 | 399 | serr = errors.New("client transport is nil") | |
| 402 | 400 | } else { | |
| 403 | - | out := ac.nextOutputFrameLocked(data, reset) | |
| 404 | - | if ac.advanceOutputOnAck && ac.outputFrames != nil { | |
| 405 | - | if msg, derr := ports.UnmarshalOutput(out.Payload); derr == nil { | |
| 406 | - | ac.outputFrames[msg.NewStateNum] = frame.Clone() | |
| 407 | - | } | |
| 401 | + | send := sendTr.Send | |
| 402 | + | if async, ok := sendTr.(ports.AsyncTransport); ok { | |
| 403 | + | send = async.SendAsync | |
| 408 | 404 | } | |
| 409 | - | serr = sendTr.Send(out) | |
| 405 | + | serr = prepared.send(data, ac.echoAck.Load(), send) | |
| 410 | 406 | } | |
| 411 | 407 | } | |
| 412 | 408 | } | |
| @@ -286,13 +286,9 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 286 | } | 286 | } | ||
| 287 | // Ack-gated coalescing: while the client is at least maxUnackedOutputStates | 287 | // Ack-gated coalescing: while the client is at least maxUnackedOutputStates | ||
| 288 | // behind, skip composing another diff and mark the paint deferred. The | 288 | // behind, skip composing another diff and mark the paint deferred. The | ||
| 289 | - | // MsgOutputAck handler flushes one cumulative paint once acks catch up. | 289 | + | // MsgOutputAck handler flushes one cumulative paint once acks catch up. A |
| 290 | - | // A reset paint is an explicit full invalidation that supersedes any queued | 290 | + | // deferred reset stays sticky so the cumulative paint is dependency-free. |
| 291 | - | // diffs, so it bypasses the gate and clears the flag. | 291 | + | if ac.output.deferIfAtCapacity(reset) { |
| 292 | - | if reset { | |||
| 293 | - | ac.paintDeferred = false | |||
| 294 | - | } else if ac.outputLagAtCapLocked() { | |||
| 295 | - | ac.paintDeferred = true | |||
| 296 | ac.sendMu.Unlock() | 292 | ac.sendMu.Unlock() | ||
| 297 | return | 293 | return | ||
| 298 | } | 294 | } | ||
| @@ -332,10 +328,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 332 | ac.sendMu.Unlock() | 328 | ac.sendMu.Unlock() | ||
| 333 | return | 329 | return | ||
| 334 | } | 330 | } | ||
| 335 | - | reset = ac.shouldResetOutputState(reset) | |||
| 336 | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | 331 | overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive | ||
| 337 | if reset || overlayActive { | 332 | if reset || overlayActive { | ||
| 338 | - | ac.rend.Reset() | |||
| 339 | ac.bars.Reset() | 333 | ac.bars.Reset() | ||
| 340 | ac.composed.invalidate() | 334 | ac.composed.invalidate() | ||
| 341 | } | 335 | } | ||
| @@ -384,7 +378,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 384 | ac.sendMu.Unlock() | 378 | ac.sendMu.Unlock() | ||
| 385 | return | 379 | return | ||
| 386 | } | 380 | } | ||
| 387 | - | data, err := ac.rend.Draw(frame, damage) | 381 | + | prepared, err := ac.output.prepare(frame, damage, reset || overlayActive) |
| 382 | + | var data []byte | |||
| 383 | + | if err == nil { | |||
| 384 | + | data = prepared.data | |||
| 385 | + | } | |||
| 388 | var cursorTail []byte | 386 | var cursorTail []byte | ||
| 389 | if err == nil { | 387 | if err == nil { | ||
| 390 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | 388 | cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0) | ||
| @@ -400,13 +398,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 400 | if sendTr == nil { | 398 | if sendTr == nil { | ||
| 401 | serr = errors.New("client transport is nil") | 399 | serr = errors.New("client transport is nil") | ||
| 402 | } else { | 400 | } else { | ||
| 403 | - | out := ac.nextOutputFrameLocked(data, reset) | 401 | + | send := sendTr.Send |
| 404 | - | if ac.advanceOutputOnAck && ac.outputFrames != nil { | 402 | + | if async, ok := sendTr.(ports.AsyncTransport); ok { |
| 405 | - | if msg, derr := ports.UnmarshalOutput(out.Payload); derr == nil { | 403 | + | send = async.SendAsync |
| 406 | - | ac.outputFrames[msg.NewStateNum] = frame.Clone() | |||
| 407 | - | } | |||
| 408 | } | 404 | } | ||
| 409 | - | serr = sendTr.Send(out) | 405 | + | serr = prepared.send(data, ac.echoAck.Load(), send) |
| 410 | } | 406 | } | ||
| 411 | } | 407 | } | ||
| 412 | } | 408 | } | ||
M internal/usecase/daemon/render_test.go +12 -12
| @@ -222,7 +222,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) { | |||
| 222 | 222 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 223 | 223 | sctx, cancel := context.WithCancel(context.Background()) | |
| 224 | 224 | win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5}) | |
| 225 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 225 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 226 | 226 | ac.initOverlays() | |
| 227 | 227 | sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 228 | 228 | ac.setSession(sess) | |
| @@ -286,7 +286,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||
| 286 | 286 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 287 | 287 | ||
| 288 | 288 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 289 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 289 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 290 | 290 | ac.initOverlays() | |
| 291 | 291 | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | |
| 292 | 292 | ac.setSession(sess) | |
| @@ -397,7 +397,7 @@ func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) { | |||
| 397 | 397 | func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) { | |
| 398 | 398 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 399 | 399 | sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0} | |
| 400 | - | ac := &attachedClient{rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 10, Rows: 4}} | |
| 400 | + | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 10, Rows: 4}} | |
| 401 | 401 | ac.initOverlays() | |
| 402 | 402 | sess.client = ac | |
| 403 | 403 | ac.setSession(sess) | |
| @@ -406,7 +406,7 @@ func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) { | |||
| 406 | 406 | tb.focusedPane().screen.Write([]byte("x")) | |
| 407 | 407 | d.render(sess, tb, tb.focusedPane()) | |
| 408 | 408 | ||
| 409 | - | require.Zero(t, ac.nextStateNum) | |
| 409 | + | require.Zero(t, ac.output.next) | |
| 410 | 410 | } | |
| 411 | 411 | ||
| 412 | 412 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | |
| @@ -560,7 +560,7 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |||
| 560 | 560 | left.screen.Write([]byte("left")) | |
| 561 | 561 | right.screen.Write([]byte("right")) | |
| 562 | 562 | sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0} | |
| 563 | - | ac := &attachedClient{tr: discardTransport{}, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 81, Rows: 26}} | |
| 563 | + | ac := &attachedClient{tr: discardTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 81, Rows: 26}} | |
| 564 | 564 | ac.initOverlays() | |
| 565 | 565 | sess.client = ac | |
| 566 | 566 | ac.setSession(sess) | |
| @@ -576,7 +576,7 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |||
| 576 | 576 | d.paint(sess, ac, false) | |
| 577 | 577 | // Mirror the client ACK pump so the steady state measures compose→diff→send, | |
| 578 | 578 | // not the ack-gate bailout after maxUnackedOutputStates frames. | |
| 579 | - | ac.advanceOutputAck(ac.nextStateNum) | |
| 579 | + | ac.ackOutputState(ac.output.next) | |
| 580 | 580 | } | |
| 581 | 581 | } | |
| 582 | 582 | ||
| @@ -849,7 +849,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||
| 849 | 849 | }).Maybe() | |
| 850 | 850 | ||
| 851 | 851 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 852 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 852 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 853 | 853 | ac.initOverlays() | |
| 854 | 854 | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | |
| 855 | 855 | ac.setSession(sess) | |
| @@ -875,7 +875,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||
| 875 | 875 | d := newTestDaemon(t, nil, stubClock{}) | |
| 876 | 876 | win := newTab(p, domain.Size{Cols: 20, Rows: 5}) | |
| 877 | 877 | sctx, cancel := context.WithCancel(context.Background()) | |
| 878 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 878 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 879 | 879 | ac.initOverlays() | |
| 880 | 880 | sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 881 | 881 | ac.setSession(sess) | |
| @@ -927,7 +927,7 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||
| 927 | 927 | win.ctx, win.cancel = sctx, cancel | |
| 928 | 928 | p := win.focusedPane() | |
| 929 | 929 | p.ctx, p.cancel = sctx, cancel | |
| 930 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 930 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 931 | 931 | ac.initOverlays() | |
| 932 | 932 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 933 | 933 | ac.setSession(sess) | |
| @@ -988,7 +988,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||
| 988 | 988 | tr, sends := newCapturingTransport(t) | |
| 989 | 989 | sctx, cancel := context.WithCancel(context.Background()) | |
| 990 | 990 | win := newTestTabWithContext(p, sctx, cancel) | |
| 991 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 991 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 992 | 992 | ac.initOverlays() | |
| 993 | 993 | sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 994 | 994 | ac.setSession(sess) | |
| @@ -1094,7 +1094,7 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) { | |||
| 1094 | 1094 | tr, sends := newCapturingTransport(t) | |
| 1095 | 1095 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1096 | 1096 | win := newTestTabWithContext(p, sctx, cancel) | |
| 1097 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 1097 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1098 | 1098 | ac.initOverlays() | |
| 1099 | 1099 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1100 | 1100 | ac.setSession(sess) | |
| @@ -1139,7 +1139,7 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) { | |||
| 1139 | 1139 | tr, sends := newCapturingTransport(t) | |
| 1140 | 1140 | sctx, cancel := context.WithCancel(context.Background()) | |
| 1141 | 1141 | win := newTestTabWithContext(p, sctx, cancel) | |
| 1142 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | |
| 1142 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 1143 | 1143 | ac.initOverlays() | |
| 1144 | 1144 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | |
| 1145 | 1145 | ac.setSession(sess) | |
| @@ -222,7 +222,7 @@ func TestSchedulerDebounceCoalesces(t *testing.T) { | |||||
| 222 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | 222 | d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 223 | sctx, cancel := context.WithCancel(context.Background()) | 223 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 224 | win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5}) | 224 | win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 5}) | ||
| 225 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 225 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 226 | ac.initOverlays() | 226 | ac.initOverlays() | ||
| 227 | sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 227 | sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 228 | ac.setSession(sess) | 228 | ac.setSession(sess) | ||
| @@ -286,7 +286,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) { | |||||
| 286 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | 286 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | ||
| 287 | 287 | ||||
| 288 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | 288 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 289 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 289 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 290 | ac.initOverlays() | 290 | ac.initOverlays() | ||
| 291 | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | 291 | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | ||
| 292 | ac.setSession(sess) | 292 | ac.setSession(sess) | ||
| @@ -397,7 +397,7 @@ func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) { | |||||
| 397 | func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) { | 397 | func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) { | ||
| 398 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | 398 | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | ||
| 399 | sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0} | 399 | sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0} | ||
| 400 | - | ac := &attachedClient{rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 10, Rows: 4}} | 400 | + | ac := &attachedClient{output: newOutputStateStream(), size: domain.Size{Cols: 10, Rows: 4}} |
| 401 | ac.initOverlays() | 401 | ac.initOverlays() | ||
| 402 | sess.client = ac | 402 | sess.client = ac | ||
| 403 | ac.setSession(sess) | 403 | ac.setSession(sess) | ||
| @@ -406,7 +406,7 @@ func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) { | |||||
| 406 | tb.focusedPane().screen.Write([]byte("x")) | 406 | tb.focusedPane().screen.Write([]byte("x")) | ||
| 407 | d.render(sess, tb, tb.focusedPane()) | 407 | d.render(sess, tb, tb.focusedPane()) | ||
| 408 | 408 | ||||
| 409 | - | require.Zero(t, ac.nextStateNum) | 409 | + | require.Zero(t, ac.output.next) |
| 410 | } | 410 | } | ||
| 411 | 411 | ||||
| 412 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | 412 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | ||
| @@ -560,7 +560,7 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |||||
| 560 | left.screen.Write([]byte("left")) | 560 | left.screen.Write([]byte("left")) | ||
| 561 | right.screen.Write([]byte("right")) | 561 | right.screen.Write([]byte("right")) | ||
| 562 | sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0} | 562 | sess := &session{id: "s", name: "work", tabs: []*tab{win}, active: 0} | ||
| 563 | - | ac := &attachedClient{tr: discardTransport{}, rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 81, Rows: 26}} | 563 | + | ac := &attachedClient{tr: discardTransport{}, output: newOutputStateStream(), size: domain.Size{Cols: 81, Rows: 26}} |
| 564 | ac.initOverlays() | 564 | ac.initOverlays() | ||
| 565 | sess.client = ac | 565 | sess.client = ac | ||
| 566 | ac.setSession(sess) | 566 | ac.setSession(sess) | ||
| @@ -576,7 +576,7 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) { | |||||
| 576 | d.paint(sess, ac, false) | 576 | d.paint(sess, ac, false) | ||
| 577 | // Mirror the client ACK pump so the steady state measures compose→diff→send, | 577 | // Mirror the client ACK pump so the steady state measures compose→diff→send, | ||
| 578 | // not the ack-gate bailout after maxUnackedOutputStates frames. | 578 | // not the ack-gate bailout after maxUnackedOutputStates frames. | ||
| 579 | - | ac.advanceOutputAck(ac.nextStateNum) | 579 | + | ac.ackOutputState(ac.output.next) |
| 580 | } | 580 | } | ||
| 581 | } | 581 | } | ||
| 582 | 582 | ||||
| @@ -849,7 +849,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) { | |||||
| 849 | }).Maybe() | 849 | }).Maybe() | ||
| 850 | 850 | ||||
| 851 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | 851 | d := New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil))) | ||
| 852 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 852 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 853 | ac.initOverlays() | 853 | ac.initOverlays() | ||
| 854 | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | 854 | sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac} | ||
| 855 | ac.setSession(sess) | 855 | ac.setSession(sess) | ||
| @@ -875,7 +875,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) { | |||||
| 875 | d := newTestDaemon(t, nil, stubClock{}) | 875 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 876 | win := newTab(p, domain.Size{Cols: 20, Rows: 5}) | 876 | win := newTab(p, domain.Size{Cols: 20, Rows: 5}) | ||
| 877 | sctx, cancel := context.WithCancel(context.Background()) | 877 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 878 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 878 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 879 | ac.initOverlays() | 879 | ac.initOverlays() | ||
| 880 | sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 880 | sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 881 | ac.setSession(sess) | 881 | ac.setSession(sess) | ||
| @@ -927,7 +927,7 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T) | |||||
| 927 | win.ctx, win.cancel = sctx, cancel | 927 | win.ctx, win.cancel = sctx, cancel | ||
| 928 | p := win.focusedPane() | 928 | p := win.focusedPane() | ||
| 929 | p.ctx, p.cancel = sctx, cancel | 929 | p.ctx, p.cancel = sctx, cancel | ||
| 930 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 930 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 931 | ac.initOverlays() | 931 | ac.initOverlays() | ||
| 932 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 932 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 933 | ac.setSession(sess) | 933 | ac.setSession(sess) | ||
| @@ -988,7 +988,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) { | |||||
| 988 | tr, sends := newCapturingTransport(t) | 988 | tr, sends := newCapturingTransport(t) | ||
| 989 | sctx, cancel := context.WithCancel(context.Background()) | 989 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 990 | win := newTestTabWithContext(p, sctx, cancel) | 990 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 991 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 991 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 992 | ac.initOverlays() | 992 | ac.initOverlays() | ||
| 993 | sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 993 | sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 994 | ac.setSession(sess) | 994 | ac.setSession(sess) | ||
| @@ -1094,7 +1094,7 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) { | |||||
| 1094 | tr, sends := newCapturingTransport(t) | 1094 | tr, sends := newCapturingTransport(t) | ||
| 1095 | sctx, cancel := context.WithCancel(context.Background()) | 1095 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1096 | win := newTestTabWithContext(p, sctx, cancel) | 1096 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 1097 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 1097 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 1098 | ac.initOverlays() | 1098 | ac.initOverlays() | ||
| 1099 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 1099 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 1100 | ac.setSession(sess) | 1100 | ac.setSession(sess) | ||
| @@ -1139,7 +1139,7 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) { | |||||
| 1139 | tr, sends := newCapturingTransport(t) | 1139 | tr, sends := newCapturingTransport(t) | ||
| 1140 | sctx, cancel := context.WithCancel(context.Background()) | 1140 | sctx, cancel := context.WithCancel(context.Background()) | ||
| 1141 | win := newTestTabWithContext(p, sctx, cancel) | 1141 | win := newTestTabWithContext(p, sctx, cancel) | ||
| 1142 | - | ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})} | 1142 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 1143 | ac.initOverlays() | 1143 | ac.initOverlays() | ||
| 1144 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | 1144 | sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac} | ||
| 1145 | ac.setSession(sess) | 1145 | ac.setSession(sess) | ||
M internal/usecase/daemon/resume.go +32 -0
| @@ -120,6 +120,34 @@ func (d *Daemon) purgeParkedForSessionLocked(sess *session) { | |||
| 120 | 120 | } | |
| 121 | 121 | } | |
| 122 | 122 | ||
| 123 | + | // resumeParked acquires the parked client's output lock before the daemon | |
| 124 | + | // registry lock, preserving the global sendMu > Daemon.mu ordering. The parked | |
| 125 | + | // entry is revalidated after both locks are held because it may expire between | |
| 126 | + | // the initial lookup and lock acquisition. | |
| 127 | + | func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) { | |
| 128 | + | d.mu.Lock() | |
| 129 | + | parked := d.parked[h.ResumeToken] | |
| 130 | + | d.mu.Unlock() | |
| 131 | + | if parked == nil || h.ResumeToken == 0 { | |
| 132 | + | return nil, nil, false, nil | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | ac := parked.ac | |
| 136 | + | ac.sendMu.Lock() | |
| 137 | + | defer ac.sendMu.Unlock() | |
| 138 | + | d.mu.Lock() | |
| 139 | + | defer d.mu.Unlock() | |
| 140 | + | if d.closing { | |
| 141 | + | return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 142 | + | } | |
| 143 | + | if d.parked[h.ResumeToken] != parked { | |
| 144 | + | return nil, nil, false, nil | |
| 145 | + | } | |
| 146 | + | return d.resumeParkedLocked(h, tr, sz) | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu | |
| 150 | + | // and d.mu in that order. | |
| 123 | 151 | func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) { | |
| 124 | 152 | parked := d.parked[h.ResumeToken] | |
| 125 | 153 | if parked == nil || h.ResumeToken == 0 { | |
| @@ -143,6 +171,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||
| 143 | 171 | delete(d.parked, h.ResumeToken) | |
| 144 | 172 | parked.timer.Stop() | |
| 145 | 173 | parked.closeDone() | |
| 174 | + | // The parked attachment has no session owner and cannot paint. Retire the | |
| 175 | + | // abandoned transport's output chain before binding the replacement so the | |
| 176 | + | // mandatory first paint cannot be blocked by ACKs that died with the link. | |
| 177 | + | ac.output.rebase() | |
| 146 | 178 | ac.replaceTransport(tr) | |
| 147 | 179 | ac.size = sz | |
| 148 | 180 | ac.resumeToken = d.nextResumeTokenLocked() | |
| @@ -120,6 +120,34 @@ func (d *Daemon) purgeParkedForSessionLocked(sess *session) { | |||||
| 120 | } | 120 | } | ||
| 121 | } | 121 | } | ||
| 122 | 122 | ||||
| 123 | + | // resumeParked acquires the parked client's output lock before the daemon | |||
| 124 | + | // registry lock, preserving the global sendMu > Daemon.mu ordering. The parked | |||
| 125 | + | // entry is revalidated after both locks are held because it may expire between | |||
| 126 | + | // the initial lookup and lock acquisition. | |||
| 127 | + | func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) { | |||
| 128 | + | d.mu.Lock() | |||
| 129 | + | parked := d.parked[h.ResumeToken] | |||
| 130 | + | d.mu.Unlock() | |||
| 131 | + | if parked == nil || h.ResumeToken == 0 { | |||
| 132 | + | return nil, nil, false, nil | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | ac := parked.ac | |||
| 136 | + | ac.sendMu.Lock() | |||
| 137 | + | defer ac.sendMu.Unlock() | |||
| 138 | + | d.mu.Lock() | |||
| 139 | + | defer d.mu.Unlock() | |||
| 140 | + | if d.closing { | |||
| 141 | + | return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |||
| 142 | + | } | |||
| 143 | + | if d.parked[h.ResumeToken] != parked { | |||
| 144 | + | return nil, nil, false, nil | |||
| 145 | + | } | |||
| 146 | + | return d.resumeParkedLocked(h, tr, sz) | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu | |||
| 150 | + | // and d.mu in that order. | |||
| 123 | func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) { | 151 | func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) { | ||
| 124 | parked := d.parked[h.ResumeToken] | 152 | parked := d.parked[h.ResumeToken] | ||
| 125 | if parked == nil || h.ResumeToken == 0 { | 153 | if parked == nil || h.ResumeToken == 0 { | ||
| @@ -143,6 +171,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain | |||||
| 143 | delete(d.parked, h.ResumeToken) | 171 | delete(d.parked, h.ResumeToken) | ||
| 144 | parked.timer.Stop() | 172 | parked.timer.Stop() | ||
| 145 | parked.closeDone() | 173 | parked.closeDone() | ||
| 174 | + | // The parked attachment has no session owner and cannot paint. Retire the | |||
| 175 | + | // abandoned transport's output chain before binding the replacement so the | |||
| 176 | + | // mandatory first paint cannot be blocked by ACKs that died with the link. | |||
| 177 | + | ac.output.rebase() | |||
| 146 | ac.replaceTransport(tr) | 178 | ac.replaceTransport(tr) | ||
| 147 | ac.size = sz | 179 | ac.size = sz | ||
| 148 | ac.resumeToken = d.nextResumeTokenLocked() | 180 | ac.resumeToken = d.nextResumeTokenLocked() | ||
M internal/usecase/daemon/resume_test.go +57 -24
| @@ -127,19 +127,19 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||
| 127 | 127 | } | |
| 128 | 128 | ||
| 129 | 129 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |
| 130 | - | ac := &attachedClient{} | |
| 131 | - | ac.nextStateNum = 3 | |
| 132 | - | ac.advanceOutputAck(3) | |
| 133 | - | ac.advanceOutputAck(2) | |
| 134 | - | ac.advanceOutputAck(4) | |
| 135 | - | require.Equal(t, uint64(3), ac.outputAck.Load(), "stale or future ACKs must not move output state incorrectly") | |
| 130 | + | ac := &attachedClient{output: newOutputStateStream()} | |
| 131 | + | ac.output.next = 3 | |
| 132 | + | ac.ackOutputState(3) | |
| 133 | + | ac.ackOutputState(2) | |
| 134 | + | ac.ackOutputState(4) | |
| 135 | + | require.Equal(t, uint64(3), ac.output.acked, "stale or future ACKs must not move output state incorrectly") | |
| 136 | 136 | ||
| 137 | 137 | ac.sendMu.Lock() | |
| 138 | - | ac.nextStateNum = 5 | |
| 139 | - | reset := ac.shouldResetOutputState(false) | |
| 138 | + | ac.output.next = 5 | |
| 139 | + | reset := false | |
| 140 | 140 | require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint") | |
| 141 | 141 | ||
| 142 | - | f := ac.nextOutputFrameLocked([]byte("incremental while reliable backlog drains"), reset) | |
| 142 | + | f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0) | |
| 143 | 143 | ac.sendMu.Unlock() | |
| 144 | 144 | out, err := ports.UnmarshalOutput(f.Payload) | |
| 145 | 145 | require.NoError(t, err) | |
| @@ -147,9 +147,9 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |||
| 147 | 147 | require.Equal(t, uint64(6), out.NewStateNum) | |
| 148 | 148 | ||
| 149 | 149 | ac.sendMu.Lock() | |
| 150 | - | reset = ac.shouldResetOutputState(true) | |
| 150 | + | reset = true | |
| 151 | 151 | require.True(t, reset, "explicit reset should still force full repaint") | |
| 152 | - | full := ac.nextOutputFrameLocked([]byte("explicit full repaint"), reset) | |
| 152 | + | full := ac.output.frame([]byte("explicit full repaint"), reset, 0) | |
| 153 | 153 | ac.sendMu.Unlock() | |
| 154 | 154 | fullOut, err := ports.UnmarshalOutput(full.Payload) | |
| 155 | 155 | require.NoError(t, err) | |
| @@ -170,8 +170,8 @@ func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) { | |||
| 170 | 170 | ||
| 171 | 171 | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | |
| 172 | 172 | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | |
| 173 | + | _, _, ok, err := d.resumeParked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 173 | 174 | d.mu.Lock() | |
| 174 | - | _, _, ok, err := d.resumeParkedLocked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 175 | 175 | _, stillParked := d.parked[token] | |
| 176 | 176 | d.mu.Unlock() | |
| 177 | 177 | require.Error(t, err) | |
| @@ -200,9 +200,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||
| 200 | 200 | require.True(t, d.parkAttachment(sess, ac)) | |
| 201 | 201 | ||
| 202 | 202 | newTr := &closeTrackingTransport{} | |
| 203 | - | d.mu.Lock() | |
| 204 | - | resumedSess, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24}) | |
| 205 | - | d.mu.Unlock() | |
| 203 | + | resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24}) | |
| 206 | 204 | require.NoError(t, err) | |
| 207 | 205 | require.True(t, ok) | |
| 208 | 206 | require.Same(t, sess, resumedSess) | |
| @@ -214,6 +212,41 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||
| 214 | 212 | require.Same(t, newTr, ac.transport()) | |
| 215 | 213 | } | |
| 216 | 214 | ||
| 215 | + | func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |
| 216 | + | pty, release := newBlockingPTY(t) | |
| 217 | + | defer release() | |
| 218 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 219 | + | oldTr := &closeTrackingTransport{} | |
| 220 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |
| 221 | + | require.NoError(t, err) | |
| 222 | + | ac.output.next = maxUnackedOutputStates | |
| 223 | + | token := ac.resumeToken | |
| 224 | + | require.True(t, sess.detachIfCurrent(ac)) | |
| 225 | + | require.True(t, d.parkAttachment(sess, ac)) | |
| 226 | + | ||
| 227 | + | newTr := &closeTrackingTransport{} | |
| 228 | + | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | |
| 229 | + | require.NoError(t, err) | |
| 230 | + | require.Same(t, ac, resumedAC) | |
| 231 | + | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | |
| 232 | + | ||
| 233 | + | sends := newTr.Sends() | |
| 234 | + | require.Len(t, sends, 1) | |
| 235 | + | first, err := ports.UnmarshalOutput(sends[0].Payload) | |
| 236 | + | require.NoError(t, err) | |
| 237 | + | require.Zero(t, first.BaseStateNum) | |
| 238 | + | require.Equal(t, uint64(maxUnackedOutputStates+1), first.NewStateNum) | |
| 239 | + | resumedAC.ackOutputState(first.NewStateNum) | |
| 240 | + | ||
| 241 | + | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | |
| 242 | + | d.paint(resumedSess, resumedAC, false) | |
| 243 | + | sends = newTr.Sends() | |
| 244 | + | require.Len(t, sends, 2) | |
| 245 | + | second, err := ports.UnmarshalOutput(sends[1].Payload) | |
| 246 | + | require.NoError(t, err) | |
| 247 | + | require.Equal(t, first.NewStateNum, second.BaseStateNum) | |
| 248 | + | } | |
| 249 | + | ||
| 217 | 250 | func TestExplicitDetachDoesNotPark(t *testing.T) { | |
| 218 | 251 | pty, release := newBlockingPTY(t) | |
| 219 | 252 | defer release() | |
| @@ -331,8 +364,8 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) { | |||
| 331 | 364 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 332 | 365 | d.mu.Lock() | |
| 333 | 366 | _, parked := d.parked[token] | |
| 334 | - | _, _, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 335 | 367 | d.mu.Unlock() | |
| 368 | + | _, _, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 336 | 369 | require.False(t, parked, "killSession purges parked token") | |
| 337 | 370 | require.NoError(t, err) | |
| 338 | 371 | require.False(t, ok, "killed session cannot be resumed") | |
| @@ -355,8 +388,8 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) { | |||
| 355 | 388 | ||
| 356 | 389 | d.mu.Lock() | |
| 357 | 390 | _, parked := d.parked[token] | |
| 358 | - | _, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 359 | 391 | d.mu.Unlock() | |
| 392 | + | _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 360 | 393 | require.False(t, parked, "normal attach invalidates stale parked token") | |
| 361 | 394 | require.NoError(t, err) | |
| 362 | 395 | require.False(t, ok) | |
| @@ -382,10 +415,10 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | |||
| 382 | 415 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | |
| 383 | 416 | } | |
| 384 | 417 | ||
| 385 | - | func TestOutputStateNumberingIsSharedAndMonotone(t *testing.T) { | |
| 418 | + | func TestRawTerminalSideEffectsAreOutputStateNeutral(t *testing.T) { | |
| 386 | 419 | d := newTestDaemon(t, nil, stubClock{}) | |
| 387 | 420 | tr := &closeTrackingTransport{} | |
| 388 | - | ac := &attachedClient{tr: tr} | |
| 421 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} | |
| 389 | 422 | ac.initOverlays() | |
| 390 | 423 | ||
| 391 | 424 | require.NoError(t, d.boundedSendOutputErr(ac, []byte("copy"))) | |
| @@ -397,8 +430,10 @@ func TestOutputStateNumberingIsSharedAndMonotone(t *testing.T) { | |||
| 397 | 430 | require.NoError(t, err) | |
| 398 | 431 | second, err := ports.UnmarshalOutput(sends[1].Payload) | |
| 399 | 432 | require.NoError(t, err) | |
| 400 | - | require.Equal(t, uint64(1), first.NewStateNum) | |
| 401 | - | require.Equal(t, uint64(2), second.NewStateNum) | |
| 433 | + | require.Zero(t, first.BaseStateNum) | |
| 434 | + | require.Zero(t, first.NewStateNum) | |
| 435 | + | require.Zero(t, second.BaseStateNum) | |
| 436 | + | require.Zero(t, second.NewStateNum) | |
| 402 | 437 | } | |
| 403 | 438 | ||
| 404 | 439 | func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) { | |
| @@ -429,9 +464,7 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) { | |||
| 429 | 464 | ||
| 430 | 465 | resumeHello := helloResumeCapable(ports.IntentResume, "work", token) | |
| 431 | 466 | resumeHello.TrueColor = true | |
| 432 | - | d.mu.Lock() | |
| 433 | - | _, _, ok, err := d.resumeParkedLocked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 434 | - | d.mu.Unlock() | |
| 467 | + | _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 435 | 468 | require.NoError(t, err) | |
| 436 | 469 | require.True(t, ok) | |
| 437 | 470 | sess.mu.Lock() | |
| @@ -127,19 +127,19 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||||
| 127 | } | 127 | } | ||
| 128 | 128 | ||||
| 129 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | 129 | func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | ||
| 130 | - | ac := &attachedClient{} | 130 | + | ac := &attachedClient{output: newOutputStateStream()} |
| 131 | - | ac.nextStateNum = 3 | 131 | + | ac.output.next = 3 |
| 132 | - | ac.advanceOutputAck(3) | 132 | + | ac.ackOutputState(3) |
| 133 | - | ac.advanceOutputAck(2) | 133 | + | ac.ackOutputState(2) |
| 134 | - | ac.advanceOutputAck(4) | 134 | + | ac.ackOutputState(4) |
| 135 | - | require.Equal(t, uint64(3), ac.outputAck.Load(), "stale or future ACKs must not move output state incorrectly") | 135 | + | require.Equal(t, uint64(3), ac.output.acked, "stale or future ACKs must not move output state incorrectly") |
| 136 | 136 | ||||
| 137 | ac.sendMu.Lock() | 137 | ac.sendMu.Lock() | ||
| 138 | - | ac.nextStateNum = 5 | 138 | + | ac.output.next = 5 |
| 139 | - | reset := ac.shouldResetOutputState(false) | 139 | + | reset := false |
| 140 | require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint") | 140 | require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint") | ||
| 141 | 141 | ||||
| 142 | - | f := ac.nextOutputFrameLocked([]byte("incremental while reliable backlog drains"), reset) | 142 | + | f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0) |
| 143 | ac.sendMu.Unlock() | 143 | ac.sendMu.Unlock() | ||
| 144 | out, err := ports.UnmarshalOutput(f.Payload) | 144 | out, err := ports.UnmarshalOutput(f.Payload) | ||
| 145 | require.NoError(t, err) | 145 | require.NoError(t, err) | ||
| @@ -147,9 +147,9 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) { | |||||
| 147 | require.Equal(t, uint64(6), out.NewStateNum) | 147 | require.Equal(t, uint64(6), out.NewStateNum) | ||
| 148 | 148 | ||||
| 149 | ac.sendMu.Lock() | 149 | ac.sendMu.Lock() | ||
| 150 | - | reset = ac.shouldResetOutputState(true) | 150 | + | reset = true |
| 151 | require.True(t, reset, "explicit reset should still force full repaint") | 151 | require.True(t, reset, "explicit reset should still force full repaint") | ||
| 152 | - | full := ac.nextOutputFrameLocked([]byte("explicit full repaint"), reset) | 152 | + | full := ac.output.frame([]byte("explicit full repaint"), reset, 0) |
| 153 | ac.sendMu.Unlock() | 153 | ac.sendMu.Unlock() | ||
| 154 | fullOut, err := ports.UnmarshalOutput(full.Payload) | 154 | fullOut, err := ports.UnmarshalOutput(full.Payload) | ||
| 155 | require.NoError(t, err) | 155 | require.NoError(t, err) | ||
| @@ -170,8 +170,8 @@ func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) { | |||||
| 170 | 170 | ||||
| 171 | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | 171 | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | ||
| 172 | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | 172 | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | ||
| 173 | + | _, _, ok, err := d.resumeParked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 173 | d.mu.Lock() | 174 | d.mu.Lock() | ||
| 174 | - | _, _, ok, err := d.resumeParkedLocked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 175 | _, stillParked := d.parked[token] | 175 | _, stillParked := d.parked[token] | ||
| 176 | d.mu.Unlock() | 176 | d.mu.Unlock() | ||
| 177 | require.Error(t, err) | 177 | require.Error(t, err) | ||
| @@ -200,9 +200,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||||
| 200 | require.True(t, d.parkAttachment(sess, ac)) | 200 | require.True(t, d.parkAttachment(sess, ac)) | ||
| 201 | 201 | ||||
| 202 | newTr := &closeTrackingTransport{} | 202 | newTr := &closeTrackingTransport{} | ||
| 203 | - | d.mu.Lock() | 203 | + | resumedSess, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24}) |
| 204 | - | resumedSess, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24}) | |||
| 205 | - | d.mu.Unlock() | |||
| 206 | require.NoError(t, err) | 204 | require.NoError(t, err) | ||
| 207 | require.True(t, ok) | 205 | require.True(t, ok) | ||
| 208 | require.Same(t, sess, resumedSess) | 206 | require.Same(t, sess, resumedSess) | ||
| @@ -214,6 +212,41 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing. | |||||
| 214 | require.Same(t, newTr, ac.transport()) | 212 | require.Same(t, newTr, ac.transport()) | ||
| 215 | } | 213 | } | ||
| 216 | 214 | ||||
| 215 | + | func TestResumeRebasesFullOutputWindowBeforeFirstPaint(t *testing.T) { | |||
| 216 | + | pty, release := newBlockingPTY(t) | |||
| 217 | + | defer release() | |||
| 218 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 219 | + | oldTr := &closeTrackingTransport{} | |||
| 220 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |||
| 221 | + | require.NoError(t, err) | |||
| 222 | + | ac.output.next = maxUnackedOutputStates | |||
| 223 | + | token := ac.resumeToken | |||
| 224 | + | require.True(t, sess.detachIfCurrent(ac)) | |||
| 225 | + | require.True(t, d.parkAttachment(sess, ac)) | |||
| 226 | + | ||||
| 227 | + | newTr := &closeTrackingTransport{} | |||
| 228 | + | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), newTr) | |||
| 229 | + | require.NoError(t, err) | |||
| 230 | + | require.Same(t, ac, resumedAC) | |||
| 231 | + | d.firstPaint(resumedSess, resumedAC, resumedAC.size) | |||
| 232 | + | ||||
| 233 | + | sends := newTr.Sends() | |||
| 234 | + | require.Len(t, sends, 1) | |||
| 235 | + | first, err := ports.UnmarshalOutput(sends[0].Payload) | |||
| 236 | + | require.NoError(t, err) | |||
| 237 | + | require.Zero(t, first.BaseStateNum) | |||
| 238 | + | require.Equal(t, uint64(maxUnackedOutputStates+1), first.NewStateNum) | |||
| 239 | + | resumedAC.ackOutputState(first.NewStateNum) | |||
| 240 | + | ||||
| 241 | + | resumedSess.tabs[0].focusedPane().screen.Write([]byte("A")) | |||
| 242 | + | d.paint(resumedSess, resumedAC, false) | |||
| 243 | + | sends = newTr.Sends() | |||
| 244 | + | require.Len(t, sends, 2) | |||
| 245 | + | second, err := ports.UnmarshalOutput(sends[1].Payload) | |||
| 246 | + | require.NoError(t, err) | |||
| 247 | + | require.Equal(t, first.NewStateNum, second.BaseStateNum) | |||
| 248 | + | } | |||
| 249 | + | ||||
| 217 | func TestExplicitDetachDoesNotPark(t *testing.T) { | 250 | func TestExplicitDetachDoesNotPark(t *testing.T) { | ||
| 218 | pty, release := newBlockingPTY(t) | 251 | pty, release := newBlockingPTY(t) | ||
| 219 | defer release() | 252 | defer release() | ||
| @@ -331,8 +364,8 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) { | |||||
| 331 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | 364 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | ||
| 332 | d.mu.Lock() | 365 | d.mu.Lock() | ||
| 333 | _, parked := d.parked[token] | 366 | _, parked := d.parked[token] | ||
| 334 | - | _, _, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 335 | d.mu.Unlock() | 367 | d.mu.Unlock() | ||
| 368 | + | _, _, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 336 | require.False(t, parked, "killSession purges parked token") | 369 | require.False(t, parked, "killSession purges parked token") | ||
| 337 | require.NoError(t, err) | 370 | require.NoError(t, err) | ||
| 338 | require.False(t, ok, "killed session cannot be resumed") | 371 | require.False(t, ok, "killed session cannot be resumed") | ||
| @@ -355,8 +388,8 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) { | |||||
| 355 | 388 | ||||
| 356 | d.mu.Lock() | 389 | d.mu.Lock() | ||
| 357 | _, parked := d.parked[token] | 390 | _, parked := d.parked[token] | ||
| 358 | - | _, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 359 | d.mu.Unlock() | 391 | d.mu.Unlock() | ||
| 392 | + | _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 360 | require.False(t, parked, "normal attach invalidates stale parked token") | 393 | require.False(t, parked, "normal attach invalidates stale parked token") | ||
| 361 | require.NoError(t, err) | 394 | require.NoError(t, err) | ||
| 362 | require.False(t, ok) | 395 | require.False(t, ok) | ||
| @@ -382,10 +415,10 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) { | |||||
| 382 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | 415 | require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop") | ||
| 383 | } | 416 | } | ||
| 384 | 417 | ||||
| 385 | - | func TestOutputStateNumberingIsSharedAndMonotone(t *testing.T) { | 418 | + | func TestRawTerminalSideEffectsAreOutputStateNeutral(t *testing.T) { |
| 386 | d := newTestDaemon(t, nil, stubClock{}) | 419 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 387 | tr := &closeTrackingTransport{} | 420 | tr := &closeTrackingTransport{} | ||
| 388 | - | ac := &attachedClient{tr: tr} | 421 | + | ac := &attachedClient{tr: tr, output: newOutputStateStream()} |
| 389 | ac.initOverlays() | 422 | ac.initOverlays() | ||
| 390 | 423 | ||||
| 391 | require.NoError(t, d.boundedSendOutputErr(ac, []byte("copy"))) | 424 | require.NoError(t, d.boundedSendOutputErr(ac, []byte("copy"))) | ||
| @@ -397,8 +430,10 @@ func TestOutputStateNumberingIsSharedAndMonotone(t *testing.T) { | |||||
| 397 | require.NoError(t, err) | 430 | require.NoError(t, err) | ||
| 398 | second, err := ports.UnmarshalOutput(sends[1].Payload) | 431 | second, err := ports.UnmarshalOutput(sends[1].Payload) | ||
| 399 | require.NoError(t, err) | 432 | require.NoError(t, err) | ||
| 400 | - | require.Equal(t, uint64(1), first.NewStateNum) | 433 | + | require.Zero(t, first.BaseStateNum) |
| 401 | - | require.Equal(t, uint64(2), second.NewStateNum) | 434 | + | require.Zero(t, first.NewStateNum) |
| 435 | + | require.Zero(t, second.BaseStateNum) | |||
| 436 | + | require.Zero(t, second.NewStateNum) | |||
| 402 | } | 437 | } | ||
| 403 | 438 | ||||
| 404 | func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) { | 439 | func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) { | ||
| @@ -429,9 +464,7 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) { | |||||
| 429 | 464 | ||||
| 430 | resumeHello := helloResumeCapable(ports.IntentResume, "work", token) | 465 | resumeHello := helloResumeCapable(ports.IntentResume, "work", token) | ||
| 431 | resumeHello.TrueColor = true | 466 | resumeHello.TrueColor = true | ||
| 432 | - | d.mu.Lock() | 467 | + | _, _, ok, err := d.resumeParked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) |
| 433 | - | _, _, ok, err := d.resumeParkedLocked(resumeHello, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 434 | - | d.mu.Unlock() | |||
| 435 | require.NoError(t, err) | 468 | require.NoError(t, err) | ||
| 436 | require.True(t, ok) | 469 | require.True(t, ok) | ||
| 437 | sess.mu.Lock() | 470 | sess.mu.Lock() | ||
D pkg/renderer/ack_test.go +0 -85
| @@ -1,85 +0,0 @@ | |||
| 1 | - | package renderer | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "strings" | |
| 5 | - | "testing" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | func TestAdvanceOnAckRediffsScrollDamageUntilAcked(t *testing.T) { | |
| 9 | - | r := New(Capabilities{AdvancePolicy: AdvanceOnAck}) | |
| 10 | - | frame := NewFrame(4, 3) | |
| 11 | - | fillFrame(frame, []string{"AAAA", "BBBB", "CCCC"}) | |
| 12 | - | if out, err := r.Draw(frame, nil); err != nil { | |
| 13 | - | t.Fatal(err) | |
| 14 | - | } else if len(out) == 0 { | |
| 15 | - | t.Fatal("initial draw empty") | |
| 16 | - | } | |
| 17 | - | if err := r.Ack(frame); err != nil { | |
| 18 | - | t.Fatal(err) | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | scrolled := NewFrame(4, 3) | |
| 22 | - | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |
| 23 | - | for i := 8; i < 12; i++ { | |
| 24 | - | scrolled.Cells[i] = BlankCell() | |
| 25 | - | } | |
| 26 | - | scrolled.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) | |
| 27 | - | damage := []Damage{ | |
| 28 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 29 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | first, err := r.Draw(scrolled, damage) | |
| 33 | - | if err != nil { | |
| 34 | - | t.Fatal(err) | |
| 35 | - | } | |
| 36 | - | again, err := r.Draw(scrolled, damage) | |
| 37 | - | if err != nil { | |
| 38 | - | t.Fatal(err) | |
| 39 | - | } | |
| 40 | - | for name, out := range map[string][]byte{"first": first, "again": again} { | |
| 41 | - | if !strings.Contains(string(out), "\x1b[1;3r") || !strings.Contains(string(out), "N") { | |
| 42 | - | t.Fatalf("%s scroll draw did not include scroll region and exposed row: %q", name, out) | |
| 43 | - | } | |
| 44 | - | } | |
| 45 | - | if err := r.Ack(scrolled); err != nil { | |
| 46 | - | t.Fatal(err) | |
| 47 | - | } | |
| 48 | - | afterAck, err := r.Draw(scrolled, nil) | |
| 49 | - | if err != nil { | |
| 50 | - | t.Fatal(err) | |
| 51 | - | } | |
| 52 | - | if len(afterAck) != 0 { | |
| 53 | - | t.Fatalf("after ack got %q", afterAck) | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | func TestAdvanceOnAckRediffsLostOutput(t *testing.T) { | |
| 58 | - | r := New(Capabilities{AdvancePolicy: AdvanceOnAck}) | |
| 59 | - | f := NewFrame(3, 1) | |
| 60 | - | fillFrame(f, []string{"abc"}) | |
| 61 | - | first, err := r.Draw(f, nil) | |
| 62 | - | if err != nil { | |
| 63 | - | t.Fatal(err) | |
| 64 | - | } | |
| 65 | - | if len(first) == 0 { | |
| 66 | - | t.Fatal("first draw empty") | |
| 67 | - | } | |
| 68 | - | again, err := r.Draw(f, nil) | |
| 69 | - | if err != nil { | |
| 70 | - | t.Fatal(err) | |
| 71 | - | } | |
| 72 | - | if len(again) == 0 { | |
| 73 | - | t.Fatal("lost unacked output was not re-diffed") | |
| 74 | - | } | |
| 75 | - | if err := r.Ack(f); err != nil { | |
| 76 | - | t.Fatal(err) | |
| 77 | - | } | |
| 78 | - | afterAck, err := r.Draw(f, nil) | |
| 79 | - | if err != nil { | |
| 80 | - | t.Fatal(err) | |
| 81 | - | } | |
| 82 | - | if len(afterAck) != 0 { | |
| 83 | - | t.Fatalf("after ack got %q", afterAck) | |
| 84 | - | } | |
| 85 | - | } | |
| @@ -1,85 +0,0 @@ | |||||
| 1 | - | package renderer | |||
| 2 | - | ||||
| 3 | - | import ( | |||
| 4 | - | "strings" | |||
| 5 | - | "testing" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | - | func TestAdvanceOnAckRediffsScrollDamageUntilAcked(t *testing.T) { | |||
| 9 | - | r := New(Capabilities{AdvancePolicy: AdvanceOnAck}) | |||
| 10 | - | frame := NewFrame(4, 3) | |||
| 11 | - | fillFrame(frame, []string{"AAAA", "BBBB", "CCCC"}) | |||
| 12 | - | if out, err := r.Draw(frame, nil); err != nil { | |||
| 13 | - | t.Fatal(err) | |||
| 14 | - | } else if len(out) == 0 { | |||
| 15 | - | t.Fatal("initial draw empty") | |||
| 16 | - | } | |||
| 17 | - | if err := r.Ack(frame); err != nil { | |||
| 18 | - | t.Fatal(err) | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | scrolled := NewFrame(4, 3) | |||
| 22 | - | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |||
| 23 | - | for i := 8; i < 12; i++ { | |||
| 24 | - | scrolled.Cells[i] = BlankCell() | |||
| 25 | - | } | |||
| 26 | - | scrolled.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) | |||
| 27 | - | damage := []Damage{ | |||
| 28 | - | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 29 | - | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 30 | - | } | |||
| 31 | - | ||||
| 32 | - | first, err := r.Draw(scrolled, damage) | |||
| 33 | - | if err != nil { | |||
| 34 | - | t.Fatal(err) | |||
| 35 | - | } | |||
| 36 | - | again, err := r.Draw(scrolled, damage) | |||
| 37 | - | if err != nil { | |||
| 38 | - | t.Fatal(err) | |||
| 39 | - | } | |||
| 40 | - | for name, out := range map[string][]byte{"first": first, "again": again} { | |||
| 41 | - | if !strings.Contains(string(out), "\x1b[1;3r") || !strings.Contains(string(out), "N") { | |||
| 42 | - | t.Fatalf("%s scroll draw did not include scroll region and exposed row: %q", name, out) | |||
| 43 | - | } | |||
| 44 | - | } | |||
| 45 | - | if err := r.Ack(scrolled); err != nil { | |||
| 46 | - | t.Fatal(err) | |||
| 47 | - | } | |||
| 48 | - | afterAck, err := r.Draw(scrolled, nil) | |||
| 49 | - | if err != nil { | |||
| 50 | - | t.Fatal(err) | |||
| 51 | - | } | |||
| 52 | - | if len(afterAck) != 0 { | |||
| 53 | - | t.Fatalf("after ack got %q", afterAck) | |||
| 54 | - | } | |||
| 55 | - | } | |||
| 56 | - | ||||
| 57 | - | func TestAdvanceOnAckRediffsLostOutput(t *testing.T) { | |||
| 58 | - | r := New(Capabilities{AdvancePolicy: AdvanceOnAck}) | |||
| 59 | - | f := NewFrame(3, 1) | |||
| 60 | - | fillFrame(f, []string{"abc"}) | |||
| 61 | - | first, err := r.Draw(f, nil) | |||
| 62 | - | if err != nil { | |||
| 63 | - | t.Fatal(err) | |||
| 64 | - | } | |||
| 65 | - | if len(first) == 0 { | |||
| 66 | - | t.Fatal("first draw empty") | |||
| 67 | - | } | |||
| 68 | - | again, err := r.Draw(f, nil) | |||
| 69 | - | if err != nil { | |||
| 70 | - | t.Fatal(err) | |||
| 71 | - | } | |||
| 72 | - | if len(again) == 0 { | |||
| 73 | - | t.Fatal("lost unacked output was not re-diffed") | |||
| 74 | - | } | |||
| 75 | - | if err := r.Ack(f); err != nil { | |||
| 76 | - | t.Fatal(err) | |||
| 77 | - | } | |||
| 78 | - | afterAck, err := r.Draw(f, nil) | |||
| 79 | - | if err != nil { | |||
| 80 | - | t.Fatal(err) | |||
| 81 | - | } | |||
| 82 | - | if len(afterAck) != 0 { | |||
| 83 | - | t.Fatalf("after ack got %q", afterAck) | |||
| 84 | - | } | |||
| 85 | - | } | |||
M pkg/renderer/renderer.go +1 -27
| @@ -12,16 +12,8 @@ var bufferPool = sync.Pool{ | |||
| 12 | 12 | New: func() any { return new(bytes.Buffer) }, | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | - | type AdvancePolicy uint8 | |
| 16 | - | ||
| 17 | - | const ( | |
| 18 | - | AdvanceOnSend AdvancePolicy = iota | |
| 19 | - | AdvanceOnAck | |
| 20 | - | ) | |
| 21 | - | ||
| 22 | 15 | type Capabilities struct { | |
| 23 | 16 | SynchronizedOutput bool | |
| 24 | - | AdvancePolicy AdvancePolicy | |
| 25 | 17 | } | |
| 26 | 18 | ||
| 27 | 19 | type Renderer struct { | |
| @@ -93,9 +85,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 93 | 85 | // emitScrollUp resets the SGR pen to default (matching st's initial | |
| 94 | 86 | // pen) but leaves the cursor wherever the DECSTBM restore put it — | |
| 95 | 87 | // terminal-dependent, so cursor tracking stays invalidated. | |
| 96 | - | if r.caps.AdvancePolicy == AdvanceOnSend { | |
| 97 | - | r.applyScroll(scroll) | |
| 98 | - | } | |
| 88 | + | r.applyScroll(scroll) | |
| 99 | 89 | r.writeDamage(buf, frame, damage, &scroll, &st) | |
| 100 | 90 | r.advanceDamage(frame, damage, &scroll) | |
| 101 | 91 | if r.caps.SynchronizedOutput { | |
| @@ -218,27 +208,11 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool { | |||
| 218 | 208 | return false | |
| 219 | 209 | } | |
| 220 | 210 | ||
| 221 | - | // Ack advances an AdvanceOnAck renderer's shadow after the caller knows the | |
| 222 | - | // bytes for frame reached the terminal. | |
| 223 | - | func (r *Renderer) Ack(frame Frame) error { | |
| 224 | - | if err := frame.Validate(); err != nil { | |
| 225 | - | return err | |
| 226 | - | } | |
| 227 | - | r.replaceShadow(frame) | |
| 228 | - | return nil | |
| 229 | - | } | |
| 230 | - | ||
| 231 | 211 | func (r *Renderer) advanceShadow(frame Frame) { | |
| 232 | - | if r.caps.AdvancePolicy == AdvanceOnAck { | |
| 233 | - | return | |
| 234 | - | } | |
| 235 | 212 | r.replaceShadow(frame) | |
| 236 | 213 | } | |
| 237 | 214 | ||
| 238 | 215 | func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) { | |
| 239 | - | if r.caps.AdvancePolicy == AdvanceOnAck { | |
| 240 | - | return | |
| 241 | - | } | |
| 242 | 216 | r.syncDamage(frame, damage, scroll) | |
| 243 | 217 | } | |
| 244 | 218 | ||
| @@ -12,16 +12,8 @@ var bufferPool = sync.Pool{ | |||||
| 12 | New: func() any { return new(bytes.Buffer) }, | 12 | New: func() any { return new(bytes.Buffer) }, | ||
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
| 15 | - | type AdvancePolicy uint8 | |||
| 16 | - | ||||
| 17 | - | const ( | |||
| 18 | - | AdvanceOnSend AdvancePolicy = iota | |||
| 19 | - | AdvanceOnAck | |||
| 20 | - | ) | |||
| 21 | - | ||||
| 22 | type Capabilities struct { | 15 | type Capabilities struct { | ||
| 23 | SynchronizedOutput bool | 16 | SynchronizedOutput bool | ||
| 24 | - | AdvancePolicy AdvancePolicy | |||
| 25 | } | 17 | } | ||
| 26 | 18 | ||||
| 27 | type Renderer struct { | 19 | type Renderer struct { | ||
| @@ -93,9 +85,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||||
| 93 | // emitScrollUp resets the SGR pen to default (matching st's initial | 85 | // emitScrollUp resets the SGR pen to default (matching st's initial | ||
| 94 | // pen) but leaves the cursor wherever the DECSTBM restore put it — | 86 | // pen) but leaves the cursor wherever the DECSTBM restore put it — | ||
| 95 | // terminal-dependent, so cursor tracking stays invalidated. | 87 | // terminal-dependent, so cursor tracking stays invalidated. | ||
| 96 | - | if r.caps.AdvancePolicy == AdvanceOnSend { | 88 | + | r.applyScroll(scroll) |
| 97 | - | r.applyScroll(scroll) | |||
| 98 | - | } | |||
| 99 | r.writeDamage(buf, frame, damage, &scroll, &st) | 89 | r.writeDamage(buf, frame, damage, &scroll, &st) | ||
| 100 | r.advanceDamage(frame, damage, &scroll) | 90 | r.advanceDamage(frame, damage, &scroll) | ||
| 101 | if r.caps.SynchronizedOutput { | 91 | if r.caps.SynchronizedOutput { | ||
| @@ -218,27 +208,11 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool { | |||||
| 218 | return false | 208 | return false | ||
| 219 | } | 209 | } | ||
| 220 | 210 | ||||
| 221 | - | // Ack advances an AdvanceOnAck renderer's shadow after the caller knows the | |||
| 222 | - | // bytes for frame reached the terminal. | |||
| 223 | - | func (r *Renderer) Ack(frame Frame) error { | |||
| 224 | - | if err := frame.Validate(); err != nil { | |||
| 225 | - | return err | |||
| 226 | - | } | |||
| 227 | - | r.replaceShadow(frame) | |||
| 228 | - | return nil | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | func (r *Renderer) advanceShadow(frame Frame) { | 211 | func (r *Renderer) advanceShadow(frame Frame) { | ||
| 232 | - | if r.caps.AdvancePolicy == AdvanceOnAck { | |||
| 233 | - | return | |||
| 234 | - | } | |||
| 235 | r.replaceShadow(frame) | 212 | r.replaceShadow(frame) | ||
| 236 | } | 213 | } | ||
| 237 | 214 | ||||
| 238 | func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) { | 215 | func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) { | ||
| 239 | - | if r.caps.AdvancePolicy == AdvanceOnAck { | |||
| 240 | - | return | |||
| 241 | - | } | |||
| 242 | r.syncDamage(frame, damage, scroll) | 216 | r.syncDamage(frame, damage, scroll) | ||
| 243 | } | 217 | } | ||
| 244 | 218 | ||||