detached b9d3800

Merge remote-tracking branch 'origin/main' into fix/udp-send-pacing-coalescing

# Conflicts:
#	internal/adapters/dgram/transport.go
commit
b9d38006f5d4bfe77e4e67236e57c756667c4fa2
author
brice · · 2026-07-09 19:11 UTC
merge of
d25aab8 + c01d864 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
38 files · +1116 -194
M README.md +1 -0
@@ -173,6 +173,7 @@ Successful commands are promoted near the top the next time the palette opens.
173173 | SSP | open session picker |
174174 | VIS | enter visual mode |
175175 | RNS | rename session |
176+| RNT | rename current tab |
176177 | DET | detach |
177178
178179 Rename prompts are prefilled with the current session name. Empty names and names already used by another session are rejected.
@@ -173,6 +173,7 @@ Successful commands are promoted near the top the next time the palette opens.
173 | SSP | open session picker |173 | SSP | open session picker |
174 | VIS | enter visual mode |174 | VIS | enter visual mode |
175 | RNS | rename session |175 | RNS | rename session |
176+| RNT | rename current tab |
176 | DET | detach |177 | DET | detach |
177 178
178 Rename prompts are prefilled with the current session name. Empty names and names already used by another session are rejected.179 Rename prompts are prefilled with the current session name. Empty names and names already used by another session are rejected.
M internal/adapters/dgram/output_pacer.go +5 -4
@@ -7,9 +7,10 @@ import (
77 )
88
99 type queuedSend struct {
10- seq uint64
11- reliable bool
12- frame ports.Frame
10+ seq uint64
11+ reliable bool
12+ frame ports.Frame
13+ outputSkipThrough uint64
1314 }
1415
1516 func (t *Transport) flushQueuedOutputLocked() []queuedSend {
@@ -104,7 +105,7 @@ func shouldPaceOutput(f ports.Frame) bool {
104105
105106 func (t *Transport) sendQueuedData(q queuedSend) error {
106107 t.markPendingSent(q.seq, q.reliable)
107- if err := t.sendData(q.seq, q.reliable, q.frame); err != nil {
108+ if err := t.sendData(q.seq, q.reliable, q.frame, q.outputSkipThrough); err != nil {
108109 t.removePending(q.seq, q.reliable)
109110 return err
110111 }
@@ -7,9 +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 uint64
13 }14 }
14 15
15 func (t *Transport) flushQueuedOutputLocked() []queuedSend {16 func (t *Transport) flushQueuedOutputLocked() []queuedSend {
@@ -104,7 +105,7 @@ func shouldPaceOutput(f ports.Frame) bool {
104 105
105 func (t *Transport) sendQueuedData(q queuedSend) error {106 func (t *Transport) sendQueuedData(q queuedSend) error {
106 t.markPendingSent(q.seq, q.reliable)107 t.markPendingSent(q.seq, q.reliable)
107- if err := t.sendData(q.seq, q.reliable, q.frame); err != nil {108+ if err := t.sendData(q.seq, q.reliable, q.frame, q.outputSkipThrough); err != nil {
108 t.removePending(q.seq, q.reliable)109 t.removePending(q.seq, q.reliable)
109 return err110 return err
110 }111 }
M internal/adapters/dgram/proxy_test.go +27 -0
@@ -78,6 +78,33 @@ func TestProxyRuntimeCopiesFramesAndDaemonEOFIsTerminal(t *testing.T) {
7878 }
7979 }
8080
81+func TestProxyRuntimeForwardsOutputAckCapabilityInHello(t *testing.T) {
82+ client := newFakeTransport()
83+ daemon := newFakeTransport()
84+ errCh := make(chan error, 1)
85+ go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
86+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", AckOutput: true}
87+ client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}}
88+
89+ select {
90+ case frame := <-daemon.sent:
91+ got, err := ports.UnmarshalHello(frame.Payload)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ if !got.AckOutput {
96+ t.Fatal("proxied Hello lost AckOutput capability")
97+ }
98+ case <-time.After(time.Second):
99+ t.Fatal("timeout waiting for proxied Hello")
100+ }
101+
102+ daemon.recv <- recvResult{err: io.EOF}
103+ if err := <-errCh; err != nil {
104+ t.Fatalf("Run err=%v, want nil", err)
105+ }
106+}
107+
81108 func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
82109 tests := []struct {
83110 name string
@@ -78,6 +78,33 @@ func TestProxyRuntimeCopiesFramesAndDaemonEOFIsTerminal(t *testing.T) {
78 }78 }
79 }79 }
80 80
81+func TestProxyRuntimeForwardsOutputAckCapabilityInHello(t *testing.T) {
82+ client := newFakeTransport()
83+ daemon := newFakeTransport()
84+ errCh := make(chan error, 1)
85+ go func() { errCh <- ProxyRuntime{Client: client, Daemon: daemon, IdleTTL: time.Hour}.Run(t.Context()) }()
86+ hello := ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", AckOutput: true}
87+ client.recv <- recvResult{frame: ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}}
88+
89+ select {
90+ case frame := <-daemon.sent:
91+ got, err := ports.UnmarshalHello(frame.Payload)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ if !got.AckOutput {
96+ t.Fatal("proxied Hello lost AckOutput capability")
97+ }
98+ case <-time.After(time.Second):
99+ t.Fatal("timeout waiting for proxied Hello")
100+ }
101+
102+ daemon.recv <- recvResult{err: io.EOF}
103+ if err := <-errCh; err != nil {
104+ t.Fatalf("Run err=%v, want nil", err)
105+ }
106+}
107+
81 func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {108 func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) {
82 tests := []struct {109 tests := []struct {
83 name string110 name string
M internal/adapters/dgram/transport.go +160 -77
@@ -182,19 +182,21 @@ type Transport struct {
182182 // writeMu directly because they do not establish new sequence order.
183183 outboundMu sync.Mutex
184184
185- deliverMu sync.Mutex
186- deliverCond *sync.Cond
187- nextRecvSeq uint64
188- recvBuf map[uint64]ports.Frame
185+ deliverMu sync.Mutex
186+ deliverCond *sync.Cond
187+ nextRecvSeq uint64
188+ recvBuf map[uint64]ports.Frame
189+ recvSkipThrough uint64
189190 }
190191
191192 type pending struct {
192- frame ports.Frame
193- first time.Time
194- last time.Time
195- initialInFlight bool
196- attempts int
197- retransmitted bool
193+ frame ports.Frame
194+ first time.Time
195+ last time.Time
196+ initialInFlight bool
197+ attempts int
198+ retransmitted bool
199+ outputSkipThrough uint64
198200 }
199201
200202 func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {
@@ -253,11 +255,14 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
253255 return t, nil
254256 }
255257
258+// DatagramTransport marks this transport as UDP/datagram-backed for daemon
259+// output-state policy selection.
260+func (t *Transport) DatagramTransport() {}
261+
256262 func (t *Transport) Send(f ports.Frame) error {
257- // Until full ACK-driven terminal state sync exists, every frame is reliable;
258- // dropping MsgOutput can permanently desynchronize the client screen.
259263 reliable := true
260- if err := t.lockOutboundSlot(reliable); err != nil {
264+ outputSkipThrough, err := t.lockOutboundSlot(f, reliable)
265+ if err != nil {
261266 return err
262267 }
263268 defer t.outboundMu.Unlock()
@@ -266,12 +271,12 @@ func (t *Transport) Send(f ports.Frame) error {
266271 t.seq++
267272 seq := t.seq
268273 if reliable {
269- t.pending[seq] = &pending{frame: f}
274+ t.pending[seq] = &pending{frame: f, outputSkipThrough: outputSkipThrough}
270275 }
271276 if shouldPaceOutput(f) {
272277 now := t.clock.Now()
273278 if len(t.outputQueue) > 0 || (!t.outputNext.IsZero() && now.Before(t.outputNext)) {
274- t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f})
279+ t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f, outputSkipThrough: outputSkipThrough})
275280 t.notifyOutputPacerLocked()
276281 t.mu.Unlock()
277282 return nil
@@ -279,7 +284,7 @@ func (t *Transport) Send(f ports.Frame) error {
279284 t.outputNext = now.Add(t.outputPaceDelayLocked())
280285 t.mu.Unlock()
281286 t.markPendingSent(seq, reliable)
282- if err := t.sendData(seq, reliable, f); err != nil {
287+ if err := t.sendData(seq, reliable, f, outputSkipThrough); err != nil {
283288 t.removePending(seq, reliable)
284289 return err
285290 }
@@ -296,7 +301,7 @@ func (t *Transport) Send(f ports.Frame) error {
296301 }
297302 }
298303 t.markPendingSent(seq, reliable)
299- if err := t.sendData(seq, reliable, f); err != nil {
304+ if err := t.sendData(seq, reliable, f, outputSkipThrough); err != nil {
300305 t.removePending(seq, reliable)
301306 return err
302307 }
@@ -304,44 +309,10 @@ func (t *Transport) Send(f ports.Frame) error {
304309 return nil
305310 }
306311
307-func (t *Transport) lockOutboundSlot(reliable bool) error {
312+func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, error) {
308313 var pendingWaitStarted time.Time
314+ var outputSkipThrough uint64
309315 for {
310- t.mu.Lock()
311- for reliable && len(t.pending) >= t.maxPending && !t.closed {
312- if t.linkState != ports.LinkStateConnected {
313- t.mu.Unlock()
314- return ErrPendingFull
315- }
316- if pendingWaitStarted.IsZero() {
317- pendingWaitStarted = t.clock.Now()
318- }
319- remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
320- if remaining <= 0 {
321- t.mu.Unlock()
322- return ErrPendingFull
323- }
324- wake := t.sendWake
325- timer := t.clock.NewTimer(remaining)
326- t.mu.Unlock()
327- select {
328- case <-timer.C():
329- case <-wake:
330- case <-t.done:
331- }
332- timer.Stop()
333- t.mu.Lock()
334- }
335- if t.closed {
336- err := t.closeErr
337- t.mu.Unlock()
338- if err != nil {
339- return err
340- }
341- return errors.New("dgram: closed")
342- }
343- t.mu.Unlock()
344-
345316 t.outboundMu.Lock()
346317 t.mu.Lock()
347318 if t.closed {
@@ -349,15 +320,42 @@ func (t *Transport) lockOutboundSlot(reliable bool) error {
349320 t.mu.Unlock()
350321 t.outboundMu.Unlock()
351322 if err != nil {
352- return err
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
353330 }
354- return errors.New("dgram: closed")
355331 }
356332 if !reliable || len(t.pending) < t.maxPending {
357- return nil
333+ return outputSkipThrough, nil
334+ }
335+ if t.linkState != ports.LinkStateConnected {
336+ t.mu.Unlock()
337+ t.outboundMu.Unlock()
338+ return 0, ErrPendingFull
339+ }
340+ if pendingWaitStarted.IsZero() {
341+ pendingWaitStarted = t.clock.Now()
342+ }
343+ remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
344+ if remaining <= 0 {
345+ t.mu.Unlock()
346+ t.outboundMu.Unlock()
347+ return 0, ErrPendingFull
358348 }
349+ wake := t.sendWake
350+ timer := t.clock.NewTimer(remaining)
359351 t.mu.Unlock()
360352 t.outboundMu.Unlock()
353+ select {
354+ case <-timer.C():
355+ case <-wake:
356+ case <-t.done:
357+ }
358+ timer.Stop()
361359 }
362360 }
363361
@@ -395,6 +393,41 @@ func (t *Transport) removePendingLocked(seq uint64) bool {
395393 return true
396394 }
397395
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+}
398431 func (t *Transport) Recv() (ports.Frame, error) {
399432 select {
400433 case f := <-t.in:
@@ -476,8 +509,8 @@ func (t *Transport) unregisterProbe(id uint64) {
476509 t.mu.Unlock()
477510 }
478511
479-func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {
480- p := encodeData(seq, reliable, f)
512+func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame, outputSkipThrough uint64) error {
513+ p := encodeData(seq, reliable, f, outputSkipThrough)
481514 return t.sendPayload(p)
482515 }
483516 func (t *Transport) sendAck(seq uint64) {
@@ -636,12 +669,12 @@ func (t *Transport) handleRecord(p []byte) {
636669 }
637670 t.mu.Unlock()
638671 case recData:
639- seq, reliable, f, ok := decodeData(p)
672+ seq, reliable, f, outputSkipThrough, ok := decodeData(p)
640673 if !ok {
641674 return
642675 }
643676 if reliable {
644- ackSeq, ack, queued := t.enqueueReliable(seq, f)
677+ ackSeq, ack, queued := t.enqueueReliable(seq, f, outputSkipThrough)
645678 if ack {
646679 t.sendAck(ackSeq)
647680 }
@@ -654,9 +687,18 @@ func (t *Transport) handleRecord(p []byte) {
654687 }
655688 }
656689
657-func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, ack bool, queued bool) {
690+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame, outputSkipThrough uint64) (ackSeq uint64, ack bool, queued bool) {
658691 t.deliverMu.Lock()
659692 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+ }
660702 if seq < t.nextRecvSeq {
661703 return t.highestContiguousRecvLocked(), true, false
662704 }
@@ -673,10 +715,15 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, a
673715 func (t *Transport) highestContiguousRecvLocked() uint64 {
674716 seq := t.nextRecvSeq
675717 for {
676- if _, ok := t.recvBuf[seq]; !ok {
677- break
718+ if _, ok := t.recvBuf[seq]; ok {
719+ seq++
720+ continue
678721 }
679- seq++
722+ if seq <= t.recvSkipThrough {
723+ seq++
724+ continue
725+ }
726+ break
680727 }
681728 return seq - 1
682729 }
@@ -691,6 +738,10 @@ func (t *Transport) deliveryLoop() {
691738 for {
692739 t.deliverMu.Lock()
693740 f, ok := t.recvBuf[t.nextRecvSeq]
741+ for !ok && t.nextRecvSeq <= t.recvSkipThrough {
742+ t.nextRecvSeq++
743+ f, ok = t.recvBuf[t.nextRecvSeq]
744+ }
694745 for !ok {
695746 if t.isClosed() {
696747 t.deliverMu.Unlock()
@@ -698,6 +749,16 @@ func (t *Transport) deliveryLoop() {
698749 }
699750 t.deliverCond.Wait()
700751 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
701762 }
702763 delete(t.recvBuf, t.nextRecvSeq)
703764 t.nextRecvSeq++
@@ -764,8 +825,9 @@ func (t *Transport) resendLoop() {
764825 func (t *Transport) resendPending() {
765826 now := t.clock.Now()
766827 var resend []struct {
767- seq uint64
768- f ports.Frame
828+ seq uint64
829+ f ports.Frame
830+ outputSkipThrough uint64
769831 }
770832 t.mu.Lock()
771833 seqs := make([]uint64, 0, len(t.pending))
@@ -786,14 +848,15 @@ func (t *Transport) resendPending() {
786848 p.attempts++
787849 p.retransmitted = true
788850 resend = append(resend, struct {
789- seq uint64
790- f ports.Frame
791- }{seq, p.frame})
851+ seq uint64
852+ f ports.Frame
853+ outputSkipThrough uint64
854+ }{seq, p.frame, p.outputSkipThrough})
792855 limit--
793856 }
794857 t.mu.Unlock()
795858 for _, r := range resend {
796- _ = t.sendData(r.seq, true, r.f)
859+ _ = t.sendData(r.seq, true, r.f, r.outputSkipThrough)
797860 }
798861 }
799862
@@ -976,21 +1039,41 @@ func (t *Transport) closeWithError(err error) {
9761039 t.deliverMu.Unlock()
9771040 }
9781041
979-func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
980- b := make([]byte, 12+len(f.Payload))
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))
9811048 b[0] = recData
9821049 binary.BigEndian.PutUint64(b[1:9], seq)
9831050 if reliable {
9841051 b[9] = 1
9851052 }
9861053 b[10] = byte(f.Type)
987- b[11] = 0
988- copy(b[12:], f.Payload)
1054+ if outputSkipThrough > 0 {
1055+ b[11] = 1
1056+ binary.BigEndian.PutUint64(b[12:20], outputSkipThrough)
1057+ }
1058+ copy(b[headerLen:], f.Payload)
9891059 return b
9901060 }
991-func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
1061+func decodeData(b []byte) (uint64, bool, ports.Frame, uint64, bool) {
9921062 if len(b) < 12 || b[0] != recData {
993- return 0, false, ports.Frame{}, false
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])
9941077 }
995- return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true
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
9961079 }
@@ -182,19 +182,21 @@ type Transport struct {
182 // writeMu directly because they do not establish new sequence order.182 // writeMu directly because they do not establish new sequence order.
183 outboundMu sync.Mutex183 outboundMu sync.Mutex
184 184
185- deliverMu sync.Mutex185+ deliverMu sync.Mutex
186- deliverCond *sync.Cond186+ deliverCond *sync.Cond
187- nextRecvSeq uint64187+ nextRecvSeq uint64
188- recvBuf map[uint64]ports.Frame188+ recvBuf map[uint64]ports.Frame
189+ recvSkipThrough uint64
189 }190 }
190 191
191 type pending struct {192 type pending struct {
192- frame ports.Frame193+ frame ports.Frame
193- first time.Time194+ first time.Time
194- last time.Time195+ last time.Time
195- initialInFlight bool196+ initialInFlight bool
196- attempts int197+ attempts int
197- retransmitted bool198+ retransmitted bool
199+ outputSkipThrough uint64
198 }200 }
199 201
200 func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {202 func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {
@@ -253,11 +255,14 @@ func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendD
253 return t, nil255 return t, nil
254 }256 }
255 257
258+// DatagramTransport marks this transport as UDP/datagram-backed for daemon
259+// output-state policy selection.
260+func (t *Transport) DatagramTransport() {}
261+
256 func (t *Transport) Send(f ports.Frame) error {262 func (t *Transport) Send(f ports.Frame) error {
257- // Until full ACK-driven terminal state sync exists, every frame is reliable;
258- // dropping MsgOutput can permanently desynchronize the client screen.
259 reliable := true263 reliable := true
260- if err := t.lockOutboundSlot(reliable); err != nil {264+ outputSkipThrough, err := t.lockOutboundSlot(f, reliable)
265+ if err != nil {
261 return err266 return err
262 }267 }
263 defer t.outboundMu.Unlock()268 defer t.outboundMu.Unlock()
@@ -266,12 +271,12 @@ func (t *Transport) Send(f ports.Frame) error {
266 t.seq++271 t.seq++
267 seq := t.seq272 seq := t.seq
268 if reliable {273 if reliable {
269- t.pending[seq] = &pending{frame: f}274+ t.pending[seq] = &pending{frame: f, outputSkipThrough: outputSkipThrough}
270 }275 }
271 if shouldPaceOutput(f) {276 if shouldPaceOutput(f) {
272 now := t.clock.Now()277 now := t.clock.Now()
273 if len(t.outputQueue) > 0 || (!t.outputNext.IsZero() && now.Before(t.outputNext)) {278 if len(t.outputQueue) > 0 || (!t.outputNext.IsZero() && now.Before(t.outputNext)) {
274- t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f})279+ t.outputQueue = append(t.outputQueue, queuedSend{seq: seq, reliable: reliable, frame: f, outputSkipThrough: outputSkipThrough})
275 t.notifyOutputPacerLocked()280 t.notifyOutputPacerLocked()
276 t.mu.Unlock()281 t.mu.Unlock()
277 return nil282 return nil
@@ -279,7 +284,7 @@ func (t *Transport) Send(f ports.Frame) error {
279 t.outputNext = now.Add(t.outputPaceDelayLocked())284 t.outputNext = now.Add(t.outputPaceDelayLocked())
280 t.mu.Unlock()285 t.mu.Unlock()
281 t.markPendingSent(seq, reliable)286 t.markPendingSent(seq, reliable)
282- if err := t.sendData(seq, reliable, f); err != nil {287+ if err := t.sendData(seq, reliable, f, outputSkipThrough); err != nil {
283 t.removePending(seq, reliable)288 t.removePending(seq, reliable)
284 return err289 return err
285 }290 }
@@ -296,7 +301,7 @@ func (t *Transport) Send(f ports.Frame) error {
296 }301 }
297 }302 }
298 t.markPendingSent(seq, reliable)303 t.markPendingSent(seq, reliable)
299- if err := t.sendData(seq, reliable, f); err != nil {304+ if err := t.sendData(seq, reliable, f, outputSkipThrough); err != nil {
300 t.removePending(seq, reliable)305 t.removePending(seq, reliable)
301 return err306 return err
302 }307 }
@@ -304,44 +309,10 @@ func (t *Transport) Send(f ports.Frame) error {
304 return nil309 return nil
305 }310 }
306 311
307-func (t *Transport) lockOutboundSlot(reliable bool) error {312+func (t *Transport) lockOutboundSlot(f ports.Frame, reliable bool) (uint64, error) {
308 var pendingWaitStarted time.Time313 var pendingWaitStarted time.Time
314+ var outputSkipThrough uint64
309 for {315 for {
310- t.mu.Lock()
311- for reliable && len(t.pending) >= t.maxPending && !t.closed {
312- if t.linkState != ports.LinkStateConnected {
313- t.mu.Unlock()
314- return ErrPendingFull
315- }
316- if pendingWaitStarted.IsZero() {
317- pendingWaitStarted = t.clock.Now()
318- }
319- remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
320- if remaining <= 0 {
321- t.mu.Unlock()
322- return ErrPendingFull
323- }
324- wake := t.sendWake
325- timer := t.clock.NewTimer(remaining)
326- t.mu.Unlock()
327- select {
328- case <-timer.C():
329- case <-wake:
330- case <-t.done:
331- }
332- timer.Stop()
333- t.mu.Lock()
334- }
335- if t.closed {
336- err := t.closeErr
337- t.mu.Unlock()
338- if err != nil {
339- return err
340- }
341- return errors.New("dgram: closed")
342- }
343- t.mu.Unlock()
344-
345 t.outboundMu.Lock()316 t.outboundMu.Lock()
346 t.mu.Lock()317 t.mu.Lock()
347 if t.closed {318 if t.closed {
@@ -349,15 +320,42 @@ func (t *Transport) lockOutboundSlot(reliable bool) error {
349 t.mu.Unlock()320 t.mu.Unlock()
350 t.outboundMu.Unlock()321 t.outboundMu.Unlock()
351 if err != nil {322 if err != nil {
352- return err323+ 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
353 }330 }
354- return errors.New("dgram: closed")
355 }331 }
356 if !reliable || len(t.pending) < t.maxPending {332 if !reliable || len(t.pending) < t.maxPending {
357- return nil333+ return outputSkipThrough, nil
334+ }
335+ if t.linkState != ports.LinkStateConnected {
336+ t.mu.Unlock()
337+ t.outboundMu.Unlock()
338+ return 0, ErrPendingFull
339+ }
340+ if pendingWaitStarted.IsZero() {
341+ pendingWaitStarted = t.clock.Now()
342+ }
343+ remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
344+ if remaining <= 0 {
345+ t.mu.Unlock()
346+ t.outboundMu.Unlock()
347+ return 0, ErrPendingFull
358 }348 }
349+ wake := t.sendWake
350+ timer := t.clock.NewTimer(remaining)
359 t.mu.Unlock()351 t.mu.Unlock()
360 t.outboundMu.Unlock()352 t.outboundMu.Unlock()
353+ select {
354+ case <-timer.C():
355+ case <-wake:
356+ case <-t.done:
357+ }
358+ timer.Stop()
361 }359 }
362 }360 }
363 361
@@ -395,6 +393,41 @@ func (t *Transport) removePendingLocked(seq uint64) bool {
395 return true393 return true
396 }394 }
397 395
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+}
398 func (t *Transport) Recv() (ports.Frame, error) {431 func (t *Transport) Recv() (ports.Frame, error) {
399 select {432 select {
400 case f := <-t.in:433 case f := <-t.in:
@@ -476,8 +509,8 @@ func (t *Transport) unregisterProbe(id uint64) {
476 t.mu.Unlock()509 t.mu.Unlock()
477 }510 }
478 511
479-func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {512+func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame, outputSkipThrough uint64) error {
480- p := encodeData(seq, reliable, f)513+ p := encodeData(seq, reliable, f, outputSkipThrough)
481 return t.sendPayload(p)514 return t.sendPayload(p)
482 }515 }
483 func (t *Transport) sendAck(seq uint64) {516 func (t *Transport) sendAck(seq uint64) {
@@ -636,12 +669,12 @@ func (t *Transport) handleRecord(p []byte) {
636 }669 }
637 t.mu.Unlock()670 t.mu.Unlock()
638 case recData:671 case recData:
639- seq, reliable, f, ok := decodeData(p)672+ seq, reliable, f, outputSkipThrough, ok := decodeData(p)
640 if !ok {673 if !ok {
641 return674 return
642 }675 }
643 if reliable {676 if reliable {
644- ackSeq, ack, queued := t.enqueueReliable(seq, f)677+ ackSeq, ack, queued := t.enqueueReliable(seq, f, outputSkipThrough)
645 if ack {678 if ack {
646 t.sendAck(ackSeq)679 t.sendAck(ackSeq)
647 }680 }
@@ -654,9 +687,18 @@ func (t *Transport) handleRecord(p []byte) {
654 }687 }
655 }688 }
656 689
657-func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, ack bool, queued bool) {690+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame, outputSkipThrough uint64) (ackSeq uint64, ack bool, queued bool) {
658 t.deliverMu.Lock()691 t.deliverMu.Lock()
659 defer t.deliverMu.Unlock()692 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+ }
660 if seq < t.nextRecvSeq {702 if seq < t.nextRecvSeq {
661 return t.highestContiguousRecvLocked(), true, false703 return t.highestContiguousRecvLocked(), true, false
662 }704 }
@@ -673,10 +715,15 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, a
673 func (t *Transport) highestContiguousRecvLocked() uint64 {715 func (t *Transport) highestContiguousRecvLocked() uint64 {
674 seq := t.nextRecvSeq716 seq := t.nextRecvSeq
675 for {717 for {
676- if _, ok := t.recvBuf[seq]; !ok {718+ if _, ok := t.recvBuf[seq]; ok {
677- break719+ seq++
720+ continue
678 }721 }
679- seq++722+ if seq <= t.recvSkipThrough {
723+ seq++
724+ continue
725+ }
726+ break
680 }727 }
681 return seq - 1728 return seq - 1
682 }729 }
@@ -691,6 +738,10 @@ func (t *Transport) deliveryLoop() {
691 for {738 for {
692 t.deliverMu.Lock()739 t.deliverMu.Lock()
693 f, ok := t.recvBuf[t.nextRecvSeq]740 f, ok := t.recvBuf[t.nextRecvSeq]
741+ for !ok && t.nextRecvSeq <= t.recvSkipThrough {
742+ t.nextRecvSeq++
743+ f, ok = t.recvBuf[t.nextRecvSeq]
744+ }
694 for !ok {745 for !ok {
695 if t.isClosed() {746 if t.isClosed() {
696 t.deliverMu.Unlock()747 t.deliverMu.Unlock()
@@ -698,6 +749,16 @@ func (t *Transport) deliveryLoop() {
698 }749 }
699 t.deliverCond.Wait()750 t.deliverCond.Wait()
700 f, ok = t.recvBuf[t.nextRecvSeq]751 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
701 }762 }
702 delete(t.recvBuf, t.nextRecvSeq)763 delete(t.recvBuf, t.nextRecvSeq)
703 t.nextRecvSeq++764 t.nextRecvSeq++
@@ -764,8 +825,9 @@ func (t *Transport) resendLoop() {
764 func (t *Transport) resendPending() {825 func (t *Transport) resendPending() {
765 now := t.clock.Now()826 now := t.clock.Now()
766 var resend []struct {827 var resend []struct {
767- seq uint64828+ seq uint64
768- f ports.Frame829+ f ports.Frame
830+ outputSkipThrough uint64
769 }831 }
770 t.mu.Lock()832 t.mu.Lock()
771 seqs := make([]uint64, 0, len(t.pending))833 seqs := make([]uint64, 0, len(t.pending))
@@ -786,14 +848,15 @@ func (t *Transport) resendPending() {
786 p.attempts++848 p.attempts++
787 p.retransmitted = true849 p.retransmitted = true
788 resend = append(resend, struct {850 resend = append(resend, struct {
789- seq uint64851+ seq uint64
790- f ports.Frame852+ f ports.Frame
791- }{seq, p.frame})853+ outputSkipThrough uint64
854+ }{seq, p.frame, p.outputSkipThrough})
792 limit--855 limit--
793 }856 }
794 t.mu.Unlock()857 t.mu.Unlock()
795 for _, r := range resend {858 for _, r := range resend {
796- _ = t.sendData(r.seq, true, r.f)859+ _ = t.sendData(r.seq, true, r.f, r.outputSkipThrough)
797 }860 }
798 }861 }
799 862
@@ -976,21 +1039,41 @@ func (t *Transport) closeWithError(err error) {
976 t.deliverMu.Unlock()1039 t.deliverMu.Unlock()
977 }1040 }
978 1041
979-func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {1042+func encodeData(seq uint64, reliable bool, f ports.Frame, outputSkipThrough uint64) []byte {
980- b := make([]byte, 12+len(f.Payload))1043+ headerLen := 12
1044+ if outputSkipThrough > 0 {
1045+ headerLen = 20
1046+ }
1047+ b := make([]byte, headerLen+len(f.Payload))
981 b[0] = recData1048 b[0] = recData
982 binary.BigEndian.PutUint64(b[1:9], seq)1049 binary.BigEndian.PutUint64(b[1:9], seq)
983 if reliable {1050 if reliable {
984 b[9] = 11051 b[9] = 1
985 }1052 }
986 b[10] = byte(f.Type)1053 b[10] = byte(f.Type)
987- b[11] = 01054+ if outputSkipThrough > 0 {
988- copy(b[12:], f.Payload)1055+ b[11] = 1
1056+ binary.BigEndian.PutUint64(b[12:20], outputSkipThrough)
1057+ }
1058+ copy(b[headerLen:], f.Payload)
989 return b1059 return b
990 }1060 }
991-func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {1061+func decodeData(b []byte) (uint64, bool, ports.Frame, uint64, bool) {
992 if len(b) < 12 || b[0] != recData {1062 if len(b) < 12 || b[0] != recData {
993- return 0, false, ports.Frame{}, false1063+ 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])
994 }1077 }
995- return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true1078+ 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
996 }1079 }
M internal/adapters/dgram/transport_test.go +102 -22
@@ -299,7 +299,7 @@ func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
299299 return nil
300300 }
301301
302-func TestOutputFirstSendsArePacedAfterInitialDatagram(t *testing.T) {
302+func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) {
303303 aPC, bPC := newPair()
304304 clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
305305 a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
@@ -318,15 +318,15 @@ func TestOutputFirstSendsArePacedAfterInitialDatagram(t *testing.T) {
318318 }
319319 waitForManualTimers(t, clk, 3)
320320 clk.advance(defaultOutputPaceMinDelay)
321- eventuallyPacketCount(t, bPC, 3)
321+ eventuallyPacketCount(t, bPC, 2)
322322 if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil {
323323 t.Fatal(err)
324324 }
325- if got := len(bPC.in); got != 3 {
326- t.Fatalf("datagrams before second pace tick=%d, want 3", got)
325+ if got := len(bPC.in); got != 2 {
326+ t.Fatalf("datagrams before second pace tick=%d, want 2", got)
327327 }
328328 clk.advance(defaultOutputPaceMinDelay)
329- eventuallyPacketCount(t, bPC, 4)
329+ eventuallyPacketCount(t, bPC, 3)
330330 }
331331
332332 func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) {
@@ -470,14 +470,11 @@ func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) {
470470
471471 a.mu.Lock()
472472 defer a.mu.Unlock()
473- for _, seq := range []uint64{2, 3, 4} {
473+ for _, seq := range []uint64{1, 2, 3, 4} {
474474 if _, ok := a.pending[seq]; ok {
475475 t.Fatalf("pending[%d] stranded after queued flush failure", seq)
476476 }
477477 }
478- if _, ok := a.pending[1]; !ok {
479- t.Fatal("pending[1] was already sent and should remain pending until ack")
480- }
481478 }
482479
483480 func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {
@@ -685,7 +682,7 @@ func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
685682 t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
686683 }
687684 c, _ := pdgram.NewCodec(key())
688- rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing})
685+ rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing}, 0)
689686 frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
690687 raw, _ := pdgram.MarshalFragment(frags[0])
691688 aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
@@ -807,11 +804,11 @@ func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
807804
808805 func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
809806 tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
810- _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")})
807+ _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")}, 0)
811808 if !ack || !queued {
812809 t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
813810 }
814- _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
811+ _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0)
815812 if !ack || !queued {
816813 t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
817814 }
@@ -820,6 +817,32 @@ func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
820817 }
821818 }
822819
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'}
823+ if !bytes.Equal(got, want) {
824+ t.Fatalf("encoded data=%v, want %v", got, want)
825+ }
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")
836+ }
837+}
838+
839+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 {
842+ t.Fatal("decodeData accepted unknown data-record flags with trailing garbage")
843+ }
844+}
845+
823846 func TestOutputRetransmitsUntilAck(t *testing.T) {
824847 aPC, bPC := newPair()
825848 var dropped atomic.Bool
@@ -847,7 +870,7 @@ func TestOutputRetransmitsUntilAck(t *testing.T) {
847870 }
848871 }
849872
850-func TestReliableDeliveryPreservesSequenceOrder(t *testing.T) {
873+func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) {
851874 aPC, bPC := newPair()
852875 a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
853876 b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
@@ -855,18 +878,51 @@ func TestReliableDeliveryPreservesSequenceOrder(t *testing.T) {
855878 defer func() { _ = b.Close() }()
856879
857880 for i := range 100 {
858- if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
881+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte{byte(i)}}); err != nil {
859882 t.Fatal(err)
860883 }
861884 }
862885 for i := range 100 {
863886 got := recvWithin(t, b, time.Second)
864- if got.Type != ports.MsgOutput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
887+ if got.Type != ports.MsgInput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
865888 t.Fatalf("frame %d delivered out of order: %+v", i, got)
866889 }
867890 }
868891 }
869892
893+func TestOutputSupersessionSkipsDroppedOlderOutput(t *testing.T) {
894+ aPC, bPC := newPair()
895+ var sendAttempts atomic.Int32
896+ aPC.drop = func(_ []byte, addr net.Addr) bool {
897+ return addr.String() == "b" && sendAttempts.Add(1) <= 2
898+ }
899+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
900+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
901+ defer func() { _ = a.Close() }()
902+ defer func() { _ = b.Close() }()
903+
904+ for _, payload := range []string{"state1", "state2", "state3"} {
905+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
906+ t.Fatal(err)
907+ }
908+ }
909+
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)
920+ }
921+ if sendAttempts.Load() < 3 {
922+ t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load())
923+ }
924+}
925+
870926 func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
871927 aPC, bPC := newPair()
872928 a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
@@ -1015,7 +1071,7 @@ func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
10151071 func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
10161072 tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
10171073
1018- ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")})
1074+ ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")}, 0)
10191075 if !ack || !queued {
10201076 t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
10211077 }
@@ -1023,7 +1079,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
10231079 t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
10241080 }
10251081
1026- ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")})
1082+ ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")}, 0)
10271083 if !ack || !queued {
10281084 t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
10291085 }
@@ -1031,7 +1087,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
10311087 t.Fatalf("first contiguous ack=%d, want %d", got, want)
10321088 }
10331089
1034- ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")})
1090+ ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}, 0)
10351091 if !ack || !queued {
10361092 t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
10371093 }
@@ -1262,12 +1318,12 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
12621318 tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
12631319 tr.deliverCond = sync.NewCond(&tr.deliverMu)
12641320 for i := range maxRecvBuffer + 100 {
1265- tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}})
1321+ tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, 0)
12661322 }
12671323 if got := len(tr.recvBuf); got != maxRecvBuffer {
12681324 t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
12691325 }
1270- tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
1326+ tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0)
12711327 if _, ok := tr.recvBuf[1]; !ok {
12721328 t.Fatalf("next expected sequence was dropped when far-future buffer was full")
12731329 }
@@ -1276,6 +1332,30 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
12761332 }
12771333 }
12781334
1335+func TestOutputSupersessionPrunesFullReceiveBuffer(t *testing.T) {
1336+ tr := &Transport{
1337+ nextRecvSeq: 1,
1338+ recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}},
1339+ maxRecvBuffer: 1,
1340+ done: make(chan struct{}),
1341+ }
1342+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
1343+
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)
1347+ }
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")
1353+ }
1354+ if got := string(tr.recvBuf[3].Payload); got != "replacement" {
1355+ t.Fatalf("queued payload=%q, want replacement", got)
1356+ }
1357+}
1358+
12791359 func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
12801360 aPC, bPC := newPair()
12811361 codec, err := pdgram.NewCodec(key())
@@ -1290,14 +1370,14 @@ func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
12901370 for i := range maxRecvBuffer {
12911371 tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
12921372 }
1293- tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}))
1373+ tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}, 0))
12941374 select {
12951375 case pkt := <-bPC.in:
12961376 t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
12971377 case <-time.After(25 * time.Millisecond):
12981378 }
12991379
1300- tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}))
1380+ tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0))
13011381 select {
13021382 case <-bPC.in:
13031383 case <-time.After(time.Second):
@@ -299,7 +299,7 @@ func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
299 return nil299 return nil
300 }300 }
301 301
302-func TestOutputFirstSendsArePacedAfterInitialDatagram(t *testing.T) {302+func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) {
303 aPC, bPC := newPair()303 aPC, bPC := newPair()
304 clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))304 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})305 a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
@@ -318,15 +318,15 @@ func TestOutputFirstSendsArePacedAfterInitialDatagram(t *testing.T) {
318 }318 }
319 waitForManualTimers(t, clk, 3)319 waitForManualTimers(t, clk, 3)
320 clk.advance(defaultOutputPaceMinDelay)320 clk.advance(defaultOutputPaceMinDelay)
321- eventuallyPacketCount(t, bPC, 3)321+ eventuallyPacketCount(t, bPC, 2)
322 if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil {322 if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil {
323 t.Fatal(err)323 t.Fatal(err)
324 }324 }
325- if got := len(bPC.in); got != 3 {325+ if got := len(bPC.in); got != 2 {
326- t.Fatalf("datagrams before second pace tick=%d, want 3", got)326+ t.Fatalf("datagrams before second pace tick=%d, want 2", got)
327 }327 }
328 clk.advance(defaultOutputPaceMinDelay)328 clk.advance(defaultOutputPaceMinDelay)
329- eventuallyPacketCount(t, bPC, 4)329+ eventuallyPacketCount(t, bPC, 3)
330 }330 }
331 331
332 func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) {332 func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) {
@@ -470,14 +470,11 @@ func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) {
470 470
471 a.mu.Lock()471 a.mu.Lock()
472 defer a.mu.Unlock()472 defer a.mu.Unlock()
473- for _, seq := range []uint64{2, 3, 4} {473+ for _, seq := range []uint64{1, 2, 3, 4} {
474 if _, ok := a.pending[seq]; ok {474 if _, ok := a.pending[seq]; ok {
475 t.Fatalf("pending[%d] stranded after queued flush failure", seq)475 t.Fatalf("pending[%d] stranded after queued flush failure", seq)
476 }476 }
477 }477 }
478- if _, ok := a.pending[1]; !ok {
479- t.Fatal("pending[1] was already sent and should remain pending until ack")
480- }
481 }478 }
482 479
483 func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {480 func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {
@@ -685,7 +682,7 @@ func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
685 t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())682 t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
686 }683 }
687 c, _ := pdgram.NewCodec(key())684 c, _ := pdgram.NewCodec(key())
688- rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing})685+ rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing}, 0)
689 frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)686 frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
690 raw, _ := pdgram.MarshalFragment(frags[0])687 raw, _ := pdgram.MarshalFragment(frags[0])
691 aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}688 aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
@@ -807,11 +804,11 @@ func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
807 804
808 func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {805 func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
809 tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}806 tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
810- _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")})807+ _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")}, 0)
811 if !ack || !queued {808 if !ack || !queued {
812 t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)809 t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
813 }810 }
814- _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})811+ _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0)
815 if !ack || !queued {812 if !ack || !queued {
816 t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)813 t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
817 }814 }
@@ -820,6 +817,32 @@ func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
820 }817 }
821 }818 }
822 819
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'}
823+ if !bytes.Equal(got, want) {
824+ t.Fatalf("encoded data=%v, want %v", got, want)
825+ }
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")
836+ }
837+}
838+
839+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 {
842+ t.Fatal("decodeData accepted unknown data-record flags with trailing garbage")
843+ }
844+}
845+
823 func TestOutputRetransmitsUntilAck(t *testing.T) {846 func TestOutputRetransmitsUntilAck(t *testing.T) {
824 aPC, bPC := newPair()847 aPC, bPC := newPair()
825 var dropped atomic.Bool848 var dropped atomic.Bool
@@ -847,7 +870,7 @@ func TestOutputRetransmitsUntilAck(t *testing.T) {
847 }870 }
848 }871 }
849 872
850-func TestReliableDeliveryPreservesSequenceOrder(t *testing.T) {873+func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) {
851 aPC, bPC := newPair()874 aPC, bPC := newPair()
852 a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)875 a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
853 b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)876 b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
@@ -855,18 +878,51 @@ func TestReliableDeliveryPreservesSequenceOrder(t *testing.T) {
855 defer func() { _ = b.Close() }()878 defer func() { _ = b.Close() }()
856 879
857 for i := range 100 {880 for i := range 100 {
858- if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {881+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte{byte(i)}}); err != nil {
859 t.Fatal(err)882 t.Fatal(err)
860 }883 }
861 }884 }
862 for i := range 100 {885 for i := range 100 {
863 got := recvWithin(t, b, time.Second)886 got := recvWithin(t, b, time.Second)
864- if got.Type != ports.MsgOutput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {887+ if got.Type != ports.MsgInput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
865 t.Fatalf("frame %d delivered out of order: %+v", i, got)888 t.Fatalf("frame %d delivered out of order: %+v", i, got)
866 }889 }
867 }890 }
868 }891 }
869 892
893+func TestOutputSupersessionSkipsDroppedOlderOutput(t *testing.T) {
894+ aPC, bPC := newPair()
895+ var sendAttempts atomic.Int32
896+ aPC.drop = func(_ []byte, addr net.Addr) bool {
897+ return addr.String() == "b" && sendAttempts.Add(1) <= 2
898+ }
899+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
900+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
901+ defer func() { _ = a.Close() }()
902+ defer func() { _ = b.Close() }()
903+
904+ for _, payload := range []string{"state1", "state2", "state3"} {
905+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
906+ t.Fatal(err)
907+ }
908+ }
909+
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)
920+ }
921+ if sendAttempts.Load() < 3 {
922+ t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load())
923+ }
924+}
925+
870 func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {926 func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
871 aPC, bPC := newPair()927 aPC, bPC := newPair()
872 a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)928 a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
@@ -1015,7 +1071,7 @@ func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
1015 func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {1071 func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
1016 tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}1072 tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
1017 1073
1018- ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")})1074+ ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")}, 0)
1019 if !ack || !queued {1075 if !ack || !queued {
1020 t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)1076 t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
1021 }1077 }
@@ -1023,7 +1079,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
1023 t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)1079 t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
1024 }1080 }
1025 1081
1026- ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")})1082+ ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")}, 0)
1027 if !ack || !queued {1083 if !ack || !queued {
1028 t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)1084 t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
1029 }1085 }
@@ -1031,7 +1087,7 @@ func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
1031 t.Fatalf("first contiguous ack=%d, want %d", got, want)1087 t.Fatalf("first contiguous ack=%d, want %d", got, want)
1032 }1088 }
1033 1089
1034- ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")})1090+ ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")}, 0)
1035 if !ack || !queued {1091 if !ack || !queued {
1036 t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)1092 t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
1037 }1093 }
@@ -1262,12 +1318,12 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
1262 tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}1318 tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
1263 tr.deliverCond = sync.NewCond(&tr.deliverMu)1319 tr.deliverCond = sync.NewCond(&tr.deliverMu)
1264 for i := range maxRecvBuffer + 100 {1320 for i := range maxRecvBuffer + 100 {
1265- tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}})1321+ tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, 0)
1266 }1322 }
1267 if got := len(tr.recvBuf); got != maxRecvBuffer {1323 if got := len(tr.recvBuf); got != maxRecvBuffer {
1268 t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)1324 t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
1269 }1325 }
1270- tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})1326+ tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0)
1271 if _, ok := tr.recvBuf[1]; !ok {1327 if _, ok := tr.recvBuf[1]; !ok {
1272 t.Fatalf("next expected sequence was dropped when far-future buffer was full")1328 t.Fatalf("next expected sequence was dropped when far-future buffer was full")
1273 }1329 }
@@ -1276,6 +1332,30 @@ func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
1276 }1332 }
1277 }1333 }
1278 1334
1335+func TestOutputSupersessionPrunesFullReceiveBuffer(t *testing.T) {
1336+ tr := &Transport{
1337+ nextRecvSeq: 1,
1338+ recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}},
1339+ maxRecvBuffer: 1,
1340+ done: make(chan struct{}),
1341+ }
1342+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
1343+
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)
1347+ }
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")
1353+ }
1354+ if got := string(tr.recvBuf[3].Payload); got != "replacement" {
1355+ t.Fatalf("queued payload=%q, want replacement", got)
1356+ }
1357+}
1358+
1279 func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {1359 func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
1280 aPC, bPC := newPair()1360 aPC, bPC := newPair()
1281 codec, err := pdgram.NewCodec(key())1361 codec, err := pdgram.NewCodec(key())
@@ -1290,14 +1370,14 @@ func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
1290 for i := range maxRecvBuffer {1370 for i := range maxRecvBuffer {
1291 tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}1371 tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
1292 }1372 }
1293- tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}))1373+ tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}, 0))
1294 select {1374 select {
1295 case pkt := <-bPC.in:1375 case pkt := <-bPC.in:
1296 t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)1376 t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
1297 case <-time.After(25 * time.Millisecond):1377 case <-time.After(25 * time.Millisecond):
1298 }1378 }
1299 1379
1300- tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}))1380+ tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}, 0))
1301 select {1381 select {
1302 case <-bPC.in:1382 case <-bPC.in:
1303 case <-time.After(time.Second):1383 case <-time.After(time.Second):
M internal/persist/persist.go +17 -1
@@ -4,6 +4,7 @@ import (
44 "log/slog"
55 "path/filepath"
66 "sort"
7+ "sync"
78
89 "github.com/bnema/vev/internal/ports"
910 "github.com/bnema/vev/pkg/kv"
@@ -20,6 +21,7 @@ func StorePath(dir string) string {
2021 // operations no-ops, so callers can keep persistence optional.
2122 type Persister struct {
2223 store ports.Store
24+ mu sync.Mutex
2325 }
2426
2527 // Open opens the session persister under dir.
@@ -50,6 +52,8 @@ func (p *Persister) Save(r Record) error {
5052 if p == nil || p.store == nil {
5153 return nil
5254 }
55+ p.mu.Lock()
56+ defer p.mu.Unlock()
5357 value, err := encodeRecordValue(r)
5458 if err != nil {
5559 return err
@@ -68,17 +72,21 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
6872 if name == "" {
6973 return errEmptyName
7074 }
75+ p.mu.Lock()
76+ defer p.mu.Unlock()
7177
7278 createdAt := at
7379 lastUsedSeq := uint64(0)
80+ var tabNames []string
7481 if v, ok := p.store.Get([]byte(name)); ok {
7582 if r, err := decodeRecordValue(name, v); err == nil {
7683 createdAt = r.CreatedAt
7784 lastUsedSeq = r.LastUsedSeq
85+ tabNames = r.TabNames
7886 }
7987 }
8088
81- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq})
89+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq, TabNames: tabNames})
8290 if err != nil {
8391 return err
8492 }
@@ -93,6 +101,8 @@ func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
93101 if name == "" {
94102 return errEmptyName
95103 }
104+ p.mu.Lock()
105+ defer p.mu.Unlock()
96106
97107 r := Record{Name: name, LastUsedSeq: lastUsedSeq}
98108 if v, ok := p.store.Get([]byte(name)); ok {
@@ -116,6 +126,8 @@ func (p *Persister) Delete(name string) error {
116126 if name == "" {
117127 return errEmptyName
118128 }
129+ p.mu.Lock()
130+ defer p.mu.Unlock()
119131 if err := p.store.Delete([]byte(name)); err != nil {
120132 return err
121133 }
@@ -127,6 +139,8 @@ func (p *Persister) LoadAll() ([]Record, error) {
127139 if p == nil || p.store == nil {
128140 return nil, nil
129141 }
142+ p.mu.Lock()
143+ defer p.mu.Unlock()
130144 data := make(map[string][]byte)
131145 p.store.Range(func(k, v []byte) bool {
132146 data[string(k)] = append([]byte(nil), v...)
@@ -140,6 +154,8 @@ func (p *Persister) Close() error {
140154 if p == nil || p.store == nil {
141155 return nil
142156 }
157+ p.mu.Lock()
158+ defer p.mu.Unlock()
143159 return p.store.Close()
144160 }
145161
@@ -4,6 +4,7 @@ import (
4 "log/slog"4 "log/slog"
5 "path/filepath"5 "path/filepath"
6 "sort"6 "sort"
7+ "sync"
7 8
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 "github.com/bnema/vev/pkg/kv"10 "github.com/bnema/vev/pkg/kv"
@@ -20,6 +21,7 @@ func StorePath(dir string) string {
20 // operations no-ops, so callers can keep persistence optional.21 // operations no-ops, so callers can keep persistence optional.
21 type Persister struct {22 type Persister struct {
22 store ports.Store23 store ports.Store
24+ mu sync.Mutex
23 }25 }
24 26
25 // Open opens the session persister under dir.27 // Open opens the session persister under dir.
@@ -50,6 +52,8 @@ func (p *Persister) Save(r Record) error {
50 if p == nil || p.store == nil {52 if p == nil || p.store == nil {
51 return nil53 return nil
52 }54 }
55+ p.mu.Lock()
56+ defer p.mu.Unlock()
53 value, err := encodeRecordValue(r)57 value, err := encodeRecordValue(r)
54 if err != nil {58 if err != nil {
55 return err59 return err
@@ -68,17 +72,21 @@ func (p *Persister) Touch(name, cwd string, at int64) error {
68 if name == "" {72 if name == "" {
69 return errEmptyName73 return errEmptyName
70 }74 }
75+ p.mu.Lock()
76+ defer p.mu.Unlock()
71 77
72 createdAt := at78 createdAt := at
73 lastUsedSeq := uint64(0)79 lastUsedSeq := uint64(0)
80+ var tabNames []string
74 if v, ok := p.store.Get([]byte(name)); ok {81 if v, ok := p.store.Get([]byte(name)); ok {
75 if r, err := decodeRecordValue(name, v); err == nil {82 if r, err := decodeRecordValue(name, v); err == nil {
76 createdAt = r.CreatedAt83 createdAt = r.CreatedAt
77 lastUsedSeq = r.LastUsedSeq84 lastUsedSeq = r.LastUsedSeq
85+ tabNames = r.TabNames
78 }86 }
79 }87 }
80 88
81- value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq})89+ value, err := encodeRecordValue(Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: at, LastUsedSeq: lastUsedSeq, TabNames: tabNames})
82 if err != nil {90 if err != nil {
83 return err91 return err
84 }92 }
@@ -93,6 +101,8 @@ func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error {
93 if name == "" {101 if name == "" {
94 return errEmptyName102 return errEmptyName
95 }103 }
104+ p.mu.Lock()
105+ defer p.mu.Unlock()
96 106
97 r := Record{Name: name, LastUsedSeq: lastUsedSeq}107 r := Record{Name: name, LastUsedSeq: lastUsedSeq}
98 if v, ok := p.store.Get([]byte(name)); ok {108 if v, ok := p.store.Get([]byte(name)); ok {
@@ -116,6 +126,8 @@ func (p *Persister) Delete(name string) error {
116 if name == "" {126 if name == "" {
117 return errEmptyName127 return errEmptyName
118 }128 }
129+ p.mu.Lock()
130+ defer p.mu.Unlock()
119 if err := p.store.Delete([]byte(name)); err != nil {131 if err := p.store.Delete([]byte(name)); err != nil {
120 return err132 return err
121 }133 }
@@ -127,6 +139,8 @@ func (p *Persister) LoadAll() ([]Record, error) {
127 if p == nil || p.store == nil {139 if p == nil || p.store == nil {
128 return nil, nil140 return nil, nil
129 }141 }
142+ p.mu.Lock()
143+ defer p.mu.Unlock()
130 data := make(map[string][]byte)144 data := make(map[string][]byte)
131 p.store.Range(func(k, v []byte) bool {145 p.store.Range(func(k, v []byte) bool {
132 data[string(k)] = append([]byte(nil), v...)146 data[string(k)] = append([]byte(nil), v...)
@@ -140,6 +154,8 @@ func (p *Persister) Close() error {
140 if p == nil || p.store == nil {154 if p == nil || p.store == nil {
141 return nil155 return nil
142 }156 }
157+ p.mu.Lock()
158+ defer p.mu.Unlock()
143 return p.store.Close()159 return p.store.Close()
144 }160 }
145 161
M internal/persist/persist_test.go +96 -4
@@ -3,7 +3,9 @@ package persist
33 import (
44 "errors"
55 "path/filepath"
6+ "sync"
67 "testing"
8+ "time"
79
810 "github.com/stretchr/testify/mock"
911 "github.com/stretchr/testify/require"
@@ -17,7 +19,7 @@ func privateDir(t *testing.T) string {
1719 }
1820
1921 func TestRecordCodecRoundTrip(t *testing.T) {
20- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}
22+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33, TabNames: []string{"shell", "logs"}}
2123 value, err := encodeRecordValue(r)
2224 require.NoError(t, err)
2325
@@ -81,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
8183 p, err := Open(dir)
8284 require.NoError(t, err)
8385
84- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))
86+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40, TabNames: []string{"shell"}}))
8587 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
8688 require.NoError(t, p.Touch("b", "/b/next", 30))
8789 require.NoError(t, p.TouchMRU("b", 60))
@@ -90,13 +92,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
9092 require.NoError(t, err)
9193 require.Equal(t, []Record{
9294 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
93- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},
95+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}},
9496 }, records)
9597
9698 require.NoError(t, p.Delete("a"))
9799 records, err = p.LoadAll()
98100 require.NoError(t, err)
99- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)
101+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}}}, records)
100102 require.NoError(t, p.Close())
101103 }
102104
@@ -151,6 +153,34 @@ func TestSyncBehavior(t *testing.T) {
151153 require.Equal(t, 2, state.syncs)
152154 }
153155
156+func TestPersisterSerializesReadModifyWriteWithStructuralSave(t *testing.T) {
157+ store := newBlockingGetStore()
158+ p := New(store)
159+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, TabNames: []string{"old"}}))
160+ store.blockNextGet()
161+
162+ touchDone := make(chan error, 1)
163+ go func() { touchDone <- p.TouchMRU("work", 2) }()
164+ <-store.getStarted
165+
166+ saveDone := make(chan error, 1)
167+ go func() {
168+ saveDone <- p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 3, LastUsedSeq: 2, TabNames: []string{"new"}})
169+ }()
170+ select {
171+ case err := <-saveDone:
172+ t.Fatalf("structural save interleaved with TouchMRU: %v", err)
173+ case <-time.After(20 * time.Millisecond):
174+ }
175+ close(store.releaseGet)
176+ require.NoError(t, <-touchDone)
177+ require.NoError(t, <-saveDone)
178+
179+ records, err := p.LoadAll()
180+ require.NoError(t, err)
181+ require.Equal(t, []string{"new"}, records[0].TabNames)
182+}
183+
154184 func TestSavePropagatesSetErrorWithoutSync(t *testing.T) {
155185 store, state := newMockStore(t)
156186 state.errSet = errors.New("set failed")
@@ -170,6 +200,68 @@ type mockStoreState struct {
170200 errSet error
171201 }
172202
203+type blockingGetStore struct {
204+ mu sync.Mutex
205+ data map[string][]byte
206+ block bool
207+ getStarted chan struct{}
208+ releaseGet chan struct{}
209+ getStartOnce sync.Once
210+}
211+
212+func newBlockingGetStore() *blockingGetStore {
213+ return &blockingGetStore{data: make(map[string][]byte), getStarted: make(chan struct{}), releaseGet: make(chan struct{})}
214+}
215+
216+func (s *blockingGetStore) blockNextGet() {
217+ s.mu.Lock()
218+ s.block = true
219+ s.mu.Unlock()
220+}
221+
222+func (s *blockingGetStore) Get(key []byte) ([]byte, bool) {
223+ s.mu.Lock()
224+ v, ok := s.data[string(key)]
225+ v = append([]byte(nil), v...)
226+ block := s.block
227+ if block {
228+ s.block = false
229+ }
230+ s.mu.Unlock()
231+ if block {
232+ s.getStartOnce.Do(func() { close(s.getStarted) })
233+ <-s.releaseGet
234+ }
235+ return v, ok
236+}
237+
238+func (s *blockingGetStore) Set(key, value []byte) error {
239+ s.mu.Lock()
240+ s.data[string(key)] = append([]byte(nil), value...)
241+ s.mu.Unlock()
242+ return nil
243+}
244+
245+func (s *blockingGetStore) Delete(key []byte) error {
246+ s.mu.Lock()
247+ delete(s.data, string(key))
248+ s.mu.Unlock()
249+ return nil
250+}
251+
252+func (s *blockingGetStore) Range(fn func(k, v []byte) bool) {
253+ s.mu.Lock()
254+ defer s.mu.Unlock()
255+ for key, value := range s.data {
256+ if !fn([]byte(key), append([]byte(nil), value...)) {
257+ return
258+ }
259+ }
260+}
261+
262+func (*blockingGetStore) Sync() error { return nil }
263+func (*blockingGetStore) Close() error { return nil }
264+
173265 func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
174266 t.Helper()
175267 state := &mockStoreState{data: make(map[string][]byte)}
@@ -3,7 +3,9 @@ package persist
3 import (3 import (
4 "errors"4 "errors"
5 "path/filepath"5 "path/filepath"
6+ "sync"
6 "testing"7 "testing"
8+ "time"
7 9
8 "github.com/stretchr/testify/mock"10 "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
@@ -17,7 +19,7 @@ func privateDir(t *testing.T) string {
17 }19 }
18 20
19 func TestRecordCodecRoundTrip(t *testing.T) {21 func TestRecordCodecRoundTrip(t *testing.T) {
20- r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33}22+ r := Record{Name: "work", Cwd: "/tmp/project", CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33, TabNames: []string{"shell", "logs"}}
21 value, err := encodeRecordValue(r)23 value, err := encodeRecordValue(r)
22 require.NoError(t, err)24 require.NoError(t, err)
23 25
@@ -81,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
81 p, err := Open(dir)83 p, err := Open(dir)
82 require.NoError(t, err)84 require.NoError(t, err)
83 85
84- require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40}))86+ require.NoError(t, p.Save(Record{Name: "b", Cwd: "/b", CreatedAt: 10, UpdatedAt: 10, LastUsedSeq: 40, TabNames: []string{"shell"}}))
85 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))87 require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50}))
86 require.NoError(t, p.Touch("b", "/b/next", 30))88 require.NoError(t, p.Touch("b", "/b/next", 30))
87 require.NoError(t, p.TouchMRU("b", 60))89 require.NoError(t, p.TouchMRU("b", 60))
@@ -90,13 +92,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) {
90 require.NoError(t, err)92 require.NoError(t, err)
91 require.Equal(t, []Record{93 require.Equal(t, []Record{
92 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},94 {Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50},
93- {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60},95+ {Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}},
94 }, records)96 }, records)
95 97
96 require.NoError(t, p.Delete("a"))98 require.NoError(t, p.Delete("a"))
97 records, err = p.LoadAll()99 records, err = p.LoadAll()
98 require.NoError(t, err)100 require.NoError(t, err)
99- require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60}}, records)101+ require.Equal(t, []Record{{Name: "b", Cwd: "/b/next", CreatedAt: 10, UpdatedAt: 30, LastUsedSeq: 60, TabNames: []string{"shell"}}}, records)
100 require.NoError(t, p.Close())102 require.NoError(t, p.Close())
101 }103 }
102 104
@@ -151,6 +153,34 @@ func TestSyncBehavior(t *testing.T) {
151 require.Equal(t, 2, state.syncs)153 require.Equal(t, 2, state.syncs)
152 }154 }
153 155
156+func TestPersisterSerializesReadModifyWriteWithStructuralSave(t *testing.T) {
157+ store := newBlockingGetStore()
158+ p := New(store)
159+ require.NoError(t, p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 1, TabNames: []string{"old"}}))
160+ store.blockNextGet()
161+
162+ touchDone := make(chan error, 1)
163+ go func() { touchDone <- p.TouchMRU("work", 2) }()
164+ <-store.getStarted
165+
166+ saveDone := make(chan error, 1)
167+ go func() {
168+ saveDone <- p.Save(Record{Name: "work", Cwd: "/work", CreatedAt: 1, UpdatedAt: 3, LastUsedSeq: 2, TabNames: []string{"new"}})
169+ }()
170+ select {
171+ case err := <-saveDone:
172+ t.Fatalf("structural save interleaved with TouchMRU: %v", err)
173+ case <-time.After(20 * time.Millisecond):
174+ }
175+ close(store.releaseGet)
176+ require.NoError(t, <-touchDone)
177+ require.NoError(t, <-saveDone)
178+
179+ records, err := p.LoadAll()
180+ require.NoError(t, err)
181+ require.Equal(t, []string{"new"}, records[0].TabNames)
182+}
183+
154 func TestSavePropagatesSetErrorWithoutSync(t *testing.T) {184 func TestSavePropagatesSetErrorWithoutSync(t *testing.T) {
155 store, state := newMockStore(t)185 store, state := newMockStore(t)
156 state.errSet = errors.New("set failed")186 state.errSet = errors.New("set failed")
@@ -170,6 +200,68 @@ type mockStoreState struct {
170 errSet error200 errSet error
171 }201 }
172 202
203+type blockingGetStore struct {
204+ mu sync.Mutex
205+ data map[string][]byte
206+ block bool
207+ getStarted chan struct{}
208+ releaseGet chan struct{}
209+ getStartOnce sync.Once
210+}
211+
212+func newBlockingGetStore() *blockingGetStore {
213+ return &blockingGetStore{data: make(map[string][]byte), getStarted: make(chan struct{}), releaseGet: make(chan struct{})}
214+}
215+
216+func (s *blockingGetStore) blockNextGet() {
217+ s.mu.Lock()
218+ s.block = true
219+ s.mu.Unlock()
220+}
221+
222+func (s *blockingGetStore) Get(key []byte) ([]byte, bool) {
223+ s.mu.Lock()
224+ v, ok := s.data[string(key)]
225+ v = append([]byte(nil), v...)
226+ block := s.block
227+ if block {
228+ s.block = false
229+ }
230+ s.mu.Unlock()
231+ if block {
232+ s.getStartOnce.Do(func() { close(s.getStarted) })
233+ <-s.releaseGet
234+ }
235+ return v, ok
236+}
237+
238+func (s *blockingGetStore) Set(key, value []byte) error {
239+ s.mu.Lock()
240+ s.data[string(key)] = append([]byte(nil), value...)
241+ s.mu.Unlock()
242+ return nil
243+}
244+
245+func (s *blockingGetStore) Delete(key []byte) error {
246+ s.mu.Lock()
247+ delete(s.data, string(key))
248+ s.mu.Unlock()
249+ return nil
250+}
251+
252+func (s *blockingGetStore) Range(fn func(k, v []byte) bool) {
253+ s.mu.Lock()
254+ defer s.mu.Unlock()
255+ for key, value := range s.data {
256+ if !fn([]byte(key), append([]byte(nil), value...)) {
257+ return
258+ }
259+ }
260+}
261+
262+func (*blockingGetStore) Sync() error { return nil }
263+func (*blockingGetStore) Close() error { return nil }
264+
173 func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {265 func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) {
174 t.Helper()266 t.Helper()
175 state := &mockStoreState{data: make(map[string][]byte)}267 state := &mockStoreState{data: make(map[string][]byte)}
M internal/persist/record.go +60 -3
@@ -14,6 +14,7 @@ type Record struct {
1414 CreatedAt int64
1515 UpdatedAt int64
1616 LastUsedSeq uint64
17+ TabNames []string
1718 }
1819
1920 var (
@@ -28,14 +29,33 @@ func encodeRecordValue(r Record) ([]byte, error) {
2829 if uint64(len(r.Cwd)) > math.MaxUint32 {
2930 return nil, fmt.Errorf("persist: cwd too large")
3031 }
32+ if uint64(len(r.TabNames)) > math.MaxUint32 {
33+ return nil, fmt.Errorf("persist: too many tab names")
34+ }
35+ tabNamesSize := 4
36+ for _, name := range r.TabNames {
37+ if uint64(len(name)) > math.MaxUint32 {
38+ return nil, fmt.Errorf("persist: tab name too large")
39+ }
40+ tabNamesSize += 4 + len(name)
41+ }
3142
32- buf := make([]byte, 4+len(r.Cwd)+8+8+8)
43+ buf := make([]byte, 4+len(r.Cwd)+8+8+8+tabNamesSize)
3344 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
3445 copy(buf[4:], r.Cwd)
3546 off := 4 + len(r.Cwd)
3647 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
3748 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
3849 binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)
50+ off += 24
51+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames)))
52+ off += 4
53+ for _, name := range r.TabNames {
54+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name)))
55+ off += 4
56+ copy(buf[off:], name)
57+ off += len(name)
58+ }
3959 return buf, nil
4060 }
4161
@@ -52,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
5272 return Record{}, errMalformedRecord
5373 }
5474 cwdLen := int(cwdLen32)
55- if len(value) != 4+cwdLen+8+8 && len(value) != 4+cwdLen+8+8+8 {
75+ baseLen := 4 + cwdLen + 8 + 8
76+ if len(value) < baseLen {
5677 return Record{}, errMalformedRecord
5778 }
5879 off := 4 + cwdLen
@@ -62,8 +83,44 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
6283 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
6384 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
6485 }
65- if len(value) == 4+cwdLen+8+8+8 {
86+ if len(value) == baseLen {
87+ return r, nil
88+ }
89+ if len(value) < baseLen+8 {
90+ return Record{}, errMalformedRecord
91+ }
92+ if len(value) == baseLen+8 {
6693 r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
94+ return r, nil
95+ }
96+ r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
97+ off += 24
98+ if len(value)-off < 4 {
99+ return Record{}, errMalformedRecord
100+ }
101+ tabCount32 := binary.BigEndian.Uint32(value[off : off+4])
102+ off += 4
103+ if uint64(tabCount32) > uint64(len(value)-off)/4 {
104+ return Record{}, errMalformedRecord
105+ }
106+ if tabCount32 > 0 {
107+ r.TabNames = make([]string, 0, int(tabCount32))
108+ }
109+ for range tabCount32 {
110+ if len(value)-off < 4 {
111+ return Record{}, errMalformedRecord
112+ }
113+ nameLen32 := binary.BigEndian.Uint32(value[off : off+4])
114+ off += 4
115+ if uint64(nameLen32) > uint64(len(value)-off) {
116+ return Record{}, errMalformedRecord
117+ }
118+ nameLen := int(nameLen32)
119+ r.TabNames = append(r.TabNames, string(value[off:off+nameLen]))
120+ off += nameLen
121+ }
122+ if off != len(value) {
123+ return Record{}, errMalformedRecord
67124 }
68125 return r, nil
69126 }
@@ -14,6 +14,7 @@ type Record struct {
14 CreatedAt int6414 CreatedAt int64
15 UpdatedAt int6415 UpdatedAt int64
16 LastUsedSeq uint6416 LastUsedSeq uint64
17+ TabNames []string
17 }18 }
18 19
19 var (20 var (
@@ -28,14 +29,33 @@ func encodeRecordValue(r Record) ([]byte, error) {
28 if uint64(len(r.Cwd)) > math.MaxUint32 {29 if uint64(len(r.Cwd)) > math.MaxUint32 {
29 return nil, fmt.Errorf("persist: cwd too large")30 return nil, fmt.Errorf("persist: cwd too large")
30 }31 }
32+ if uint64(len(r.TabNames)) > math.MaxUint32 {
33+ return nil, fmt.Errorf("persist: too many tab names")
34+ }
35+ tabNamesSize := 4
36+ for _, name := range r.TabNames {
37+ if uint64(len(name)) > math.MaxUint32 {
38+ return nil, fmt.Errorf("persist: tab name too large")
39+ }
40+ tabNamesSize += 4 + len(name)
41+ }
31 42
32- buf := make([]byte, 4+len(r.Cwd)+8+8+8)43+ buf := make([]byte, 4+len(r.Cwd)+8+8+8+tabNamesSize)
33 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))44 binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd)))
34 copy(buf[4:], r.Cwd)45 copy(buf[4:], r.Cwd)
35 off := 4 + len(r.Cwd)46 off := 4 + len(r.Cwd)
36 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))47 binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt))
37 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))48 binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt))
38 binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)49 binary.BigEndian.PutUint64(buf[off+16:off+24], r.LastUsedSeq)
50+ off += 24
51+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(r.TabNames)))
52+ off += 4
53+ for _, name := range r.TabNames {
54+ binary.BigEndian.PutUint32(buf[off:off+4], uint32(len(name)))
55+ off += 4
56+ copy(buf[off:], name)
57+ off += len(name)
58+ }
39 return buf, nil59 return buf, nil
40 }60 }
41 61
@@ -52,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
52 return Record{}, errMalformedRecord72 return Record{}, errMalformedRecord
53 }73 }
54 cwdLen := int(cwdLen32)74 cwdLen := int(cwdLen32)
55- if len(value) != 4+cwdLen+8+8 && len(value) != 4+cwdLen+8+8+8 {75+ baseLen := 4 + cwdLen + 8 + 8
76+ if len(value) < baseLen {
56 return Record{}, errMalformedRecord77 return Record{}, errMalformedRecord
57 }78 }
58 off := 4 + cwdLen79 off := 4 + cwdLen
@@ -62,8 +83,44 @@ func decodeRecordValue(name string, value []byte) (Record, error) {
62 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),83 CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])),
63 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),84 UpdatedAt: int64(binary.BigEndian.Uint64(value[off+8 : off+16])),
64 }85 }
65- if len(value) == 4+cwdLen+8+8+8 {86+ if len(value) == baseLen {
87+ return r, nil
88+ }
89+ if len(value) < baseLen+8 {
90+ return Record{}, errMalformedRecord
91+ }
92+ if len(value) == baseLen+8 {
66 r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])93 r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
94+ return r, nil
95+ }
96+ r.LastUsedSeq = binary.BigEndian.Uint64(value[off+16 : off+24])
97+ off += 24
98+ if len(value)-off < 4 {
99+ return Record{}, errMalformedRecord
100+ }
101+ tabCount32 := binary.BigEndian.Uint32(value[off : off+4])
102+ off += 4
103+ if uint64(tabCount32) > uint64(len(value)-off)/4 {
104+ return Record{}, errMalformedRecord
105+ }
106+ if tabCount32 > 0 {
107+ r.TabNames = make([]string, 0, int(tabCount32))
108+ }
109+ for range tabCount32 {
110+ if len(value)-off < 4 {
111+ return Record{}, errMalformedRecord
112+ }
113+ nameLen32 := binary.BigEndian.Uint32(value[off : off+4])
114+ off += 4
115+ if uint64(nameLen32) > uint64(len(value)-off) {
116+ return Record{}, errMalformedRecord
117+ }
118+ nameLen := int(nameLen32)
119+ r.TabNames = append(r.TabNames, string(value[off:off+nameLen]))
120+ off += nameLen
121+ }
122+ if off != len(value) {
123+ return Record{}, errMalformedRecord
67 }124 }
68 return r, nil125 return r, nil
69 }126 }
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 = 10
4+const ProtocolVersion uint16 = 12
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 = 104+const ProtocolVersion uint16 = 12
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 != 10 {
7- t.Fatalf("ProtocolVersion = %d, want 10", ProtocolVersion)
6+ if ProtocolVersion != 12 {
7+ t.Fatalf("ProtocolVersion = %d, want 12", 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 != 10 {6+ if ProtocolVersion != 12 {
7- t.Fatalf("ProtocolVersion = %d, want 10", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 12", ProtocolVersion)
8 }8 }
9 }9 }
10 10
M internal/ports/ports.go +6 -0
@@ -45,6 +45,12 @@ 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()
52+}
53+
4854 // Dialer establishes outbound Transport connections.
4955 type Dialer interface {
5056 Dial(ctx context.Context) (Transport, error)
@@ -45,6 +45,12 @@ type Transport interface {
45 Close() error45 Close() error
46 }46 }
47 47
48+// DatagramTransport marks transports with datagram/reordered/lossy delivery
49+// semantics behind the framed Transport interface.
50+type DatagramTransport interface {
51+ DatagramTransport()
52+}
53+
48 // Dialer establishes outbound Transport connections.54 // Dialer establishes outbound Transport connections.
49 type Dialer interface {55 type Dialer interface {
50 Dial(ctx context.Context) (Transport, error)56 Dial(ctx context.Context) (Transport, error)
M internal/ports/wire.go +11 -0
@@ -66,6 +66,7 @@ type Hello struct {
6666 TermEnv string
6767 Cwd string
6868 TrueColor bool
69+ AckOutput bool
6970 }
7071
7172 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -309,6 +310,11 @@ func MarshalHello(h Hello) []byte {
309310 } else {
310311 w.putUint8(0)
311312 }
313+ if h.AckOutput {
314+ w.putUint8(1)
315+ } else {
316+ w.putUint8(0)
317+ }
312318 return w.b
313319 }
314320
@@ -355,6 +361,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
355361 return Hello{}, err
356362 }
357363 h.TrueColor = trueColor != 0
364+ ackOutput, err := r.getUint8()
365+ if err != nil {
366+ return Hello{}, err
367+ }
368+ h.AckOutput = ackOutput != 0
358369 if err := r.done(); err != nil {
359370 return Hello{}, err
360371 }
@@ -66,6 +66,7 @@ type Hello struct {
66 TermEnv string66 TermEnv string
67 Cwd string67 Cwd string
68 TrueColor bool68 TrueColor bool
69+ AckOutput bool
69 }70 }
70 71
71 // Input carries raw bytes typed/pasted by the client, destined for the PTY.72 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
@@ -309,6 +310,11 @@ func MarshalHello(h Hello) []byte {
309 } else {310 } else {
310 w.putUint8(0)311 w.putUint8(0)
311 }312 }
313+ if h.AckOutput {
314+ w.putUint8(1)
315+ } else {
316+ w.putUint8(0)
317+ }
312 return w.b318 return w.b
313 }319 }
314 320
@@ -355,6 +361,11 @@ func UnmarshalHello(b []byte) (Hello, error) {
355 return Hello{}, err361 return Hello{}, err
356 }362 }
357 h.TrueColor = trueColor != 0363 h.TrueColor = trueColor != 0
364+ ackOutput, err := r.getUint8()
365+ if err != nil {
366+ return Hello{}, err
367+ }
368+ h.AckOutput = ackOutput != 0
358 if err := r.done(); err != nil {369 if err := r.done(); err != nil {
359 return Hello{}, err370 return Hello{}, err
360 }371 }
M internal/ports/wire_test.go +3 -2
@@ -69,8 +69,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
6969 TermEnv: "xterm-256color",
7070 Cwd: "/tmp/project",
7171 TrueColor: true,
72+ AckOutput: true,
7273 },
73- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01},
74+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x01},
7475 },
7576 {
7677 name: "empty strings",
@@ -83,7 +84,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
8384 Cwd: "",
8485 TrueColor: false,
8586 },
86- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
87+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
8788 },
8889 }
8990
@@ -69,8 +69,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
69 TermEnv: "xterm-256color",69 TermEnv: "xterm-256color",
70 Cwd: "/tmp/project",70 Cwd: "/tmp/project",
71 TrueColor: true,71 TrueColor: true,
72+ AckOutput: true,
72 },73 },
73- want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01},74+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x01, 0x01},
74 },75 },
75 {76 {
76 name: "empty strings",77 name: "empty strings",
@@ -83,7 +84,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
83 Cwd: "",84 Cwd: "",
84 TrueColor: false,85 TrueColor: false,
85 },86 },
86- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},87+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
87 },88 },
88 }89 }
89 90
M internal/usecase/client/client.go +2 -0
@@ -352,6 +352,7 @@ 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)
355356 hello := ports.Hello{
356357 Version: ports.ProtocolVersion,
357358 Intent: intent,
@@ -362,6 +363,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
362363 TermEnv: termEnv,
363364 Cwd: cwd,
364365 TrueColor: trueColor,
366+ AckOutput: ackOutput,
365367 }
366368 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
367369 return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
@@ -352,6 +352,7 @@ 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)
355 hello := ports.Hello{356 hello := ports.Hello{
356 Version: ports.ProtocolVersion,357 Version: ports.ProtocolVersion,
357 Intent: intent,358 Intent: intent,
@@ -362,6 +363,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
362 TermEnv: termEnv,363 TermEnv: termEnv,
363 Cwd: cwd,364 Cwd: cwd,
364 TrueColor: trueColor,365 TrueColor: trueColor,
366+ AckOutput: ackOutput,
365 }367 }
366 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {368 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
367 return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}369 return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
M internal/usecase/client/client_test.go +6 -1
@@ -151,6 +151,10 @@ 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+
154158 func TestAttachHelloIncludesTrueColor(t *testing.T) {
155159 t.Setenv("TERM", "xterm-256color")
156160 t.Setenv("COLORTERM", "truecolor")
@@ -176,13 +180,14 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
176180 defer unblock()
177181 tr.EXPECT().Close().Return(nil).Once()
178182
179- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
183+ err := client.Attach(context.Background(), datagramMarkedTransport{Transport: tr}, tm, realClock{}, ports.IntentEphemeral, "")
180184 require.NoError(t, err)
181185
182186 select {
183187 case hello := <-gotHello:
184188 require.Equal(t, "xterm-256color", hello.TermEnv)
185189 require.True(t, hello.TrueColor)
190+ require.True(t, hello.AckOutput)
186191 case <-time.After(2 * time.Second):
187192 t.Fatal("hello frame was not sent")
188193 }
@@ -151,6 +151,10 @@ 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+
154 func TestAttachHelloIncludesTrueColor(t *testing.T) {158 func TestAttachHelloIncludesTrueColor(t *testing.T) {
155 t.Setenv("TERM", "xterm-256color")159 t.Setenv("TERM", "xterm-256color")
156 t.Setenv("COLORTERM", "truecolor")160 t.Setenv("COLORTERM", "truecolor")
@@ -176,13 +180,14 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
176 defer unblock()180 defer unblock()
177 tr.EXPECT().Close().Return(nil).Once()181 tr.EXPECT().Close().Return(nil).Once()
178 182
179- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")183+ err := client.Attach(context.Background(), datagramMarkedTransport{Transport: tr}, tm, realClock{}, ports.IntentEphemeral, "")
180 require.NoError(t, err)184 require.NoError(t, err)
181 185
182 select {186 select {
183 case hello := <-gotHello:187 case hello := <-gotHello:
184 require.Equal(t, "xterm-256color", hello.TermEnv)188 require.Equal(t, "xterm-256color", hello.TermEnv)
185 require.True(t, hello.TrueColor)189 require.True(t, hello.TrueColor)
190+ require.True(t, hello.AckOutput)
186 case <-time.After(2 * time.Second):191 case <-time.After(2 * time.Second):
187 t.Fatal("hello frame was not sent")192 t.Fatal("hello frame was not sent")
188 }193 }
M internal/usecase/command/command.go +1 -0
@@ -23,6 +23,7 @@ type Context interface {
2323 Detach() error
2424 EnterVisualMode() error
2525 RenameSession() error
26+ RenameTab() error
2627 OpenSessionPicker() error
2728 }
2829
@@ -23,6 +23,7 @@ type Context interface {
23 Detach() error23 Detach() error
24 EnterVisualMode() error24 EnterVisualMode() error
25 RenameSession() error25 RenameSession() error
26+ RenameTab() error
26 OpenSessionPicker() error27 OpenSessionPicker() error
27 }28 }
28 29
M internal/usecase/command/context_mock_test.go +44 -0
@@ -739,6 +739,50 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo
739739 return _c
740740 }
741741
742+// RenameTab provides a mock function for the type MockContext
743+func (_mock *MockContext) RenameTab() error {
744+ ret := _mock.Called()
745+
746+ if len(ret) == 0 {
747+ panic("no return value specified for RenameTab")
748+ }
749+
750+ var r0 error
751+ if returnFunc, ok := ret.Get(0).(func() error); ok {
752+ r0 = returnFunc()
753+ } else {
754+ r0 = ret.Error(0)
755+ }
756+ return r0
757+}
758+
759+// MockContext_RenameTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameTab'
760+type MockContext_RenameTab_Call struct {
761+ *mock.Call
762+}
763+
764+// RenameTab is a helper method to define mock.On call
765+func (_e *MockContext_Expecter) RenameTab() *MockContext_RenameTab_Call {
766+ return &MockContext_RenameTab_Call{Call: _e.mock.On("RenameTab")}
767+}
768+
769+func (_c *MockContext_RenameTab_Call) Run(run func()) *MockContext_RenameTab_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ run()
772+ })
773+ return _c
774+}
775+
776+func (_c *MockContext_RenameTab_Call) Return(err error) *MockContext_RenameTab_Call {
777+ _c.Call.Return(err)
778+ return _c
779+}
780+
781+func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContext_RenameTab_Call {
782+ _c.Call.Return(run)
783+ return _c
784+}
785+
742786 // SplitDown provides a mock function for the type MockContext
743787 func (_mock *MockContext) SplitDown() error {
744788 ret := _mock.Called()
@@ -739,6 +739,50 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo
739 return _c739 return _c
740 }740 }
741 741
742+// RenameTab provides a mock function for the type MockContext
743+func (_mock *MockContext) RenameTab() error {
744+ ret := _mock.Called()
745+
746+ if len(ret) == 0 {
747+ panic("no return value specified for RenameTab")
748+ }
749+
750+ var r0 error
751+ if returnFunc, ok := ret.Get(0).(func() error); ok {
752+ r0 = returnFunc()
753+ } else {
754+ r0 = ret.Error(0)
755+ }
756+ return r0
757+}
758+
759+// MockContext_RenameTab_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RenameTab'
760+type MockContext_RenameTab_Call struct {
761+ *mock.Call
762+}
763+
764+// RenameTab is a helper method to define mock.On call
765+func (_e *MockContext_Expecter) RenameTab() *MockContext_RenameTab_Call {
766+ return &MockContext_RenameTab_Call{Call: _e.mock.On("RenameTab")}
767+}
768+
769+func (_c *MockContext_RenameTab_Call) Run(run func()) *MockContext_RenameTab_Call {
770+ _c.Call.Run(func(args mock.Arguments) {
771+ run()
772+ })
773+ return _c
774+}
775+
776+func (_c *MockContext_RenameTab_Call) Return(err error) *MockContext_RenameTab_Call {
777+ _c.Call.Return(err)
778+ return _c
779+}
780+
781+func (_c *MockContext_RenameTab_Call) RunAndReturn(run func() error) *MockContext_RenameTab_Call {
782+ _c.Call.Return(run)
783+ return _c
784+}
785+
742 // SplitDown provides a mock function for the type MockContext786 // SplitDown provides a mock function for the type MockContext
743 func (_mock *MockContext) SplitDown() error {787 func (_mock *MockContext) SplitDown() error {
744 ret := _mock.Called()788 ret := _mock.Called()
M internal/usecase/command/registry.go +1 -0
@@ -26,6 +26,7 @@ func Registry() []Command {
2626 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
2727 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
2828 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
29+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
2930 {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
3031 }
3132
@@ -26,6 +26,7 @@ func Registry() []Command {
26 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},26 {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }},
27 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},27 {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }},
28 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},28 {Slug: "rename-session", Code: "RNS", Name: "Rename session", Desc: "Rename the session (an ephemeral session becomes named)", Run: func(ctx Context) error { return ctx.RenameSession() }},
29+ {Slug: "rename-tab", Code: "RNT", Name: "Rename tab", Desc: "Rename the current tab", Run: func(ctx Context) error { return ctx.RenameTab() }},
29 {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},30 {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }},
30 }31 }
31 32
M internal/usecase/command/registry_test.go +2 -1
@@ -7,7 +7,7 @@ import (
77
88 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
99 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
1111
1212 if len(commands) != len(wantCodes) {
1313 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -79,6 +79,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
7979 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
8080 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
8181 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
82+ {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
8283 {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
8384 }
8485
@@ -7,7 +7,7 @@ import (
7 7
8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {8 func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) {
9 commands := Registry()9 commands := Registry()
10- wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}10+ wantCodes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
11 11
12 if len(commands) != len(wantCodes) {12 if len(commands) != len(wantCodes) {
13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))13 t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes))
@@ -79,6 +79,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) {
79 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},79 {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }},
80 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},80 {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }},
81 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},81 {code: "RNS", expect: func(ctx *MockContext) { ctx.EXPECT().RenameSession().Return(nil).Once() }},
82+ {code: "RNT", expect: func(ctx *MockContext) { ctx.EXPECT().RenameTab().Return(nil).Once() }},
82 {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},83 {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }},
83 }84 }
84 85
M internal/usecase/daemon/client.go +68 -29
@@ -39,28 +39,30 @@ import (
3939 )
4040
4141 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- size domain.Size
56- keys *keys.Router
57- sess Guarded[*session]
58- mouseScan mouse.Scanner
59- themeMu sync.Mutex
60- theme themeui.Theme
61- clientTheme themeui.Theme
62- lastCursor cursorOut
63- recentNav recentSessionNavigator
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
6466 // historyNavMu protects historyNav and historyNavTimer. When paint needs
6567 // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
6668 historyNavMu sync.Mutex
@@ -213,6 +215,10 @@ func (ac *attachedClient) shouldResetOutputState(reset bool) bool {
213215 func (ac *attachedClient) advanceOutputAck(state uint64) {
214216 ac.sendMu.Lock()
215217 defer ac.sendMu.Unlock()
218+ ac.advanceOutputAckLocked(state)
219+}
220+
221+func (ac *attachedClient) advanceOutputAckLocked(state uint64) {
216222 if state > ac.nextStateNum {
217223 return
218224 }
@@ -227,6 +233,29 @@ func (ac *attachedClient) advanceOutputAck(state uint64) {
227233 }
228234 }
229235
236+func (ac *attachedClient) ackOutputState(state uint64) error {
237+ ac.sendMu.Lock()
238+ 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+
230259 // outputLagAtCapLocked reports whether the client has at least
231260 // maxUnackedOutputStates output states in flight (sent but unacked). Caller
232261 // holds sendMu.
@@ -396,6 +425,7 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
396425 type attachClientOptions struct {
397426 clientID [16]byte
398427 resumeCapable bool
428+ ackOutput bool
399429 }
400430
401431 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
@@ -403,13 +433,20 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
403433 if opts.resumeCapable {
404434 resumeToken = d.nextResumeTokenLocked()
405435 }
436+ caps := renderer.Capabilities{}
437+ advanceOutputOnAck := opts.ackOutput
438+ if advanceOutputOnAck {
439+ caps.AdvancePolicy = renderer.AdvanceOnAck
440+ }
406441 ac := &attachedClient{
407- tr: tr,
408- rend: renderer.New(renderer.Capabilities{}),
409- size: sz,
410- clientID: opts.clientID,
411- resumeCapable: opts.resumeCapable,
412- resumeToken: resumeToken,
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,
413450 }
414451 ac.initOverlays()
415452 ac.setSession(sess)
@@ -521,7 +558,9 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
521558 return
522559 case ports.MsgAck:
523560 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
524- ac.advanceOutputAck(ack.AckedStateNum)
561+ if err := ac.ackOutputState(ack.AckedStateNum); err != nil {
562+ d.log.Warn("output ack rejected", "err", err, "state", ack.AckedStateNum)
563+ }
525564 if ac.takeDeferredPaint() {
526565 d.paint(sess, ac, false)
527566 }
@@ -39,28 +39,30 @@ import (
39 )39 )
40 40
41 type attachedClient struct {41 type attachedClient struct {
42- tr ports.Transport42+ tr ports.Transport
43- rend *renderer.Renderer43+ rend *renderer.Renderer
44- overlays *overlayRuntime44+ overlays *overlayRuntime
45- clientID [16]byte45+ clientID [16]byte
46- resumeCapable bool46+ resumeCapable bool
47- resumeToken uint6447+ resumeToken uint64
48- parked bool48+ parked bool
49- nextStateNum uint6449+ nextStateNum uint64
50- echoAck atomic.Uint6450+ echoAck atomic.Uint64
51- outputAck atomic.Uint6451+ outputAck atomic.Uint64
52- paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag52+ paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag
53- bars barCache // only touched while sendMu is held53+ bars barCache // only touched while sendMu is held
54- composed composedFrameCache // only touched while sendMu is held54+ composed composedFrameCache // only touched while sendMu is held
55- size domain.Size55+ advanceOutputOnAck bool // renderer shadow advances after client MsgAck instead of send
56- keys *keys.Router56+ outputFrames map[uint64]renderer.Frame // only touched while sendMu is held; frames sent by output state
57- sess Guarded[*session]57+ size domain.Size
58- mouseScan mouse.Scanner58+ keys *keys.Router
59- themeMu sync.Mutex59+ sess Guarded[*session]
60- theme themeui.Theme60+ mouseScan mouse.Scanner
61- clientTheme themeui.Theme61+ themeMu sync.Mutex
62- lastCursor cursorOut62+ theme themeui.Theme
63- recentNav recentSessionNavigator63+ clientTheme themeui.Theme
64+ lastCursor cursorOut
65+ recentNav recentSessionNavigator
64 // historyNavMu protects historyNav and historyNavTimer. When paint needs66 // historyNavMu protects historyNav and historyNavTimer. When paint needs
65 // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.67 // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu.
66 historyNavMu sync.Mutex68 historyNavMu sync.Mutex
@@ -213,6 +215,10 @@ func (ac *attachedClient) shouldResetOutputState(reset bool) bool {
213 func (ac *attachedClient) advanceOutputAck(state uint64) {215 func (ac *attachedClient) advanceOutputAck(state uint64) {
214 ac.sendMu.Lock()216 ac.sendMu.Lock()
215 defer ac.sendMu.Unlock()217 defer ac.sendMu.Unlock()
218+ ac.advanceOutputAckLocked(state)
219+}
220+
221+func (ac *attachedClient) advanceOutputAckLocked(state uint64) {
216 if state > ac.nextStateNum {222 if state > ac.nextStateNum {
217 return223 return
218 }224 }
@@ -227,6 +233,29 @@ func (ac *attachedClient) advanceOutputAck(state uint64) {
227 }233 }
228 }234 }
229 235
236+func (ac *attachedClient) ackOutputState(state uint64) error {
237+ ac.sendMu.Lock()
238+ 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+
230 // outputLagAtCapLocked reports whether the client has at least259 // outputLagAtCapLocked reports whether the client has at least
231 // maxUnackedOutputStates output states in flight (sent but unacked). Caller260 // maxUnackedOutputStates output states in flight (sent but unacked). Caller
232 // holds sendMu.261 // holds sendMu.
@@ -396,6 +425,7 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
396 type attachClientOptions struct {425 type attachClientOptions struct {
397 clientID [16]byte426 clientID [16]byte
398 resumeCapable bool427 resumeCapable bool
428+ ackOutput bool
399 }429 }
400 430
401 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {431 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
@@ -403,13 +433,20 @@ func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size,
403 if opts.resumeCapable {433 if opts.resumeCapable {
404 resumeToken = d.nextResumeTokenLocked()434 resumeToken = d.nextResumeTokenLocked()
405 }435 }
436+ caps := renderer.Capabilities{}
437+ advanceOutputOnAck := opts.ackOutput
438+ if advanceOutputOnAck {
439+ caps.AdvancePolicy = renderer.AdvanceOnAck
440+ }
406 ac := &attachedClient{441 ac := &attachedClient{
407- tr: tr,442+ tr: tr,
408- rend: renderer.New(renderer.Capabilities{}),443+ rend: renderer.New(caps),
409- size: sz,444+ advanceOutputOnAck: advanceOutputOnAck,
410- clientID: opts.clientID,445+ outputFrames: make(map[uint64]renderer.Frame),
411- resumeCapable: opts.resumeCapable,446+ size: sz,
412- resumeToken: resumeToken,447+ clientID: opts.clientID,
448+ resumeCapable: opts.resumeCapable,
449+ resumeToken: resumeToken,
413 }450 }
414 ac.initOverlays()451 ac.initOverlays()
415 ac.setSession(sess)452 ac.setSession(sess)
@@ -521,7 +558,9 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
521 return558 return
522 case ports.MsgAck:559 case ports.MsgAck:
523 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {560 if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
524- ac.advanceOutputAck(ack.AckedStateNum)561+ if err := ac.ackOutputState(ack.AckedStateNum); err != nil {
562+ d.log.Warn("output ack rejected", "err", err, "state", ack.AckedStateNum)
563+ }
525 if ac.takeDeferredPaint() {564 if ac.takeDeferredPaint() {
526 d.paint(sess, ac, false)565 d.paint(sess, ac, false)
527 }566 }
M internal/usecase/daemon/daemon.go +19 -8
@@ -31,6 +31,7 @@ import (
3131 "errors"
3232 "log/slog"
3333 "os"
34+ "slices"
3435 "sort"
3536 "sync"
3637 "sync/atomic"
@@ -163,9 +164,19 @@ type stoppedSession struct {
163164 cwd string
164165 createdAt int64
165166 lastUsedSeq uint64
167+ tabNames []string
166168 purging bool
167169 }
168170
171+func (s stoppedSession) same(other stoppedSession) bool {
172+ return s.name == other.name &&
173+ s.cwd == other.cwd &&
174+ s.createdAt == other.createdAt &&
175+ s.lastUsedSeq == other.lastUsedSeq &&
176+ s.purging == other.purging &&
177+ slices.Equal(s.tabNames, other.tabNames)
178+}
179+
169180 type Option func(*Daemon)
170181
171182 // WithShell overrides the command (and its args) each session spawns. The
@@ -320,7 +331,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
320331 } else {
321332 var maxSeq uint64
322333 for _, r := range records {
323- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}
334+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
324335 if r.LastUsedSeq > maxSeq {
325336 maxSeq = r.LastUsedSeq
326337 }
@@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
537548 return
538549 }
539550 d.mu.Lock()
540- if cur, ok := d.stopped[k.Name]; ok && cur == stopped {
551+ if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
541552 delete(d.stopped, k.Name)
542553 }
543554 d.mu.Unlock()
@@ -647,14 +658,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
647658 }
648659 cwd := d.dirOrHome(stopped.cwd)
649660 var err error
650- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
661+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
651662 if err != nil {
652663 d.mu.Unlock()
653664 return nil, nil, err
654665 }
655666 }
656667 d.purgeParkedForSessionLocked(sess)
657- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
668+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
658669 sess.mu.Lock()
659670 sess.terminal = term
660671 sess.mu.Unlock()
@@ -670,7 +681,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
670681 return nil, nil, err
671682 }
672683 d.purgeParkedForSessionLocked(sess)
673- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
684+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
674685 sess.mu.Lock()
675686 sess.terminal = term
676687 sess.mu.Unlock()
@@ -697,7 +708,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
697708 return nil, nil, err
698709 }
699710 d.purgeParkedForSessionLocked(sess)
700- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
711+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
701712 sess.mu.Lock()
702713 sess.terminal = term
703714 sess.mu.Unlock()
@@ -715,14 +726,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
715726 }
716727 cwd := d.dirOrHome(stopped.cwd)
717728 var err error
718- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)
729+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
719730 if err != nil {
720731 d.mu.Unlock()
721732 return nil, nil, err
722733 }
723734 }
724735 d.purgeParkedForSessionLocked(sess)
725- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
736+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
726737 sess.mu.Lock()
727738 sess.terminal = term
728739 sess.mu.Unlock()
@@ -31,6 +31,7 @@ import (
31 "errors"31 "errors"
32 "log/slog"32 "log/slog"
33 "os"33 "os"
34+ "slices"
34 "sort"35 "sort"
35 "sync"36 "sync"
36 "sync/atomic"37 "sync/atomic"
@@ -163,9 +164,19 @@ type stoppedSession struct {
163 cwd string164 cwd string
164 createdAt int64165 createdAt int64
165 lastUsedSeq uint64166 lastUsedSeq uint64
167+ tabNames []string
166 purging bool168 purging bool
167 }169 }
168 170
171+func (s stoppedSession) same(other stoppedSession) bool {
172+ return s.name == other.name &&
173+ s.cwd == other.cwd &&
174+ s.createdAt == other.createdAt &&
175+ s.lastUsedSeq == other.lastUsedSeq &&
176+ s.purging == other.purging &&
177+ slices.Equal(s.tabNames, other.tabNames)
178+}
179+
169 type Option func(*Daemon)180 type Option func(*Daemon)
170 181
171 // WithShell overrides the command (and its args) each session spawns. The182 // WithShell overrides the command (and its args) each session spawns. The
@@ -320,7 +331,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
320 } else {331 } else {
321 var maxSeq uint64332 var maxSeq uint64
322 for _, r := range records {333 for _, r := range records {
323- d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq}334+ d.stopped[r.Name] = stoppedSession{name: r.Name, cwd: r.Cwd, createdAt: r.CreatedAt, lastUsedSeq: r.LastUsedSeq, tabNames: r.TabNames}
324 if r.LastUsedSeq > maxSeq {335 if r.LastUsedSeq > maxSeq {
325 maxSeq = r.LastUsedSeq336 maxSeq = r.LastUsedSeq
326 }337 }
@@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) {
537 return548 return
538 }549 }
539 d.mu.Lock()550 d.mu.Lock()
540- if cur, ok := d.stopped[k.Name]; ok && cur == stopped {551+ if cur, ok := d.stopped[k.Name]; ok && cur.same(stopped) {
541 delete(d.stopped, k.Name)552 delete(d.stopped, k.Name)
542 }553 }
543 d.mu.Unlock()554 d.mu.Unlock()
@@ -647,14 +658,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
647 }658 }
648 cwd := d.dirOrHome(stopped.cwd)659 cwd := d.dirOrHome(stopped.cwd)
649 var err error660 var err error
650- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)661+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
651 if err != nil {662 if err != nil {
652 d.mu.Unlock()663 d.mu.Unlock()
653 return nil, nil, err664 return nil, nil, err
654 }665 }
655 }666 }
656 d.purgeParkedForSessionLocked(sess)667 d.purgeParkedForSessionLocked(sess)
657- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})668+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
658 sess.mu.Lock()669 sess.mu.Lock()
659 sess.terminal = term670 sess.terminal = term
660 sess.mu.Unlock()671 sess.mu.Unlock()
@@ -670,7 +681,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
670 return nil, nil, err681 return nil, nil, err
671 }682 }
672 d.purgeParkedForSessionLocked(sess)683 d.purgeParkedForSessionLocked(sess)
673- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})684+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
674 sess.mu.Lock()685 sess.mu.Lock()
675 sess.terminal = term686 sess.terminal = term
676 sess.mu.Unlock()687 sess.mu.Unlock()
@@ -697,7 +708,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
697 return nil, nil, err708 return nil, nil, err
698 }709 }
699 d.purgeParkedForSessionLocked(sess)710 d.purgeParkedForSessionLocked(sess)
700- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})711+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
701 sess.mu.Lock()712 sess.mu.Lock()
702 sess.terminal = term713 sess.terminal = term
703 sess.mu.Unlock()714 sess.mu.Unlock()
@@ -715,14 +726,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
715 }726 }
716 cwd := d.dirOrHome(stopped.cwd)727 cwd := d.dirOrHome(stopped.cwd)
717 var err error728 var err error
718- sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term)729+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz, term, stopped.tabNames)
719 if err != nil {730 if err != nil {
720 d.mu.Unlock()731 d.mu.Unlock()
721 return nil, nil, err732 return nil, nil, err
722 }733 }
723 }734 }
724 d.purgeParkedForSessionLocked(sess)735 d.purgeParkedForSessionLocked(sess)
725- ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})736+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true, ackOutput: h.AckOutput})
726 sess.mu.Lock()737 sess.mu.Lock()
727 sess.terminal = term738 sess.terminal = term
728 sess.mu.Unlock()739 sess.mu.Unlock()
M internal/usecase/daemon/input_test.go +26 -0
@@ -641,6 +641,32 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
641641 require.Equal(t, "0", sess.name)
642642 }
643643
644+func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
645+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
646+ defer func() {
647+ for _, release := range releases {
648+ release()
649+ }
650+ }()
651+ sess.active = 1
652+
653+ d.handleInput(sess, ac, []byte("\x1b "))
654+ awaitFrame(t, sends, ports.MsgOutput)
655+ d.handleInput(sess, ac, []byte("RNT\r"))
656+ out := awaitFrame(t, sends, ports.MsgOutput)
657+ require.True(t, ac.overlays.promptActive())
658+ msg, err := ports.UnmarshalOutput(out.Payload)
659+ require.NoError(t, err)
660+ require.Contains(t, string(msg.Data), "> 2")
661+
662+ d.handleInput(sess, ac, []byte("\x7flogs\r"))
663+ awaitFrame(t, sends, ports.MsgOutput)
664+
665+ require.False(t, ac.overlays.promptActive())
666+ require.Equal(t, "logs", sess.tabs[1].name)
667+ require.Empty(t, sess.tabs[0].name)
668+}
669+
644670 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
645671 writes := make(chan []byte, 4)
646672 p, _ := newBlockingPTYWithWrites(t, writes)
@@ -641,6 +641,32 @@ func TestRNSOpensPromptAndEnterPromotesEphemeralSession(t *testing.T) {
641 require.Equal(t, "0", sess.name)641 require.Equal(t, "0", sess.name)
642 }642 }
643 643
644+func TestRNTOpensPromptAndRenamesActiveTab(t *testing.T) {
645+ d, sess, ac, sends, releases := newManualTabSession(t, 2)
646+ defer func() {
647+ for _, release := range releases {
648+ release()
649+ }
650+ }()
651+ sess.active = 1
652+
653+ d.handleInput(sess, ac, []byte("\x1b "))
654+ awaitFrame(t, sends, ports.MsgOutput)
655+ d.handleInput(sess, ac, []byte("RNT\r"))
656+ out := awaitFrame(t, sends, ports.MsgOutput)
657+ require.True(t, ac.overlays.promptActive())
658+ msg, err := ports.UnmarshalOutput(out.Payload)
659+ require.NoError(t, err)
660+ require.Contains(t, string(msg.Data), "> 2")
661+
662+ d.handleInput(sess, ac, []byte("\x7flogs\r"))
663+ awaitFrame(t, sends, ports.MsgOutput)
664+
665+ require.False(t, ac.overlays.promptActive())
666+ require.Equal(t, "logs", sess.tabs[1].name)
667+ require.Empty(t, sess.tabs[0].name)
668+}
669+
644 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {670 func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) {
645 writes := make(chan []byte, 4)671 writes := make(chan []byte, 4)
646 p, _ := newBlockingPTYWithWrites(t, writes)672 p, _ := newBlockingPTYWithWrites(t, writes)
M internal/usecase/daemon/nil_pty_test.go +1 -0
@@ -18,6 +18,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
1818 ctx, cancel := context.WithCancel(context.Background())
1919 defer cancel()
2020 sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
21+ d.sessions[sess.id] = sess
2122 first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
2223 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
2324 sess.tabs = []*tab{first, second}
@@ -18,6 +18,7 @@ func TestNilPTYLifecyclePathsDoNotPanic(t *testing.T) {
18 ctx, cancel := context.WithCancel(context.Background())18 ctx, cancel := context.WithCancel(context.Background())
19 defer cancel()19 defer cancel()
20 sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}20 sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel}
21+ d.sessions[sess.id] = sess
21 first := newTab(nil, domain.Size{Cols: 41, Rows: 10})22 first := newTab(nil, domain.Size{Cols: 41, Rows: 10})
22 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})23 second := newTab(nil, domain.Size{Cols: 41, Rows: 10})
23 sess.tabs = []*tab{first, second}24 sess.tabs = []*tab{first, second}
A internal/usecase/daemon/output_state_sync_test.go +104 -0
@@ -0,0 +1,104 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type datagramTestTransport struct {
14+ sends chan ports.Frame
15+ recv chan ports.Frame
16+ once sync.Once
17+}
18+
19+func newDatagramTestTransport() *datagramTestTransport {
20+ return &datagramTestTransport{sends: make(chan ports.Frame, 64), recv: make(chan ports.Frame, 64)}
21+}
22+
23+func (t *datagramTestTransport) Send(f ports.Frame) error { t.sends <- f; return nil }
24+func (t *datagramTestTransport) Recv() (ports.Frame, error) {
25+ f, ok := <-t.recv
26+ if !ok {
27+ return ports.Frame{}, io.EOF
28+ }
29+ return f, nil
30+}
31+func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }
32+
33+func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) {
34+ p, releasePTY := newBlockingPTY(t)
35+ defer releasePTY()
36+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
37+ tr := newDatagramTestTransport()
38+ routed, ac, err := d.route(ports.Hello{
39+ Version: ports.ProtocolVersion,
40+ Intent: ports.IntentAttach,
41+ Name: sess.name,
42+ Size: domain.Size{Cols: 80, Rows: 24},
43+ AckOutput: true,
44+ }, tr)
45+ require.NoError(t, err)
46+ require.Same(t, sess, routed)
47+
48+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
49+ d.paint(sess, ac, false)
50+ first := awaitFrame(t, tr.sends, ports.MsgOutput)
51+ out, err := ports.UnmarshalOutput(first.Payload)
52+ require.NoError(t, err)
53+ require.Equal(t, uint64(1), out.NewStateNum)
54+
55+ // Before the MsgAck, AdvanceOnAck must keep rediffing from the unacked
56+ // shadow even if the composed screen is unchanged.
57+ d.paint(sess, ac, false)
58+ awaitFrame(t, tr.sends, ports.MsgOutput)
59+
60+ tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}
61+ require.NoError(t, tr.Close())
62+ d.runConnLoop(ac)
63+
64+ // After the production MsgAck path advances the renderer shadow to state 1,
65+ // an unchanged repaint is a no-op.
66+ d.paint(sess, ac, false)
67+ requireNoOutputFrame(t, tr.sends)
68+}
69+
70+func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) {
71+ p, releasePTY := newBlockingPTY(t)
72+ defer releasePTY()
73+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
74+
75+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
76+ d.paint(sess, ac, false)
77+ awaitFrame(t, sends, ports.MsgOutput)
78+
79+ // Local/default transports keep AdvanceOnSend: the first paint updates the
80+ // renderer shadow without waiting for MsgAck.
81+ d.paint(sess, ac, false)
82+ requireNoOutputFrame(t, sends)
83+}
84+
85+func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
86+ p, releasePTY := newBlockingPTY(t)
87+ defer releasePTY()
88+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
89+ pane := sess.tabs[0].focusedPane()
90+
91+ pane.screen.Write([]byte("A"))
92+ d.paint(sess, ac, false)
93+ first := awaitFrame(t, sends, ports.MsgOutput)
94+ out, err := ports.UnmarshalOutput(first.Payload)
95+ require.NoError(t, err)
96+
97+ pane.screen.Write([]byte("\rB"))
98+ d.paint(sess, ac, false)
99+ awaitFrame(t, sends, ports.MsgOutput)
100+
101+ require.NoError(t, ac.ackOutputState(out.NewStateNum))
102+ d.paint(sess, ac, false)
103+ requireNoOutputFrame(t, sends)
104+}
@@ -0,0 +1,104 @@
1+package daemon
2+
3+import (
4+ "io"
5+ "sync"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type datagramTestTransport struct {
14+ sends chan ports.Frame
15+ recv chan ports.Frame
16+ once sync.Once
17+}
18+
19+func newDatagramTestTransport() *datagramTestTransport {
20+ return &datagramTestTransport{sends: make(chan ports.Frame, 64), recv: make(chan ports.Frame, 64)}
21+}
22+
23+func (t *datagramTestTransport) Send(f ports.Frame) error { t.sends <- f; return nil }
24+func (t *datagramTestTransport) Recv() (ports.Frame, error) {
25+ f, ok := <-t.recv
26+ if !ok {
27+ return ports.Frame{}, io.EOF
28+ }
29+ return f, nil
30+}
31+func (t *datagramTestTransport) Close() error { t.once.Do(func() { close(t.recv) }); return nil }
32+
33+func TestDatagramAttachAdvancesRendererOnlyOnAck(t *testing.T) {
34+ p, releasePTY := newBlockingPTY(t)
35+ defer releasePTY()
36+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
37+ tr := newDatagramTestTransport()
38+ routed, ac, err := d.route(ports.Hello{
39+ Version: ports.ProtocolVersion,
40+ Intent: ports.IntentAttach,
41+ Name: sess.name,
42+ Size: domain.Size{Cols: 80, Rows: 24},
43+ AckOutput: true,
44+ }, tr)
45+ require.NoError(t, err)
46+ require.Same(t, sess, routed)
47+
48+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
49+ d.paint(sess, ac, false)
50+ first := awaitFrame(t, tr.sends, ports.MsgOutput)
51+ out, err := ports.UnmarshalOutput(first.Payload)
52+ require.NoError(t, err)
53+ require.Equal(t, uint64(1), out.NewStateNum)
54+
55+ // Before the MsgAck, AdvanceOnAck must keep rediffing from the unacked
56+ // shadow even if the composed screen is unchanged.
57+ d.paint(sess, ac, false)
58+ awaitFrame(t, tr.sends, ports.MsgOutput)
59+
60+ tr.recv <- ports.Frame{Type: ports.MsgAck, Payload: ports.MarshalAck(ports.Ack{AckedStateNum: out.NewStateNum})}
61+ require.NoError(t, tr.Close())
62+ d.runConnLoop(ac)
63+
64+ // After the production MsgAck path advances the renderer shadow to state 1,
65+ // an unchanged repaint is a no-op.
66+ d.paint(sess, ac, false)
67+ requireNoOutputFrame(t, tr.sends)
68+}
69+
70+func TestLocalAttachStillAdvancesRendererOnSend(t *testing.T) {
71+ p, releasePTY := newBlockingPTY(t)
72+ defer releasePTY()
73+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
74+
75+ sess.tabs[0].focusedPane().screen.Write([]byte("A"))
76+ d.paint(sess, ac, false)
77+ awaitFrame(t, sends, ports.MsgOutput)
78+
79+ // Local/default transports keep AdvanceOnSend: the first paint updates the
80+ // renderer shadow without waiting for MsgAck.
81+ d.paint(sess, ac, false)
82+ requireNoOutputFrame(t, sends)
83+}
84+
85+func TestLocalOutputAckDoesNotMoveRendererShadowBackward(t *testing.T) {
86+ p, releasePTY := newBlockingPTY(t)
87+ defer releasePTY()
88+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
89+ pane := sess.tabs[0].focusedPane()
90+
91+ pane.screen.Write([]byte("A"))
92+ d.paint(sess, ac, false)
93+ first := awaitFrame(t, sends, ports.MsgOutput)
94+ out, err := ports.UnmarshalOutput(first.Payload)
95+ require.NoError(t, err)
96+
97+ pane.screen.Write([]byte("\rB"))
98+ d.paint(sess, ac, false)
99+ awaitFrame(t, sends, ports.MsgOutput)
100+
101+ require.NoError(t, ac.ackOutputState(out.NewStateNum))
102+ d.paint(sess, ac, false)
103+ requireNoOutputFrame(t, sends)
104+}
M internal/usecase/daemon/palette.go +14 -0
@@ -256,6 +256,20 @@ func (e paletteExec) RenameSession() error {
256256 return nil
257257 }
258258
259+func (e paletteExec) RenameTab() error {
260+ tb := e.sess.activeTab()
261+ if tb == nil {
262+ return nil
263+ }
264+ e.sess.mu.Lock()
265+ currentName := tabDisplayName(tb, e.sess.active)
266+ e.sess.mu.Unlock()
267+ e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
268+ return e.d.renameTab(e.sess, tb, name)
269+ })
270+ return nil
271+}
272+
259273 func (e paletteExec) OpenSessionPicker() error {
260274 e.d.enterPicker(e.sess, e.ac)
261275 return nil
@@ -256,6 +256,20 @@ func (e paletteExec) RenameSession() error {
256 return nil256 return nil
257 }257 }
258 258
259+func (e paletteExec) RenameTab() error {
260+ tb := e.sess.activeTab()
261+ if tb == nil {
262+ return nil
263+ }
264+ e.sess.mu.Lock()
265+ currentName := tabDisplayName(tb, e.sess.active)
266+ e.sess.mu.Unlock()
267+ e.d.enterPrompt(e.sess, e.ac, " Rename tab ", currentName, func(name string) error {
268+ return e.d.renameTab(e.sess, tb, name)
269+ })
270+ return nil
271+}
272+
259 func (e paletteExec) OpenSessionPicker() error {273 func (e paletteExec) OpenSessionPicker() error {
260 e.d.enterPicker(e.sess, e.ac)274 e.d.enterPicker(e.sess, e.ac)
261 return nil275 return nil
M internal/usecase/daemon/palette_test.go +2 -2
@@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112112 for i, cmd := range commands {
113113 codes[i] = cmd.Code
114114 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "DET"}, codes)
115+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
116116 }
117117
118118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119119 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}
120+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
121121
122122 var wg sync.WaitGroup
123123 for range 50 {
@@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) {
112 for i, cmd := range commands {112 for i, cmd := range commands {
113 codes[i] = cmd.Code113 codes[i] = cmd.Code
114 }114 }
115- require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "DET"}, codes)115+ require.Equal(t, []string{"SSP", "NXT", "CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "PVT", "BSK", "FSK", "VIS", "RNS", "RNT", "DET"}, codes)
116 }116 }
117 117
118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {118 func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) {
119 d := &Daemon{}119 d := &Daemon{}
120- codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "DET"}120+ codes := []string{"CNT", "CNS", "CLT", "SPR", "SPL", "SPU", "SPD", "STP", "TST", "CLP", "FPL", "FPR", "FPU", "FPD", "NXT", "PVT", "BSK", "FSK", "SSP", "VIS", "RNS", "RNT", "DET"}
121 121
122 var wg sync.WaitGroup122 var wg sync.WaitGroup
123 for range 50 {123 for range 50 {
M internal/usecase/daemon/pane_actions_test.go +1 -0
@@ -219,6 +219,7 @@ func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {
219219 otherPTY := portsmocks.NewMockPTY(t)
220220 other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10})
221221 sess.tabs = append(sess.tabs, other)
222+ d.sessions[sess.id] = sess
222223 oldPTY.EXPECT().Close().Return(nil).Once()
223224
224225 require.NoError(t, d.closeFocusedPane(sess, nil))
@@ -219,6 +219,7 @@ func TestCloseFocusedLastPaneDelegatesToCloseTab(t *testing.T) {
219 otherPTY := portsmocks.NewMockPTY(t)219 otherPTY := portsmocks.NewMockPTY(t)
220 other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10})220 other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10})
221 sess.tabs = append(sess.tabs, other)221 sess.tabs = append(sess.tabs, other)
222+ d.sessions[sess.id] = sess
222 oldPTY.EXPECT().Close().Return(nil).Once()223 oldPTY.EXPECT().Close().Return(nil).Once()
223 224
224 require.NoError(t, d.closeFocusedPane(sess, nil))225 require.NoError(t, d.closeFocusedPane(sess, nil))
M internal/usecase/daemon/picker.go +3 -6
@@ -26,7 +26,6 @@ package daemon
2626
2727 import (
2828 "sort"
29- "strconv"
3029
3130 "github.com/bnema/vev/internal/domain"
3231 "github.com/bnema/vev/internal/ports"
@@ -71,7 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
7170 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
7271 sessionAttention := false
7372 for i, tb := range s.tabs {
74- label := strconv.Itoa(i + 1)
73+ label := tabDisplayName(tb, i)
7574 if tb.attention {
7675 label = attentionSuffix(label)
7776 sessionAttention = true
@@ -425,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
425424 }
426425 term := from.terminal
427426 cwd := d.dirOrHome(stopped.cwd)
428- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term)
427+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, stopped.tabNames)
429428 if err != nil {
430429 from.mu.Unlock()
431430 d.mu.Unlock()
@@ -455,9 +454,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
455454 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
456455 return
457456 }
458- if cur, ok := d.stopped[target.Name]; ok && cur == stopped {
459- delete(d.stopped, target.Name)
460- }
457+ delete(d.stopped, target.Name)
461458 }
462459 d.mu.Unlock()
463460 return
@@ -26,7 +26,6 @@ package daemon
26 26
27 import (27 import (
28 "sort"28 "sort"
29- "strconv"
30 29
31 "github.com/bnema/vev/internal/domain"30 "github.com/bnema/vev/internal/domain"
32 "github.com/bnema/vev/internal/ports"31 "github.com/bnema/vev/internal/ports"
@@ -71,7 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) {
71 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}70 view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))}
72 sessionAttention := false71 sessionAttention := false
73 for i, tb := range s.tabs {72 for i, tb := range s.tabs {
74- label := strconv.Itoa(i + 1)73+ label := tabDisplayName(tb, i)
75 if tb.attention {74 if tb.attention {
76 label = attentionSuffix(label)75 label = attentionSuffix(label)
77 sessionAttention = true76 sessionAttention = true
@@ -425,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe
425 }424 }
426 term := from.terminal425 term := from.terminal
427 cwd := d.dirOrHome(stopped.cwd)426 cwd := d.dirOrHome(stopped.cwd)
428- targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term)427+ targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.size, term, stopped.tabNames)
429 if err != nil {428 if err != nil {
430 from.mu.Unlock()429 from.mu.Unlock()
431 d.mu.Unlock()430 d.mu.Unlock()
@@ -455,9 +454,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) {
455 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)454 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
456 return455 return
457 }456 }
458- if cur, ok := d.stopped[target.Name]; ok && cur == stopped {457+ delete(d.stopped, target.Name)
459- delete(d.stopped, target.Name)
460- }
461 }458 }
462 d.mu.Unlock()459 d.mu.Unlock()
463 return460 return
M internal/usecase/daemon/picker_test.go +24 -2
@@ -49,7 +49,7 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
4949 ctx, cancel := context.WithCancel(d.serveCtx)
5050 defer cancel()
5151 current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
52- ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
52+ ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}
5353 ringing.mu.Lock()
5454 ringing.tabs[1].attention = true
5555 ringing.tabs[1].attentionAt = time.Unix(10, 0)
@@ -64,7 +64,29 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
6464 require.Equal(t, "alpha", views[0].Name)
6565 require.Equal(t, []string{"1", "2"}, views[0].Tabs)
6666 require.Equal(t, "beta ", views[1].Name)
67- require.Equal(t, []string{"1", "2 "}, views[1].Tabs)
67+ require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)
68+}
69+
70+func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
71+ p1, release1 := newBlockingPTY(t)
72+ p2, release2 := newBlockingPTY(t)
73+ p3, release3 := newBlockingPTY(t)
74+ defer release1()
75+ defer release2()
76+ defer release3()
77+ d, from, ac, sends := newManualSessionWithPTYs(t, p1)
78+ d.ptys = newFactorySeq(t, p2, p3)
79+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
80+
81+ d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
82+ awaitFrame(t, sends, ports.MsgOutput)
83+
84+ target := ac.currentSession()
85+ require.NotNil(t, target)
86+ require.Equal(t, "work", target.name)
87+ require.Len(t, target.tabs, 2)
88+ require.Equal(t, "shell", target.tabs[0].name)
89+ require.Equal(t, "logs", target.tabs[1].name)
6890 }
6991
7092 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
@@ -49,7 +49,7 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
49 ctx, cancel := context.WithCancel(d.serveCtx)49 ctx, cancel := context.WithCancel(d.serveCtx)
50 defer cancel()50 defer cancel()
51 current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}51 current := &session{id: "s1", name: "alpha", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}
52- ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{}, {}}}52+ ringing := &session{id: "s2", name: "beta", ctx: ctx, cancel: cancel, tabs: []*tab{{name: "shell"}, {name: "logs"}}}
53 ringing.mu.Lock()53 ringing.mu.Lock()
54 ringing.tabs[1].attention = true54 ringing.tabs[1].attention = true
55 ringing.tabs[1].attentionAt = time.Unix(10, 0)55 ringing.tabs[1].attentionAt = time.Unix(10, 0)
@@ -64,7 +64,29 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) {
64 require.Equal(t, "alpha", views[0].Name)64 require.Equal(t, "alpha", views[0].Name)
65 require.Equal(t, []string{"1", "2"}, views[0].Tabs)65 require.Equal(t, []string{"1", "2"}, views[0].Tabs)
66 require.Equal(t, "beta ", views[1].Name)66 require.Equal(t, "beta ", views[1].Name)
67- require.Equal(t, []string{"1", "2 "}, views[1].Tabs)67+ require.Equal(t, []string{"shell", "logs "}, views[1].Tabs)
68+}
69+
70+func TestPickerResumesStoppedSessionWithPersistedTabNames(t *testing.T) {
71+ p1, release1 := newBlockingPTY(t)
72+ p2, release2 := newBlockingPTY(t)
73+ p3, release3 := newBlockingPTY(t)
74+ defer release1()
75+ defer release2()
76+ defer release3()
77+ d, from, ac, sends := newManualSessionWithPTYs(t, p1)
78+ d.ptys = newFactorySeq(t, p2, p3)
79+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
80+
81+ d.resumeStoppedAndSwitch(from, ac, picker.Target{Name: "work", Stopped: true})
82+ awaitFrame(t, sends, ports.MsgOutput)
83+
84+ target := ac.currentSession()
85+ require.NotNil(t, target)
86+ require.Equal(t, "work", target.name)
87+ require.Len(t, target.tabs, 2)
88+ require.Equal(t, "shell", target.tabs[0].name)
89+ require.Equal(t, "logs", target.tabs[1].name)
68 }90 }
69 91
70 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {92 func TestPickerSameSessionNavigationSwitchAndEscClose(t *testing.T) {
M internal/usecase/daemon/render.go +7 -1
@@ -400,7 +400,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
400400 if sendTr == nil {
401401 serr = errors.New("client transport is nil")
402402 } else {
403- serr = sendTr.Send(ac.nextOutputFrameLocked(data, reset))
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+ }
408+ }
409+ serr = sendTr.Send(out)
404410 }
405411 }
406412 }
@@ -400,7 +400,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
400 if sendTr == nil {400 if sendTr == nil {
401 serr = errors.New("client transport is nil")401 serr = errors.New("client transport is nil")
402 } else {402 } else {
403- serr = sendTr.Send(ac.nextOutputFrameLocked(data, reset))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+ }
408+ }
409+ serr = sendTr.Send(out)
404 }410 }
405 }411 }
406 }412 }
M internal/usecase/daemon/session.go +135 -18
@@ -29,6 +29,7 @@ import (
2929 "errors"
3030 "fmt"
3131 "os"
32+ "slices"
3233 "strconv"
3334 "strings"
3435 "sync"
@@ -76,6 +77,7 @@ type tab struct {
7677 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
7778
7879 stableID string
80+ name string
7981 tree *layout.Tree
8082 panes map[layout.PaneID]*pane
8183 nextPaneID int
@@ -127,25 +129,40 @@ func (d *Daemon) touchMRU(sess *session) {
127129 }
128130 }
129131
130-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv) (*session, error) {
132+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, restoredTabNames ...[]string) (*session, error) {
131133 tbSize := tabSize(sz)
132- tabStableID, paneStableID, err := d.newTabPaneStableIDs()
133- if err != nil {
134- return nil, err
135- }
136- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
137- if err != nil {
138- d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
139- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
134+ var names []string
135+ if len(restoredTabNames) > 0 {
136+ names = append([]string(nil), restoredTabNames[0]...)
137+ }
138+ tabs := make([]*tab, 0, max(1, len(names)))
139+ for i := range max(1, len(names)) {
140+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
141+ if err != nil {
142+ closeTabs(tabs)
143+ return nil, err
144+ }
145+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
146+ if err != nil {
147+ closeTabs(tabs)
148+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
149+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
150+ }
151+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
152+ if i < len(names) {
153+ tb.name = names[i]
154+ }
155+ tabs = append(tabs, tb)
140156 }
141157
142158 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
143159 d.nextID++
144160 createdAt := time.Now().UnixNano()
145161
146- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
147162 sctx, cancel := context.WithCancel(d.serveCtx)
148- tb.ctx, tb.cancel = context.WithCancel(sctx)
163+ for _, tb := range tabs {
164+ tb.ctx, tb.cancel = context.WithCancel(sctx)
165+ }
149166 lastUsedSeq := uint64(0)
150167 if !ephemeral {
151168 if stopped, ok := d.stopped[name]; ok {
@@ -158,7 +175,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
158175 ephemeral: ephemeral,
159176 ctx: sctx,
160177 cancel: cancel,
161- tabs: []*tab{tb},
178+ tabs: tabs,
162179 cwd: cwd,
163180 terminal: term,
164181 createdAt: createdAt,
@@ -168,8 +185,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
168185 }
169186 sess.snapEligible.Store(!ephemeral && name != "")
170187 if !ephemeral {
171- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
172- _ = pty.Close()
188+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {
189+ closeTabs(tabs)
173190 cancel()
174191 return nil, err
175192 }
@@ -180,11 +197,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
180197 d.touchMRU(sess)
181198 }
182199 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
183- d.log.Info("tab created", "session", name, "tab", 0)
184- d.startTabGoroutines(sess, tb)
200+ for i, tb := range tabs {
201+ d.log.Info("tab created", "session", name, "tab", i)
202+ d.startTabGoroutines(sess, tb)
203+ }
185204 return sess, nil
186205 }
187206
207+func closeTabs(tabs []*tab) {
208+ for _, tb := range tabs {
209+ for _, p := range tb.panes {
210+ _ = p.pty.Close()
211+ }
212+ }
213+}
214+
188215 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {
189216 if name == "" {
190217 return errors.New("name required")
@@ -280,9 +307,25 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
280307 p.ctx, p.cancel = context.WithCancel(tb.ctx)
281308 }
282309 sess.mu.Lock()
310+ oldActive := sess.active
283311 sess.tabs = append(sess.tabs, tb)
284312 sess.active = len(sess.tabs) - 1
285313 tabIndex := sess.active
314+ record := sess.persistRecordLocked(time.Now().UnixNano())
315+ ephemeral := sess.ephemeral
316+ if !ephemeral {
317+ if err := d.persist.Save(record); err != nil {
318+ sess.tabs = sess.tabs[:len(sess.tabs)-1]
319+ sess.active = oldActive
320+ if tb.cancel != nil {
321+ tb.cancel()
322+ }
323+ sess.mu.Unlock()
324+ d.mu.Unlock()
325+ _ = pty.Close()
326+ return err
327+ }
328+ }
286329 sess.mu.Unlock()
287330 d.log.Info("tab created", "session", name, "tab", tabIndex)
288331 d.startTabGoroutines(sess, tb)
@@ -437,7 +480,11 @@ func (d *Daemon) renameSession(sess *session, name string) error {
437480 }
438481 lastUsedSeq := sess.mruAt.Load()
439482 if wasEphemeral || oldName != name {
440- if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil {
483+ record := sess.persistRecordLocked(time.Now().UnixNano())
484+ record.Name = name
485+ record.CreatedAt = createdAt
486+ record.LastUsedSeq = lastUsedSeq
487+ if err := d.persist.Save(record); err != nil {
441488 sess.mu.Unlock()
442489 return err
443490 }
@@ -470,7 +517,66 @@ func (d *Daemon) renameSession(sess *session, name string) error {
470517 return nil
471518 }
472519
520+func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
521+ if tb == nil {
522+ return errors.New("tab required")
523+ }
524+ d.mu.Lock()
525+ if d.sessions[sess.id] != sess {
526+ d.mu.Unlock()
527+ return errors.New("daemon: session closed")
528+ }
529+ sess.mu.Lock()
530+ if !slices.Contains(sess.tabs, tb) {
531+ sess.mu.Unlock()
532+ d.mu.Unlock()
533+ return errors.New("tab not found")
534+ }
535+ oldName := tb.name
536+ tb.name = name
537+ record := sess.persistRecordLocked(time.Now().UnixNano())
538+ ephemeral := sess.ephemeral
539+ if !ephemeral {
540+ if err := d.persist.Save(record); err != nil {
541+ tb.name = oldName
542+ sess.mu.Unlock()
543+ d.mu.Unlock()
544+ return err
545+ }
546+ }
547+ sess.mu.Unlock()
548+ d.mu.Unlock()
549+ return nil
550+}
551+
552+func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
553+ createdAt := s.createdAt
554+ if createdAt == 0 {
555+ createdAt = updatedAt
556+ s.createdAt = createdAt
557+ }
558+ tabNames := make([]string, len(s.tabs))
559+ lastCustom := -1
560+ for i, tb := range s.tabs {
561+ tabNames[i] = tb.name
562+ if tb.name != "" {
563+ lastCustom = i
564+ }
565+ }
566+ if lastCustom == -1 {
567+ tabNames = nil
568+ } else {
569+ tabNames = tabNames[:lastCustom+1]
570+ }
571+ return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
572+}
573+
473574 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
575+ d.mu.Lock()
576+ if d.sessions[sess.id] != sess {
577+ d.mu.Unlock()
578+ return
579+ }
474580 sess.mu.Lock()
475581 idx := -1
476582 for i, w := range sess.tabs {
@@ -481,11 +587,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
481587 }
482588 if idx == -1 {
483589 sess.mu.Unlock()
590+ d.mu.Unlock()
484591 return
485592 }
486593 if len(sess.tabs) == 1 {
487594 name := sess.name
488595 sess.mu.Unlock()
596+ d.mu.Unlock()
489597 d.log.Info("tab closed", "session", name, "last", true)
490598 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
491599 return
@@ -499,7 +607,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
499607 }
500608 ac := sess.client
501609 name := sess.name
610+ record := sess.persistRecordLocked(time.Now().UnixNano())
611+ ephemeral := sess.ephemeral
612+ if !ephemeral {
613+ if err := d.persist.Save(record); err != nil {
614+ d.log.Warn("persisting closed tab failed", "err", err, "session", name)
615+ }
616+ }
502617 sess.mu.Unlock()
618+ d.mu.Unlock()
503619 d.log.Info("tab closed", "session", name)
504620 markSnapshotDirty(sess)
505621
@@ -553,10 +669,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
553669 stoppedName := sess.name
554670 stoppedCwd := sess.cwd
555671 createdAt := sess.createdAt
672+ tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
556673 ephemeral := sess.ephemeral
557674 sess.mu.Unlock()
558675 if !ephemeral {
559- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), purging: purge}
676+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
560677 d.stopped[stoppedName] = stopped
561678 }
562679 empty := len(d.sessions) == 0
@@ -29,6 +29,7 @@ import (
29 "errors"29 "errors"
30 "fmt"30 "fmt"
31 "os"31 "os"
32+ "slices"
32 "strconv"33 "strconv"
33 "strings"34 "strings"
34 "sync"35 "sync"
@@ -76,6 +77,7 @@ type tab struct {
76 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership77 mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership
77 78
78 stableID string79 stableID string
80+ name string
79 tree *layout.Tree81 tree *layout.Tree
80 panes map[layout.PaneID]*pane82 panes map[layout.PaneID]*pane
81 nextPaneID int83 nextPaneID int
@@ -127,25 +129,40 @@ func (d *Daemon) touchMRU(sess *session) {
127 }129 }
128 }130 }
129 131
130-func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv) (*session, error) {132+func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz domain.Size, term terminalEnv, restoredTabNames ...[]string) (*session, error) {
131 tbSize := tabSize(sz)133 tbSize := tabSize(sz)
132- tabStableID, paneStableID, err := d.newTabPaneStableIDs()134+ var names []string
133- if err != nil {135+ if len(restoredTabNames) > 0 {
134- return nil, err136+ names = append([]string(nil), restoredTabNames[0]...)
135- }137+ }
136- pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)138+ tabs := make([]*tab, 0, max(1, len(names)))
137- if err != nil {139+ for i := range max(1, len(names)) {
138- d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")140+ tabStableID, paneStableID, err := d.newTabPaneStableIDs()
139- return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)141+ if err != nil {
142+ closeTabs(tabs)
143+ return nil, err
144+ }
145+ pty, err := d.ptys.Open(d.shell, d.shellArgs, d.childEnv(name, tabStableID, paneStableID, term), cwd, tbSize)
146+ if err != nil {
147+ closeTabs(tabs)
148+ d.log.Warn("pty spawn failed", "err", err, "session", name, "kind", "session")
149+ return nil, fmt.Errorf("daemon: spawning session %q: %w", name, err)
150+ }
151+ tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
152+ if i < len(names) {
153+ tb.name = names[i]
154+ }
155+ tabs = append(tabs, tb)
140 }156 }
141 157
142 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))158 id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID))
143 d.nextID++159 d.nextID++
144 createdAt := time.Now().UnixNano()160 createdAt := time.Now().UnixNano()
145 161
146- tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize)
147 sctx, cancel := context.WithCancel(d.serveCtx)162 sctx, cancel := context.WithCancel(d.serveCtx)
148- tb.ctx, tb.cancel = context.WithCancel(sctx)163+ for _, tb := range tabs {
164+ tb.ctx, tb.cancel = context.WithCancel(sctx)
165+ }
149 lastUsedSeq := uint64(0)166 lastUsedSeq := uint64(0)
150 if !ephemeral {167 if !ephemeral {
151 if stopped, ok := d.stopped[name]; ok {168 if stopped, ok := d.stopped[name]; ok {
@@ -158,7 +175,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
158 ephemeral: ephemeral,175 ephemeral: ephemeral,
159 ctx: sctx,176 ctx: sctx,
160 cancel: cancel,177 cancel: cancel,
161- tabs: []*tab{tb},178+ tabs: tabs,
162 cwd: cwd,179 cwd: cwd,
163 terminal: term,180 terminal: term,
164 createdAt: createdAt,181 createdAt: createdAt,
@@ -168,8 +185,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
168 }185 }
169 sess.snapEligible.Store(!ephemeral && name != "")186 sess.snapEligible.Store(!ephemeral && name != "")
170 if !ephemeral {187 if !ephemeral {
171- if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {188+ if err := d.persist.Save(persist.Record{Name: name, Cwd: cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq, TabNames: names}); err != nil {
172- _ = pty.Close()189+ closeTabs(tabs)
173 cancel()190 cancel()
174 return nil, err191 return nil, err
175 }192 }
@@ -180,11 +197,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz
180 d.touchMRU(sess)197 d.touchMRU(sess)
181 }198 }
182 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)199 d.log.Info("session created", "session", name, "id", id, "ephemeral", ephemeral)
183- d.log.Info("tab created", "session", name, "tab", 0)200+ for i, tb := range tabs {
184- d.startTabGoroutines(sess, tb)201+ d.log.Info("tab created", "session", name, "tab", i)
202+ d.startTabGoroutines(sess, tb)
203+ }
185 return sess, nil204 return sess, nil
186 }205 }
187 206
207+func closeTabs(tabs []*tab) {
208+ for _, tb := range tabs {
209+ for _, p := range tb.panes {
210+ _ = p.pty.Close()
211+ }
212+ }
213+}
214+
188 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {215 func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error {
189 if name == "" {216 if name == "" {
190 return errors.New("name required")217 return errors.New("name required")
@@ -280,9 +307,25 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error {
280 p.ctx, p.cancel = context.WithCancel(tb.ctx)307 p.ctx, p.cancel = context.WithCancel(tb.ctx)
281 }308 }
282 sess.mu.Lock()309 sess.mu.Lock()
310+ oldActive := sess.active
283 sess.tabs = append(sess.tabs, tb)311 sess.tabs = append(sess.tabs, tb)
284 sess.active = len(sess.tabs) - 1312 sess.active = len(sess.tabs) - 1
285 tabIndex := sess.active313 tabIndex := sess.active
314+ record := sess.persistRecordLocked(time.Now().UnixNano())
315+ ephemeral := sess.ephemeral
316+ if !ephemeral {
317+ if err := d.persist.Save(record); err != nil {
318+ sess.tabs = sess.tabs[:len(sess.tabs)-1]
319+ sess.active = oldActive
320+ if tb.cancel != nil {
321+ tb.cancel()
322+ }
323+ sess.mu.Unlock()
324+ d.mu.Unlock()
325+ _ = pty.Close()
326+ return err
327+ }
328+ }
286 sess.mu.Unlock()329 sess.mu.Unlock()
287 d.log.Info("tab created", "session", name, "tab", tabIndex)330 d.log.Info("tab created", "session", name, "tab", tabIndex)
288 d.startTabGoroutines(sess, tb)331 d.startTabGoroutines(sess, tb)
@@ -437,7 +480,11 @@ func (d *Daemon) renameSession(sess *session, name string) error {
437 }480 }
438 lastUsedSeq := sess.mruAt.Load()481 lastUsedSeq := sess.mruAt.Load()
439 if wasEphemeral || oldName != name {482 if wasEphemeral || oldName != name {
440- if err := d.persist.Save(persist.Record{Name: name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: time.Now().UnixNano(), LastUsedSeq: lastUsedSeq}); err != nil {483+ record := sess.persistRecordLocked(time.Now().UnixNano())
484+ record.Name = name
485+ record.CreatedAt = createdAt
486+ record.LastUsedSeq = lastUsedSeq
487+ if err := d.persist.Save(record); err != nil {
441 sess.mu.Unlock()488 sess.mu.Unlock()
442 return err489 return err
443 }490 }
@@ -470,7 +517,66 @@ func (d *Daemon) renameSession(sess *session, name string) error {
470 return nil517 return nil
471 }518 }
472 519
520+func (d *Daemon) renameTab(sess *session, tb *tab, name string) error {
521+ if tb == nil {
522+ return errors.New("tab required")
523+ }
524+ d.mu.Lock()
525+ if d.sessions[sess.id] != sess {
526+ d.mu.Unlock()
527+ return errors.New("daemon: session closed")
528+ }
529+ sess.mu.Lock()
530+ if !slices.Contains(sess.tabs, tb) {
531+ sess.mu.Unlock()
532+ d.mu.Unlock()
533+ return errors.New("tab not found")
534+ }
535+ oldName := tb.name
536+ tb.name = name
537+ record := sess.persistRecordLocked(time.Now().UnixNano())
538+ ephemeral := sess.ephemeral
539+ if !ephemeral {
540+ if err := d.persist.Save(record); err != nil {
541+ tb.name = oldName
542+ sess.mu.Unlock()
543+ d.mu.Unlock()
544+ return err
545+ }
546+ }
547+ sess.mu.Unlock()
548+ d.mu.Unlock()
549+ return nil
550+}
551+
552+func (s *session) persistRecordLocked(updatedAt int64) persist.Record {
553+ createdAt := s.createdAt
554+ if createdAt == 0 {
555+ createdAt = updatedAt
556+ s.createdAt = createdAt
557+ }
558+ tabNames := make([]string, len(s.tabs))
559+ lastCustom := -1
560+ for i, tb := range s.tabs {
561+ tabNames[i] = tb.name
562+ if tb.name != "" {
563+ lastCustom = i
564+ }
565+ }
566+ if lastCustom == -1 {
567+ tabNames = nil
568+ } else {
569+ tabNames = tabNames[:lastCustom+1]
570+ }
571+ return persist.Record{Name: s.name, Cwd: s.cwd, CreatedAt: createdAt, UpdatedAt: updatedAt, LastUsedSeq: s.mruAt.Load(), TabNames: tabNames}
572+}
573+
473 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {574 func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
575+ d.mu.Lock()
576+ if d.sessions[sess.id] != sess {
577+ d.mu.Unlock()
578+ return
579+ }
474 sess.mu.Lock()580 sess.mu.Lock()
475 idx := -1581 idx := -1
476 for i, w := range sess.tabs {582 for i, w := range sess.tabs {
@@ -481,11 +587,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
481 }587 }
482 if idx == -1 {588 if idx == -1 {
483 sess.mu.Unlock()589 sess.mu.Unlock()
590+ d.mu.Unlock()
484 return591 return
485 }592 }
486 if len(sess.tabs) == 1 {593 if len(sess.tabs) == 1 {
487 name := sess.name594 name := sess.name
488 sess.mu.Unlock()595 sess.mu.Unlock()
596+ d.mu.Unlock()
489 d.log.Info("tab closed", "session", name, "last", true)597 d.log.Info("tab closed", "session", name, "last", true)
490 _ = d.killSession(sess, ports.ReasonSessionKilled, false)598 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
491 return599 return
@@ -499,7 +607,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) {
499 }607 }
500 ac := sess.client608 ac := sess.client
501 name := sess.name609 name := sess.name
610+ record := sess.persistRecordLocked(time.Now().UnixNano())
611+ ephemeral := sess.ephemeral
612+ if !ephemeral {
613+ if err := d.persist.Save(record); err != nil {
614+ d.log.Warn("persisting closed tab failed", "err", err, "session", name)
615+ }
616+ }
502 sess.mu.Unlock()617 sess.mu.Unlock()
618+ d.mu.Unlock()
503 d.log.Info("tab closed", "session", name)619 d.log.Info("tab closed", "session", name)
504 markSnapshotDirty(sess)620 markSnapshotDirty(sess)
505 621
@@ -553,10 +669,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
553 stoppedName := sess.name669 stoppedName := sess.name
554 stoppedCwd := sess.cwd670 stoppedCwd := sess.cwd
555 createdAt := sess.createdAt671 createdAt := sess.createdAt
672+ tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames
556 ephemeral := sess.ephemeral673 ephemeral := sess.ephemeral
557 sess.mu.Unlock()674 sess.mu.Unlock()
558 if !ephemeral {675 if !ephemeral {
559- stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), purging: purge}676+ stopped := stoppedSession{name: stoppedName, cwd: stoppedCwd, createdAt: createdAt, lastUsedSeq: sess.mruAt.Load(), tabNames: tabNames, purging: purge}
560 d.stopped[stoppedName] = stopped677 d.stopped[stoppedName] = stopped
561 }678 }
562 empty := len(d.sessions) == 0679 empty := len(d.sessions) == 0
M internal/usecase/daemon/session_test.go +114 -4
@@ -799,24 +799,134 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
799799
800800 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
801801 sz := domain.Size{Cols: 80, Rows: 24}
802- p, release := newBlockingPTY(t)
803- defer release()
802+ p1, release1 := newBlockingPTY(t)
803+ p2, release2 := newBlockingPTY(t)
804+ defer release1()
805+ defer release2()
804806 store, state := newMockStore(t)
805- d := newTestDaemon(t, newFactory(t, p), stubClock{})
807+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
806808 WithStore(store)(d)
807809
808810 sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
809811 require.NoError(t, err)
810812 require.True(t, state.has("work"))
813+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
814+ require.NoError(t, d.createTab(sess, sz))
815+ require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs"))
811816
812817 require.NoError(t, d.renameSession(sess, "renamed"))
813818 require.False(t, state.has("work"))
814819 require.True(t, state.has("renamed"))
820+ require.Equal(t, []string{"shell", "logs"}, state.record(t, "renamed").TabNames)
815821
816- _ = d.killSession(sess, ports.ReasonSessionKilled, true)
822+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
817823 require.False(t, state.has("renamed"))
818824 }
819825
826+func TestRenameTabPersistsForNamedSession(t *testing.T) {
827+ sz := domain.Size{Cols: 80, Rows: 24}
828+ p, release := newBlockingPTY(t)
829+ defer release()
830+ store, _ := newMockStore(t)
831+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
832+ WithStore(store)(d)
833+
834+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
835+ require.NoError(t, err)
836+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
837+
838+ records, err := d.persist.LoadAll()
839+ require.NoError(t, err)
840+ require.Len(t, records, 1)
841+ require.Equal(t, "work", records[0].Name)
842+ require.Equal(t, "/tmp/work", records[0].Cwd)
843+ require.Equal(t, sess.createdAt, records[0].CreatedAt)
844+ require.GreaterOrEqual(t, records[0].UpdatedAt, records[0].CreatedAt)
845+ require.Equal(t, []string{"shell"}, records[0].TabNames)
846+}
847+
848+func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
849+ tests := []struct {
850+ name string
851+ tabNames []string
852+ closeIndex int
853+ want []string
854+ }{
855+ {name: "close before named tab", tabNames: []string{"shell", "", "logs"}, closeIndex: 0, want: []string{"", "logs"}},
856+ {name: "close after named tab", tabNames: []string{"shell", "logs", ""}, closeIndex: 2, want: []string{"shell", "logs"}},
857+ }
858+ for _, tt := range tests {
859+ t.Run(tt.name, func(t *testing.T) {
860+ ptys := make([]ports.PTY, 3)
861+ for i := range ptys {
862+ p, release := newBlockingPTY(t)
863+ ptys[i] = p
864+ t.Cleanup(release)
865+ }
866+ store, _ := newMockStore(t)
867+ d := newTestDaemon(t, newFactorySeq(t, ptys...), stubClock{})
868+ WithStore(store)(d)
869+ sz := domain.Size{Cols: 80, Rows: 24}
870+
871+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
872+ require.NoError(t, err)
873+ require.NoError(t, d.createTab(sess, sz))
874+ require.NoError(t, d.createTab(sess, sz))
875+ for i, name := range tt.tabNames {
876+ require.NoError(t, d.renameTab(sess, sess.tabs[i], name))
877+ }
878+
879+ d.closeTab(sess, sess.tabs[tt.closeIndex], false)
880+ records, err := d.persist.LoadAll()
881+ require.NoError(t, err)
882+ require.Len(t, records, 1)
883+ require.Equal(t, tt.want, records[0].TabNames)
884+ })
885+ }
886+}
887+
888+func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
889+ sz := domain.Size{Cols: 80, Rows: 24}
890+ p, release := newBlockingPTY(t)
891+ defer release()
892+ store, state := newMockStore(t)
893+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
894+ WithStore(store)(d)
895+
896+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{})
897+ require.NoError(t, err)
898+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
899+ require.Equal(t, "shell", sess.tabs[0].name)
900+
901+ require.False(t, state.has("0"))
902+ records, err := d.persist.LoadAll()
903+ require.NoError(t, err)
904+ require.Empty(t, records)
905+}
906+
907+func TestAttachRestoresPersistedTabNames(t *testing.T) {
908+ sz := domain.Size{Cols: 80, Rows: 24}
909+ p1, release1 := newBlockingPTY(t)
910+ p2, release2 := newBlockingPTY(t)
911+ defer release1()
912+ defer release2()
913+ store, _ := newMockStore(t)
914+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
915+ WithStore(store)(d)
916+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
917+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
918+ tr := portsmocks.NewMockTransport(t)
919+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
920+ tr.EXPECT().Close().Return(nil).Maybe()
921+
922+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
923+ require.NoError(t, err)
924+ require.NotNil(t, ac)
925+ require.Len(t, sess.tabs, 2)
926+ require.Equal(t, "shell", sess.tabs[0].name)
927+ require.Equal(t, "logs", sess.tabs[1].name)
928+}
929+
820930 func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
821931 sz := domain.Size{Cols: 80, Rows: 24}
822932 p, release := newBlockingPTY(t)
@@ -799,24 +799,134 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) {
799 799
800 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {800 func TestCreateRenameKillPersistenceLifecycle(t *testing.T) {
801 sz := domain.Size{Cols: 80, Rows: 24}801 sz := domain.Size{Cols: 80, Rows: 24}
802- p, release := newBlockingPTY(t)802+ p1, release1 := newBlockingPTY(t)
803- defer release()803+ p2, release2 := newBlockingPTY(t)
804+ defer release1()
805+ defer release2()
804 store, state := newMockStore(t)806 store, state := newMockStore(t)
805- d := newTestDaemon(t, newFactory(t, p), stubClock{})807+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
806 WithStore(store)(d)808 WithStore(store)(d)
807 809
808 sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})810 sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
809 require.NoError(t, err)811 require.NoError(t, err)
810 require.True(t, state.has("work"))812 require.True(t, state.has("work"))
813+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
814+ require.NoError(t, d.createTab(sess, sz))
815+ require.NoError(t, d.renameTab(sess, sess.tabs[1], "logs"))
811 816
812 require.NoError(t, d.renameSession(sess, "renamed"))817 require.NoError(t, d.renameSession(sess, "renamed"))
813 require.False(t, state.has("work"))818 require.False(t, state.has("work"))
814 require.True(t, state.has("renamed"))819 require.True(t, state.has("renamed"))
820+ require.Equal(t, []string{"shell", "logs"}, state.record(t, "renamed").TabNames)
815 821
816- _ = d.killSession(sess, ports.ReasonSessionKilled, true)822+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
817 require.False(t, state.has("renamed"))823 require.False(t, state.has("renamed"))
818 }824 }
819 825
826+func TestRenameTabPersistsForNamedSession(t *testing.T) {
827+ sz := domain.Size{Cols: 80, Rows: 24}
828+ p, release := newBlockingPTY(t)
829+ defer release()
830+ store, _ := newMockStore(t)
831+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
832+ WithStore(store)(d)
833+
834+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
835+ require.NoError(t, err)
836+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
837+
838+ records, err := d.persist.LoadAll()
839+ require.NoError(t, err)
840+ require.Len(t, records, 1)
841+ require.Equal(t, "work", records[0].Name)
842+ require.Equal(t, "/tmp/work", records[0].Cwd)
843+ require.Equal(t, sess.createdAt, records[0].CreatedAt)
844+ require.GreaterOrEqual(t, records[0].UpdatedAt, records[0].CreatedAt)
845+ require.Equal(t, []string{"shell"}, records[0].TabNames)
846+}
847+
848+func TestTabNamePersistenceTracksTabIndexShifts(t *testing.T) {
849+ tests := []struct {
850+ name string
851+ tabNames []string
852+ closeIndex int
853+ want []string
854+ }{
855+ {name: "close before named tab", tabNames: []string{"shell", "", "logs"}, closeIndex: 0, want: []string{"", "logs"}},
856+ {name: "close after named tab", tabNames: []string{"shell", "logs", ""}, closeIndex: 2, want: []string{"shell", "logs"}},
857+ }
858+ for _, tt := range tests {
859+ t.Run(tt.name, func(t *testing.T) {
860+ ptys := make([]ports.PTY, 3)
861+ for i := range ptys {
862+ p, release := newBlockingPTY(t)
863+ ptys[i] = p
864+ t.Cleanup(release)
865+ }
866+ store, _ := newMockStore(t)
867+ d := newTestDaemon(t, newFactorySeq(t, ptys...), stubClock{})
868+ WithStore(store)(d)
869+ sz := domain.Size{Cols: 80, Rows: 24}
870+
871+ sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{})
872+ require.NoError(t, err)
873+ require.NoError(t, d.createTab(sess, sz))
874+ require.NoError(t, d.createTab(sess, sz))
875+ for i, name := range tt.tabNames {
876+ require.NoError(t, d.renameTab(sess, sess.tabs[i], name))
877+ }
878+
879+ d.closeTab(sess, sess.tabs[tt.closeIndex], false)
880+ records, err := d.persist.LoadAll()
881+ require.NoError(t, err)
882+ require.Len(t, records, 1)
883+ require.Equal(t, tt.want, records[0].TabNames)
884+ })
885+ }
886+}
887+
888+func TestRenameTabDoesNotPersistForEphemeralSession(t *testing.T) {
889+ sz := domain.Size{Cols: 80, Rows: 24}
890+ p, release := newBlockingPTY(t)
891+ defer release()
892+ store, state := newMockStore(t)
893+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
894+ WithStore(store)(d)
895+
896+ sess, err := d.createSessionLocked("0", true, "/tmp/work", sz, terminalEnv{})
897+ require.NoError(t, err)
898+ require.NoError(t, d.renameTab(sess, sess.tabs[0], "shell"))
899+ require.Equal(t, "shell", sess.tabs[0].name)
900+
901+ require.False(t, state.has("0"))
902+ records, err := d.persist.LoadAll()
903+ require.NoError(t, err)
904+ require.Empty(t, records)
905+}
906+
907+func TestAttachRestoresPersistedTabNames(t *testing.T) {
908+ sz := domain.Size{Cols: 80, Rows: 24}
909+ p1, release1 := newBlockingPTY(t)
910+ p2, release2 := newBlockingPTY(t)
911+ defer release1()
912+ defer release2()
913+ store, _ := newMockStore(t)
914+ d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{})
915+ WithStore(store)(d)
916+ require.NoError(t, d.persist.Save(persist.Record{Name: "work", Cwd: "/tmp/work", CreatedAt: 7, UpdatedAt: 8, TabNames: []string{"shell", "logs"}}))
917+ d.stopped["work"] = stoppedSession{name: "work", cwd: "/tmp/work", createdAt: 7, tabNames: []string{"shell", "logs"}}
918+ tr := portsmocks.NewMockTransport(t)
919+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
920+ tr.EXPECT().Close().Return(nil).Maybe()
921+
922+ sess, ac, err := d.route(ports.Hello{Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: sz}, tr)
923+ require.NoError(t, err)
924+ require.NotNil(t, ac)
925+ require.Len(t, sess.tabs, 2)
926+ require.Equal(t, "shell", sess.tabs[0].name)
927+ require.Equal(t, "logs", sess.tabs[1].name)
928+}
929+
820 func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {930 func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) {
821 sz := domain.Size{Cols: 80, Rows: 24}931 sz := domain.Size{Cols: 80, Rows: 24}
822 p, release := newBlockingPTY(t)932 p, release := newBlockingPTY(t)
M internal/usecase/daemon/snapshot.go +9 -4
@@ -7,7 +7,6 @@ import (
77 "time"
88
99 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/persist"
1110 scopy "github.com/bnema/vev/internal/usecase/copy"
1211 "github.com/bnema/vev/internal/usecase/layout"
1312 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
@@ -78,7 +77,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
7877 cancel()
7978 }
8079 allowlist := d.restoreProcessAllowlistSnapshot()
81- for _, tabSnap := range snap.Tabs {
80+ d.mu.Lock()
81+ persisted := d.stopped[snap.Name]
82+ d.mu.Unlock()
83+ for tabIndex, tabSnap := range snap.Tabs {
8284 if err := ctx.Err(); err != nil {
8385 closeOpened()
8486 return err
@@ -107,6 +109,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
107109 }
108110 }
109111 tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
112+ if tabIndex < len(persisted.tabNames) {
113+ tb.name = persisted.tabNames[tabIndex]
114+ }
110115 if tb.nextPaneID <= 0 {
111116 tb.nextPaneID = 1
112117 }
@@ -171,8 +176,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
171176 sess.mruAt.Store(stopped.lastUsedSeq)
172177 }
173178 d.mu.Unlock()
174- lastUsedSeq := sess.mruAt.Load()
175- if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {
179+ record := sess.persistRecordLocked(createdAt)
180+ if err := d.persist.Save(record); err != nil {
176181 closeOpened()
177182 return err
178183 }
@@ -7,7 +7,6 @@ import (
7 "time"7 "time"
8 8
9 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
10- "github.com/bnema/vev/internal/persist"
11 scopy "github.com/bnema/vev/internal/usecase/copy"10 scopy "github.com/bnema/vev/internal/usecase/copy"
12 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
13 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"12 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
@@ -78,7 +77,10 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
78 cancel()77 cancel()
79 }78 }
80 allowlist := d.restoreProcessAllowlistSnapshot()79 allowlist := d.restoreProcessAllowlistSnapshot()
81- for _, tabSnap := range snap.Tabs {80+ d.mu.Lock()
81+ persisted := d.stopped[snap.Name]
82+ d.mu.Unlock()
83+ for tabIndex, tabSnap := range snap.Tabs {
82 if err := ctx.Err(); err != nil {84 if err := ctx.Err(); err != nil {
83 closeOpened()85 closeOpened()
84 return err86 return err
@@ -107,6 +109,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
107 }109 }
108 }110 }
109 tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}111 tb := &tab{stableID: tabStableID, tree: tabSnap.Tree.Clone(), panes: make(map[layout.PaneID]*pane, len(tabSnap.Panes)), nextPaneID: int(tabSnap.NextPaneID), size: tbSize}
112+ if tabIndex < len(persisted.tabNames) {
113+ tb.name = persisted.tabNames[tabIndex]
114+ }
110 if tb.nextPaneID <= 0 {115 if tb.nextPaneID <= 0 {
111 tb.nextPaneID = 1116 tb.nextPaneID = 1
112 }117 }
@@ -171,8 +176,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err
171 sess.mruAt.Store(stopped.lastUsedSeq)176 sess.mruAt.Store(stopped.lastUsedSeq)
172 }177 }
173 d.mu.Unlock()178 d.mu.Unlock()
174- lastUsedSeq := sess.mruAt.Load()179+ record := sess.persistRecordLocked(createdAt)
175- if err := d.persist.Save(persist.Record{Name: snap.Name, Cwd: sess.cwd, CreatedAt: createdAt, UpdatedAt: createdAt, LastUsedSeq: lastUsedSeq}); err != nil {180+ if err := d.persist.Save(record); err != nil {
176 closeOpened()181 closeOpened()
177 return err182 return err
178 }183 }
M internal/usecase/daemon/snapshot_restore_test.go +5 -0
@@ -44,7 +44,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
4444 })}}
4545 factory := &restorePTYFactory{}
4646 d := newTestDaemon(t, factory, stubClock{})
47+ persistStore, persistState := newMockStore(t)
48+ WithStore(persistStore)(d)
4749 WithSnapshotStore(store)(d)
50+ d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}}
4851
4952 d.restoreSnapshots(context.Background())
5053
@@ -66,6 +69,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
6669 require.Len(t, restored.tabs, 1)
6770 tb := restored.tabs[0]
6871 restored.mu.Unlock()
72+ require.Equal(t, "logs", tb.name)
73+ require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames)
6974 tb.mu.Lock()
7075 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
7176 require.Equal(t, 3, tb.nextPaneID)
@@ -44,7 +44,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
44 })}}44 })}}
45 factory := &restorePTYFactory{}45 factory := &restorePTYFactory{}
46 d := newTestDaemon(t, factory, stubClock{})46 d := newTestDaemon(t, factory, stubClock{})
47+ persistStore, persistState := newMockStore(t)
48+ WithStore(persistStore)(d)
47 WithSnapshotStore(store)(d)49 WithSnapshotStore(store)(d)
50+ d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}}
48 51
49 d.restoreSnapshots(context.Background())52 d.restoreSnapshots(context.Background())
50 53
@@ -66,6 +69,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) {
66 require.Len(t, restored.tabs, 1)69 require.Len(t, restored.tabs, 1)
67 tb := restored.tabs[0]70 tb := restored.tabs[0]
68 restored.mu.Unlock()71 restored.mu.Unlock()
72+ require.Equal(t, "logs", tb.name)
73+ require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames)
69 tb.mu.Lock()74 tb.mu.Lock()
70 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)75 require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus)
71 require.Equal(t, 3, tb.nextPaneID)76 require.Equal(t, 3, tb.nextPaneID)
M internal/usecase/daemon/status.go +8 -1
@@ -204,7 +204,7 @@ func (s *session) statusSegments() statusSnapshot {
204204 }
205205 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
206206 for i, tb := range s.tabs {
207- name := strconv.Itoa(i + 1)
207+ name := tabDisplayName(tb, i)
208208 active := i == s.active
209209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
210210 snap.tabs[i] = statusTab{name: name, active: active, attention: attention}
@@ -212,6 +212,13 @@ func (s *session) statusSegments() statusSnapshot {
212212 return snap
213213 }
214214
215+func tabDisplayName(tb *tab, index int) string {
216+ if tb.name != "" {
217+ return tb.name
218+ }
219+ return strconv.Itoa(index + 1)
220+}
221+
215222 func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
216223 state := d.barStateFor(cur, copyFeedback)
217224 state.history = d.historyNavBarState(ac)
@@ -204,7 +204,7 @@ func (s *session) statusSegments() statusSnapshot {
204 }204 }
205 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}205 snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))}
206 for i, tb := range s.tabs {206 for i, tb := range s.tabs {
207- name := strconv.Itoa(i + 1)207+ name := tabDisplayName(tb, i)
208 active := i == s.active208 active := i == s.active
209 attention := tb.attention && (!active || tb.attentionVisiblePaint)209 attention := tb.attention && (!active || tb.attentionVisiblePaint)
210 snap.tabs[i] = statusTab{name: name, active: active, attention: attention}210 snap.tabs[i] = statusTab{name: name, active: active, attention: attention}
@@ -212,6 +212,13 @@ func (s *session) statusSegments() statusSnapshot {
212 return snap212 return snap
213 }213 }
214 214
215+func tabDisplayName(tb *tab, index int) string {
216+ if tb.name != "" {
217+ return tb.name
218+ }
219+ return strconv.Itoa(index + 1)
220+}
221+
215 func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {222 func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState {
216 state := d.barStateFor(cur, copyFeedback)223 state := d.barStateFor(cur, copyFeedback)
217 state.history = d.historyNavBarState(ac)224 state.history = d.historyNavBarState(ac)
M internal/usecase/daemon/status_test.go +17 -0
@@ -97,6 +97,23 @@ func TestStatusCompositionGolden(t *testing.T) {
9797 }
9898 }
9999
100+func TestTabDisplayName(t *testing.T) {
101+ tests := []struct {
102+ name string
103+ tab *tab
104+ index int
105+ want string
106+ }{
107+ {name: "custom name", tab: &tab{name: "logs"}, index: 3, want: "logs"},
108+ {name: "numeric fallback", tab: &tab{}, index: 3, want: "4"},
109+ }
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ require.Equal(t, tt.want, tabDisplayName(tt.tab, tt.index))
113+ })
114+ }
115+}
116+
100117 func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
101118 p, release := newBlockingPTY(t)
102119 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
@@ -97,6 +97,23 @@ func TestStatusCompositionGolden(t *testing.T) {
97 }97 }
98 }98 }
99 99
100+func TestTabDisplayName(t *testing.T) {
101+ tests := []struct {
102+ name string
103+ tab *tab
104+ index int
105+ want string
106+ }{
107+ {name: "custom name", tab: &tab{name: "logs"}, index: 3, want: "logs"},
108+ {name: "numeric fallback", tab: &tab{}, index: 3, want: "4"},
109+ }
110+ for _, tt := range tests {
111+ t.Run(tt.name, func(t *testing.T) {
112+ require.Equal(t, tt.want, tabDisplayName(tt.tab, tt.index))
113+ })
114+ }
115+}
116+
100 func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {117 func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
101 p, release := newBlockingPTY(t)118 p, release := newBlockingPTY(t)
102 _, sess, ac, _ := newManualSessionWithPTYs(t, p)119 _, sess, ac, _ := newManualSessionWithPTYs(t, p)
M pkg/renderer/frame.go +11 -0
@@ -29,6 +29,17 @@ func NewFrame(width, height int) Frame {
2929 return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset}
3030 }
3131
32+// Clone returns an independent copy preserving logical row contents. The clone
33+// uses canonical row storage so later scrolls or writes to the source frame do
34+// not affect it.
35+func (f Frame) Clone() Frame {
36+ clone := NewFrame(f.Width, f.Height)
37+ for y := range f.Height {
38+ copy(clone.Row(y), f.Row(y))
39+ }
40+ return clone
41+}
42+
3243 func (f Frame) Validate() error {
3344 if f.Width <= 0 || f.Height <= 0 {
3445 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)
@@ -29,6 +29,17 @@ func NewFrame(width, height int) Frame {
29 return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset}29 return Frame{Width: width, Height: height, Cells: cells, lineOffset: lineOffset}
30 }30 }
31 31
32+// Clone returns an independent copy preserving logical row contents. The clone
33+// uses canonical row storage so later scrolls or writes to the source frame do
34+// not affect it.
35+func (f Frame) Clone() Frame {
36+ clone := NewFrame(f.Width, f.Height)
37+ for y := range f.Height {
38+ copy(clone.Row(y), f.Row(y))
39+ }
40+ return clone
41+}
42+
32 func (f Frame) Validate() error {43 func (f Frame) Validate() error {
33 if f.Width <= 0 || f.Height <= 0 {44 if f.Width <= 0 || f.Height <= 0 {
34 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)45 return fmt.Errorf("invalid frame size %dx%d", f.Width, f.Height)
M pkg/renderer/renderer.go +1 -1
@@ -219,7 +219,7 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool {
219219 }
220220
221221 // Ack advances an AdvanceOnAck renderer's shadow after the caller knows the
222-// bytes for frame reached the terminal. It is harmless for AdvanceOnSend.
222+// bytes for frame reached the terminal.
223223 func (r *Renderer) Ack(frame Frame) error {
224224 if err := frame.Validate(); err != nil {
225225 return err
@@ -219,7 +219,7 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool {
219 }219 }
220 220
221 // Ack advances an AdvanceOnAck renderer's shadow after the caller knows the221 // Ack advances an AdvanceOnAck renderer's shadow after the caller knows the
222-// bytes for frame reached the terminal. It is harmless for AdvanceOnSend.222+// bytes for frame reached the terminal.
223 func (r *Renderer) Ack(frame Frame) error {223 func (r *Renderer) Ack(frame Frame) error {
224 if err := frame.Validate(); err != nil {224 if err := frame.Validate(); err != nil {
225 return err225 return err

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