detached 4480b59

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 (
77 )
88
99 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
2314 }
2415
2516 func (t *Transport) outputPaceLoop() {
@@ -59,7 +50,16 @@ func (t *Transport) outputPaceLoop() {
5950 timer.Stop()
6051 continue
6152 }
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+ }
6363 batch := append([]queuedSend(nil), t.outputQueue[:limit]...)
6464 copy(t.outputQueue, t.outputQueue[limit:])
6565 t.outputQueue = t.outputQueue[:len(t.outputQueue)-limit]
@@ -67,7 +67,7 @@ func (t *Transport) outputPaceLoop() {
6767 t.mu.Unlock()
6868 for i, q := range batch {
6969 if err := t.sendQueuedData(q); err != nil {
70- t.removeQueuedPending(batch[i+1:])
70+ t.removeQueuedPending(batch[i+1:], err)
7171 t.closeWithError(err)
7272 t.outboundMu.Unlock()
7373 return
@@ -105,23 +105,25 @@ func shouldPaceOutput(f ports.Frame) bool {
105105
106106 func (t *Transport) sendQueuedData(q queuedSend) error {
107107 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 {
109110 t.removePending(q.seq, q.reliable)
110- return err
111+ } else {
112+ t.markPendingReady(q.seq, q.reliable)
111113 }
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
114119 }
115120
116121 func (t *Transport) markPendingSent(seq uint64, reliable bool) {
117122 if !reliable {
118123 return
119124 }
120- now := t.clock.Now()
121125 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 {
125127 p.initialInFlight = true
126128 }
127129 t.mu.Unlock()
@@ -137,3 +139,15 @@ func (t *Transport) markPendingReady(seq uint64, reliable bool) {
137139 }
138140 t.mu.Unlock()
139141 }
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 uint6410+ seq uint64
11- reliable bool11+ reliable bool
12- frame ports.Frame12+ frame ports.Frame
13- outputSkipThrough uint6413+ 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 continue51 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 return73 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 err111+ } else {
112+ t.markPendingReady(q.seq, q.reliable)
111 }113 }
112- t.markPendingReady(q.seq, q.reliable)114+ if q.done != nil {
113- return nil115+ 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 return123 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 = true127 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 (
3636 defaultOutputPaceMinDelay = 8 * time.Millisecond
3737 defaultOutputPaceMinCadence = 20 * time.Millisecond
3838 defaultOutputPaceMaxDelay = 250 * time.Millisecond
39- defaultOutputPaceBatch = 16
39+ defaultOutputPaceBatch = 2
40+ defaultPacketPaceBudget = 8
41+ dataRecordHeaderSize = 12
4042 maxRecvBuffer = defaultMaxRecvBuf
4143 linkEventBufferSize = 16
4244 probeReplyBufferSize = 16
@@ -177,26 +179,28 @@ type Transport struct {
177179 in chan ports.Frame
178180 done chan struct{}
179181
180- writeMu sync.Mutex
182+ writeMu sync.Mutex
183+ dataPaceMu sync.Mutex
184+ dataPaceRemaining int
185+ dataPaceNext time.Time
181186 // outboundMu orders first transmissions and paced batches. Retransmits use
182187 // writeMu directly because they do not establish new sequence order.
183188 outboundMu sync.Mutex
184189
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
190194 }
191195
192196 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
200204 }
201205
202206 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
255259 return t, nil
256260 }
257261
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) }
261263
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 {
263271 reliable := true
264- outputSkipThrough, err := t.lockOutboundSlot(f, reliable)
265- if err != nil {
272+ if err := t.lockOutboundSlot(reliable); err != nil {
266273 return err
267274 }
268- defer t.outboundMu.Unlock()
275+ unlockOutbound := true
276+ defer func() {
277+ if unlockOutbound {
278+ t.outboundMu.Unlock()
279+ }
280+ }()
269281 // lockOutboundSlot returns with both outboundMu and mu held.
270282
271283 t.seq++
272284 seq := t.seq
273285 if reliable {
274- t.pending[seq] = &pending{frame: f, outputSkipThrough: outputSkipThrough}
286+ t.pending[seq] = &pending{frame: f, enqueued: t.clock.Now()}
275287 }
276288 if shouldPaceOutput(f) {
277289 now := t.clock.Now()
278290 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+ }
281300 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)
283307 }
284308 t.outputNext = now.Add(t.outputPaceDelayLocked())
285309 t.mu.Unlock()
286310 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 {
288312 t.removePending(seq, reliable)
289313 return err
290314 }
291315 t.markPendingReady(seq, reliable)
292316 return nil
293317 }
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)
301322 }
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)
302331 }
332+ t.mu.Unlock()
303333 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 {
305335 t.removePending(seq, reliable)
306336 return err
307337 }
@@ -309,9 +339,32 @@ func (t *Transport) Send(f ports.Frame) error {
309339 return nil
310340 }
311341
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 {
313367 var pendingWaitStarted time.Time
314- var outputSkipThrough uint64
315368 for {
316369 t.outboundMu.Lock()
317370 t.mu.Lock()
@@ -320,22 +373,17 @@ func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, erro
320373 t.mu.Unlock()
321374 t.outboundMu.Unlock()
322375 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
330377 }
378+ return errors.New("dgram: closed")
331379 }
332380 if !reliable || len(t.pending) < t.maxPending {
333- return outputSkipThrough, nil
381+ return nil
334382 }
335383 if t.linkState != ports.LinkStateConnected {
336384 t.mu.Unlock()
337385 t.outboundMu.Unlock()
338- return 0, ErrPendingFull
386+ return ErrPendingFull
339387 }
340388 if pendingWaitStarted.IsZero() {
341389 pendingWaitStarted = t.clock.Now()
@@ -344,7 +392,7 @@ func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, erro
344392 if remaining <= 0 {
345393 t.mu.Unlock()
346394 t.outboundMu.Unlock()
347- return 0, ErrPendingFull
395+ return ErrPendingFull
348396 }
349397 wake := t.sendWake
350398 timer := t.clock.NewTimer(remaining)
@@ -371,13 +419,17 @@ func (t *Transport) removePending(seq uint64, reliable bool) {
371419 t.mu.Unlock()
372420 }
373421
374-func (t *Transport) removeQueuedPending(queued []queuedSend) {
422+func (t *Transport) removeQueuedPending(queued []queuedSend, err error) {
375423 t.mu.Lock()
376424 removed := false
377425 for _, q := range queued {
378426 if q.reliable {
379427 removed = t.removePendingLocked(q.seq) || removed
380428 }
429+ if q.done != nil {
430+ q.done <- err
431+ close(q.done)
432+ }
381433 }
382434 if removed {
383435 t.notifySendWaitersLocked()
@@ -393,41 +445,6 @@ func (t *Transport) removePendingLocked(seq uint64) bool {
393445 return true
394446 }
395447
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-}
431448 func (t *Transport) Recv() (ports.Frame, error) {
432449 select {
433450 case f := <-t.in:
@@ -509,28 +526,40 @@ func (t *Transport) unregisterProbe(id uint64) {
509526 t.mu.Unlock()
510527 }
511528
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)
515532 }
516533 func (t *Transport) sendAck(seq uint64) {
517534 var b [9]byte
518535 b[0] = recAck
519536 binary.BigEndian.PutUint64(b[1:], seq)
520- _ = t.sendPayload(b[:])
537+ _ = t.sendPayload(b[:], false, 0)
521538 }
522539 func (t *Transport) sendProbe(kind byte, id uint64) error {
523540 var b [9]byte
524541 b[0] = kind
525542 binary.BigEndian.PutUint64(b[1:], id)
526- return t.sendPayload(b[:])
543+ return t.sendPayload(b[:], false, 0)
527544 }
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+ }
529550 frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
530551 if err != nil {
531552 return err
532553 }
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+ }
534563 raw, err := pdgram.MarshalFragment(f)
535564 if err != nil {
536565 return err
@@ -550,6 +579,9 @@ func (t *Transport) sendPayload(p []byte) error {
550579 if peer == nil {
551580 return errors.New("dgram: no peer")
552581 }
582+ if pendingSeq != 0 && i == len(frags)-1 {
583+ t.markPendingFinalWrite(pendingSeq)
584+ }
553585 if err := t.writePacket(pkt, peer); err != nil {
554586 return err
555587 }
@@ -557,6 +589,37 @@ func (t *Transport) sendPayload(p []byte) error {
557589 return nil
558590 }
559591
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+
560623 func (t *Transport) writePacket(pkt []byte, peer net.Addr) error {
561624 t.writeMu.Lock()
562625 defer t.writeMu.Unlock()
@@ -669,12 +732,12 @@ func (t *Transport) handleRecord(p []byte) {
669732 }
670733 t.mu.Unlock()
671734 case recData:
672- seq, reliable, f, outputSkipThrough, ok := decodeData(p)
735+ seq, reliable, f, ok := decodeData(p)
673736 if !ok {
674737 return
675738 }
676739 if reliable {
677- ackSeq, ack, queued := t.enqueueReliable(seq, f, outputSkipThrough)
740+ ackSeq, ack, queued := t.enqueueReliable(seq, f)
678741 if ack {
679742 t.sendAck(ackSeq)
680743 }
@@ -687,18 +750,9 @@ func (t *Transport) handleRecord(p []byte) {
687750 }
688751 }
689752
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) {
691754 t.deliverMu.Lock()
692755 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- }
702756 if seq < t.nextRecvSeq {
703757 return t.highestContiguousRecvLocked(), true, false
704758 }
@@ -719,10 +773,6 @@ func (t *Transport) highestContiguousRecvLocked() uint64 {
719773 seq++
720774 continue
721775 }
722- if seq <= t.recvSkipThrough {
723- seq++
724- continue
725- }
726776 break
727777 }
728778 return seq - 1
@@ -738,10 +788,6 @@ func (t *Transport) deliveryLoop() {
738788 for {
739789 t.deliverMu.Lock()
740790 f, ok := t.recvBuf[t.nextRecvSeq]
741- for !ok && t.nextRecvSeq <= t.recvSkipThrough {
742- t.nextRecvSeq++
743- f, ok = t.recvBuf[t.nextRecvSeq]
744- }
745791 for !ok {
746792 if t.isClosed() {
747793 t.deliverMu.Unlock()
@@ -749,16 +795,6 @@ func (t *Transport) deliveryLoop() {
749795 }
750796 t.deliverCond.Wait()
751797 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
762798 }
763799 delete(t.recvBuf, t.nextRecvSeq)
764800 t.nextRecvSeq++
@@ -825,9 +861,8 @@ func (t *Transport) resendLoop() {
825861 func (t *Transport) resendPending() {
826862 now := t.clock.Now()
827863 var resend []struct {
828- seq uint64
829- f ports.Frame
830- outputSkipThrough uint64
864+ seq uint64
865+ f ports.Frame
831866 }
832867 t.mu.Lock()
833868 seqs := make([]uint64, 0, len(t.pending))
@@ -844,19 +879,26 @@ func (t *Transport) resendPending() {
844879 if p == nil || p.last.IsZero() || p.initialInFlight || now.Sub(p.last) < t.resendDelayLocked(p) {
845880 continue
846881 }
847- p.last = now
882+ p.initialInFlight = true
848883 p.attempts++
849884 p.retransmitted = true
850885 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})
855889 limit--
856890 }
857891 t.mu.Unlock()
858892 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+ }
860902 }
861903 }
862904
@@ -966,7 +1008,7 @@ func (t *Transport) checkSilence() {
9661008 // has gone unacknowledged for deadAfter despite at least one retransmit. The
9671009 // receive-silence check in checkSilence misses one-way-dead links whose receive
9681010 // 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,
9701012 // so acks (which delete pending entries) keep the oldest age bounded on a
9711013 // progressing link — only a frame that is never acked crosses the threshold.
9721014 func (t *Transport) checkSendStall() {
@@ -981,11 +1023,11 @@ func (t *Transport) checkSendStall() {
9811023 var attempts int
9821024 found := false
9831025 for _, p := range t.pending {
984- if p == nil || p.first.IsZero() {
1026+ if p == nil || p.enqueued.IsZero() {
9851027 continue
9861028 }
987- if !found || p.first.Before(oldest) {
988- oldest = p.first
1029+ if !found || p.enqueued.Before(oldest) {
1030+ oldest = p.enqueued
9891031 attempts = p.attempts
9901032 found = true
9911033 }
@@ -1030,6 +1072,13 @@ func (t *Transport) closeWithError(err error) {
10301072 }
10311073 t.closed = true
10321074 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
10331082 t.probeWait = make(map[uint64]chan struct{})
10341083 close(t.done)
10351084 t.notifySendWaitersLocked()
@@ -1039,41 +1088,23 @@ func (t *Transport) closeWithError(err error) {
10391088 t.deliverMu.Unlock()
10401089 }
10411090
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))
10481093 b[0] = recData
10491094 binary.BigEndian.PutUint64(b[1:9], seq)
10501095 if reliable {
10511096 b[9] = 1
10521097 }
10531098 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)
10591100 return b
10601101 }
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
10771108 }
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
10791110 }
@@ -36,7 +36,9 @@ const (
36 defaultOutputPaceMinDelay = 8 * time.Millisecond36 defaultOutputPaceMinDelay = 8 * time.Millisecond
37 defaultOutputPaceMinCadence = 20 * time.Millisecond37 defaultOutputPaceMinCadence = 20 * time.Millisecond
38 defaultOutputPaceMaxDelay = 250 * time.Millisecond38 defaultOutputPaceMaxDelay = 250 * time.Millisecond
39- defaultOutputPaceBatch = 1639+ defaultOutputPaceBatch = 2
40+ defaultPacketPaceBudget = 8
41+ dataRecordHeaderSize = 12
40 maxRecvBuffer = defaultMaxRecvBuf42 maxRecvBuffer = defaultMaxRecvBuf
41 linkEventBufferSize = 1643 linkEventBufferSize = 16
42 probeReplyBufferSize = 1644 probeReplyBufferSize = 16
@@ -177,26 +179,28 @@ type Transport struct {
177 in chan ports.Frame179 in chan ports.Frame
178 done chan struct{}180 done chan struct{}
179 181
180- writeMu sync.Mutex182+ writeMu sync.Mutex
183+ dataPaceMu sync.Mutex
184+ dataPaceRemaining int
185+ dataPaceNext time.Time
181 // outboundMu orders first transmissions and paced batches. Retransmits use186 // 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.Mutex188 outboundMu sync.Mutex
184 189
185- deliverMu sync.Mutex190+ deliverMu sync.Mutex
186- deliverCond *sync.Cond191+ deliverCond *sync.Cond
187- nextRecvSeq uint64192+ nextRecvSeq uint64
188- recvBuf map[uint64]ports.Frame193+ recvBuf map[uint64]ports.Frame
189- recvSkipThrough uint64
190 }194 }
191 195
192 type pending struct {196 type pending struct {
193- frame ports.Frame197+ frame ports.Frame
194- first time.Time198+ enqueued time.Time
195- last time.Time199+ first time.Time
196- initialInFlight bool200+ last time.Time
197- attempts int201+ initialInFlight bool
198- retransmitted bool202+ attempts int
199- outputSkipThrough uint64203+ 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, nil259 return t, nil
256 }260 }
257 261
258-// DatagramTransport marks this transport as UDP/datagram-backed for daemon262+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 := true271 reliable := true
264- outputSkipThrough, err := t.lockOutboundSlot(f, reliable)272+ if err := t.lockOutboundSlot(reliable); err != nil {
265- if err != nil {
266 return err273 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.seq284 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 nil301+ 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 err313 return err
290 }314 }
291 t.markPendingReady(seq, reliable)315 t.markPendingReady(seq, reliable)
292 return nil316 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 err336 return err
307 }337 }
@@ -309,9 +339,32 @@ func (t *Transport) Send(f ports.Frame) error {
309 return nil339 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.Time367 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, err376+ 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, nil381+ 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, ErrPendingFull386+ 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, ErrPendingFull395+ return ErrPendingFull
348 }396 }
349 wake := t.sendWake397 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 := false424 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) || removed427 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 true445 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]byte534 var b [9]byte
518 b[0] = recAck535 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]byte540 var b [9]byte
524 b[0] = kind541 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 err552 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 err565 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 err586 return err
555 }587 }
@@ -557,6 +589,37 @@ func (t *Transport) sendPayload(p []byte) error {
557 return nil589 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 return737 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, false757 return t.highestContiguousRecvLocked(), true, false
704 }758 }
@@ -719,10 +773,6 @@ func (t *Transport) highestContiguousRecvLocked() uint64 {
719 seq++773 seq++
720 continue774 continue
721 }775 }
722- if seq <= t.recvSkipThrough {
723- seq++
724- continue
725- }
726 break776 break
727 }777 }
728 return seq - 1778 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 uint64864+ seq uint64
829- f ports.Frame865+ 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 continue880 continue
846 }881 }
847- p.last = now882+ p.initialInFlight = true
848 p.attempts++883 p.attempts++
849 p.retransmitted = true884 p.retransmitted = true
850 resend = append(resend, struct {885 resend = append(resend, struct {
851- seq uint64886+ seq uint64
852- f ports.Frame887+ f ports.Frame
853- outputSkipThrough uint64888+ }{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. The1008 // has gone unacknowledged for deadAfter despite at least one retransmit. The
967 // receive-silence check in checkSilence misses one-way-dead links whose receive1009 // 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 that1010 // 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 a1012 // 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 int1023 var attempts int
982 found := false1024 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 continue1027 continue
986 }1028 }
987- if !found || p.first.Before(oldest) {1029+ if !found || p.enqueued.Before(oldest) {
988- oldest = p.first1030+ oldest = p.enqueued
989 attempts = p.attempts1031 attempts = p.attempts
990 found = true1032 found = true
991 }1033 }
@@ -1030,6 +1072,13 @@ func (t *Transport) closeWithError(err error) {
1030 }1072 }
1031 t.closed = true1073 t.closed = true
1032 t.closeErr = err1074 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 := 121092+ 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] = recData1093 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] = 11096 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 b1100 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, false1104+ 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, true1109+ 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 (
1313
1414 "github.com/bnema/vev/internal/ports"
1515 pdgram "github.com/bnema/vev/pkg/dgram"
16+ "github.com/stretchr/testify/require"
1617 )
1718
1819 type testAddr string
@@ -25,12 +26,13 @@ type packet struct {
2526 addr net.Addr
2627 }
2728 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()
3436 }
3537
3638 func newPair() (*fakePC, *fakePC) {
@@ -60,11 +62,15 @@ func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
6062 }
6163 peer := p.peers[addr.String()]
6264 peer.mu.Lock()
63- defer peer.mu.Unlock()
6465 if peer.closed {
66+ peer.mu.Unlock()
6567 return 0, errors.New("peer closed")
6668 }
6769 peer.in <- packet{append([]byte(nil), b...), p.addr}
70+ peer.mu.Unlock()
71+ if p.afterWrite != nil {
72+ p.afterWrite()
73+ }
6874 return len(b), nil
6975 }
7076 func (p *fakePC) Close() error {
@@ -299,7 +305,7 @@ func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
299305 return nil
300306 }
301307
302-func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) {
308+func TestOutputFirstSendIsPacedAndQueuedOutputsAreBatched(t *testing.T) {
303309 aPC, bPC := newPair()
304310 clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
305311 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) {
309315 defer func() { _ = a.Close() }()
310316
311317 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 {
313319 t.Fatal(err)
314320 }
315321 }
@@ -318,15 +324,156 @@ func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) {
318324 }
319325 waitForManualTimers(t, clk, 3)
320326 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 {
323329 t.Fatal(err)
324330 }
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)
327333 }
328334 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")
330477 }
331478
332479 func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) {
@@ -399,7 +546,7 @@ func TestInputWaitsForDequeuedOutputBatch(t *testing.T) {
399546 defer func() { _ = a.Close() }()
400547
401548 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 {
403550 t.Fatal(err)
404551 }
405552 }
@@ -443,7 +590,7 @@ func TestInputWaitsForDequeuedOutputBatch(t *testing.T) {
443590 }
444591 }
445592
446-func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) {
593+func TestPacedControlQueueFailureClosesTransport(t *testing.T) {
447594 aPC, bPC := newPair()
448595 clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
449596 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) {
453600 defer func() { _ = a.Close() }()
454601
455602 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 {
457604 t.Fatal(err)
458605 }
459606 }
@@ -464,16 +611,78 @@ func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) {
464611 t.Fatal(err)
465612 }
466613
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")
469630 }
631+}
470632
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+ })
477686 }
478687 }
479688
@@ -487,7 +696,7 @@ func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {
487696 defer func() { _ = a.Close() }()
488697
489698 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 {
491700 t.Fatal(err)
492701 }
493702 }
@@ -507,7 +716,7 @@ func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {
507716 }
508717 }
509718
510-func TestInputAndControlSendsBypassOutputPacing(t *testing.T) {
719+func TestInputAndControlRemainOrderedInBoundedPacedQueue(t *testing.T) {
511720 aPC, bPC := newPair()
512721 clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
513722 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) {
516725 }
517726 defer func() { _ = a.Close() }()
518727
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 {
520729 t.Fatal(err)
521730 }
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 {
523732 t.Fatal(err)
524733 }
525734 if got := len(bPC.in); got != 1 {
526735 t.Fatalf("immediate output datagrams=%d, want 1", got)
527736 }
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)
530741 }
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)
533747 }
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 {
535749 t.Fatal(err)
536750 }
537751 if got := len(bPC.in); got != 3 {
538752 t.Fatalf("datagrams before control=%d, want queued output still paced", got)
539753 }
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)
542763 }
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)
545768 }
546769 }
547770
@@ -557,6 +780,18 @@ func eventuallyPacketCount(t *testing.T, pc *fakePC, want int) {
557780 t.Fatalf("datagrams=%d, want at least %d", len(pc.in), want)
558781 }
559782
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+
560795 func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
561796 pc := newBlockingWritePC()
562797 tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
@@ -682,7 +917,7 @@ func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
682917 t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
683918 }
684919 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})
686921 frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
687922 raw, _ := pdgram.MarshalFragment(frags[0])
688923 aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
@@ -804,11 +1039,11 @@ func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
8041039
8051040 func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
8061041 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")})
8081043 if !ack || !queued {
8091044 t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
8101045 }
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")})
8121047 if !ack || !queued {
8131048 t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
8141049 }
@@ -817,28 +1052,21 @@ func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
8171052 }
8181053 }
8191054
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'}
8231058 if !bytes.Equal(got, want) {
8241059 t.Fatalf("encoded data=%v, want %v", got, want)
8251060 }
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)
8361064 }
8371065 }
8381066
8391067 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 {
8421070 t.Fatal("decodeData accepted unknown data-record flags with trailing garbage")
8431071 }
8441072 }
@@ -861,7 +1089,7 @@ func TestOutputRetransmitsUntilAck(t *testing.T) {
8611089 t.Errorf("close b: %v", err)
8621090 }
8631091 }()
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 {
8651093 t.Fatal(err)
8661094 }
8671095 got := recvWithin(t, b, time.Second)
@@ -870,6 +1098,40 @@ func TestOutputRetransmitsUntilAck(t *testing.T) {
8701098 }
8711099 }
8721100
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+
8731135 func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) {
8741136 aPC, bPC := newPair()
8751137 a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
@@ -890,7 +1152,7 @@ func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) {
8901152 }
8911153 }
8921154
893-func TestOutputSupersessionSkipsDroppedOlderOutput(t *testing.T) {
1155+func TestOutputDependencyChainRetransmitsDroppedPredecessors(t *testing.T) {
8941156 aPC, bPC := newPair()
8951157 var sendAttempts atomic.Int32
8961158 aPC.drop = func(_ []byte, addr net.Addr) bool {
@@ -902,21 +1164,16 @@ func TestOutputSupersessionSkipsDroppedOlderOutput(t *testing.T) {
9021164 defer func() { _ = b.Close() }()
9031165
9041166 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 {
9061168 t.Fatal(err)
9071169 }
9081170 }
9091171
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+ }
9201177 }
9211178 if sendAttempts.Load() < 3 {
9221179 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) {
9311188 defer func() { _ = b.Close() }()
9321189
9331190 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 {
9351192 t.Fatal(err)
9361193 }
9371194 }
@@ -994,6 +1251,39 @@ func TestResendPendingCapsBurst(t *testing.T) {
9941251 }
9951252 }
9961253
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+
9971287 func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) {
9981288 tr, writes := newResendTestTransport(t, 10)
9991289 now := tr.clock.Now()
@@ -1071,7 +1361,7 @@ func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
10711361 func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
10721362 tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
10731363
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")})
10751365 if !ack || !queued {
10761366 t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
10771367 }
@@ -1079,7 +1369,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
10791369 t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
10801370 }
10811371
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")})
10831373 if !ack || !queued {
10841374 t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
10851375 }
@@ -1087,7 +1377,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
10871377 t.Fatalf("first contiguous ack=%d, want %d", got, want)
10881378 }
10891379
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")})
10911381 if !ack || !queued {
10921382 t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
10931383 }
@@ -1318,12 +1608,12 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
13181608 tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
13191609 tr.deliverCond = sync.NewCond(&tr.deliverMu)
13201610 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)}})
13221612 }
13231613 if got := len(tr.recvBuf); got != maxRecvBuffer {
13241614 t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
13251615 }
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")})
13271617 if _, ok := tr.recvBuf[1]; !ok {
13281618 t.Fatalf("next expected sequence was dropped when far-future buffer was full")
13291619 }
@@ -1332,7 +1622,7 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
13321622 }
13331623 }
13341624
1335-func TestOutputSupersessionPrunesFullReceiveBuffer(t *testing.T) {
1625+func TestOutputPrerequisitesRemainInFullReceiveBuffer(t *testing.T) {
13361626 tr := &Transport{
13371627 nextRecvSeq: 1,
13381628 recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}},
@@ -1341,18 +1631,15 @@ func TestOutputSupersessionPrunesFullReceiveBuffer(t *testing.T) {
13411631 }
13421632 tr.deliverCond = sync.NewCond(&tr.deliverMu)
13431633
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)
13471637 }
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")
13531640 }
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")
13561643 }
13571644 }
13581645
@@ -1370,14 +1657,14 @@ func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
13701657 for i := range maxRecvBuffer {
13711658 tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
13721659 }
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")}))
13741661 select {
13751662 case pkt := <-bPC.in:
13761663 t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
13771664 case <-time.After(25 * time.Millisecond):
13781665 }
13791666
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")}))
13811668 select {
13821669 case <-bPC.in:
13831670 case <-time.After(time.Second):
@@ -1507,7 +1794,7 @@ func TestSendStallDeathRequiresOldRetransmittedFrame(t *testing.T) {
15071794 a.lastHeard = now
15081795 a.pending[1] = &pending{
15091796 frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stuck")},
1510- first: now.Add(-tt.age),
1797+ enqueued: now.Add(-tt.age),
15111798 last: now,
15121799 attempts: tt.attempts,
15131800 }
@@ -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 string19 type testAddr string
@@ -25,12 +26,13 @@ type packet struct {
25 addr net.Addr26 addr net.Addr
26 }27 }
27 type fakePC struct {28 type fakePC struct {
28- addr net.Addr29+ addr net.Addr
29- mu sync.Mutex30+ mu sync.Mutex
30- closed bool31+ closed bool
31- in chan packet32+ in chan packet
32- peers map[string]*fakePC33+ peers map[string]*fakePC
33- drop func([]byte, net.Addr) bool34+ 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), nil74 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 nil305 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.Int321157 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 = now1794 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 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 12
4+const ProtocolVersion uint16 = 13
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -1,7 +1,7 @@
1 package ports1 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 = 124+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 uint87 type MsgType uint8
M internal/ports/frame_test.go +2 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 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)
88 }
99 }
1010
@@ -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
11201120 return _c
11211121 }
11221122
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+
11231279 // 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.
11241280 // The first argument is typically a *testing.T value.
11251281 func NewMockDialer(t interface {
@@ -1120,6 +1120,162 @@ func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) erro
1120 return _c1120 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 {
4545 Close() error
4646 }
4747
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
5259 }
5360
5461 // Dialer establishes outbound Transport connections.
@@ -45,10 +45,17 @@ type Transport interface {
45 Close() error45 Close() error
46 }46 }
47 47
48-// DatagramTransport marks transports with datagram/reordered/lossy delivery48+// 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 {
6666 TermEnv string
6767 Cwd string
6868 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
7072 }
7173
7274 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -66,7 +66,9 @@ type Hello struct {
66 TermEnv string66 TermEnv string
67 Cwd string67 Cwd string
68 TrueColor bool68 TrueColor bool
69- AckOutput bool69+ // 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
352352 termEnv := os.Getenv("TERM")
353353 colorTerm := os.Getenv("COLORTERM")
354354 trueColor := DetectTrueColor(termEnv, colorTerm)
355- _, ackOutput := transport.(ports.DatagramTransport)
356355 hello := ports.Hello{
357356 Version: ports.ProtocolVersion,
358357 Intent: intent,
@@ -363,7 +362,9 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
363362 TermEnv: termEnv,
364363 Cwd: cwd,
365364 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,
367368 }
368369 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
369370 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
412413 defer cancel()
413414
414415 sendCh := make(chan ports.Frame, sendQueueDepth)
416+ ackQueue := newCumulativeAckQueue()
415417 sendErrCh := make(chan error, 1)
416418
417419 // 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
431433 if remote {
432434 clip = clipboard
433435 }
434- go runSender(loopCtx, cancel, transport, sendCh, sendErrCh, log)
436+ go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
435437 go runStdin(loopCtx, cancel, term.In(), sendCh, clk, trueColor, requestColors, clip, log)
436438 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
437439
@@ -490,14 +492,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
490492 return welcomedResult(fmt.Errorf("vev: flushing terminal: %w", ferr))
491493 }
492494 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)
501496 }
502497 if !ms.firstOutput {
503498 ms.firstOutput = true
@@ -552,22 +547,66 @@ func runRecv(ctx context.Context, transport ports.Transport, out chan<- recvResu
552547 // runSender is the sole caller of transport.Send: serialising the input and
553548 // resize pumps through one goroutine keeps the transport's single-writer
554549 // 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) {
556573 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+ }
557589 for {
558590 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+ }
559603 case f := <-in:
560604 select {
561605 case <-ctx.Done():
562606 return
563607 default:
564608 }
565- if err := transport.Send(f); err != nil {
566- select {
567- case errCh <- err:
568- default:
569- }
570- cancel()
609+ if !send(f) {
571610 return
572611 }
573612 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 to419 // 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 = clipboard434 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 = true498 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 and547 // runSender is the sole caller of transport.Send: serialising the input and
553 // resize pumps through one goroutine keeps the transport's single-writer548 // 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 return606 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 return610 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 {
151151 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
152152 }
153153
154-type datagramMarkedTransport struct{ ports.Transport }
155-
156-func (datagramMarkedTransport) DatagramTransport() {}
157-
158154 func TestAttachHelloIncludesTrueColor(t *testing.T) {
159155 t.Setenv("TERM", "xterm-256color")
160156 t.Setenv("COLORTERM", "truecolor")
@@ -180,7 +176,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
180176 defer unblock()
181177 tr.EXPECT().Close().Return(nil).Once()
182178
183- err := client.Attach(context.Background(), datagramMarkedTransport{Transport: tr}, tm, realClock{}, ports.IntentEphemeral, "")
179+ err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
184180 require.NoError(t, err)
185181
186182 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) {
169169 return nil
170170 }).Maybe()
171171 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}}
173173 acW.initOverlays()
174174 sctxW, cancelW := context.WithCancel(d.serveCtx)
175175 t.Cleanup(cancelW)
@@ -417,7 +417,7 @@ func TestCloseRingingTabRefreshesOtherSessionBottomBar(t *testing.T) {
417417 d.setAttentionFrame(1) // pulse frame 0 renders the bell glyph as blank
418418
419419 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}}
421421 acB.initOverlays()
422422 sctxB, cancelB := context.WithCancel(d.serveCtx)
423423 t.Cleanup(cancelB)
@@ -169,7 +169,7 @@ func TestNoteAttentionDoesNotBlockOnWedgedOtherClient(t *testing.T) {
169 return nil169 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 blank417 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 (
3535 "github.com/bnema/vev/internal/usecase/keys"
3636 "github.com/bnema/vev/internal/usecase/mouse"
3737 themeui "github.com/bnema/vev/internal/usecase/theme"
38- "github.com/bnema/vev/pkg/renderer"
3938 )
4039
4140 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
6660 // historyNavMu protects historyNav and historyNavTimer. When paint needs
6761 // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
6862 historyNavMu sync.Mutex
@@ -197,90 +191,26 @@ func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
197191 return tr != nil && ac.transportIs(tr)
198192 }
199193
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) {
237195 ac.sendMu.Lock()
238196 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)
264198 }
265199
266200 // takeDeferredPaint reports whether a deferred diff paint is now flushable
267201 // because acks have caught up, clearing the flag when it is. The caller must
268202 // then paint outside sendMu (paint re-acquires it).
269-func (ac *attachedClient) takeDeferredPaint() bool {
203+func (ac *attachedClient) takeDeferredPaint() (reset bool, ok bool) {
270204 ac.sendMu.Lock()
271205 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()
277207 }
278208
279209 // clearPaintDeferred drops any pending deferred-paint flag. Used on park so a
280210 // resumed client does not inherit a stale deferral.
281211 func (ac *attachedClient) clearPaintDeferred() {
282212 ac.sendMu.Lock()
283- ac.paintDeferred = false
213+ ac.output.clearDeferred()
284214 ac.sendMu.Unlock()
285215 }
286216
@@ -327,6 +257,19 @@ func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
327257 }
328258
329259 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()
330273 return d.boundedSendWith(func(capture func(ports.Transport)) error {
331274 ac.sendMu.Lock()
332275 defer ac.sendMu.Unlock()
@@ -335,14 +278,18 @@ func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (po
335278 if tr == nil {
336279 return errors.New("client transport is nil")
337280 }
338- return tr.Send(ac.nextOutputFrameLocked(b, false))
281+ return tr.Send(frame)
339282 })
340283 }
341284
342285 var errSendTimedOut = errors.New("send timed out")
343286
344287 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)
346293 result := make(chan error, 1)
347294 var (
348295 capturedMu sync.Mutex
@@ -425,7 +372,6 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
425372 type attachClientOptions struct {
426373 clientID [16]byte
427374 resumeCapable bool
428- ackOutput bool
429375 }
430376
431377 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,
433379 if opts.resumeCapable {
434380 resumeToken = d.nextResumeTokenLocked()
435381 }
436- caps := renderer.Capabilities{}
437- advanceOutputOnAck := opts.ackOutput
438- if advanceOutputOnAck {
439- caps.AdvancePolicy = renderer.AdvanceOnAck
440- }
441382 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,
450389 }
451390 ac.initOverlays()
452391 ac.setSession(sess)
@@ -558,11 +497,9 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
558497 return
559498 case ports.MsgAck:
560499 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)
566503 }
567504 }
568505 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.Transport41+ tr ports.Transport
43- rend *renderer.Renderer42+ output *outputStateStream
44- overlays *overlayRuntime43+ overlays *overlayRuntime
45- clientID [16]byte44+ clientID [16]byte
46- resumeCapable bool45+ resumeCapable bool
47- resumeToken uint6446+ resumeToken uint64
48- parked bool47+ parked bool
49- nextStateNum uint6448+ echoAck atomic.Uint64
50- echoAck atomic.Uint6449+ bars barCache // only touched while sendMu is held
51- outputAck atomic.Uint6450+ composed composedFrameCache // only touched while sendMu is held
52- paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag51+ size domain.Size
53- bars barCache // only touched while sendMu is held52+ keys *keys.Router
54- composed composedFrameCache // only touched while sendMu is held53+ sess Guarded[*session]
55- advanceOutputOnAck bool // renderer shadow advances after client MsgAck instead of send54+ mouseScan mouse.Scanner
56- outputFrames map[uint64]renderer.Frame // only touched while sendMu is held; frames sent by output state55+ themeMu sync.Mutex
57- size domain.Size56+ theme themeui.Theme
58- keys *keys.Router57+ clientTheme themeui.Theme
59- sess Guarded[*session]58+ lastCursor cursorOut
60- mouseScan mouse.Scanner59+ 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 needs60 // 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.Mutex62 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 flushable200 // takeDeferredPaint reports whether a deferred diff paint is now flushable
267 // because acks have caught up, clearing the flag when it is. The caller must201 // 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 a209 // 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 = false213+ 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.Mutex295 capturedMu sync.Mutex
@@ -425,7 +372,6 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
425 type attachClientOptions struct {372 type attachClientOptions struct {
426 clientID [16]byte373 clientID [16]byte
427 resumeCapable bool374 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 return497 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 (
1616 "github.com/bnema/vev/internal/ports"
1717 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1818 scopy "github.com/bnema/vev/internal/usecase/copy"
19- "github.com/bnema/vev/pkg/renderer"
2019 )
2120
2221 func TestHandleImagePushWritesFileWithExactBytesAndMode0600(t *testing.T) {
@@ -190,7 +189,7 @@ func TestPTYReaderForwardsOSC52ClipboardToAttachedClient(t *testing.T) {
190189 sctx, cancel := context.WithCancel(context.Background())
191190 defer cancel()
192191 win := newTestTabWithContext(p, sctx, cancel)
193- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
192+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
194193 ac.initOverlays()
195194 sess := &session{id: "clip", name: "clip", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
196195 ac.setSession(sess)
@@ -215,7 +214,7 @@ func TestPTYReaderDropsOversizedClipboardPayload(t *testing.T) {
215214 sctx, cancel := context.WithCancel(context.Background())
216215 defer cancel()
217216 win := newTestTabWithContext(p, sctx, cancel)
218- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
217+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
219218 ac.initOverlays()
220219 sess := &session{id: "clip-big", name: "clip-big", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
221220 ac.setSession(sess)
@@ -239,7 +238,7 @@ func TestPTYReaderDropsInvalidBase64Clipboard(t *testing.T) {
239238 sctx, cancel := context.WithCancel(context.Background())
240239 defer cancel()
241240 win := newTestTabWithContext(p, sctx, cancel)
242- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
241+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
243242 ac.initOverlays()
244243 sess := &session{id: "clip-bad", name: "clip-bad", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
245244 ac.setSession(sess)
@@ -274,7 +273,7 @@ func TestPTYReaderClipboardNoAttachedClientDoesNotPanic(t *testing.T) {
274273 func TestForwardClipboardAsyncSerializesClipboardWrites(t *testing.T) {
275274 d := newTestDaemon(t, nil, stubClock{})
276275 tr := newBlockingClipboardTransport()
277- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
276+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
278277 ac.initOverlays()
279278 sctx, cancel := context.WithCancel(context.Background())
280279 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 (
2525
2626 func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) {
2727 d := newTestDaemon(t, nil, stubClock{})
28- ac := &attachedClient{}
28+ ac := &attachedClient{output: newOutputStateStream()}
2929 ac.initOverlays()
3030 replacement := &closeTrackingTransport{}
3131 sendErr := errors.New("send failed")
@@ -39,6 +39,29 @@ func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) {
3939 require.Same(t, replacement, ac.transport())
4040 }
4141
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+
4265 type swapErrorTransport struct {
4366 ac *attachedClient
4467 replacement ports.Transport
@@ -53,6 +76,22 @@ func (t *swapErrorTransport) Send(ports.Frame) error {
5376 func (t *swapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
5477 func (t *swapErrorTransport) Close() error { return nil }
5578
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+
5695 // stubClock returns timers whose channel never fires, so a scheduler under it
5796 // blocks in its debounce loop until the session context is cancelled. Used by
5897
@@ -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 *attachedClient66 ac *attachedClient
44 replacement ports.Transport67 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 it95 // 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 by96 // 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
633633 }
634634 term := terminalEnv{TrueColor: h.TrueColor}
635635
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+
636642 d.mu.Lock()
637643 // Shutdown/create interlock: once shutdown has begun (last session removed,
638644 // 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
644650 }
645651 switch h.Intent {
646652 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- }
651653 // Resume miss/expiry falls back to normal attach semantics.
652654 sess := d.findByNameLocked(h.Name)
653655 if sess == nil {
@@ -665,7 +667,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
665667 }
666668 }
667669 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})
669671 sess.mu.Lock()
670672 sess.terminal = term
671673 sess.mu.Unlock()
@@ -681,7 +683,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
681683 return nil, nil, err
682684 }
683685 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})
685687 sess.mu.Lock()
686688 sess.terminal = term
687689 sess.mu.Unlock()
@@ -708,7 +710,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
708710 return nil, nil, err
709711 }
710712 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})
712714 sess.mu.Lock()
713715 sess.terminal = term
714716 sess.mu.Unlock()
@@ -733,7 +735,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
733735 }
734736 }
735737 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})
737739 sess.mu.Lock()
738740 sess.terminal = term
739741 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 may644 // 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 = term672 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, err683 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 = term688 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, err710 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 = term715 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 = term740 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
239239 t.Helper()
240240 d := newTestDaemon(t, nil, stubClock{})
241241 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}}
243243 ac.initOverlays()
244244 sctx, cancel := context.WithCancel(d.serveCtx)
245245 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 @@
11 package daemon
22
33 import (
4+ "errors"
45 "io"
56 "sync"
67 "testing"
8+ "time"
79
810 "github.com/bnema/vev/internal/domain"
911 "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/pkg/renderer"
13+ "github.com/bnema/vev/pkg/vt"
1014 "github.com/stretchr/testify/require"
1115 )
1216
@@ -16,6 +20,37 @@ type datagramTestTransport struct {
1620 once sync.Once
1721 }
1822
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+
1954 func newDatagramTestTransport() *datagramTestTransport {
2055 return &datagramTestTransport{sends: make(chan ports.Frame, 64), recv: make(chan ports.Frame, 64)}
2156 }
@@ -30,7 +65,7 @@ func (t *datagramTestTransport) Recv() (ports.Frame, error) {
3065 }
3166 func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }
3267
33-func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) {
68+func TestDatagramAttachPipelinesRendererBeforeAck(t *testing.T) {
3469 p, releasePTY := newBlockingPTY(t)
3570 defer releasePTY()
3671 d, sess, _, _ := newManualSessionWithPTYs(t, p)
@@ -52,21 +87,114 @@ func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) {
5287 require.NoError(t, err)
5388 require.Equal(t, uint64(1), out.NewStateNum)
5489
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.
5792 d.paint(sess, ac, false)
58- awaitFrame(t, tr.sends, ports.MsgOutput)
93+ requireNoOutputFrame(t, tr.sends)
5994
6095 tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}
6196 require.NoError(t, tr.Close())
6297 d.runConnLoop(ac)
6398
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.
66101 d.paint(sess, ac, false)
67102 requireNoOutputFrame(t, tr.sends)
68103 }
69104
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+
70198 func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) {
71199 p, releasePTY := newBlockingPTY(t)
72200 defer releasePTY()
@@ -76,8 +204,7 @@ func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) {
76204 d.paint(sess, ac, false)
77205 awaitFrame(t, sends, ports.MsgOutput)
78206
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.
81208 d.paint(sess, ac, false)
82209 requireNoOutputFrame(t, sends)
83210 }
@@ -98,7 +225,46 @@ func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
98225 d.paint(sess, ac, false)
99226 awaitFrame(t, sends, ports.MsgOutput)
100227
101- require.NoError(t, ac.ackOutputState(out.NewStateNum))
228+ ac.ackOutputState(out.NewStateNum)
102229 d.paint(sess, ac, false)
103230 requireNoOutputFrame(t, sends)
104231 }
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 daemon1 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.Once20 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 unacked90+ // 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 the207+ // 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 (
77
88 "github.com/bnema/vev/internal/ports"
99 portsmocks "github.com/bnema/vev/internal/ports/mocks"
10+ "github.com/bnema/vev/pkg/vt"
1011 "github.com/stretchr/testify/require"
1112 )
1213
@@ -31,12 +32,12 @@ func TestPaintDefersWhenOutputAckLagsAtCap(t *testing.T) {
3132 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
3233
3334 // Client is maxUnackedOutputStates behind (outputAck stays 0).
34- ac.nextStateNum = maxUnackedOutputStates
35+ ac.output.next = maxUnackedOutputStates
3536 d.paint(sess, ac, false)
3637
3738 requireNoOutputFrame(t, sends)
3839 ac.sendMu.Lock()
39- deferred := ac.paintDeferred
40+ deferred := ac.output.deferred
4041 ac.sendMu.Unlock()
4142 require.True(t, deferred, "diff paint at ack cap must defer")
4243 }
@@ -46,7 +47,7 @@ func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) {
4647 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
4748 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
4849
49- ac.nextStateNum = maxUnackedOutputStates
50+ ac.output.next = maxUnackedOutputStates
5051 d.paint(sess, ac, false)
5152 requireNoOutputFrame(t, sends)
5253
@@ -74,25 +75,61 @@ func TestDeferredPaintFlushesOnceWhenAcksCatchUp(t *testing.T) {
7475 awaitFrame(t, sends, ports.MsgOutput)
7576 requireNoOutputFrame(t, sends)
7677 ac.sendMu.Lock()
77- deferred := ac.paintDeferred
78+ deferred := ac.output.deferred
7879 ac.sendMu.Unlock()
7980 require.False(t, deferred, "flag must clear after the flush paint")
8081 }
8182
82-func TestResetPaintBypassesAckGate(t *testing.T) {
83+func TestResetPaintCoalescesAtAckGate(t *testing.T) {
8384 p, _ := newBlockingPTY(t)
8485 d, sess, ac, sends := newManualSessionWithPTYs(t, p)
8586 sess.tabs[0].focusedPane().screen.Write([]byte("A"))
8687
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+ }
9294
93- awaitFrame(t, sends, ports.MsgOutput)
95+ requireNoOutputFrame(t, sends)
9496 ac.sendMu.Lock()
95- deferred := ac.paintDeferred
97+ deferred := ac.output.deferred
98+ deferredReset := ac.output.deferredReset
99+ retained := ac.output.outstanding()
96100 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)
98135 }
@@ -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 = maxUnackedOutputStates35+ 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.paintDeferred40+ 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 = maxUnackedOutputStates50+ 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.paintDeferred78+ 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 that88+ // 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 = maxUnackedOutputStates90+ ac.output.next = maxUnackedOutputStates
90- ac.paintDeferred = true91+ 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.paintDeferred97+ 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 (
1616 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1717 scopy "github.com/bnema/vev/internal/usecase/copy"
1818 "github.com/bnema/vev/internal/usecase/layout"
19- "github.com/bnema/vev/pkg/renderer"
2019 )
2120
2221 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
@@ -151,7 +150,7 @@ func TestFocusDirMovesFocusAndExitsCopyMode(t *testing.T) {
151150 tb.panes["pane-1"].rect = domain.Rect{Width: 20, Height: 10}
152151 tb.panes["pane-2"].rect = domain.Rect{X: 21, Width: 20, Height: 10}
153152 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}}
155154 ac.initOverlays()
156155 ac.overlays.copyMode = &scopy.Mode{}
157156
@@ -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 (
1515 "github.com/bnema/vev/internal/usecase/keys"
1616 "github.com/bnema/vev/internal/usecase/layout"
1717 "github.com/bnema/vev/internal/usecase/picker"
18- "github.com/bnema/vev/pkg/renderer"
1918 )
2019
2120 // --- test doubles -----------------------------------------------------------
@@ -172,9 +171,9 @@ func TestPickerCrossSessionSwitchDetachesExistingClient(t *testing.T) {
172171 d := newTestDaemon(t, nil, stubClock{})
173172 tr1, sends1 := newCapturingTransport(t)
174173 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}}
176175 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}}
178177 ac2.initOverlays()
179178 sctx1, cancel1 := context.WithCancel(d.serveCtx)
180179 sctx2, cancel2 := context.WithCancel(d.serveCtx)
@@ -214,9 +213,9 @@ func TestPickerStalePaintAfterSessionSwitchSendsNoFrame(t *testing.T) {
214213 d := newTestDaemon(t, nil, stubClock{})
215214 tr1, sends1 := newCapturingTransport(t)
216215 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}}
218217 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}}
220219 ac2.initOverlays()
221220 sctx1, cancel1 := context.WithCancel(d.serveCtx)
222221 sctx2, cancel2 := context.WithCancel(d.serveCtx)
@@ -263,7 +262,7 @@ func TestPickerSessionSwitchWaitsForInFlightPaintSend(t *testing.T) {
263262 return nil
264263 }).Once()
265264 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}}
267266 ac.initOverlays()
268267 sctx1, cancel1 := context.WithCancel(d.serveCtx)
269268 sctx2, cancel2 := context.WithCancel(d.serveCtx)
@@ -324,8 +323,8 @@ func TestPickerCrossSessionSwitchCopiesTerminalEnvForFutureTabs(t *testing.T) {
324323 d := newTestDaemon(t, f, stubClock{})
325324 tr1, _ := newCapturingTransport(t)
326325 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}}
329328 sctx1, cancel1 := context.WithCancel(d.serveCtx)
330329 sctx2, cancel2 := context.WithCancel(d.serveCtx)
331330 defer cancel1()
@@ -425,9 +424,9 @@ func TestPickerLivePreviewRepaintsCrossSessionTab(t *testing.T) {
425424 d := newTestDaemon(t, nil, stubClock{})
426425 tr1, sends1 := newCapturingTransport(t)
427426 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}}
429428 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}}
431430 ac2.initOverlays()
432431 sctx1, cancel1 := context.WithCancel(d.serveCtx)
433432 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 nil262 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) {
286286 }
287287 // Ack-gated coalescing: while the client is at least maxUnackedOutputStates
288288 // 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) {
296292 ac.sendMu.Unlock()
297293 return
298294 }
@@ -332,10 +328,8 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
332328 ac.sendMu.Unlock()
333329 return
334330 }
335- reset = ac.shouldResetOutputState(reset)
336331 overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive
337332 if reset || overlayActive {
338- ac.rend.Reset()
339333 ac.bars.Reset()
340334 ac.composed.invalidate()
341335 }
@@ -384,7 +378,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
384378 ac.sendMu.Unlock()
385379 return
386380 }
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+ }
388386 var cursorTail []byte
389387 if err == nil {
390388 cursorTail = ac.encodeCursorTail(desiredCursor, len(data) > 0)
@@ -400,13 +398,11 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
400398 if sendTr == nil {
401399 serr = errors.New("client transport is nil")
402400 } 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
408404 }
409- serr = sendTr.Send(out)
405+ serr = prepared.send(data, ac.echoAck.Load(), send)
410406 }
411407 }
412408 }
@@ -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 maxUnackedOutputStates287 // Ack-gated coalescing: while the client is at least maxUnackedOutputStates
288 // behind, skip composing another diff and mark the paint deferred. The288 // 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 queued290+ // 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 return293 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 return329 return
334 }330 }
335- reset = ac.shouldResetOutputState(reset)
336 overlayActive := overlays.copyActive || overlays.copySearchModel != nil || overlays.pickerActive || overlays.paletteActive || overlays.promptActive331 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 return379 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 []byte386 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) {
222222 d := New(nil, mc, slog.New(slog.NewTextHandler(io.Discard, nil)))
223223 sctx, cancel := context.WithCancel(context.Background())
224224 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()}
226226 ac.initOverlays()
227227 sess := &session{id: "s", name: "s", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
228228 ac.setSession(sess)
@@ -286,7 +286,7 @@ func TestResizePreservesLiveContentAndEvictsScrollback(t *testing.T) {
286286 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
287287
288288 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()}
290290 ac.initOverlays()
291291 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
292292 ac.setSession(sess)
@@ -397,7 +397,7 @@ func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) {
397397 func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) {
398398 tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
399399 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}}
401401 ac.initOverlays()
402402 sess.client = ac
403403 ac.setSession(sess)
@@ -406,7 +406,7 @@ func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) {
406406 tb.focusedPane().screen.Write([]byte("x"))
407407 d.render(sess, tb, tb.focusedPane())
408408
409- require.Zero(t, ac.nextStateNum)
409+ require.Zero(t, ac.output.next)
410410 }
411411
412412 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
@@ -560,7 +560,7 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
560560 left.screen.Write([]byte("left"))
561561 right.screen.Write([]byte("right"))
562562 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}}
564564 ac.initOverlays()
565565 sess.client = ac
566566 ac.setSession(sess)
@@ -576,7 +576,7 @@ func BenchmarkPaintCachedSinglePaneDamage(b *testing.B) {
576576 d.paint(sess, ac, false)
577577 // Mirror the client ACK pump so the steady state measures compose→diff→send,
578578 // not the ack-gate bailout after maxUnackedOutputStates frames.
579- ac.advanceOutputAck(ac.nextStateNum)
579+ ac.ackOutputState(ac.output.next)
580580 }
581581 }
582582
@@ -849,7 +849,7 @@ func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
849849 }).Maybe()
850850
851851 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()}
853853 ac.initOverlays()
854854 sess := &session{id: "s", name: "s", tabs: []*tab{win}, client: ac}
855855 ac.setSession(sess)
@@ -875,7 +875,7 @@ func TestSendErrorKeepsEphemeralHeadless(t *testing.T) {
875875 d := newTestDaemon(t, nil, stubClock{})
876876 win := newTab(p, domain.Size{Cols: 20, Rows: 5})
877877 sctx, cancel := context.WithCancel(context.Background())
878- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
878+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
879879 ac.initOverlays()
880880 sess := &session{id: "e", name: "0", ephemeral: true, tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
881881 ac.setSession(sess)
@@ -927,7 +927,7 @@ func TestSchedulerDefersPendingDirtyTimerDuringSynchronizedUpdate(t *testing.T)
927927 win.ctx, win.cancel = sctx, cancel
928928 p := win.focusedPane()
929929 p.ctx, p.cancel = sctx, cancel
930- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
930+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
931931 ac.initOverlays()
932932 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
933933 ac.setSession(sess)
@@ -988,7 +988,7 @@ func TestPTYQueryGetsResponseWrittenBackToPTY(t *testing.T) {
988988 tr, sends := newCapturingTransport(t)
989989 sctx, cancel := context.WithCancel(context.Background())
990990 win := newTestTabWithContext(p, sctx, cancel)
991- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
991+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
992992 ac.initOverlays()
993993 sess := &session{id: "query", name: "query", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
994994 ac.setSession(sess)
@@ -1094,7 +1094,7 @@ func TestSyncUpdateWatchdogAbandonedSyncRenders(t *testing.T) {
10941094 tr, sends := newCapturingTransport(t)
10951095 sctx, cancel := context.WithCancel(context.Background())
10961096 win := newTestTabWithContext(p, sctx, cancel)
1097- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1097+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
10981098 ac.initOverlays()
10991099 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
11001100 ac.setSession(sess)
@@ -1139,7 +1139,7 @@ func TestSyncUpdateWatchdogStaleGenerationNoopAfterEnd(t *testing.T) {
11391139 tr, sends := newCapturingTransport(t)
11401140 sctx, cancel := context.WithCancel(context.Background())
11411141 win := newTestTabWithContext(p, sctx, cancel)
1142- ac := &attachedClient{tr: tr, rend: renderer.New(renderer.Capabilities{})}
1142+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
11431143 ac.initOverlays()
11441144 sess := &session{id: "sync", name: "sync", tabs: []*tab{win}, ctx: sctx, cancel: cancel, client: ac}
11451145 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 = ac402 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 = ac565 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, cancel927 win.ctx, win.cancel = sctx, cancel
928 p := win.focusedPane()928 p := win.focusedPane()
929 p.ctx, p.cancel = sctx, cancel929 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) {
120120 }
121121 }
122122
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.
123151 func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) {
124152 parked := d.parked[h.ResumeToken]
125153 if parked == nil || h.ResumeToken == 0 {
@@ -143,6 +171,10 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
143171 delete(d.parked, h.ResumeToken)
144172 parked.timer.Stop()
145173 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()
146178 ac.replaceTransport(tr)
147179 ac.size = sz
148180 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 = sz179 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) {
127127 }
128128
129129 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")
136136
137137 ac.sendMu.Lock()
138- ac.nextStateNum = 5
139- reset := ac.shouldResetOutputState(false)
138+ ac.output.next = 5
139+ reset := false
140140 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
141141
142- f := ac.nextOutputFrameLocked([]byte("incremental while reliable backlog drains"), reset)
142+ f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0)
143143 ac.sendMu.Unlock()
144144 out, err := ports.UnmarshalOutput(f.Payload)
145145 require.NoError(t, err)
@@ -147,9 +147,9 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
147147 require.Equal(t, uint64(6), out.NewStateNum)
148148
149149 ac.sendMu.Lock()
150- reset = ac.shouldResetOutputState(true)
150+ reset = true
151151 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)
153153 ac.sendMu.Unlock()
154154 fullOut, err := ports.UnmarshalOutput(full.Payload)
155155 require.NoError(t, err)
@@ -170,8 +170,8 @@ func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
170170
171171 wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
172172 wrongClient.ClientID = [16]byte{9, 9, 9, 9}
173+ _, _, ok, err := d.resumeParked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
173174 d.mu.Lock()
174- _, _, ok, err := d.resumeParkedLocked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
175175 _, stillParked := d.parked[token]
176176 d.mu.Unlock()
177177 require.Error(t, err)
@@ -200,9 +200,7 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
200200 require.True(t, d.parkAttachment(sess, ac))
201201
202202 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})
206204 require.NoError(t, err)
207205 require.True(t, ok)
208206 require.Same(t, sess, resumedSess)
@@ -214,6 +212,41 @@ func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.
214212 require.Same(t, newTr, ac.transport())
215213 }
216214
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+
217250 func TestExplicitDetachDoesNotPark(t *testing.T) {
218251 pty, release := newBlockingPTY(t)
219252 defer release()
@@ -331,8 +364,8 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
331364 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
332365 d.mu.Lock()
333366 _, parked := d.parked[token]
334- _, _, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
335367 d.mu.Unlock()
368+ _, _, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
336369 require.False(t, parked, "killSession purges parked token")
337370 require.NoError(t, err)
338371 require.False(t, ok, "killed session cannot be resumed")
@@ -355,8 +388,8 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
355388
356389 d.mu.Lock()
357390 _, parked := d.parked[token]
358- _, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
359391 d.mu.Unlock()
392+ _, resumedAC, ok, err := d.resumeParked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
360393 require.False(t, parked, "normal attach invalidates stale parked token")
361394 require.NoError(t, err)
362395 require.False(t, ok)
@@ -382,10 +415,10 @@ func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
382415 require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")
383416 }
384417
385-func TestOutputStateNumberingIsSharedAndMonotone(t *testing.T) {
418+func TestRawTerminalSideEffectsAreOutputStateNeutral(t *testing.T) {
386419 d := newTestDaemon(t, nil, stubClock{})
387420 tr := &closeTrackingTransport{}
388- ac := &attachedClient{tr: tr}
421+ ac := &attachedClient{tr: tr, output: newOutputStateStream()}
389422 ac.initOverlays()
390423
391424 require.NoError(t, d.boundedSendOutputErr(ac, []byte("copy")))
@@ -397,8 +430,10 @@ func TestOutputStateNumberingIsSharedAndMonotone(t *testing.T) {
397430 require.NoError(t, err)
398431 second, err := ports.UnmarshalOutput(sends[1].Payload)
399432 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)
402437 }
403438
404439 func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) {
@@ -429,9 +464,7 @@ func TestResumeParkedUpdatesTerminalEnv(t *testing.T) {
429464
430465 resumeHello := helloResumeCapable(ports.IntentResume, "work", token)
431466 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})
435468 require.NoError(t, err)
436469 require.True(t, ok)
437470 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 = 3131+ 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 = 5138+ 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 = true466 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{
1212 New: func() any { return new(bytes.Buffer) },
1313 }
1414
15-type AdvancePolicy uint8
16-
17-const (
18- AdvanceOnSend AdvancePolicy = iota
19- AdvanceOnAck
20-)
21-
2215 type Capabilities struct {
2316 SynchronizedOutput bool
24- AdvancePolicy AdvancePolicy
2517 }
2618
2719 type Renderer struct {
@@ -93,9 +85,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
9385 // emitScrollUp resets the SGR pen to default (matching st's initial
9486 // pen) but leaves the cursor wherever the DECSTBM restore put it —
9587 // terminal-dependent, so cursor tracking stays invalidated.
96- if r.caps.AdvancePolicy == AdvanceOnSend {
97- r.applyScroll(scroll)
98- }
88+ r.applyScroll(scroll)
9989 r.writeDamage(buf, frame, damage, &scroll, &st)
10090 r.advanceDamage(frame, damage, &scroll)
10191 if r.caps.SynchronizedOutput {
@@ -218,27 +208,11 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool {
218208 return false
219209 }
220210
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-
231211 func (r *Renderer) advanceShadow(frame Frame) {
232- if r.caps.AdvancePolicy == AdvanceOnAck {
233- return
234- }
235212 r.replaceShadow(frame)
236213 }
237214
238215 func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) {
239- if r.caps.AdvancePolicy == AdvanceOnAck {
240- return
241- }
242216 r.syncDamage(frame, damage, scroll)
243217 }
244218
@@ -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 bool16 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 initial85 // 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 false208 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close