Merge remote-tracking branch 'origin/main' into fix/udp-send-pacing-coalescing
# Conflicts: # internal/adapters/dgram/transport.go
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. | |||
| 173 | 173 | | SSP | open session picker | | |
| 174 | 174 | | VIS | enter visual mode | | |
| 175 | 175 | | RNS | rename session | | |
| 176 | + | | RNT | rename current tab | | |
| 176 | 177 | | DET | detach | | |
| 177 | 178 | ||
| 178 | 179 | 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 ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | 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 | |
| 13 | 14 | } | |
| 14 | 15 | ||
| 15 | 16 | func (t *Transport) flushQueuedOutputLocked() []queuedSend { | |
| @@ -104,7 +105,7 @@ func shouldPaceOutput(f ports.Frame) bool { | |||
| 104 | 105 | ||
| 105 | 106 | func (t *Transport) sendQueuedData(q queuedSend) error { | |
| 106 | 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 | 109 | t.removePending(q.seq, q.reliable) | |
| 109 | 110 | return err | |
| 110 | 111 | } | |
| @@ -7,9 +7,10 @@ import ( | |||||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | type queuedSend struct { | 9 | type queuedSend struct { | ||
| 10 | - | seq uint64 | 10 | + | seq uint64 |
| 11 | - | reliable bool | 11 | + | reliable bool |
| 12 | - | frame ports.Frame | 12 | + | frame ports.Frame |
| 13 | + | outputSkipThrough uint64 | |||
| 13 | } | 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 err | 110 | return err | ||
| 110 | } | 111 | } | ||
M internal/adapters/dgram/proxy_test.go +27 -0
| @@ -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 | 108 | func TestProxyRuntimeRetriesRecoverableClientSendWithoutDroppingFrame(t *testing.T) { | |
| 82 | 109 | tests := []struct { | |
| 83 | 110 | 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 string | 110 | name string | ||
M internal/adapters/dgram/transport.go +160 -77
| @@ -182,19 +182,21 @@ type Transport struct { | |||
| 182 | 182 | // writeMu directly because they do not establish new sequence order. | |
| 183 | 183 | outboundMu sync.Mutex | |
| 184 | 184 | ||
| 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 | |
| 189 | 190 | } | |
| 190 | 191 | ||
| 191 | 192 | 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 | |
| 198 | 200 | } | |
| 199 | 201 | ||
| 200 | 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 | 255 | 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 | 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 | 263 | reliable := true | |
| 260 | - | if err := t.lockOutboundSlot(reliable); err != nil { | |
| 264 | + | outputSkipThrough, err := t.lockOutboundSlot(f, reliable) | |
| 265 | + | if err != nil { | |
| 261 | 266 | return err | |
| 262 | 267 | } | |
| 263 | 268 | defer t.outboundMu.Unlock() | |
| @@ -266,12 +271,12 @@ func (t *Transport) Send(f ports.Frame) error { | |||
| 266 | 271 | t.seq++ | |
| 267 | 272 | seq := t.seq | |
| 268 | 273 | if reliable { | |
| 269 | - | t.pending[seq] = &pending{frame: f} | |
| 274 | + | t.pending[seq] = &pending{frame: f, outputSkipThrough: outputSkipThrough} | |
| 270 | 275 | } | |
| 271 | 276 | if shouldPaceOutput(f) { | |
| 272 | 277 | now := t.clock.Now() | |
| 273 | 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 | 280 | t.notifyOutputPacerLocked() | |
| 276 | 281 | t.mu.Unlock() | |
| 277 | 282 | return nil | |
| @@ -279,7 +284,7 @@ func (t *Transport) Send(f ports.Frame) error { | |||
| 279 | 284 | t.outputNext = now.Add(t.outputPaceDelayLocked()) | |
| 280 | 285 | t.mu.Unlock() | |
| 281 | 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 | 288 | t.removePending(seq, reliable) | |
| 284 | 289 | return err | |
| 285 | 290 | } | |
| @@ -296,7 +301,7 @@ func (t *Transport) Send(f ports.Frame) error { | |||
| 296 | 301 | } | |
| 297 | 302 | } | |
| 298 | 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 | 305 | t.removePending(seq, reliable) | |
| 301 | 306 | return err | |
| 302 | 307 | } | |
| @@ -304,44 +309,10 @@ func (t *Transport) Send(f ports.Frame) error { | |||
| 304 | 309 | 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 | 313 | var pendingWaitStarted time.Time | |
| 314 | + | var outputSkipThrough uint64 | |
| 309 | 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 | 316 | t.outboundMu.Lock() | |
| 346 | 317 | t.mu.Lock() | |
| 347 | 318 | if t.closed { | |
| @@ -349,15 +320,42 @@ func (t *Transport) lockOutboundSlot(reliable bool) error { | |||
| 349 | 320 | t.mu.Unlock() | |
| 350 | 321 | t.outboundMu.Unlock() | |
| 351 | 322 | 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 | |
| 353 | 330 | } | |
| 354 | - | return errors.New("dgram: closed") | |
| 355 | 331 | } | |
| 356 | 332 | 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 | |
| 358 | 348 | } | |
| 349 | + | wake := t.sendWake | |
| 350 | + | timer := t.clock.NewTimer(remaining) | |
| 359 | 351 | t.mu.Unlock() | |
| 360 | 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 | 393 | 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 | 431 | func (t *Transport) Recv() (ports.Frame, error) { | |
| 399 | 432 | select { | |
| 400 | 433 | case f := <-t.in: | |
| @@ -476,8 +509,8 @@ func (t *Transport) unregisterProbe(id uint64) { | |||
| 476 | 509 | t.mu.Unlock() | |
| 477 | 510 | } | |
| 478 | 511 | ||
| 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) | |
| 481 | 514 | return t.sendPayload(p) | |
| 482 | 515 | } | |
| 483 | 516 | func (t *Transport) sendAck(seq uint64) { | |
| @@ -636,12 +669,12 @@ func (t *Transport) handleRecord(p []byte) { | |||
| 636 | 669 | } | |
| 637 | 670 | t.mu.Unlock() | |
| 638 | 671 | case recData: | |
| 639 | - | seq, reliable, f, ok := decodeData(p) | |
| 672 | + | seq, reliable, f, outputSkipThrough, ok := decodeData(p) | |
| 640 | 673 | if !ok { | |
| 641 | 674 | return | |
| 642 | 675 | } | |
| 643 | 676 | if reliable { | |
| 644 | - | ackSeq, ack, queued := t.enqueueReliable(seq, f) | |
| 677 | + | ackSeq, ack, queued := t.enqueueReliable(seq, f, outputSkipThrough) | |
| 645 | 678 | if ack { | |
| 646 | 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 | 691 | t.deliverMu.Lock() | |
| 659 | 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 | 702 | if seq < t.nextRecvSeq { | |
| 661 | 703 | return t.highestContiguousRecvLocked(), true, false | |
| 662 | 704 | } | |
| @@ -673,10 +715,15 @@ func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, a | |||
| 673 | 715 | func (t *Transport) highestContiguousRecvLocked() uint64 { | |
| 674 | 716 | seq := t.nextRecvSeq | |
| 675 | 717 | for { | |
| 676 | - | if _, ok := t.recvBuf[seq]; !ok { | |
| 677 | - | break | |
| 718 | + | if _, ok := t.recvBuf[seq]; ok { | |
| 719 | + | seq++ | |
| 720 | + | continue | |
| 678 | 721 | } | |
| 679 | - | seq++ | |
| 722 | + | if seq <= t.recvSkipThrough { | |
| 723 | + | seq++ | |
| 724 | + | continue | |
| 725 | + | } | |
| 726 | + | break | |
| 680 | 727 | } | |
| 681 | 728 | return seq - 1 | |
| 682 | 729 | } | |
| @@ -691,6 +738,10 @@ func (t *Transport) deliveryLoop() { | |||
| 691 | 738 | for { | |
| 692 | 739 | t.deliverMu.Lock() | |
| 693 | 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 | 745 | for !ok { | |
| 695 | 746 | if t.isClosed() { | |
| 696 | 747 | t.deliverMu.Unlock() | |
| @@ -698,6 +749,16 @@ func (t *Transport) deliveryLoop() { | |||
| 698 | 749 | } | |
| 699 | 750 | t.deliverCond.Wait() | |
| 700 | 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 | 763 | delete(t.recvBuf, t.nextRecvSeq) | |
| 703 | 764 | t.nextRecvSeq++ | |
| @@ -764,8 +825,9 @@ func (t *Transport) resendLoop() { | |||
| 764 | 825 | func (t *Transport) resendPending() { | |
| 765 | 826 | now := t.clock.Now() | |
| 766 | 827 | var resend []struct { | |
| 767 | - | seq uint64 | |
| 768 | - | f ports.Frame | |
| 828 | + | seq uint64 | |
| 829 | + | f ports.Frame | |
| 830 | + | outputSkipThrough uint64 | |
| 769 | 831 | } | |
| 770 | 832 | t.mu.Lock() | |
| 771 | 833 | seqs := make([]uint64, 0, len(t.pending)) | |
| @@ -786,14 +848,15 @@ func (t *Transport) resendPending() { | |||
| 786 | 848 | p.attempts++ | |
| 787 | 849 | p.retransmitted = true | |
| 788 | 850 | 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}) | |
| 792 | 855 | limit-- | |
| 793 | 856 | } | |
| 794 | 857 | t.mu.Unlock() | |
| 795 | 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 | 1039 | t.deliverMu.Unlock() | |
| 977 | 1040 | } | |
| 978 | 1041 | ||
| 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)) | |
| 981 | 1048 | b[0] = recData | |
| 982 | 1049 | binary.BigEndian.PutUint64(b[1:9], seq) | |
| 983 | 1050 | if reliable { | |
| 984 | 1051 | b[9] = 1 | |
| 985 | 1052 | } | |
| 986 | 1053 | 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) | |
| 989 | 1059 | 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 | 1062 | 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]) | |
| 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:]...)}, 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 | |
| 996 | 1079 | } | |
| @@ -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.Mutex | 183 | outboundMu sync.Mutex | ||
| 184 | 184 | ||||
| 185 | - | deliverMu sync.Mutex | 185 | + | deliverMu sync.Mutex |
| 186 | - | deliverCond *sync.Cond | 186 | + | deliverCond *sync.Cond |
| 187 | - | nextRecvSeq uint64 | 187 | + | nextRecvSeq uint64 |
| 188 | - | recvBuf map[uint64]ports.Frame | 188 | + | recvBuf map[uint64]ports.Frame |
| 189 | + | recvSkipThrough uint64 | |||
| 189 | } | 190 | } | ||
| 190 | 191 | ||||
| 191 | type pending struct { | 192 | type pending struct { | ||
| 192 | - | frame ports.Frame | 193 | + | frame ports.Frame |
| 193 | - | first time.Time | 194 | + | first time.Time |
| 194 | - | last time.Time | 195 | + | last time.Time |
| 195 | - | initialInFlight bool | 196 | + | initialInFlight bool |
| 196 | - | attempts int | 197 | + | attempts int |
| 197 | - | retransmitted bool | 198 | + | 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, nil | 255 | 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 := true | 263 | reliable := true | ||
| 260 | - | if err := t.lockOutboundSlot(reliable); err != nil { | 264 | + | outputSkipThrough, err := t.lockOutboundSlot(f, reliable) |
| 265 | + | if err != nil { | |||
| 261 | return err | 266 | 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.seq | 272 | 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 nil | 282 | 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 err | 289 | 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 err | 306 | return err | ||
| 302 | } | 307 | } | ||
| @@ -304,44 +309,10 @@ func (t *Transport) Send(f ports.Frame) error { | |||||
| 304 | return nil | 309 | 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.Time | 313 | 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 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 | |||
| 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 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 | |||
| 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 true | 393 | 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 | return | 674 | 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, false | 703 | 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.nextRecvSeq | 716 | seq := t.nextRecvSeq | ||
| 675 | for { | 717 | for { | ||
| 676 | - | if _, ok := t.recvBuf[seq]; !ok { | 718 | + | if _, ok := t.recvBuf[seq]; ok { |
| 677 | - | break | 719 | + | seq++ |
| 720 | + | continue | |||
| 678 | } | 721 | } | ||
| 679 | - | seq++ | 722 | + | if seq <= t.recvSkipThrough { |
| 723 | + | seq++ | |||
| 724 | + | continue | |||
| 725 | + | } | |||
| 726 | + | break | |||
| 680 | } | 727 | } | ||
| 681 | return seq - 1 | 728 | 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 uint64 | 828 | + | seq uint64 |
| 768 | - | f ports.Frame | 829 | + | 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 = true | 849 | p.retransmitted = true | ||
| 788 | resend = append(resend, struct { | 850 | resend = append(resend, struct { | ||
| 789 | - | seq uint64 | 851 | + | seq uint64 |
| 790 | - | f ports.Frame | 852 | + | 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] = recData | 1048 | 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] = 1 | 1051 | b[9] = 1 | ||
| 985 | } | 1052 | } | ||
| 986 | b[10] = byte(f.Type) | 1053 | b[10] = byte(f.Type) | ||
| 987 | - | b[11] = 0 | 1054 | + | 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 b | 1059 | 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{}, 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]) | |||
| 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:]...)}, 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 |
| 996 | } | 1079 | } | ||
M internal/adapters/dgram/transport_test.go +102 -22
| @@ -299,7 +299,7 @@ func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error { | |||
| 299 | 299 | return nil | |
| 300 | 300 | } | |
| 301 | 301 | ||
| 302 | - | func TestOutputFirstSendsArePacedAfterInitialDatagram(t *testing.T) { | |
| 302 | + | func TestOutputFirstSendIsPacedAndQueuedOutputsAreCoalesced(t *testing.T) { | |
| 303 | 303 | aPC, bPC := newPair() | |
| 304 | 304 | clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)) | |
| 305 | 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 | 319 | waitForManualTimers(t, clk, 3) | |
| 320 | 320 | clk.advance(defaultOutputPaceMinDelay) | |
| 321 | - | eventuallyPacketCount(t, bPC, 3) | |
| 321 | + | eventuallyPacketCount(t, bPC, 2) | |
| 322 | 322 | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil { | |
| 323 | 323 | t.Fatal(err) | |
| 324 | 324 | } | |
| 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) | |
| 327 | 327 | } | |
| 328 | 328 | clk.advance(defaultOutputPaceMinDelay) | |
| 329 | - | eventuallyPacketCount(t, bPC, 4) | |
| 329 | + | eventuallyPacketCount(t, bPC, 3) | |
| 330 | 330 | } | |
| 331 | 331 | ||
| 332 | 332 | func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) { | |
| @@ -470,14 +470,11 @@ func TestInputFlushFailureRemovesUnsentQueuedPendingFrames(t *testing.T) { | |||
| 470 | 470 | ||
| 471 | 471 | a.mu.Lock() | |
| 472 | 472 | defer a.mu.Unlock() | |
| 473 | - | for _, seq := range []uint64{2, 3, 4} { | |
| 473 | + | for _, seq := range []uint64{1, 2, 3, 4} { | |
| 474 | 474 | if _, ok := a.pending[seq]; ok { | |
| 475 | 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 | 480 | func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) { | |
| @@ -685,7 +682,7 @@ func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) { | |||
| 685 | 682 | t.Fatalf("unauthenticated packet changed peer to %v", a.Peer()) | |
| 686 | 683 | } | |
| 687 | 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 | 686 | frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU) | |
| 690 | 687 | raw, _ := pdgram.MarshalFragment(frags[0]) | |
| 691 | 688 | aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")} | |
| @@ -807,11 +804,11 @@ func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) { | |||
| 807 | 804 | ||
| 808 | 805 | func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) { | |
| 809 | 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 | 808 | if !ack || !queued { | |
| 812 | 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 | 812 | if !ack || !queued { | |
| 816 | 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 | 846 | func TestOutputRetransmitsUntilAck(t *testing.T) { | |
| 824 | 847 | aPC, bPC := newPair() | |
| 825 | 848 | 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 | 874 | aPC, bPC := newPair() | |
| 852 | 875 | a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |
| 853 | 876 | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |
| @@ -855,18 +878,51 @@ func TestReliableDeliveryPreservesSequenceOrder(t *testing.T) { | |||
| 855 | 878 | defer func() { _ = b.Close() }() | |
| 856 | 879 | ||
| 857 | 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 | 882 | t.Fatal(err) | |
| 860 | 883 | } | |
| 861 | 884 | } | |
| 862 | 885 | for i := range 100 { | |
| 863 | 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 | 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 | 926 | func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) { | |
| 871 | 927 | aPC, bPC := newPair() | |
| 872 | 928 | a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |
| @@ -1015,7 +1071,7 @@ func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) { | |||
| 1015 | 1071 | func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) { | |
| 1016 | 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 | 1075 | if !ack || !queued { | |
| 1020 | 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 | 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 | 1083 | if !ack || !queued { | |
| 1028 | 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 | 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 | 1091 | if !ack || !queued { | |
| 1036 | 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 | 1318 | tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})} | |
| 1263 | 1319 | tr.deliverCond = sync.NewCond(&tr.deliverMu) | |
| 1264 | 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 | 1323 | if got := len(tr.recvBuf); got != maxRecvBuffer { | |
| 1268 | 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 | 1327 | if _, ok := tr.recvBuf[1]; !ok { | |
| 1272 | 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 | 1359 | func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) { | |
| 1280 | 1360 | aPC, bPC := newPair() | |
| 1281 | 1361 | codec, err := pdgram.NewCodec(key()) | |
| @@ -1290,14 +1370,14 @@ func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) { | |||
| 1290 | 1370 | for i := range maxRecvBuffer { | |
| 1291 | 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 | 1374 | select { | |
| 1295 | 1375 | case pkt := <-bPC.in: | |
| 1296 | 1376 | t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b) | |
| 1297 | 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 | 1381 | select { | |
| 1302 | 1382 | case <-bPC.in: | |
| 1303 | 1383 | case <-time.After(time.Second): | |
| @@ -299,7 +299,7 @@ func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error { | |||||
| 299 | return nil | 299 | 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.Bool | 848 | 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 ( | |||
| 4 | 4 | "log/slog" | |
| 5 | 5 | "path/filepath" | |
| 6 | 6 | "sort" | |
| 7 | + | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/vev/internal/ports" | |
| 9 | 10 | "github.com/bnema/vev/pkg/kv" | |
| @@ -20,6 +21,7 @@ func StorePath(dir string) string { | |||
| 20 | 21 | // operations no-ops, so callers can keep persistence optional. | |
| 21 | 22 | type Persister struct { | |
| 22 | 23 | store ports.Store | |
| 24 | + | mu sync.Mutex | |
| 23 | 25 | } | |
| 24 | 26 | ||
| 25 | 27 | // Open opens the session persister under dir. | |
| @@ -50,6 +52,8 @@ func (p *Persister) Save(r Record) error { | |||
| 50 | 52 | if p == nil || p.store == nil { | |
| 51 | 53 | return nil | |
| 52 | 54 | } | |
| 55 | + | p.mu.Lock() | |
| 56 | + | defer p.mu.Unlock() | |
| 53 | 57 | value, err := encodeRecordValue(r) | |
| 54 | 58 | if err != nil { | |
| 55 | 59 | return err | |
| @@ -68,17 +72,21 @@ func (p *Persister) Touch(name, cwd string, at int64) error { | |||
| 68 | 72 | if name == "" { | |
| 69 | 73 | return errEmptyName | |
| 70 | 74 | } | |
| 75 | + | p.mu.Lock() | |
| 76 | + | defer p.mu.Unlock() | |
| 71 | 77 | ||
| 72 | 78 | createdAt := at | |
| 73 | 79 | lastUsedSeq := uint64(0) | |
| 80 | + | var tabNames []string | |
| 74 | 81 | if v, ok := p.store.Get([]byte(name)); ok { | |
| 75 | 82 | if r, err := decodeRecordValue(name, v); err == nil { | |
| 76 | 83 | createdAt = r.CreatedAt | |
| 77 | 84 | 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 | 90 | if err != nil { | |
| 83 | 91 | return err | |
| 84 | 92 | } | |
| @@ -93,6 +101,8 @@ func (p *Persister) TouchMRU(name string, lastUsedSeq uint64) error { | |||
| 93 | 101 | if name == "" { | |
| 94 | 102 | return errEmptyName | |
| 95 | 103 | } | |
| 104 | + | p.mu.Lock() | |
| 105 | + | defer p.mu.Unlock() | |
| 96 | 106 | ||
| 97 | 107 | r := Record{Name: name, LastUsedSeq: lastUsedSeq} | |
| 98 | 108 | if v, ok := p.store.Get([]byte(name)); ok { | |
| @@ -116,6 +126,8 @@ func (p *Persister) Delete(name string) error { | |||
| 116 | 126 | if name == "" { | |
| 117 | 127 | return errEmptyName | |
| 118 | 128 | } | |
| 129 | + | p.mu.Lock() | |
| 130 | + | defer p.mu.Unlock() | |
| 119 | 131 | if err := p.store.Delete([]byte(name)); err != nil { | |
| 120 | 132 | return err | |
| 121 | 133 | } | |
| @@ -127,6 +139,8 @@ func (p *Persister) LoadAll() ([]Record, error) { | |||
| 127 | 139 | if p == nil || p.store == nil { | |
| 128 | 140 | return nil, nil | |
| 129 | 141 | } | |
| 142 | + | p.mu.Lock() | |
| 143 | + | defer p.mu.Unlock() | |
| 130 | 144 | data := make(map[string][]byte) | |
| 131 | 145 | p.store.Range(func(k, v []byte) bool { | |
| 132 | 146 | data[string(k)] = append([]byte(nil), v...) | |
| @@ -140,6 +154,8 @@ func (p *Persister) Close() error { | |||
| 140 | 154 | if p == nil || p.store == nil { | |
| 141 | 155 | return nil | |
| 142 | 156 | } | |
| 157 | + | p.mu.Lock() | |
| 158 | + | defer p.mu.Unlock() | |
| 143 | 159 | return p.store.Close() | |
| 144 | 160 | } | |
| 145 | 161 | ||
| @@ -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.Store | 23 | 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 nil | 53 | 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 err | 59 | return err | ||
| @@ -68,17 +72,21 @@ func (p *Persister) Touch(name, cwd string, at int64) error { | |||||
| 68 | if name == "" { | 72 | if name == "" { | ||
| 69 | return errEmptyName | 73 | return errEmptyName | ||
| 70 | } | 74 | } | ||
| 75 | + | p.mu.Lock() | |||
| 76 | + | defer p.mu.Unlock() | |||
| 71 | 77 | ||||
| 72 | createdAt := at | 78 | 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.CreatedAt | 83 | createdAt = r.CreatedAt | ||
| 77 | lastUsedSeq = r.LastUsedSeq | 84 | 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 err | 91 | 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 errEmptyName | 102 | 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 errEmptyName | 127 | 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 err | 132 | 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, nil | 140 | 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 nil | 155 | 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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "errors" | |
| 5 | 5 | "path/filepath" | |
| 6 | + | "sync" | |
| 6 | 7 | "testing" | |
| 8 | + | "time" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/stretchr/testify/mock" | |
| 9 | 11 | "github.com/stretchr/testify/require" | |
| @@ -17,7 +19,7 @@ func privateDir(t *testing.T) string { | |||
| 17 | 19 | } | |
| 18 | 20 | ||
| 19 | 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 | 23 | value, err := encodeRecordValue(r) | |
| 22 | 24 | require.NoError(t, err) | |
| 23 | 25 | ||
| @@ -81,7 +83,7 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |||
| 81 | 83 | p, err := Open(dir) | |
| 82 | 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 | 87 | require.NoError(t, p.Save(Record{Name: "a", Cwd: "/a", CreatedAt: 20, UpdatedAt: 20, LastUsedSeq: 50})) | |
| 86 | 88 | require.NoError(t, p.Touch("b", "/b/next", 30)) | |
| 87 | 89 | require.NoError(t, p.TouchMRU("b", 60)) | |
| @@ -90,13 +92,13 @@ func TestPersisterSaveTouchDeleteLoadAll(t *testing.T) { | |||
| 90 | 92 | require.NoError(t, err) | |
| 91 | 93 | require.Equal(t, []Record{ | |
| 92 | 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 | 96 | }, records) | |
| 95 | 97 | ||
| 96 | 98 | require.NoError(t, p.Delete("a")) | |
| 97 | 99 | records, err = p.LoadAll() | |
| 98 | 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 | 102 | require.NoError(t, p.Close()) | |
| 101 | 103 | } | |
| 102 | 104 | ||
| @@ -151,6 +153,34 @@ func TestSyncBehavior(t *testing.T) { | |||
| 151 | 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 | 184 | func TestSavePropagatesSetErrorWithoutSync(t *testing.T) { | |
| 155 | 185 | store, state := newMockStore(t) | |
| 156 | 186 | state.errSet = errors.New("set failed") | |
| @@ -170,6 +200,68 @@ type mockStoreState struct { | |||
| 170 | 200 | 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 | 265 | func newMockStore(t *testing.T) (*portsmocks.MockStore, *mockStoreState) { | |
| 174 | 266 | t.Helper() | |
| 175 | 267 | 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 error | 200 | 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 { | |||
| 14 | 14 | CreatedAt int64 | |
| 15 | 15 | UpdatedAt int64 | |
| 16 | 16 | LastUsedSeq uint64 | |
| 17 | + | TabNames []string | |
| 17 | 18 | } | |
| 18 | 19 | ||
| 19 | 20 | var ( | |
| @@ -28,14 +29,33 @@ func encodeRecordValue(r Record) ([]byte, error) { | |||
| 28 | 29 | if uint64(len(r.Cwd)) > math.MaxUint32 { | |
| 29 | 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 | 44 | binary.BigEndian.PutUint32(buf[:4], uint32(len(r.Cwd))) | |
| 34 | 45 | copy(buf[4:], r.Cwd) | |
| 35 | 46 | off := 4 + len(r.Cwd) | |
| 36 | 47 | binary.BigEndian.PutUint64(buf[off:off+8], uint64(r.CreatedAt)) | |
| 37 | 48 | binary.BigEndian.PutUint64(buf[off+8:off+16], uint64(r.UpdatedAt)) | |
| 38 | 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 | 59 | return buf, nil | |
| 40 | 60 | } | |
| 41 | 61 | ||
| @@ -52,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||
| 52 | 72 | return Record{}, errMalformedRecord | |
| 53 | 73 | } | |
| 54 | 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 | 77 | return Record{}, errMalformedRecord | |
| 57 | 78 | } | |
| 58 | 79 | off := 4 + cwdLen | |
| @@ -62,8 +83,44 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||
| 62 | 83 | CreatedAt: int64(binary.BigEndian.Uint64(value[off : off+8])), | |
| 63 | 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 | 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 | 125 | return r, nil | |
| 69 | 126 | } | |
| @@ -14,6 +14,7 @@ type Record struct { | |||||
| 14 | CreatedAt int64 | 14 | CreatedAt int64 | ||
| 15 | UpdatedAt int64 | 15 | UpdatedAt int64 | ||
| 16 | LastUsedSeq uint64 | 16 | 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, nil | 59 | return buf, nil | ||
| 40 | } | 60 | } | ||
| 41 | 61 | ||||
| @@ -52,7 +72,8 @@ func decodeRecordValue(name string, value []byte) (Record, error) { | |||||
| 52 | return Record{}, errMalformedRecord | 72 | 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{}, errMalformedRecord | 77 | return Record{}, errMalformedRecord | ||
| 57 | } | 78 | } | ||
| 58 | off := 4 + cwdLen | 79 | 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, nil | 125 | return r, nil | ||
| 69 | } | 126 | } | ||
M internal/ports/frame.go +1 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 10 | |
| 4 | + | const ProtocolVersion uint16 = 12 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 10 | 4 | + | 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 uint8 | 7 | type MsgType uint8 | ||
M internal/ports/frame_test.go +2 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 10 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 10", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 12 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 12", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 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 { | |||
| 45 | 45 | Close() error | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | + | // DatagramTransport marks transports with datagram/reordered/lossy delivery | |
| 49 | + | // semantics behind the framed Transport interface. | |
| 50 | + | type DatagramTransport interface { | |
| 51 | + | DatagramTransport() | |
| 52 | + | } | |
| 53 | + | ||
| 48 | 54 | // Dialer establishes outbound Transport connections. | |
| 49 | 55 | type Dialer interface { | |
| 50 | 56 | Dial(ctx context.Context) (Transport, error) | |
| @@ -45,6 +45,12 @@ type Transport interface { | |||||
| 45 | Close() error | 45 | Close() error | ||
| 46 | } | 46 | } | ||
| 47 | 47 | ||||
| 48 | + | // DatagramTransport marks transports with datagram/reordered/lossy delivery | |||
| 49 | + | // semantics behind the framed Transport interface. | |||
| 50 | + | type DatagramTransport interface { | |||
| 51 | + | DatagramTransport() | |||
| 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 { | |||
| 66 | 66 | TermEnv string | |
| 67 | 67 | Cwd string | |
| 68 | 68 | TrueColor bool | |
| 69 | + | AckOutput bool | |
| 69 | 70 | } | |
| 70 | 71 | ||
| 71 | 72 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | |
| @@ -309,6 +310,11 @@ func MarshalHello(h Hello) []byte { | |||
| 309 | 310 | } else { | |
| 310 | 311 | w.putUint8(0) | |
| 311 | 312 | } | |
| 313 | + | if h.AckOutput { | |
| 314 | + | w.putUint8(1) | |
| 315 | + | } else { | |
| 316 | + | w.putUint8(0) | |
| 317 | + | } | |
| 312 | 318 | return w.b | |
| 313 | 319 | } | |
| 314 | 320 | ||
| @@ -355,6 +361,11 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 355 | 361 | return Hello{}, err | |
| 356 | 362 | } | |
| 357 | 363 | h.TrueColor = trueColor != 0 | |
| 364 | + | ackOutput, err := r.getUint8() | |
| 365 | + | if err != nil { | |
| 366 | + | return Hello{}, err | |
| 367 | + | } | |
| 368 | + | h.AckOutput = ackOutput != 0 | |
| 358 | 369 | if err := r.done(); err != nil { | |
| 359 | 370 | return Hello{}, err | |
| 360 | 371 | } | |
| @@ -66,6 +66,7 @@ type Hello struct { | |||||
| 66 | TermEnv string | 66 | TermEnv string | ||
| 67 | Cwd string | 67 | Cwd string | ||
| 68 | TrueColor bool | 68 | TrueColor bool | ||
| 69 | + | AckOutput bool | |||
| 69 | } | 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.b | 318 | return w.b | ||
| 313 | } | 319 | } | ||
| 314 | 320 | ||||
| @@ -355,6 +361,11 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 355 | return Hello{}, err | 361 | return Hello{}, err | ||
| 356 | } | 362 | } | ||
| 357 | h.TrueColor = trueColor != 0 | 363 | 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{}, err | 370 | return Hello{}, err | ||
| 360 | } | 371 | } | ||
M internal/ports/wire_test.go +3 -2
| @@ -69,8 +69,9 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 69 | 69 | TermEnv: "xterm-256color", | |
| 70 | 70 | Cwd: "/tmp/project", | |
| 71 | 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 | 77 | name: "empty strings", | |
| @@ -83,7 +84,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 83 | 84 | Cwd: "", | |
| 84 | 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 | ||
| @@ -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 | |||
| 352 | 352 | termEnv := os.Getenv("TERM") | |
| 353 | 353 | colorTerm := os.Getenv("COLORTERM") | |
| 354 | 354 | trueColor := DetectTrueColor(termEnv, colorTerm) | |
| 355 | + | _, ackOutput := transport.(ports.DatagramTransport) | |
| 355 | 356 | hello := ports.Hello{ | |
| 356 | 357 | Version: ports.ProtocolVersion, | |
| 357 | 358 | Intent: intent, | |
| @@ -362,6 +363,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 362 | 363 | TermEnv: termEnv, | |
| 363 | 364 | Cwd: cwd, | |
| 364 | 365 | TrueColor: trueColor, | |
| 366 | + | AckOutput: ackOutput, | |
| 365 | 367 | } | |
| 366 | 368 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 367 | 369 | 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 { | |||
| 151 | 151 | return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ }) | |
| 152 | 152 | } | |
| 153 | 153 | ||
| 154 | + | type datagramMarkedTransport struct{ ports.Transport } | |
| 155 | + | ||
| 156 | + | func (datagramMarkedTransport) DatagramTransport() {} | |
| 157 | + | ||
| 154 | 158 | func TestAttachHelloIncludesTrueColor(t *testing.T) { | |
| 155 | 159 | t.Setenv("TERM", "xterm-256color") | |
| 156 | 160 | t.Setenv("COLORTERM", "truecolor") | |
| @@ -176,13 +180,14 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) { | |||
| 176 | 180 | defer unblock() | |
| 177 | 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 | 184 | require.NoError(t, err) | |
| 181 | 185 | ||
| 182 | 186 | select { | |
| 183 | 187 | case hello := <-gotHello: | |
| 184 | 188 | require.Equal(t, "xterm-256color", hello.TermEnv) | |
| 185 | 189 | require.True(t, hello.TrueColor) | |
| 190 | + | require.True(t, hello.AckOutput) | |
| 186 | 191 | case <-time.After(2 * time.Second): | |
| 187 | 192 | t.Fatal("hello frame was not sent") | |
| 188 | 193 | } | |
| @@ -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 { | |||
| 23 | 23 | Detach() error | |
| 24 | 24 | EnterVisualMode() error | |
| 25 | 25 | RenameSession() error | |
| 26 | + | RenameTab() error | |
| 26 | 27 | OpenSessionPicker() error | |
| 27 | 28 | } | |
| 28 | 29 | ||
| @@ -23,6 +23,7 @@ type Context interface { | |||||
| 23 | Detach() error | 23 | Detach() error | ||
| 24 | EnterVisualMode() error | 24 | EnterVisualMode() error | ||
| 25 | RenameSession() error | 25 | RenameSession() error | ||
| 26 | + | RenameTab() error | |||
| 26 | OpenSessionPicker() error | 27 | 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 | |||
| 739 | 739 | 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 | 786 | // SplitDown provides a mock function for the type MockContext | |
| 743 | 787 | func (_mock *MockContext) SplitDown() error { | |
| 744 | 788 | ret := _mock.Called() | |
| @@ -739,6 +739,50 @@ func (_c *MockContext_RenameSession_Call) RunAndReturn(run func() error) *MockCo | |||||
| 739 | return _c | 739 | 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 MockContext | 786 | // 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 { | |||
| 26 | 26 | {Slug: "session-picker", Code: "SSP", Name: "Session picker", Desc: "Open the session picker", Run: func(ctx Context) error { return ctx.OpenSessionPicker() }}, | |
| 27 | 27 | {Slug: "visual-mode", Code: "VIS", Name: "Visual mode", Desc: "Enter visual mode", Run: func(ctx Context) error { return ctx.EnterVisualMode() }}, | |
| 28 | 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 | 30 | {Slug: "detach", Code: "DET", Name: "Detach", Desc: "Detach from the session", Run: func(ctx Context) error { return ctx.Detach() }}, | |
| 30 | 31 | } | |
| 31 | 32 | ||
| @@ -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 ( | |||
| 7 | 7 | ||
| 8 | 8 | func TestRegistryCodesAndSlugsAreUniqueInOrder(t *testing.T) { | |
| 9 | 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 | 12 | if len(commands) != len(wantCodes) { | |
| 13 | 13 | t.Fatalf("Registry() returned %d commands, want %d", len(commands), len(wantCodes)) | |
| @@ -79,6 +79,7 @@ func TestCommandRunCallsMatchingContextMethod(t *testing.T) { | |||
| 79 | 79 | {code: "SSP", expect: func(ctx *MockContext) { ctx.EXPECT().OpenSessionPicker().Return(nil).Once() }}, | |
| 80 | 80 | {code: "VIS", expect: func(ctx *MockContext) { ctx.EXPECT().EnterVisualMode().Return(nil).Once() }}, | |
| 81 | 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 | 83 | {code: "DET", expect: func(ctx *MockContext) { ctx.EXPECT().Detach().Return(nil).Once() }}, | |
| 83 | 84 | } | |
| 84 | 85 | ||
| @@ -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 ( | |||
| 39 | 39 | ) | |
| 40 | 40 | ||
| 41 | 41 | 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 | |
| 64 | 66 | // historyNavMu protects historyNav and historyNavTimer. When paint needs | |
| 65 | 67 | // several locks, take sendMu before historyNavMu, then Daemon.mu/session.mu. | |
| 66 | 68 | historyNavMu sync.Mutex | |
| @@ -213,6 +215,10 @@ func (ac *attachedClient) shouldResetOutputState(reset bool) bool { | |||
| 213 | 215 | func (ac *attachedClient) advanceOutputAck(state uint64) { | |
| 214 | 216 | ac.sendMu.Lock() | |
| 215 | 217 | defer ac.sendMu.Unlock() | |
| 218 | + | ac.advanceOutputAckLocked(state) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func (ac *attachedClient) advanceOutputAckLocked(state uint64) { | |
| 216 | 222 | if state > ac.nextStateNum { | |
| 217 | 223 | 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 | 259 | // outputLagAtCapLocked reports whether the client has at least | |
| 231 | 260 | // maxUnackedOutputStates output states in flight (sent but unacked). Caller | |
| 232 | 261 | // holds sendMu. | |
| @@ -396,6 +425,7 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} { | |||
| 396 | 425 | type attachClientOptions struct { | |
| 397 | 426 | clientID [16]byte | |
| 398 | 427 | resumeCapable bool | |
| 428 | + | ackOutput bool | |
| 399 | 429 | } | |
| 400 | 430 | ||
| 401 | 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 | 433 | if opts.resumeCapable { | |
| 404 | 434 | resumeToken = d.nextResumeTokenLocked() | |
| 405 | 435 | } | |
| 436 | + | caps := renderer.Capabilities{} | |
| 437 | + | advanceOutputOnAck := opts.ackOutput | |
| 438 | + | if advanceOutputOnAck { | |
| 439 | + | caps.AdvancePolicy = renderer.AdvanceOnAck | |
| 440 | + | } | |
| 406 | 441 | 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, | |
| 413 | 450 | } | |
| 414 | 451 | ac.initOverlays() | |
| 415 | 452 | ac.setSession(sess) | |
| @@ -521,7 +558,9 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 521 | 558 | return | |
| 522 | 559 | case ports.MsgAck: | |
| 523 | 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 | 564 | if ac.takeDeferredPaint() { | |
| 526 | 565 | d.paint(sess, ac, false) | |
| 527 | 566 | } | |
| @@ -39,28 +39,30 @@ import ( | |||||
| 39 | ) | 39 | ) | ||
| 40 | 40 | ||||
| 41 | type attachedClient struct { | 41 | type attachedClient struct { | ||
| 42 | - | tr ports.Transport | 42 | + | tr ports.Transport |
| 43 | - | rend *renderer.Renderer | 43 | + | rend *renderer.Renderer |
| 44 | - | overlays *overlayRuntime | 44 | + | overlays *overlayRuntime |
| 45 | - | clientID [16]byte | 45 | + | clientID [16]byte |
| 46 | - | resumeCapable bool | 46 | + | resumeCapable bool |
| 47 | - | resumeToken uint64 | 47 | + | resumeToken uint64 |
| 48 | - | parked bool | 48 | + | parked bool |
| 49 | - | nextStateNum uint64 | 49 | + | nextStateNum uint64 |
| 50 | - | echoAck atomic.Uint64 | 50 | + | echoAck atomic.Uint64 |
| 51 | - | outputAck atomic.Uint64 | 51 | + | outputAck atomic.Uint64 |
| 52 | - | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag | 52 | + | paintDeferred bool // guarded by sendMu; a diff paint skipped while acks lag |
| 53 | - | bars barCache // only touched while sendMu is held | 53 | + | bars barCache // only touched while sendMu is held |
| 54 | - | composed composedFrameCache // only touched while sendMu is held | 54 | + | composed composedFrameCache // only touched while sendMu is held |
| 55 | - | size domain.Size | 55 | + | advanceOutputOnAck bool // renderer shadow advances after client MsgAck instead of send |
| 56 | - | keys *keys.Router | 56 | + | 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.Scanner | 58 | + | keys *keys.Router |
| 59 | - | themeMu sync.Mutex | 59 | + | sess Guarded[*session] |
| 60 | - | theme themeui.Theme | 60 | + | mouseScan mouse.Scanner |
| 61 | - | clientTheme themeui.Theme | 61 | + | themeMu sync.Mutex |
| 62 | - | lastCursor cursorOut | 62 | + | theme themeui.Theme |
| 63 | - | recentNav recentSessionNavigator | 63 | + | clientTheme themeui.Theme |
| 64 | + | lastCursor cursorOut | |||
| 65 | + | recentNav recentSessionNavigator | |||
| 64 | // historyNavMu protects historyNav and historyNavTimer. When paint needs | 66 | // 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.Mutex | 68 | 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 | return | 223 | 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 least | 259 | // outputLagAtCapLocked reports whether the client has at least | ||
| 231 | // maxUnackedOutputStates output states in flight (sent but unacked). Caller | 260 | // 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]byte | 426 | clientID [16]byte | ||
| 398 | resumeCapable bool | 427 | 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 | return | 558 | 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 ( | |||
| 31 | 31 | "errors" | |
| 32 | 32 | "log/slog" | |
| 33 | 33 | "os" | |
| 34 | + | "slices" | |
| 34 | 35 | "sort" | |
| 35 | 36 | "sync" | |
| 36 | 37 | "sync/atomic" | |
| @@ -163,9 +164,19 @@ type stoppedSession struct { | |||
| 163 | 164 | cwd string | |
| 164 | 165 | createdAt int64 | |
| 165 | 166 | lastUsedSeq uint64 | |
| 167 | + | tabNames []string | |
| 166 | 168 | 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 | 180 | type Option func(*Daemon) | |
| 170 | 181 | ||
| 171 | 182 | // 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 | 331 | } else { | |
| 321 | 332 | var maxSeq uint64 | |
| 322 | 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 | 335 | if r.LastUsedSeq > maxSeq { | |
| 325 | 336 | maxSeq = r.LastUsedSeq | |
| 326 | 337 | } | |
| @@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||
| 537 | 548 | return | |
| 538 | 549 | } | |
| 539 | 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 | 552 | delete(d.stopped, k.Name) | |
| 542 | 553 | } | |
| 543 | 554 | d.mu.Unlock() | |
| @@ -647,14 +658,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 647 | 658 | } | |
| 648 | 659 | cwd := d.dirOrHome(stopped.cwd) | |
| 649 | 660 | 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 | 662 | if err != nil { | |
| 652 | 663 | d.mu.Unlock() | |
| 653 | 664 | return nil, nil, err | |
| 654 | 665 | } | |
| 655 | 666 | } | |
| 656 | 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 | 669 | sess.mu.Lock() | |
| 659 | 670 | sess.terminal = term | |
| 660 | 671 | sess.mu.Unlock() | |
| @@ -670,7 +681,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 670 | 681 | return nil, nil, err | |
| 671 | 682 | } | |
| 672 | 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 | 685 | sess.mu.Lock() | |
| 675 | 686 | sess.terminal = term | |
| 676 | 687 | sess.mu.Unlock() | |
| @@ -697,7 +708,7 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 697 | 708 | return nil, nil, err | |
| 698 | 709 | } | |
| 699 | 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 | 712 | sess.mu.Lock() | |
| 702 | 713 | sess.terminal = term | |
| 703 | 714 | sess.mu.Unlock() | |
| @@ -715,14 +726,14 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 715 | 726 | } | |
| 716 | 727 | cwd := d.dirOrHome(stopped.cwd) | |
| 717 | 728 | 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 | 730 | if err != nil { | |
| 720 | 731 | d.mu.Unlock() | |
| 721 | 732 | return nil, nil, err | |
| 722 | 733 | } | |
| 723 | 734 | } | |
| 724 | 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 | 737 | sess.mu.Lock() | |
| 727 | 738 | sess.terminal = term | |
| 728 | 739 | 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 string | 164 | cwd string | ||
| 164 | createdAt int64 | 165 | createdAt int64 | ||
| 165 | lastUsedSeq uint64 | 166 | lastUsedSeq uint64 | ||
| 167 | + | tabNames []string | |||
| 166 | purging bool | 168 | 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. The | 182 | // 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 uint64 | 332 | 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.LastUsedSeq | 336 | maxSeq = r.LastUsedSeq | ||
| 326 | } | 337 | } | ||
| @@ -537,7 +548,7 @@ func (d *Daemon) handleKill(tr ports.Transport, f ports.Frame) { | |||||
| 537 | return | 548 | 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 error | 660 | 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, err | 664 | 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 = term | 670 | 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, err | 681 | 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 = term | 686 | 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, err | 708 | 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 = term | 713 | 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 error | 728 | 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, err | 732 | 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 = term | 738 | 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) { | |||
| 641 | 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 | 670 | func TestMouseWheelEntersScrollbackModeAndExitsAtBottom(t *testing.T) { | |
| 645 | 671 | writes := make(chan []byte, 4) | |
| 646 | 672 | 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) { | |||
| 18 | 18 | ctx, cancel := context.WithCancel(context.Background()) | |
| 19 | 19 | defer cancel() | |
| 20 | 20 | sess := &session{id: "s", name: "s", ctx: ctx, cancel: cancel} | |
| 21 | + | d.sessions[sess.id] = sess | |
| 21 | 22 | first := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 22 | 23 | second := newTab(nil, domain.Size{Cols: 41, Rows: 10}) | |
| 23 | 24 | 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 { | |||
| 256 | 256 | 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 | 273 | func (e paletteExec) OpenSessionPicker() error { | |
| 260 | 274 | e.d.enterPicker(e.sess, e.ac) | |
| 261 | 275 | return nil | |
| @@ -256,6 +256,20 @@ func (e paletteExec) RenameSession() error { | |||||
| 256 | return nil | 256 | 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 nil | 275 | return nil | ||
M internal/usecase/daemon/palette_test.go +2 -2
| @@ -112,12 +112,12 @@ func TestPaletteRecentCommandsNewestFirstThenRegistryOrder(t *testing.T) { | |||
| 112 | 112 | for i, cmd := range commands { | |
| 113 | 113 | 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 | 118 | func TestPaletteRecencyCanBeUpdatedConcurrently(t *testing.T) { | |
| 119 | 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 | 122 | var wg sync.WaitGroup | |
| 123 | 123 | 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.Code | 113 | 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.WaitGroup | 122 | 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) { | |||
| 219 | 219 | otherPTY := portsmocks.NewMockPTY(t) | |
| 220 | 220 | other := newTab(otherPTY, domain.Size{Cols: 41, Rows: 10}) | |
| 221 | 221 | sess.tabs = append(sess.tabs, other) | |
| 222 | + | d.sessions[sess.id] = sess | |
| 222 | 223 | oldPTY.EXPECT().Close().Return(nil).Once() | |
| 223 | 224 | ||
| 224 | 225 | 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 | |||
| 26 | 26 | ||
| 27 | 27 | import ( | |
| 28 | 28 | "sort" | |
| 29 | - | "strconv" | |
| 30 | 29 | ||
| 31 | 30 | "github.com/bnema/vev/internal/domain" | |
| 32 | 31 | "github.com/bnema/vev/internal/ports" | |
| @@ -71,7 +70,7 @@ func (d *Daemon) pickerViews(cur *session) ([]picker.SessionView, int) { | |||
| 71 | 70 | view := picker.SessionView{ID: s.id, Name: s.name, Active: s.active, Tabs: make([]string, len(s.tabs))} | |
| 72 | 71 | sessionAttention := false | |
| 73 | 72 | for i, tb := range s.tabs { | |
| 74 | - | label := strconv.Itoa(i + 1) | |
| 73 | + | label := tabDisplayName(tb, i) | |
| 75 | 74 | if tb.attention { | |
| 76 | 75 | label = attentionSuffix(label) | |
| 77 | 76 | sessionAttention = true | |
| @@ -425,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||
| 425 | 424 | } | |
| 426 | 425 | term := from.terminal | |
| 427 | 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 | 428 | if err != nil { | |
| 430 | 429 | from.mu.Unlock() | |
| 431 | 430 | d.mu.Unlock() | |
| @@ -455,9 +454,7 @@ func (d *Daemon) killPickerTarget(target picker.Target) { | |||
| 455 | 454 | d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name) | |
| 456 | 455 | return | |
| 457 | 456 | } | |
| 458 | - | if cur, ok := d.stopped[target.Name]; ok && cur == stopped { | |
| 459 | - | delete(d.stopped, target.Name) | |
| 460 | - | } | |
| 457 | + | delete(d.stopped, target.Name) | |
| 461 | 458 | } | |
| 462 | 459 | d.mu.Unlock() | |
| 463 | 460 | 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 := false | 71 | 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 = true | 76 | sessionAttention = true | ||
| @@ -425,7 +424,7 @@ func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, targe | |||||
| 425 | } | 424 | } | ||
| 426 | term := from.terminal | 425 | 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 | return | 455 | 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 | return | 460 | return | ||
M internal/usecase/daemon/picker_test.go +24 -2
| @@ -49,7 +49,7 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 49 | 49 | ctx, cancel := context.WithCancel(d.serveCtx) | |
| 50 | 50 | defer cancel() | |
| 51 | 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 | 53 | ringing.mu.Lock() | |
| 54 | 54 | ringing.tabs[1].attention = true | |
| 55 | 55 | ringing.tabs[1].attentionAt = time.Unix(10, 0) | |
| @@ -64,7 +64,29 @@ func TestPickerViewsAddsBellSuffixForAttention(t *testing.T) { | |||
| 64 | 64 | require.Equal(t, "alpha", views[0].Name) | |
| 65 | 65 | require.Equal(t, []string{"1", "2"}, views[0].Tabs) | |
| 66 | 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 | 92 | 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 = true | 54 | 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) { | |||
| 400 | 400 | if sendTr == nil { | |
| 401 | 401 | serr = errors.New("client transport is nil") | |
| 402 | 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 | } | |
| @@ -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 ( | |||
| 29 | 29 | "errors" | |
| 30 | 30 | "fmt" | |
| 31 | 31 | "os" | |
| 32 | + | "slices" | |
| 32 | 33 | "strconv" | |
| 33 | 34 | "strings" | |
| 34 | 35 | "sync" | |
| @@ -76,6 +77,7 @@ type tab struct { | |||
| 76 | 77 | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | |
| 77 | 78 | ||
| 78 | 79 | stableID string | |
| 80 | + | name string | |
| 79 | 81 | tree *layout.Tree | |
| 80 | 82 | panes map[layout.PaneID]*pane | |
| 81 | 83 | 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 | 133 | 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) | |
| 140 | 156 | } | |
| 141 | 157 | ||
| 142 | 158 | id := domain.SessionID(fmt.Sprintf("sess-%d", d.nextID)) | |
| 143 | 159 | d.nextID++ | |
| 144 | 160 | createdAt := time.Now().UnixNano() | |
| 145 | 161 | ||
| 146 | - | tb := newTabWithStableID(tabStableID, paneStableID, pty, tbSize) | |
| 147 | 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 | 166 | lastUsedSeq := uint64(0) | |
| 150 | 167 | if !ephemeral { | |
| 151 | 168 | if stopped, ok := d.stopped[name]; ok { | |
| @@ -158,7 +175,7 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 158 | 175 | ephemeral: ephemeral, | |
| 159 | 176 | ctx: sctx, | |
| 160 | 177 | cancel: cancel, | |
| 161 | - | tabs: []*tab{tb}, | |
| 178 | + | tabs: tabs, | |
| 162 | 179 | cwd: cwd, | |
| 163 | 180 | terminal: term, | |
| 164 | 181 | createdAt: createdAt, | |
| @@ -168,8 +185,8 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 168 | 185 | } | |
| 169 | 186 | sess.snapEligible.Store(!ephemeral && name != "") | |
| 170 | 187 | 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) | |
| 173 | 190 | cancel() | |
| 174 | 191 | return nil, err | |
| 175 | 192 | } | |
| @@ -180,11 +197,21 @@ func (d *Daemon) createSessionLocked(name string, ephemeral bool, cwd string, sz | |||
| 180 | 197 | d.touchMRU(sess) | |
| 181 | 198 | } | |
| 182 | 199 | 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 | + | } | |
| 185 | 204 | 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 | 215 | func (d *Daemon) createSessionAndSwitch(from *session, ac *attachedClient, name string) error { | |
| 189 | 216 | if name == "" { | |
| 190 | 217 | return errors.New("name required") | |
| @@ -280,9 +307,25 @@ func (d *Daemon) createTab(sess *session, sz domain.Size) error { | |||
| 280 | 307 | p.ctx, p.cancel = context.WithCancel(tb.ctx) | |
| 281 | 308 | } | |
| 282 | 309 | sess.mu.Lock() | |
| 310 | + | oldActive := sess.active | |
| 283 | 311 | sess.tabs = append(sess.tabs, tb) | |
| 284 | 312 | sess.active = len(sess.tabs) - 1 | |
| 285 | 313 | 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 | 329 | sess.mu.Unlock() | |
| 287 | 330 | d.log.Info("tab created", "session", name, "tab", tabIndex) | |
| 288 | 331 | d.startTabGoroutines(sess, tb) | |
| @@ -437,7 +480,11 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 437 | 480 | } | |
| 438 | 481 | lastUsedSeq := sess.mruAt.Load() | |
| 439 | 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 | 488 | sess.mu.Unlock() | |
| 442 | 489 | return err | |
| 443 | 490 | } | |
| @@ -470,7 +517,66 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||
| 470 | 517 | 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 | 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 | 580 | sess.mu.Lock() | |
| 475 | 581 | idx := -1 | |
| 476 | 582 | for i, w := range sess.tabs { | |
| @@ -481,11 +587,13 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 481 | 587 | } | |
| 482 | 588 | if idx == -1 { | |
| 483 | 589 | sess.mu.Unlock() | |
| 590 | + | d.mu.Unlock() | |
| 484 | 591 | return | |
| 485 | 592 | } | |
| 486 | 593 | if len(sess.tabs) == 1 { | |
| 487 | 594 | name := sess.name | |
| 488 | 595 | sess.mu.Unlock() | |
| 596 | + | d.mu.Unlock() | |
| 489 | 597 | d.log.Info("tab closed", "session", name, "last", true) | |
| 490 | 598 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 491 | 599 | return | |
| @@ -499,7 +607,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||
| 499 | 607 | } | |
| 500 | 608 | ac := sess.client | |
| 501 | 609 | 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 | 617 | sess.mu.Unlock() | |
| 618 | + | d.mu.Unlock() | |
| 503 | 619 | d.log.Info("tab closed", "session", name) | |
| 504 | 620 | markSnapshotDirty(sess) | |
| 505 | 621 | ||
| @@ -553,10 +669,11 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 553 | 669 | stoppedName := sess.name | |
| 554 | 670 | stoppedCwd := sess.cwd | |
| 555 | 671 | createdAt := sess.createdAt | |
| 672 | + | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | |
| 556 | 673 | ephemeral := sess.ephemeral | |
| 557 | 674 | sess.mu.Unlock() | |
| 558 | 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 | 677 | d.stopped[stoppedName] = stopped | |
| 561 | 678 | } | |
| 562 | 679 | 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 membership | 77 | mu sync.Mutex // guards tree, panes, nextPaneID, size, previewClient, and pane map membership | ||
| 77 | 78 | ||||
| 78 | stableID string | 79 | stableID string | ||
| 80 | + | name string | |||
| 79 | tree *layout.Tree | 81 | tree *layout.Tree | ||
| 80 | panes map[layout.PaneID]*pane | 82 | panes map[layout.PaneID]*pane | ||
| 81 | nextPaneID int | 83 | 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, err | 136 | + | 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, err | 191 | 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, nil | 204 | 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) - 1 | 312 | sess.active = len(sess.tabs) - 1 | ||
| 285 | tabIndex := sess.active | 313 | 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 err | 489 | return err | ||
| 443 | } | 490 | } | ||
| @@ -470,7 +517,66 @@ func (d *Daemon) renameSession(sess *session, name string) error { | |||||
| 470 | return nil | 517 | 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 := -1 | 581 | 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 | return | 591 | return | ||
| 485 | } | 592 | } | ||
| 486 | if len(sess.tabs) == 1 { | 593 | if len(sess.tabs) == 1 { | ||
| 487 | name := sess.name | 594 | 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 | return | 599 | return | ||
| @@ -499,7 +607,15 @@ func (d *Daemon) closeTab(sess *session, tb *tab, repaint bool) { | |||||
| 499 | } | 607 | } | ||
| 500 | ac := sess.client | 608 | ac := sess.client | ||
| 501 | name := sess.name | 609 | 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.name | 669 | stoppedName := sess.name | ||
| 554 | stoppedCwd := sess.cwd | 670 | stoppedCwd := sess.cwd | ||
| 555 | createdAt := sess.createdAt | 671 | createdAt := sess.createdAt | ||
| 672 | + | tabNames := sess.persistRecordLocked(time.Now().UnixNano()).TabNames | |||
| 556 | ephemeral := sess.ephemeral | 673 | 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] = stopped | 677 | d.stopped[stoppedName] = stopped | ||
| 561 | } | 678 | } | ||
| 562 | empty := len(d.sessions) == 0 | 679 | empty := len(d.sessions) == 0 | ||
M internal/usecase/daemon/session_test.go +114 -4
| @@ -799,24 +799,134 @@ func TestCreateSessionSeedsMRUFromStopped(t *testing.T) { | |||
| 799 | 799 | ||
| 800 | 800 | func TestCreateRenameKillPersistenceLifecycle(t *testing.T) { | |
| 801 | 801 | 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() | |
| 804 | 806 | store, state := newMockStore(t) | |
| 805 | - | d := newTestDaemon(t, newFactory(t, p), stubClock{}) | |
| 807 | + | d := newTestDaemon(t, newFactorySeq(t, p1, p2), stubClock{}) | |
| 806 | 808 | WithStore(store)(d) | |
| 807 | 809 | ||
| 808 | 810 | sess, err := d.createSessionLocked("work", false, "/tmp/work", sz, terminalEnv{}) | |
| 809 | 811 | require.NoError(t, err) | |
| 810 | 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 | 817 | require.NoError(t, d.renameSession(sess, "renamed")) | |
| 813 | 818 | require.False(t, state.has("work")) | |
| 814 | 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 | 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 | 930 | func TestEphemeralRenamePromotesAndStoppedCollisionRejected(t *testing.T) { | |
| 821 | 931 | sz := domain.Size{Cols: 80, Rows: 24} | |
| 822 | 932 | 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 ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | - | "github.com/bnema/vev/internal/persist" | |
| 11 | 10 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 12 | 11 | "github.com/bnema/vev/internal/usecase/layout" | |
| 13 | 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 | 77 | cancel() | |
| 79 | 78 | } | |
| 80 | 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 | 84 | if err := ctx.Err(); err != nil { | |
| 83 | 85 | closeOpened() | |
| 84 | 86 | return err | |
| @@ -107,6 +109,9 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 107 | 109 | } | |
| 108 | 110 | } | |
| 109 | 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 | 115 | if tb.nextPaneID <= 0 { | |
| 111 | 116 | tb.nextPaneID = 1 | |
| 112 | 117 | } | |
| @@ -171,8 +176,8 @@ func (d *Daemon) restoreSession(ctx context.Context, snap snapcodec.Session) err | |||
| 171 | 176 | sess.mruAt.Store(stopped.lastUsedSeq) | |
| 172 | 177 | } | |
| 173 | 178 | 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 { | |
| 176 | 181 | closeOpened() | |
| 177 | 182 | return err | |
| 178 | 183 | } | |
| @@ -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 err | 86 | 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 = 1 | 116 | 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 err | 182 | return err | ||
| 178 | } | 183 | } | ||
M internal/usecase/daemon/snapshot_restore_test.go +5 -0
| @@ -44,7 +44,10 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 44 | 44 | })}} | |
| 45 | 45 | factory := &restorePTYFactory{} | |
| 46 | 46 | d := newTestDaemon(t, factory, stubClock{}) | |
| 47 | + | persistStore, persistState := newMockStore(t) | |
| 48 | + | WithStore(persistStore)(d) | |
| 47 | 49 | WithSnapshotStore(store)(d) | |
| 50 | + | d.stopped["work"] = stoppedSession{name: "work", createdAt: 99, tabNames: []string{"logs"}} | |
| 48 | 51 | ||
| 49 | 52 | d.restoreSnapshots(context.Background()) | |
| 50 | 53 | ||
| @@ -66,6 +69,8 @@ func TestRestoreSnapshotsRestoresLayoutCwdAndRows(t *testing.T) { | |||
| 66 | 69 | require.Len(t, restored.tabs, 1) | |
| 67 | 70 | tb := restored.tabs[0] | |
| 68 | 71 | restored.mu.Unlock() | |
| 72 | + | require.Equal(t, "logs", tb.name) | |
| 73 | + | require.Equal(t, []string{"logs"}, persistState.record(t, "work").TabNames) | |
| 69 | 74 | tb.mu.Lock() | |
| 70 | 75 | require.Equal(t, layout.PaneID("pane-2"), tb.tree.Focus) | |
| 71 | 76 | 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 { | |||
| 204 | 204 | } | |
| 205 | 205 | snap := statusSnapshot{session: name, tabs: make([]statusTab, len(s.tabs))} | |
| 206 | 206 | for i, tb := range s.tabs { | |
| 207 | - | name := strconv.Itoa(i + 1) | |
| 207 | + | name := tabDisplayName(tb, i) | |
| 208 | 208 | active := i == s.active | |
| 209 | 209 | attention := tb.attention && (!active || tb.attentionVisiblePaint) | |
| 210 | 210 | snap.tabs[i] = statusTab{name: name, active: active, attention: attention} | |
| @@ -212,6 +212,13 @@ func (s *session) statusSegments() statusSnapshot { | |||
| 212 | 212 | 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 | 222 | func (d *Daemon) barStateForClient(cur *session, ac *attachedClient, copyFeedback string) barState { | |
| 216 | 223 | state := d.barStateFor(cur, copyFeedback) | |
| 217 | 224 | 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.active | 208 | 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 snap | 212 | 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) { | |||
| 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 | 117 | func TestStatusCompositionUsesTruecolorTheme(t *testing.T) { | |
| 101 | 118 | p, release := newBlockingPTY(t) | |
| 102 | 119 | _, 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 { | |||
| 29 | 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 | 43 | func (f Frame) Validate() error { | |
| 33 | 44 | if f.Width <= 0 || f.Height <= 0 { | |
| 34 | 45 | 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 { | |||
| 219 | 219 | } | |
| 220 | 220 | ||
| 221 | 221 | // 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 | 223 | func (r *Renderer) Ack(frame Frame) error { | |
| 224 | 224 | if err := frame.Validate(); err != nil { | |
| 225 | 225 | 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 the | 221 | // 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 err | 225 | return err | ||