detached 33a165d

Merge remote-tracking branch 'origin/main' into feature/attach-environment

commit
33a165db4a3b8ca958f0cbe5f591626c615ace03
author
brice · · 2026-07-15 05:43 UTC
merge of
451ff75 + f8d0bc7 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
91 files · +13792 -13077
M .mockery.yml +1 -1
@@ -26,7 +26,7 @@ packages:
2626 config:
2727 all: true
2828 dir: internal/ports/mocks
29- filename: mocks.go
29+ filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
3030 pkgname: portsmocks
3131 github.com/bnema/vev/internal/usecase/command:
3232 interfaces:
M AGENTS.md +2 -1
@@ -16,7 +16,8 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemo
1616 ```
1717
1818 - Format with **goimports**.
19-- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v2@latest`.
19+- Install dev tools once with `go install golang.org/x/tools/cmd/goimports@latest` and `go install github.com/vektra/mockery/v3@v3.7.1`.
20+- Use Mockery v3 consistently across Go projects.
2021 - `make test` runs `go test ./... -race`.
2122 - `make lint` checks `goimports -l`, then `go vet`.
2223 - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.
M Makefile +5 -0
@@ -15,4 +15,9 @@ lint:
1515 go vet ./...
1616
1717 mocks:
18+ @version="$$(mockery version 2>/dev/null || true)"; \
19+ if [ "$$version" != "v3.7.1" ]; then \
20+ echo "mockery v3.7.1 required; install with: go install github.com/vektra/mockery/v3@v3.7.1" >&2; \
21+ exit 1; \
22+ fi
1823 mockery
A internal/adapters/dgram/ack_rebind_test.go +143 -0
@@ -0,0 +1,143 @@
1+package dgram
2+
3+import (
4+ "net"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+// gatedControlPC stops the first control write after sendControl has selected
13+// its socket and installed its deadline. Close is separately gated so the test
14+// can observe whether a hop attempts to retire that socket too early.
15+type gatedControlPC struct {
16+ *fakePC
17+ entered chan struct{}
18+ releaseWrite chan struct{}
19+ closeStarted chan struct{}
20+ releaseClose chan struct{}
21+ gateWrite sync.Once
22+ gateClose sync.Once
23+}
24+
25+func (p *gatedControlPC) SetWriteDeadline(time.Time) error {
26+ p.gateWrite.Do(func() {
27+ close(p.entered)
28+ <-p.releaseWrite
29+ })
30+ return nil
31+}
32+
33+func (p *gatedControlPC) Close() error {
34+ p.gateClose.Do(func() {
35+ close(p.closeStarted)
36+ <-p.releaseClose
37+ })
38+ return p.fakePC.Close()
39+}
40+
41+func TestACKWriteCompletesBeforeRebindRetiresPacketConn(t *testing.T) {
42+ old, bPC := newPair()
43+ gated := &gatedControlPC{
44+ fakePC: old,
45+ entered: make(chan struct{}),
46+ releaseWrite: make(chan struct{}),
47+ closeStarted: make(chan struct{}),
48+ releaseClose: make(chan struct{}),
49+ }
50+ var releaseWrite, releaseClose sync.Once
51+ release := func() {
52+ releaseWrite.Do(func() { close(gated.releaseWrite) })
53+ releaseClose.Do(func() { close(gated.releaseClose) })
54+ }
55+
56+ var fresh *fakePC
57+ a, err := NewTransportWithOptions(gated, bPC.addr, key(), 1, 2, Options{
58+ ResendAfter: time.Hour,
59+ Heartbeat: time.Hour,
60+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
61+ fresh = &fakePC{
62+ addr: testAddr("a2"),
63+ in: make(chan packet, 10),
64+ read: make(chan struct{}, 10),
65+ peers: map[string]*fakePC{"b": bPC},
66+ }
67+ bPC.peers["a2"] = fresh
68+ return fresh, nil
69+ },
70+ })
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ b, err := NewTransportWithOptions(bPC, old.addr, key(), 2, 1, Options{
75+ ResendAfter: time.Hour,
76+ Heartbeat: time.Hour,
77+ })
78+ if err != nil {
79+ release()
80+ _ = a.Close()
81+ t.Fatal(err)
82+ }
83+ defer func() {
84+ release()
85+ _ = a.Close()
86+ _ = b.Close()
87+ }()
88+
89+ // A decoded ACK clears this pending record, proving delivery to the peer,
90+ // rather than merely observing a write on the old PacketConn.
91+ b.mu.Lock()
92+ b.pending[7] = &pending{}
93+ processed := make(chan struct{}, 1)
94+ b.afterPacketProcessed = func() { processed <- struct{}{} }
95+ b.mu.Unlock()
96+
97+ a.mu.Lock()
98+ a.linkState = ports.LinkStateProbing
99+ a.health.generation = 1
100+ a.mu.Unlock()
101+ controlDone := make(chan error, 1)
102+ go func() { controlDone <- a.sendControl(recAck, 7) }()
103+ awaitSignal(t, gated.entered, "ACK control write deadline")
104+
105+ hopped := make(chan struct{})
106+ go func() {
107+ a.hopPacketConnOnce(1)
108+ close(hopped)
109+ }()
110+
111+ // A hop must wait for the control write, even though this ACK does not wait
112+ // for data's writeMu. Before the write is released it must not retire old.
113+ select {
114+ case <-gated.closeStarted:
115+ t.Fatal("rebind began closing packet conn during control write")
116+ case <-hopped:
117+ t.Fatal("rebind completed during control write")
118+ case <-time.After(25 * time.Millisecond):
119+ }
120+
121+ releaseWrite.Do(func() { close(gated.releaseWrite) })
122+ if err := awaitResult(t, controlDone, "ACK control write"); err != nil {
123+ t.Fatalf("ACK control write: %v", err)
124+ }
125+ awaitSignal(t, processed, "decoded ACK delivery")
126+ b.mu.Lock()
127+ _, pending := b.pending[7]
128+ b.mu.Unlock()
129+ if pending {
130+ t.Fatal("decoded ACK did not clear peer pending record")
131+ }
132+
133+ awaitSignal(t, gated.closeStarted, "rebind retires old packet conn")
134+ releaseClose.Do(func() { close(gated.releaseClose) })
135+ select {
136+ case <-hopped:
137+ case <-time.After(time.Second):
138+ t.Fatal("rebind did not complete after control write")
139+ }
140+ if fresh == nil {
141+ t.Fatal("rebind did not install fresh packet conn")
142+ }
143+}
A internal/adapters/dgram/congestion_test.go +61 -0
@@ -0,0 +1,61 @@
1+package dgram
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestCongestionControllerBoundsAndIntegerGrowth(t *testing.T) {
9+ const mtu = 1200
10+ c := newCongestionController(mtu)
11+ if got, want := c.cwndBytes, initialCongestionPackets*mtu; got != want {
12+ t.Fatalf("initial cwnd=%d, want %d", got, want)
13+ }
14+ if got, want := c.burstBytes(), dataBurstPackets*mtu; got != want {
15+ t.Fatalf("initial burst=%d, want %d", got, want)
16+ }
17+ if got, want := c.bytesPerSecond(), initialCongestionPackets*mtu*int(time.Second/initialPacingRTT); got != want {
18+ t.Fatalf("initial pacing rate=%d, want %d", got, want)
19+ }
20+
21+ c.onLoss()
22+ if got, want := c.cwndBytes, initialCongestionPackets*mtu/2; got != want {
23+ t.Fatalf("cwnd after first loss=%d, want %d", got, want)
24+ }
25+ c.cwndBytes = 3 * mtu
26+ c.onLoss()
27+ if got, want := c.cwndBytes, minimumCongestionPackets*mtu; got != want {
28+ t.Fatalf("minimum cwnd=%d, want %d", got, want)
29+ }
30+
31+ c.cwndBytes = 10 * mtu
32+ c.onACK(1)
33+ if got, want := c.cwndBytes, 10*mtu+1; got != want {
34+ t.Fatalf("minimum additive increase cwnd=%d, want %d", got, want)
35+ }
36+ c.onACK(10 * mtu)
37+ if got, want := c.cwndBytes, 10*mtu+1+(mtu*10*mtu)/(10*mtu+1); got != want {
38+ t.Fatalf("integer additive increase cwnd=%d, want %d", got, want)
39+ }
40+ c.cwndBytes = maximumCongestionBytes
41+ c.onACK(maximumCongestionBytes)
42+ if got := c.cwndBytes; got != maximumCongestionBytes {
43+ t.Fatalf("maximum cwnd=%d, want %d", got, maximumCongestionBytes)
44+ }
45+
46+ c.onRTT(500 * time.Microsecond)
47+ if got, want := c.bytesPerSecond(), maximumCongestionBytes*1000; got != want {
48+ t.Fatalf("sub-millisecond pacing rate=%d, want %d", got, want)
49+ }
50+}
51+
52+func BenchmarkCongestionControllerACK(b *testing.B) {
53+ c := newCongestionController(defaultMTU)
54+ b.ReportAllocs()
55+ for b.Loop() {
56+ c.onACK(defaultMTU)
57+ if c.cwndBytes == c.maxBytes {
58+ c = newCongestionController(defaultMTU)
59+ }
60+ }
61+}
M internal/adapters/dgram/control.go +10 -0
@@ -176,6 +176,12 @@ func (t *Transport) ackSendLoop() {
176176 }
177177
178178 func (t *Transport) sendControl(kind byte, id uint64) error {
179+ // Do not take writeMu: control must remain available while data writes are
180+ // blocked. The read lock instead keeps the selected connection alive until
181+ // every control fragment has been written.
182+ t.controlConnMu.RLock()
183+ defer t.controlConnMu.RUnlock()
184+
179185 var payload [9]byte
180186 payload[0] = kind
181187 binary.BigEndian.PutUint64(payload[1:], id)
@@ -214,3 +220,7 @@ func (t *Transport) sendControl(kind byte, id uint64) error {
214220 }
215221 return nil
216222 }
223+
224+func (t *Transport) sendAck(seq uint64) {
225+ _ = t.sendControl(recAck, seq)
226+}
M internal/adapters/dgram/control_plane_test.go +38 -0
@@ -208,6 +208,44 @@ func TestACKDeadlineDispatchesWhileAdvisoryWriteIsBlocked(t *testing.T) {
208208 awaitSignal(t, ackStarted, "ACK write while advisory probe remains blocked")
209209 }
210210
211+func TestACKBypassesDataWriterAndRemainsDecodable(t *testing.T) {
212+ aPC, bPC := newPair()
213+ a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
214+ ResendAfter: time.Hour, Heartbeat: time.Hour,
215+ })
216+ if err != nil {
217+ t.Fatal(err)
218+ }
219+ defer func() { _ = a.Close() }()
220+
221+ acks := make(chan uint64, 1)
222+ aPC.drop = func(pkt []byte, _ net.Addr) bool {
223+ _, raw, err := a.codec.Open(pkt, a.sendDir, nil, nil)
224+ if err != nil {
225+ t.Errorf("open ACK: %v", err)
226+ return true
227+ }
228+ frag, err := pdgram.UnmarshalFragment(raw)
229+ if err != nil {
230+ t.Errorf("unmarshal ACK: %v", err)
231+ return true
232+ }
233+ if frag.Count != 1 || len(frag.Data) != 9 || frag.Data[0] != recAck {
234+ t.Errorf("control record=%x, want encoded ACK", frag.Data)
235+ return true
236+ }
237+ acks <- binary.BigEndian.Uint64(frag.Data[1:])
238+ return true
239+ }
240+
241+ a.writeMu.Lock()
242+ defer a.writeMu.Unlock()
243+ go a.sendAck(42)
244+ if seq := awaitResult(t, acks, "ACK while data writer is blocked"); seq != 42 {
245+ t.Fatalf("ACK sequence=%d, want 42", seq)
246+ }
247+}
248+
211249 func TestProbeDoesNotBlockOnDataWriter(t *testing.T) {
212250 aPC, bPC := newPair()
213251 a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{
A internal/adapters/dgram/pacer_test.go +98 -0
@@ -0,0 +1,98 @@
1+package dgram
2+
3+import (
4+ "errors"
5+ "testing"
6+ "time"
7+)
8+
9+func TestBytePacerUsesBurstThenFakeClockRate(t *testing.T) {
10+ const mtu = 1200
11+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
12+ c := newCongestionController(mtu)
13+ p := bytePacer{clk: clk}
14+ done := make(chan struct{})
15+ limits := func() (int, int) { return c.bytesPerSecond(), c.burstBytes() }
16+
17+ for range dataBurstPackets {
18+ if err := p.wait(done, mtu, limits); err != nil {
19+ t.Fatal(err)
20+ }
21+ }
22+ wait := make(chan error, 1)
23+ go func() { wait <- p.wait(done, mtu, limits) }()
24+ awaitSignal(t, clk.timerCreated, "byte pacer timer")
25+ clk.advance(24 * time.Millisecond)
26+ select {
27+ case err := <-wait:
28+ t.Fatalf("third MTU released early: %v", err)
29+ default:
30+ }
31+ clk.advance(time.Millisecond)
32+ if err := awaitResult(t, wait, "third paced MTU"); err != nil {
33+ t.Fatal(err)
34+ }
35+
36+ c.onLoss()
37+ next := make(chan error, 1)
38+ go func() { next <- p.wait(done, mtu, limits) }()
39+ awaitSignal(t, clk.timerCreated, "reduced-cwnd byte pacer timer")
40+ clk.advance(49 * time.Millisecond)
41+ select {
42+ case err := <-next:
43+ t.Fatalf("reduced cwnd did not lengthen wait: %v", err)
44+ default:
45+ }
46+ clk.advance(time.Millisecond)
47+ if err := awaitResult(t, next, "reduced-cwnd paced MTU"); err != nil {
48+ t.Fatal(err)
49+ }
50+}
51+
52+func TestBytePacerMaximumRateRefillsAfterMultiSecondFakeClockJump(t *testing.T) {
53+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
54+ p := bytePacer{clk: clk}
55+ done := make(chan struct{})
56+ burst := int(^uint(0) >> 1)
57+ limits := func() (int, int) { return burst, burst }
58+
59+ if err := p.wait(done, burst, limits); err != nil {
60+ t.Fatal(err)
61+ }
62+ clk.advance(3 * time.Second)
63+ if err := p.wait(done, burst, limits); err != nil {
64+ t.Fatal(err)
65+ }
66+
67+ wait := make(chan error, 1)
68+ go func() { wait <- p.wait(done, burst, limits) }()
69+ awaitSignal(t, clk.timerCreated, "maximum-rate byte pacer timer")
70+ clk.advance(time.Second - time.Nanosecond)
71+ select {
72+ case err := <-wait:
73+ t.Fatalf("maximum-rate burst released early: %v", err)
74+ default:
75+ }
76+ clk.advance(time.Nanosecond)
77+ if err := awaitResult(t, wait, "maximum-rate burst"); err != nil {
78+ t.Fatal(err)
79+ }
80+}
81+
82+func TestBytePacerCancellation(t *testing.T) {
83+ const mtu = 1200
84+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
85+ p := bytePacer{clk: clk}
86+ done := make(chan struct{})
87+ limits := func() (int, int) { return mtu, mtu }
88+ if err := p.wait(done, mtu, limits); err != nil {
89+ t.Fatal(err)
90+ }
91+ wait := make(chan error, 1)
92+ go func() { wait <- p.wait(done, mtu, limits) }()
93+ awaitSignal(t, clk.timerCreated, "cancelled byte pacer timer")
94+ close(done)
95+ if err := awaitResult(t, wait, "byte pacer cancellation"); !errors.Is(err, errPacerClosed) {
96+ t.Fatalf("cancellation error=%v, want %v", err, errPacerClosed)
97+ }
98+}
M internal/adapters/dgram/transport.go +6 -1288
@@ -3,10 +3,8 @@ package dgram
33
44 import (
55 "context"
6- "encoding/binary"
76 "errors"
87 "net"
9- "slices"
108 "sync"
119 "time"
1210
@@ -226,7 +224,12 @@ type Transport struct {
226224 in chan ports.Frame
227225 done chan struct{}
228226
229- writeMu sync.Mutex
227+ // Connection replacement takes writeMu, controlConnMu, then mu. Data writes
228+ // take only writeMu; control writes take controlConnMu's read lock, then mu.
229+ // This keeps control independent of data writes while preventing a rebind
230+ // from retiring its PacketConn during a control write.
231+ writeMu sync.Mutex
232+ controlConnMu sync.RWMutex
230233 // beforeDataPace is a test synchronization hook. It is read under mu.
231234 beforeDataPace func()
232235 // afterDataPaceWait observes a data pacer's next manual-clock deadline.
@@ -324,319 +327,6 @@ func (s *writeDeadlineState) expire(now time.Time) {
324327 s.mu.Unlock()
325328 }
326329
327-func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, runtimeOpts ...RuntimeOption) (*Transport, error) {
328- return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}, runtimeOpts...)
329-}
330-
331-func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options, runtimeOpts ...RuntimeOption) (*Transport, error) {
332- c, err := pdgram.NewCodec(key)
333- if err != nil {
334- return nil, err
335- }
336- opts = normalizeOptions(opts)
337- t := &Transport{
338- pc: pc,
339- codec: c,
340- sendDir: sendDir,
341- recvDir: recvDir,
342- mtu: opts.MTU,
343- peer: peer,
344- pending: make(map[uint64]*pending),
345- replay: pdgram.NewReplayWindow(),
346- reasm: pdgram.NewReassembler(),
347- in: make(chan ports.Frame, 32),
348- done: make(chan struct{}),
349- health: newHealthTracker(opts.Clock.Now()),
350- lastAuthenticatedPacket: opts.Clock.Now(),
351- lastCompleteRecord: opts.Clock.Now(),
352- lastACKProgress: opts.Clock.Now(),
353- observe: opts.Observe,
354- heartbeat: opts.Heartbeat,
355- resendAfter: opts.ResendAfter,
356- maxResendAfter: opts.MaxResendAfter,
357- maxResendPerTick: opts.MaxResendPerTick,
358- rto: opts.ResendAfter,
359- writeTimeout: opts.WriteTimeout,
360- degradedAfter: opts.DegradedAfter,
361- probeAfter: opts.ProbeAfter,
362- offlineAfter: opts.OfflineAfter,
363- deadAfter: opts.DeadAfter,
364- maxPending: opts.MaxPending,
365- maxPendingWait: opts.MaxPendingWait,
366- maxRecvBuffer: opts.MaxRecvBuffer,
367- clock: opts.Clock,
368- congestion: newCongestionController(opts.MTU),
369- linkState: ports.LinkStateConnected,
370- linkEvents: make(chan ports.LinkEvent, linkEventBufferSize),
371- probeWait: make(map[uint64]chan struct{}),
372- rebind: opts.RebindPacketConn,
373- nextRecvSeq: 1,
374- recvBuf: make(map[uint64]ports.Frame),
375- }
376- for _, opt := range runtimeOpts {
377- if opt != nil {
378- opt(t)
379- }
380- }
381- t.sendWake = make(chan struct{})
382- t.control = make(chan controlRecord, controlQueueSize)
383- t.ackWake = make(chan struct{}, 1)
384- t.ackSend = make(chan uint64, 1)
385- t.outputWake = make(chan struct{})
386- t.dataSend = make(chan dataSendJob, opts.MaxPending)
387- t.writeDeadlines = newWriteDeadlineState(pc)
388- // One queued batch bounds retransmit work while its sole sender is paced.
389- t.retransmitWork = make(chan []retransmitRecord, 1)
390- if t.observe != nil {
391- t.diagnosticCh = make(chan Diagnostic, diagnosticBufferSize)
392- go t.diagnosticLoop()
393- }
394- t.deliverCond = sync.NewCond(&t.deliverMu)
395- go t.readLoop(pc)
396- go t.retransmitLoop()
397- go t.heartbeatLoop()
398- go t.healthLoop()
399- go t.dataSendLoop()
400- go t.deliveryLoop()
401- go t.controlLoop()
402- go t.ackSendLoop()
403- go t.outputPaceLoop()
404- t.emitDiagnostic()
405- return t, nil
406-}
407-
408-func (t *Transport) Send(f ports.Frame) error {
409- end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
410- err := t.send(f, false)
411- end(err == nil)
412- return err
413-}
414-
415-func (t *Transport) SendAsync(f ports.Frame) error {
416- end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
417- err := t.send(f, true)
418- end(err == nil)
419- return err
420-}
421-
422-// SendSynchronous owns queue reservation, fragment pacing, per-write deadlines,
423-// and close cancellation. Callers must not layer a shorter preflight timer over it.
424-func (t *Transport) SendSynchronous(f ports.Frame) error { return t.Send(f) }
425-
426-func (t *Transport) send(f ports.Frame, async bool) error {
427- reliable := true
428- if err := t.lockOutboundSlot(reliable); err != nil {
429- return err
430- }
431- unlockOutbound := true
432- defer func() {
433- if unlockOutbound {
434- t.outboundMu.Unlock()
435- }
436- }()
437- // lockOutboundSlot returns with both outboundMu and mu held.
438-
439- t.seq++
440- seq := t.seq
441- if reliable {
442- now := t.clock.Now()
443- if len(t.pending) == 0 {
444- t.health.pendingStarted(now)
445- }
446- t.pending[seq] = &pending{frame: f, enqueued: now}
447- }
448- var done chan error
449- if !async {
450- done = make(chan error, 1)
451- }
452- job := dataSendJob{seq: seq, reliable: reliable, frame: f, done: done}
453- if shouldPaceOutput(f) || len(t.outputQueue) > 0 {
454- wasEmpty := len(t.outputQueue) == 0
455- t.outputQueue = append(t.outputQueue, queuedSend(job))
456- if wasEmpty {
457- t.notifyOutputPacerLocked()
458- }
459- t.mu.Unlock()
460- t.outboundMu.Unlock()
461- unlockOutbound = false
462- if async {
463- return nil
464- }
465- return t.waitQueuedSend(done)
466- }
467- t.mu.Unlock()
468- if err := t.queueDataJob(job); err != nil {
469- t.removePending(seq, reliable)
470- return err
471- }
472- t.outboundMu.Unlock()
473- unlockOutbound = false
474- if async {
475- return nil
476- }
477- return t.waitQueuedSend(done)
478-}
479-
480-func (t *Transport) queueDataJob(job dataSendJob) error {
481- select {
482- case t.dataSend <- job:
483- return nil
484- case <-t.done:
485- return t.closedError()
486- }
487-}
488-
489-func (t *Transport) waitQueuedSend(done <-chan error) error {
490- select {
491- case err := <-done:
492- return err
493- case <-t.done:
494- t.mu.Lock()
495- err := t.closeErr
496- t.mu.Unlock()
497- if err != nil {
498- return err
499- }
500- return errors.New("dgram: closed")
501- }
502-}
503-
504-func (t *Transport) closedError() error {
505- t.mu.Lock()
506- defer t.mu.Unlock()
507- if t.closeErr != nil {
508- return t.closeErr
509- }
510- return errors.New("dgram: closed")
511-}
512-
513-func (t *Transport) lockOutboundSlot(reliable bool) error {
514- var pendingWaitStarted time.Time
515- for {
516- t.outboundMu.Lock()
517- t.mu.Lock()
518- if t.closed {
519- err := t.closeErr
520- t.mu.Unlock()
521- t.outboundMu.Unlock()
522- if err != nil {
523- return err
524- }
525- return errors.New("dgram: closed")
526- }
527- if !reliable || len(t.pending) < t.maxPending {
528- return nil
529- }
530- if t.linkState != ports.LinkStateConnected {
531- t.mu.Unlock()
532- t.outboundMu.Unlock()
533- return ErrPendingFull
534- }
535- if pendingWaitStarted.IsZero() {
536- pendingWaitStarted = t.clock.Now()
537- }
538- remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
539- if remaining <= 0 {
540- t.mu.Unlock()
541- t.outboundMu.Unlock()
542- return ErrPendingFull
543- }
544- wake := t.sendWake
545- timer := t.clock.NewTimer(remaining)
546- t.mu.Unlock()
547- t.outboundMu.Unlock()
548- select {
549- case <-timer.C():
550- case <-wake:
551- case <-t.done:
552- }
553- timer.Stop()
554- }
555-}
556-
557-func (t *Transport) removePending(seq uint64, reliable bool) {
558- if !reliable {
559- return
560- }
561- t.mu.Lock()
562- removed := t.removePendingLocked(seq)
563- if removed {
564- t.notifySendWaitersLocked()
565- }
566- t.mu.Unlock()
567-}
568-
569-func (t *Transport) removeQueuedPending(queued []queuedSend, err error) {
570- t.mu.Lock()
571- removed := false
572- for _, q := range queued {
573- if q.reliable {
574- removed = t.removePendingLocked(q.seq) || removed
575- }
576- if q.done != nil {
577- q.done <- err
578- close(q.done)
579- }
580- }
581- if removed {
582- t.notifySendWaitersLocked()
583- }
584- t.mu.Unlock()
585-}
586-
587-func (t *Transport) removePendingLocked(seq uint64) bool {
588- if _, ok := t.pending[seq]; !ok {
589- return false
590- }
591- delete(t.pending, seq)
592- if len(t.pending) == 0 {
593- t.health.pendingCleared()
594- }
595- return true
596-}
597-
598-func (t *Transport) Recv() (ports.Frame, error) {
599- end := t.beginRuntimeOperation(ports.RuntimeAdapterReceiveStart, 0)
600- select {
601- case f := <-t.in:
602- end(true)
603- return f, nil
604- case <-t.done:
605- t.mu.Lock()
606- err := t.closeErr
607- t.mu.Unlock()
608- if err != nil {
609- end(false)
610- return ports.Frame{}, err
611- }
612- err = errors.New("dgram: closed")
613- end(false)
614- return ports.Frame{}, err
615- }
616-}
617-func (t *Transport) Close() error {
618- done := t.beginShutdown()
619- t.closeWithError(errors.New("dgram: closed"))
620- t.mu.Lock()
621- pc := t.pc
622- t.mu.Unlock()
623- err := pc.Close()
624- if done != nil {
625- <-done
626- }
627- return err
628-}
629-
630-func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
631-
632-func (t *Transport) LinkState() ports.LinkState {
633- t.mu.Lock()
634- defer t.mu.Unlock()
635- return t.linkState
636-}
637-
638-func (t *Transport) LinkEvents() <-chan ports.LinkEvent { return t.linkEvents }
639-
640330 // Probe sends an authenticated datagram and waits for the peer to authenticate a
641331 // response or for ctx to expire. It is intended for UDP bootstrap reachability checks.
642332 func (t *Transport) Probe(ctx context.Context) error {
@@ -694,44 +384,6 @@ func (t *Transport) unregisterProbe(id uint64) {
694384 t.mu.Unlock()
695385 }
696386
697-func (t *Transport) sendAck(seq uint64) {
698- var b [9]byte
699- b[0] = recAck
700- binary.BigEndian.PutUint64(b[1:], seq)
701- _ = t.sendPayload(b[:])
702-}
703-func (t *Transport) sendPayload(p []byte) error {
704- frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
705- if err != nil {
706- return err
707- }
708- for _, f := range frags {
709- raw, err := pdgram.MarshalFragment(f)
710- if err != nil {
711- return err
712- }
713- pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
714- t.mu.Lock()
715- peer := t.peer
716- closed := t.closed
717- closeErr := t.closeErr
718- t.mu.Unlock()
719- if closed {
720- if closeErr != nil {
721- return closeErr
722- }
723- return errors.New("dgram: closed")
724- }
725- if peer == nil {
726- return errors.New("dgram: no peer")
727- }
728- if err := t.writePacket(pkt, peer); err != nil {
729- return err
730- }
731- }
732- return nil
733-}
734-
735387 func (t *Transport) pendingExists(seq uint64) bool {
736388 t.mu.Lock()
737389 defer t.mu.Unlock()
@@ -739,939 +391,5 @@ func (t *Transport) pendingExists(seq uint64) bool {
739391 return ok
740392 }
741393
742-func (t *Transport) writePacket(pkt []byte, peer net.Addr) error {
743- t.writeMu.Lock()
744- defer t.writeMu.Unlock()
745-
746- t.mu.Lock()
747- pc := t.pc
748- writeTimeout := t.writeTimeout
749- writeDeadlines := t.writeDeadlines
750- t.mu.Unlock()
751-
752- return t.writeDatagram(pc, peer, pkt, writeDeadlines, writeTimeout)
753-}
754-
755-func (t *Transport) writeDatagram(pc net.PacketConn, peer net.Addr, pkt []byte, deadlines *writeDeadlineState, timeout time.Duration) error {
756- if timeout <= 0 {
757- _, err := pc.WriteTo(pkt, peer)
758- return err
759- }
760- ownDeadline := t.clock.Now().Add(timeout)
761- finish, err := deadlines.begin(ownDeadline)
762- if err != nil {
763- return err
764- }
765- defer finish()
766- for {
767- _, err = pc.WriteTo(pkt, peer)
768- if err == nil {
769- return nil
770- }
771- var timeoutErr interface{ Timeout() bool }
772- now := t.clock.Now()
773- if !errors.As(err, &timeoutErr) || !timeoutErr.Timeout() || !now.Before(ownDeadline) {
774- return err
775- }
776- deadlines.expire(now)
777- }
778-}
779-
780-func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr }
781-
782-func (t *Transport) readLoop(pc net.PacketConn) {
783- buf := make([]byte, 64*1024)
784- for {
785- n, addr, err := pc.ReadFrom(buf)
786- if err != nil {
787- t.mu.Lock()
788- current := t.pc == pc
789- closed := t.closed
790- t.mu.Unlock()
791- if current && !closed {
792- t.closeWithError(err)
793- }
794- return
795- }
796- t.recvMu.Lock()
797- counter, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
798- if err != nil {
799- t.recvMu.Unlock()
800- continue
801- }
802- frag, err := pdgram.UnmarshalFragment(pt)
803- if err != nil {
804- t.recvMu.Unlock()
805- t.notifyMalformedFragment()
806- continue
807- }
808- payload, complete, err := t.reasm.Add(frag)
809- inflight := t.reasm.Inflight()
810- // Keep the diagnostic mirror ordered with reassembly mutations. Rebind can
811- // overlap old and new read loops, so recvMu must remain held through commit.
812- t.mu.Lock()
813- t.reassemblyInflight = inflight
814- var afterAuthenticated func()
815- if err == nil {
816- now := t.clock.Now()
817- t.health.authenticatedPacket(now)
818- t.lastAuthenticatedPacket = now
819- if !t.peerCounterSet || counter > t.peerCounter {
820- t.peer = addr
821- t.peerCounter = counter
822- t.peerCounterSet = true
823- }
824- afterAuthenticated = t.afterAuthenticatedPacket
825- }
826- t.mu.Unlock()
827- t.recvMu.Unlock()
828- if afterAuthenticated != nil {
829- afterAuthenticated()
830- }
831- if err != nil {
832- t.notifyMalformedFragment()
833- t.notifyPacketProcessed()
834- continue
835- }
836- if !complete {
837- t.notifyPacketProcessed()
838- continue
839- }
840- t.recordCompleteRecord()
841- t.handleRecord(payload)
842- t.checkSilence()
843- t.notifyPacketProcessed()
844- }
845-}
846-
847-func (t *Transport) notifyPacketProcessed() {
848- t.mu.Lock()
849- afterPacketProcessed := t.afterPacketProcessed
850- t.mu.Unlock()
851- if afterPacketProcessed != nil {
852- afterPacketProcessed()
853- }
854-}
855-
856-func (t *Transport) notifyMalformedFragment() {
857- t.mu.Lock()
858- afterMalformed := t.afterMalformedFragment
859- t.mu.Unlock()
860- if afterMalformed != nil {
861- afterMalformed()
862- }
863-}
864-
865-func (t *Transport) recordCompleteRecord() {
866- t.mu.Lock()
867- now := t.clock.Now()
868- t.health.completeRecord(now)
869- t.lastCompleteRecord = now
870- t.mu.Unlock()
871-}
872-
873-func (t *Transport) handleRecord(p []byte) {
874- if len(p) < 1 {
875- return
876- }
877- switch p[0] {
878- case recAck:
879- if len(p) != 9 {
880- return
881- }
882- seq := binary.BigEndian.Uint64(p[1:])
883- t.mu.Lock()
884- acked := false
885- ackedBytes := 0
886- for pendingSeq, p := range t.pending {
887- if pendingSeq > seq {
888- continue
889- }
890- if pendingSeq == seq && p != nil && !p.retransmitted {
891- t.updateRTTLocked(t.clock.Now().Sub(p.first))
892- }
893- if p != nil {
894- ackedBytes += p.wireBytes
895- }
896- delete(t.pending, pendingSeq)
897- acked = true
898- }
899- if acked {
900- t.congestion.onACK(ackedBytes)
901- now := t.clock.Now()
902- t.health.ackProgress(now)
903- if len(t.pending) == 0 {
904- t.health.pendingCleared()
905- }
906- t.lastACKProgress = now
907- t.notifySendWaitersLocked()
908- }
909- t.mu.Unlock()
910- if acked {
911- t.setLinkState(ports.LinkStateConnected, nil)
912- t.emitDiagnostic()
913- }
914- case recProbe:
915- if len(p) != 9 {
916- return
917- }
918- t.queueControl(recPong, binary.BigEndian.Uint64(p[1:]))
919- case recPong:
920- if len(p) != 9 {
921- return
922- }
923- id := binary.BigEndian.Uint64(p[1:])
924- t.mu.Lock()
925- ch := t.probeWait[id]
926- if ch != nil {
927- delete(t.probeWait, id)
928- close(ch)
929- }
930- t.mu.Unlock()
931- case recData:
932- seq, reliable, f, ok := decodeData(p)
933- if !ok {
934- return
935- }
936- if reliable {
937- ackSeq, ack, queued := t.enqueueReliable(seq, f)
938- if ack {
939- t.queueACK(ackSeq)
940- }
941- if queued {
942- t.signalDelivery()
943- }
944- return
945- }
946- t.deliver(f)
947- }
948-}
949-
950-func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, ack bool, queued bool) {
951- t.deliverMu.Lock()
952- defer t.deliverMu.Unlock()
953- if seq < t.nextRecvSeq {
954- return t.highestContiguousRecvLocked(), true, false
955- }
956- if _, exists := t.recvBuf[seq]; exists {
957- return t.highestContiguousRecvLocked(), true, false
958- }
959- if len(t.recvBuf) >= t.maxRecvBuffer && seq != t.nextRecvSeq {
960- return 0, false, false
961- }
962- t.recvBuf[seq] = f
963- return t.highestContiguousRecvLocked(), true, true
964-}
965-
966-func (t *Transport) highestContiguousRecvLocked() uint64 {
967- seq := t.nextRecvSeq
968- for {
969- if _, ok := t.recvBuf[seq]; ok {
970- seq++
971- continue
972- }
973- break
974- }
975- return seq - 1
976-}
977-
978-func (t *Transport) signalDelivery() {
979- t.deliverMu.Lock()
980- t.deliverCond.Signal()
981- t.deliverMu.Unlock()
982-}
983-
984-func (t *Transport) deliveryLoop() {
985- for {
986- t.deliverMu.Lock()
987- f, ok := t.recvBuf[t.nextRecvSeq]
988- for !ok {
989- if t.isClosed() {
990- t.deliverMu.Unlock()
991- return
992- }
993- t.deliverCond.Wait()
994- f, ok = t.recvBuf[t.nextRecvSeq]
995- }
996- delete(t.recvBuf, t.nextRecvSeq)
997- t.nextRecvSeq++
998- t.deliverMu.Unlock()
999- t.deliver(f)
1000- }
1001-}
1002-
1003-func (t *Transport) isClosed() bool {
1004- t.mu.Lock()
1005- defer t.mu.Unlock()
1006- return t.closed
1007-}
1008-
1009-func (t *Transport) deliver(f ports.Frame) {
1010- select {
1011- case t.in <- f:
1012- case <-t.done:
1013- }
1014-}
1015-
1016-func (t *Transport) notifySendWaitersLocked() {
1017- if t.sendWake == nil {
1018- return
1019- }
1020- close(t.sendWake)
1021- t.sendWake = make(chan struct{})
1022-}
1023-
1024-func (t *Transport) retransmitLoop() {
1025- resend := t.clock.NewTimer(t.resendAfter)
1026- defer resend.Stop()
1027- for {
1028- select {
1029- case <-resend.C():
1030- t.queueRetransmits()
1031- resend.Reset(t.resendAfter)
1032- t.notifyTimerHook(func() func() { return t.afterRetransmitTimer })
1033- case <-t.done:
1034- return
1035- }
1036- }
1037-}
1038-
1039-// healthLoop owns link-health timing so bulk retransmission cannot delay state
1040-// transitions or socket hopping.
1041-func (t *Transport) healthLoop() {
1042- health := t.clock.NewTimer(t.resendAfter)
1043- defer health.Stop()
1044- for {
1045- select {
1046- case <-health.C():
1047- t.checkSilence()
1048- health.Reset(t.resendAfter)
1049- t.notifyTimerHook(func() func() { return t.afterHealthTimer })
1050- case <-t.done:
1051- return
1052- }
1053- }
1054-}
1055-
1056-func (t *Transport) dataSendLoop() {
1057- pacer := bytePacer{clk: t.clock, afterTimer: func(deadline time.Time) {
1058- t.mu.Lock()
1059- hook := t.afterDataPaceWait
1060- t.mu.Unlock()
1061- if hook != nil {
1062- hook(deadline)
1063- }
1064- }}
1065- for {
1066- select {
1067- case job := <-t.dataSend:
1068- t.runDataSendJob(&pacer, job, true)
1069- case batch := <-t.retransmitWork:
1070- t.runRetransmitBatch(&pacer, batch)
1071- case <-t.done:
1072- return
1073- }
1074- }
1075-}
1076-
1077-func (t *Transport) runDataSendJob(pacer *bytePacer, job dataSendJob, initial bool) {
1078- if initial {
1079- t.markPendingSent(job.seq, job.reliable)
1080- }
1081- _, err := t.writeDataJob(pacer, job, nil)
1082- if err != nil {
1083- if initial {
1084- t.removePending(job.seq, job.reliable)
1085- } else {
1086- t.markPendingReady(job.seq, job.reliable)
1087- }
1088- if !errors.Is(err, errPacerClosed) {
1089- t.closeWithError(err)
1090- }
1091- } else {
1092- t.markPendingReady(job.seq, job.reliable)
1093- }
1094- t.mu.Lock()
1095- afterDataJob := t.afterDataJob
1096- t.mu.Unlock()
1097- if afterDataJob != nil {
1098- afterDataJob()
1099- }
1100- if job.done != nil {
1101- if errors.Is(err, errPacerClosed) {
1102- err = t.closedError()
1103- }
1104- job.done <- err
1105- close(job.done)
1106- }
1107-}
1108-
1109-func (t *Transport) writeDataJob(pacer *bytePacer, job dataSendJob, onFirstWrite func()) (bool, error) {
1110- if job.reliable && !t.pendingExists(job.seq) {
1111- return false, nil
1112- }
1113- payload := encodeData(job.seq, job.reliable, job.frame)
1114- frags, err := pdgram.FragmentPayload(t.nextCounter(), payload, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
1115- if err != nil {
1116- return false, err
1117- }
1118- t.mu.Lock()
1119- beforeDataPace := t.beforeDataPace
1120- t.mu.Unlock()
1121- if beforeDataPace != nil {
1122- beforeDataPace()
1123- }
1124- limits := func() (int, int) {
1125- t.mu.Lock()
1126- defer t.mu.Unlock()
1127- return t.congestion.bytesPerSecond(), t.congestion.burstBytes()
1128- }
1129- emitted := false
1130- wireBytes := 0
1131- for i, frag := range frags {
1132- if job.reliable && !t.pendingExists(job.seq) {
1133- return emitted, nil
1134- }
1135- raw, err := pdgram.MarshalFragment(frag)
1136- if err != nil {
1137- return emitted, err
1138- }
1139- pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
1140- wireBytes += len(pkt)
1141- if job.reliable {
1142- t.mu.Lock()
1143- if pending := t.pending[job.seq]; pending != nil && pending.wireBytes < wireBytes {
1144- pending.wireBytes = wireBytes
1145- }
1146- t.mu.Unlock()
1147- }
1148- if err := pacer.wait(t.done, len(pkt), limits); err != nil {
1149- return emitted, err
1150- }
1151- if job.reliable && !t.pendingExists(job.seq) {
1152- return emitted, nil
1153- }
1154- t.mu.Lock()
1155- peer := t.peer
1156- closed := t.closed
1157- closeErr := t.closeErr
1158- t.mu.Unlock()
1159- if closed {
1160- if closeErr != nil {
1161- return emitted, closeErr
1162- }
1163- return emitted, net.ErrClosed
1164- }
1165- if peer == nil {
1166- return emitted, errors.New("dgram: no peer")
1167- }
1168- if i == len(frags)-1 {
1169- t.markPendingFinalWrite(job.seq)
1170- }
1171- if err := t.writePacket(pkt, peer); err != nil {
1172- return emitted, err
1173- }
1174- if !emitted {
1175- emitted = true
1176- if onFirstWrite != nil {
1177- onFirstWrite()
1178- }
1179- }
1180- }
1181- return emitted, nil
1182-}
1183-
1184-func (t *Transport) runRetransmitBatch(pacer *bytePacer, batch []retransmitRecord) {
1185- defer func() {
1186- t.mu.Lock()
1187- hook := t.afterDataJob
1188- t.mu.Unlock()
1189- if hook != nil {
1190- hook()
1191- }
1192- }()
1193- lossApplied := false
1194- onFirstWrite := func() {
1195- if lossApplied {
1196- return
1197- }
1198- t.mu.Lock()
1199- t.congestion.onLoss()
1200- t.mu.Unlock()
1201- lossApplied = true
1202- }
1203- for _, r := range batch {
1204- job := dataSendJob{seq: r.seq, reliable: true, frame: r.f}
1205- _, err := t.writeDataJob(pacer, job, onFirstWrite)
1206- if err == nil {
1207- t.markPendingReady(r.seq, true)
1208- continue
1209- }
1210- t.markPendingReady(r.seq, true)
1211- return
1212- }
1213-}
1214-
1215-func (t *Transport) heartbeatLoop() {
1216- heartbeat := t.clock.NewTimer(t.heartbeat)
1217- defer heartbeat.Stop()
1218- for {
1219- select {
1220- case <-heartbeat.C():
1221- t.queueControl(recProbe, 0)
1222- heartbeat.Reset(t.heartbeat)
1223- t.notifyTimerHook(func() func() { return t.afterHeartbeatTimer })
1224- case <-t.done:
1225- return
1226- }
1227- }
1228-}
1229-
1230-func (t *Transport) notifyTimerHook(get func() func()) {
1231- t.mu.Lock()
1232- hook := get()
1233- t.mu.Unlock()
1234- if hook != nil {
1235- hook()
1236- }
1237-}
1238-
1239-func (t *Transport) queueRetransmits() {
1240- // Do not mark records in-flight while the one-slot sender queue is full.
1241- // There is one producer and the sender only consumes, so capacity cannot be
1242- // lost after this check.
1243- if len(t.retransmitWork) == cap(t.retransmitWork) {
1244- return
1245- }
1246-
1247- now := t.clock.Now()
1248- t.mu.Lock()
1249- resend, emitDiagnostic := t.selectRetransmitsLocked(now)
1250- t.mu.Unlock()
1251-
1252- if len(resend) > 0 {
1253- select {
1254- case t.retransmitWork <- resend:
1255- case <-t.done:
1256- return
1257- }
1258- }
1259- if emitDiagnostic {
1260- t.emitDiagnostic()
1261- }
1262-}
1263-
1264-// selectRetransmitsLocked deterministically reserves one bounded retransmit
1265-// turn. The caller must hold t.mu and performs all pacing and writes after
1266-// releasing it.
1267-func (t *Transport) selectRetransmitsLocked(now time.Time) ([]retransmitRecord, bool) {
1268- resend := make([]retransmitRecord, 0, t.maxResendPerTick)
1269- seqs := make([]uint64, 0, len(t.pending))
1270- for seq := range t.pending {
1271- seqs = append(seqs, seq)
1272- }
1273- slices.Sort(seqs)
1274- limit := t.maxResendPerTick
1275- emitDiagnostic := false
1276- for _, seq := range seqs {
1277- if limit <= 0 {
1278- break
1279- }
1280- p := t.pending[seq]
1281- if p == nil || p.last.IsZero() || p.initialInFlight || now.Sub(p.last) < t.resendDelayLocked(p) {
1282- continue
1283- }
1284- p.initialInFlight = true
1285- p.attempts++
1286- p.retransmitted = true
1287- t.retransmits++
1288- emitDiagnostic = emitDiagnostic || t.retransmits%64 == 0
1289- resend = append(resend, retransmitRecord{seq: seq, f: p.frame})
1290- limit--
1291- }
1292- return resend, emitDiagnostic
1293-}
1294-
1295-// resendPending remains a test hook for retransmission selection. Constructed
1296-// transports transfer its batch to the sole data sender; small unit fixtures
1297-// without transport goroutines execute the selected writes inline.
1298-func (t *Transport) resendPending() {
1299- now := t.clock.Now()
1300- t.mu.Lock()
1301- resend, _ := t.selectRetransmitsLocked(now)
1302- t.mu.Unlock()
1303- if t.retransmitWork != nil {
1304- select {
1305- case t.retransmitWork <- resend:
1306- case <-t.done:
1307- }
1308- return
1309- }
1310- pacer := bytePacer{clk: t.clock}
1311- t.runRetransmitBatch(&pacer, resend)
1312-}
1313-
1314-func (t *Transport) resendDelayLocked(p *pending) time.Duration {
1315- d := t.rto
1316- if d <= 0 {
1317- d = t.resendAfter
1318- }
1319- for i := 0; i < p.attempts && d < t.maxResendAfter; i++ {
1320- d *= 2
1321- if d > t.maxResendAfter {
1322- return t.maxResendAfter
1323- }
1324- }
1325- return d
1326-}
1327-
1328-func (t *Transport) updateRTTLocked(sample time.Duration) {
1329- if sample <= 0 {
1330- return
1331- }
1332- if t.srtt <= 0 {
1333- t.srtt = sample
1334- t.rttvar = sample / 2
1335- } else {
1336- delta := t.srtt - sample
1337- if delta < 0 {
1338- delta = -delta
1339- }
1340- t.rttvar = (3*t.rttvar + delta) / 4
1341- t.srtt = (7*t.srtt + sample) / 8
1342- }
1343- variation := 4 * t.rttvar
1344- if variation < time.Millisecond {
1345- variation = time.Millisecond
1346- }
1347- rto := t.srtt + variation
1348- if rto < t.resendAfter {
1349- rto = t.resendAfter
1350- }
1351- if rto > t.maxResendAfter {
1352- rto = t.maxResendAfter
1353- }
1354- t.rto = rto
1355- t.congestion.onRTT(t.srtt)
1356-}
1357-
1358-func (t *Transport) hopPacketConnOnce(generation uint64) {
1359- t.mu.Lock()
1360- if t.rebind == nil || t.hoppedOffline || t.closed || t.linkState != ports.LinkStateProbing || t.health.generation != generation {
1361- t.mu.Unlock()
1362- return
1363- }
1364- old := t.pc
1365- rebind := t.rebind
1366- t.hoppedOffline = true
1367- t.hopGeneration = generation
1368- t.mu.Unlock()
1369- pc, err := rebind(old)
1370-
1371- // Serialize the swap with writes. writePacket takes writeMu before mu, so
1372- // preserve that order here while retiring old.
1373- t.writeMu.Lock()
1374- defer t.writeMu.Unlock()
1375- t.mu.Lock()
1376- if err != nil {
1377- t.rollbackHopLocked(generation)
1378- t.mu.Unlock()
1379- return
1380- }
1381- if t.closed || t.pc != old || t.health.generation != generation || t.linkState != ports.LinkStateProbing {
1382- t.rollbackHopLocked(generation)
1383- t.mu.Unlock()
1384- _ = pc.Close()
1385- return
1386- }
1387- t.pc = pc
1388- t.writeDeadlines = newWriteDeadlineState(pc)
1389- t.mu.Unlock()
1390- go t.readLoop(pc)
1391- _ = old.Close()
1392-}
1393-
1394-func (t *Transport) rollbackHopLocked(generation uint64) {
1395- if t.hoppedOffline && t.hopGeneration == generation {
1396- t.hoppedOffline = false
1397- t.hopGeneration = 0
1398- }
1399-}
1400-
1401-func (t *Transport) checkSilence() {
1402- runHook := true
1403- for {
1404- t.mu.Lock()
1405- if t.closed {
1406- t.mu.Unlock()
1407- return
1408- }
1409- now := t.clock.Now()
1410- generation := t.health.generation
1411- state, hop, dead := t.health.decide(now, len(t.pending) > 0, t.degradedAfter, t.probeAfter, t.offlineAfter, t.deadAfter)
1412- afterDecision := t.afterHealthDecision
1413- t.mu.Unlock()
1414-
1415- if runHook {
1416- runHook = false
1417- if afterDecision != nil {
1418- afterDecision()
1419- }
1420- }
1421-
1422- t.mu.Lock()
1423- if t.closed {
1424- t.mu.Unlock()
1425- return
1426- }
1427- if t.health.generation != generation {
1428- t.mu.Unlock()
1429- continue
1430- }
1431- changed, stateGeneration := t.setLinkStateLocked(state)
1432- afterStateCommit := t.afterLinkStateCommit
1433- closed := false
1434- if dead {
1435- closed = t.closeWithErrorLocked(ErrLinkDead)
1436- }
1437- t.mu.Unlock()
1438-
1439- if changed {
1440- if afterStateCommit != nil {
1441- afterStateCommit(state)
1442- }
1443- var linkErr error
1444- if dead {
1445- linkErr = ErrLinkDead
1446- }
1447- t.publishLinkState(state, now, linkErr, stateGeneration)
1448- }
1449- if closed {
1450- t.broadcastClosed()
1451- return
1452- }
1453- if hop {
1454- t.hopPacketConnOnce(generation)
1455- }
1456- return
1457- }
1458-}
1459-
1460-func (t *Transport) setLinkState(state ports.LinkState, err error) {
1461- now := t.clock.Now()
1462- t.mu.Lock()
1463- changed, stateGeneration := t.setLinkStateLocked(state)
1464- afterStateCommit := t.afterLinkStateCommit
1465- t.mu.Unlock()
1466- if changed {
1467- if afterStateCommit != nil {
1468- afterStateCommit(state)
1469- }
1470- t.publishLinkState(state, now, err, stateGeneration)
1471- }
1472-}
1473-
1474-func (t *Transport) setLinkStateLocked(state ports.LinkState) (bool, uint64) {
1475- if t.linkState == state {
1476- return false, t.linkStateGeneration
1477- }
1478- t.linkState = state
1479- t.linkStateGeneration++
1480- if state == ports.LinkStateConnected {
1481- t.hoppedOffline = false
1482- t.hopGeneration = 0
1483- for _, p := range t.pending {
1484- if p != nil {
1485- p.attempts = 0
1486- }
1487- }
1488- }
1489- t.notifySendWaitersLocked()
1490- return true, t.linkStateGeneration
1491-}
1492-
1493-func (t *Transport) publishLinkState(state ports.LinkState, now time.Time, err error, generation uint64) {
1494- t.linkEventMu.Lock()
1495- defer t.linkEventMu.Unlock()
1496- t.mu.Lock()
1497- if t.linkState != state || t.linkStateGeneration != generation {
1498- t.mu.Unlock()
1499- return
1500- }
1501- event := ports.LinkEvent{State: state, At: now, Err: err}
1502- select {
1503- case t.linkEvents <- event:
1504- default:
1505- }
1506- t.mu.Unlock()
1507- t.emitDiagnostic()
1508-}
1509-
1510-func (t *Transport) diagnosticLoop() {
1511- for {
1512- select {
1513- case d := <-t.diagnosticCh:
1514- t.observe(d)
1515- case <-t.done:
1516- return
1517- }
1518- }
1519-}
1520-
1521-func (t *Transport) emitDiagnostic() {
1522- if t.diagnosticCh == nil {
1523- return
1524- }
1525- d := t.diagnosticSnapshot()
1526- select {
1527- case t.diagnosticCh <- d:
1528- default:
1529- }
1530-}
1531-
1532-func (t *Transport) diagnosticSnapshot() Diagnostic {
1533- t.mu.Lock()
1534- now := t.clock.Now()
1535- lastAuthenticatedPacket := t.lastAuthenticatedPacket
1536- lastCompleteRecord := t.lastCompleteRecord
1537- lastACKProgress := t.lastACKProgress
1538- pendingBytes := 0
1539- for _, p := range t.pending {
1540- if p != nil {
1541- pendingBytes += len(p.frame.Payload)
1542- }
1543- }
1544- state := t.linkState
1545- pendingRecords := len(t.pending)
1546- retransmits := t.retransmits
1547- reassemblyInflight := t.reassemblyInflight
1548- t.mu.Unlock()
1549-
1550- return Diagnostic{
1551- At: now,
1552- State: state,
1553- SinceAuthenticatedPacket: diagnosticAge(now, lastAuthenticatedPacket),
1554- SinceCompleteRecord: diagnosticAge(now, lastCompleteRecord),
1555- SinceACKProgress: diagnosticAge(now, lastACKProgress),
1556- PendingRecords: pendingRecords,
1557- PendingBytes: pendingBytes,
1558- Retransmits: retransmits,
1559- ReassemblyInflight: reassemblyInflight,
1560- }
1561-}
1562-
1563-func diagnosticAge(now, then time.Time) time.Duration {
1564- if then.After(now) {
1565- return 0
1566- }
1567- return now.Sub(then)
1568-}
1569-
1570-func (t *Transport) closeWithError(err error) {
1571- t.mu.Lock()
1572- closed := t.closeWithErrorLocked(err)
1573- t.mu.Unlock()
1574- if closed {
1575- t.broadcastClosed()
1576- }
1577-}
1578-
1579-func (t *Transport) closeWithErrorLocked(err error) bool {
1580- if t.closed {
1581- return false
1582- }
1583- t.closed = true
1584- t.closeErr = err
1585- for _, q := range t.outputQueue {
1586- if q.done != nil {
1587- q.done <- err
1588- close(q.done)
1589- }
1590- }
1591- t.outputQueue = nil
1592- t.probeWait = make(map[uint64]chan struct{})
1593- close(t.done)
1594- t.notifySendWaitersLocked()
1595- return true
1596-}
1597-
1598-func (t *Transport) broadcastClosed() {
1599- t.deliverMu.Lock()
1600- t.deliverCond.Broadcast()
1601- t.deliverMu.Unlock()
1602-}
1603-
1604-func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
1605- b := make([]byte, dataRecordHeaderSize+len(f.Payload))
1606- b[0] = recData
1607- binary.BigEndian.PutUint64(b[1:9], seq)
1608- if reliable {
1609- b[9] = 1
1610- }
1611- b[10] = byte(f.Type)
1612- copy(b[dataRecordHeaderSize:], f.Payload)
1613- return b
1614-}
1615-func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
1616- if len(b) < dataRecordHeaderSize || b[0] != recData {
1617- return 0, false, ports.Frame{}, false
1618- }
1619- if b[11] != 0 {
1620- return 0, false, ports.Frame{}, false
1621- }
1622- return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[dataRecordHeaderSize:]...)}, true
1623-}
1624-
1625394 // DatagramTransport marks Transport as a UDP-style datagram transport.
1626-func (t *Transport) beginRuntimeOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
1627- if t.runtimeObserver == nil {
1628- return func(bool) {}
1629- }
1630- if !t.beginObservedOperation() {
1631- return func(bool) {}
1632- }
1633- correlation := ports.NewRuntimeCorrelation()
1634- t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, start, bytes, true))
1635- end := ports.RuntimeAdapterSendEnd
1636- if start == ports.RuntimeAdapterReceiveStart {
1637- end = ports.RuntimeAdapterReceiveEnd
1638- }
1639- return func(valid bool) {
1640- defer t.finishObservedOperation()
1641- t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, end, bytes, valid))
1642- }
1643-}
1644-
1645-func (t *Transport) beginObservedOperation() bool {
1646- t.operationMu.Lock()
1647- defer t.operationMu.Unlock()
1648- if t.closing {
1649- return false
1650- }
1651- if t.operationCount == 0 {
1652- t.operationsDone = make(chan struct{})
1653- }
1654- t.operationCount++
1655- return true
1656-}
1657-
1658-func (t *Transport) finishObservedOperation() {
1659- t.operationMu.Lock()
1660- defer t.operationMu.Unlock()
1661- t.operationCount--
1662- if t.operationCount == 0 {
1663- close(t.operationsDone)
1664- }
1665-}
1666-
1667-func (t *Transport) beginShutdown() <-chan struct{} {
1668- t.operationMu.Lock()
1669- defer t.operationMu.Unlock()
1670- t.closing = true
1671- if t.operationCount == 0 {
1672- return nil
1673- }
1674- return t.operationsDone
1675-}
1676-
1677395 func (*Transport) DatagramTransport() {}
A internal/adapters/dgram/transport_diagnostics.go +118 -0
@@ -0,0 +1,118 @@
1+package dgram
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+func (t *Transport) diagnosticLoop() {
10+ for {
11+ select {
12+ case d := <-t.diagnosticCh:
13+ t.observe(d)
14+ case <-t.done:
15+ return
16+ }
17+ }
18+}
19+
20+func (t *Transport) emitDiagnostic() {
21+ if t.diagnosticCh == nil {
22+ return
23+ }
24+ d := t.diagnosticSnapshot()
25+ select {
26+ case t.diagnosticCh <- d:
27+ default:
28+ }
29+}
30+
31+func (t *Transport) diagnosticSnapshot() Diagnostic {
32+ t.mu.Lock()
33+ now := t.clock.Now()
34+ lastAuthenticatedPacket := t.lastAuthenticatedPacket
35+ lastCompleteRecord := t.lastCompleteRecord
36+ lastACKProgress := t.lastACKProgress
37+ pendingBytes := 0
38+ for _, p := range t.pending {
39+ if p != nil {
40+ pendingBytes += len(p.frame.Payload)
41+ }
42+ }
43+ state := t.linkState
44+ pendingRecords := len(t.pending)
45+ retransmits := t.retransmits
46+ reassemblyInflight := t.reassemblyInflight
47+ t.mu.Unlock()
48+
49+ return Diagnostic{
50+ At: now,
51+ State: state,
52+ SinceAuthenticatedPacket: diagnosticAge(now, lastAuthenticatedPacket),
53+ SinceCompleteRecord: diagnosticAge(now, lastCompleteRecord),
54+ SinceACKProgress: diagnosticAge(now, lastACKProgress),
55+ PendingRecords: pendingRecords,
56+ PendingBytes: pendingBytes,
57+ Retransmits: retransmits,
58+ ReassemblyInflight: reassemblyInflight,
59+ }
60+}
61+
62+func diagnosticAge(now, then time.Time) time.Duration {
63+ if then.After(now) {
64+ return 0
65+ }
66+ return now.Sub(then)
67+}
68+
69+func (t *Transport) beginRuntimeOperation(start ports.RuntimeMarkKind, bytes uint64) func(bool) {
70+ if t.runtimeObserver == nil {
71+ return func(bool) {}
72+ }
73+ if !t.beginObservedOperation() {
74+ return func(bool) {}
75+ }
76+ correlation := ports.NewRuntimeCorrelation()
77+ t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, start, bytes, true))
78+ end := ports.RuntimeAdapterSendEnd
79+ if start == ports.RuntimeAdapterReceiveStart {
80+ end = ports.RuntimeAdapterReceiveEnd
81+ }
82+ return func(valid bool) {
83+ defer t.finishObservedOperation()
84+ t.runtimeObserver.ObserveRuntime(ports.NewRuntimeMarkWithCorrelation("dgram", correlation, end, bytes, valid))
85+ }
86+}
87+
88+func (t *Transport) beginObservedOperation() bool {
89+ t.operationMu.Lock()
90+ defer t.operationMu.Unlock()
91+ if t.closing {
92+ return false
93+ }
94+ if t.operationCount == 0 {
95+ t.operationsDone = make(chan struct{})
96+ }
97+ t.operationCount++
98+ return true
99+}
100+
101+func (t *Transport) finishObservedOperation() {
102+ t.operationMu.Lock()
103+ defer t.operationMu.Unlock()
104+ t.operationCount--
105+ if t.operationCount == 0 {
106+ close(t.operationsDone)
107+ }
108+}
109+
110+func (t *Transport) beginShutdown() <-chan struct{} {
111+ t.operationMu.Lock()
112+ defer t.operationMu.Unlock()
113+ t.closing = true
114+ if t.operationCount == 0 {
115+ return nil
116+ }
117+ return t.operationsDone
118+}
A internal/adapters/dgram/transport_health.go +204 -0
@@ -0,0 +1,204 @@
1+package dgram
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/ports"
7+)
8+
9+// healthLoop owns link-health timing so bulk retransmission cannot delay state
10+// transitions or socket hopping.
11+func (t *Transport) healthLoop() {
12+ health := t.clock.NewTimer(t.resendAfter)
13+ defer health.Stop()
14+ for {
15+ select {
16+ case <-health.C():
17+ t.checkSilence()
18+ health.Reset(t.resendAfter)
19+ t.notifyTimerHook(func() func() { return t.afterHealthTimer })
20+ case <-t.done:
21+ return
22+ }
23+ }
24+}
25+
26+func (t *Transport) heartbeatLoop() {
27+ heartbeat := t.clock.NewTimer(t.heartbeat)
28+ defer heartbeat.Stop()
29+ for {
30+ select {
31+ case <-heartbeat.C():
32+ t.queueControl(recProbe, 0)
33+ heartbeat.Reset(t.heartbeat)
34+ t.notifyTimerHook(func() func() { return t.afterHeartbeatTimer })
35+ case <-t.done:
36+ return
37+ }
38+ }
39+}
40+
41+func (t *Transport) notifyTimerHook(get func() func()) {
42+ t.mu.Lock()
43+ hook := get()
44+ t.mu.Unlock()
45+ if hook != nil {
46+ hook()
47+ }
48+}
49+
50+func (t *Transport) hopPacketConnOnce(generation uint64) {
51+ t.mu.Lock()
52+ if t.rebind == nil || t.hoppedOffline || t.closed || t.linkState != ports.LinkStateProbing || t.health.generation != generation {
53+ t.mu.Unlock()
54+ return
55+ }
56+ old := t.pc
57+ rebind := t.rebind
58+ t.hoppedOffline = true
59+ t.hopGeneration = generation
60+ t.mu.Unlock()
61+ pc, err := rebind(old)
62+
63+ // Lock order is writeMu -> controlConnMu -> mu. Data writes take only
64+ // writeMu, while control writes hold controlConnMu's read lock without
65+ // writeMu; therefore a hop waits for active control writes but does not make
66+ // them wait for a blocked data write.
67+ t.writeMu.Lock()
68+ defer t.writeMu.Unlock()
69+ t.controlConnMu.Lock()
70+ defer t.controlConnMu.Unlock()
71+ t.mu.Lock()
72+ if err != nil {
73+ t.rollbackHopLocked(generation)
74+ t.mu.Unlock()
75+ return
76+ }
77+ if t.closed || t.pc != old || t.health.generation != generation || t.linkState != ports.LinkStateProbing {
78+ t.rollbackHopLocked(generation)
79+ t.mu.Unlock()
80+ _ = pc.Close()
81+ return
82+ }
83+ t.pc = pc
84+ t.writeDeadlines = newWriteDeadlineState(pc)
85+ t.mu.Unlock()
86+ go t.readLoop(pc)
87+ _ = old.Close()
88+}
89+
90+func (t *Transport) rollbackHopLocked(generation uint64) {
91+ if t.hoppedOffline && t.hopGeneration == generation {
92+ t.hoppedOffline = false
93+ t.hopGeneration = 0
94+ }
95+}
96+
97+func (t *Transport) checkSilence() {
98+ runHook := true
99+ for {
100+ t.mu.Lock()
101+ if t.closed {
102+ t.mu.Unlock()
103+ return
104+ }
105+ now := t.clock.Now()
106+ generation := t.health.generation
107+ state, hop, dead := t.health.decide(now, len(t.pending) > 0, t.degradedAfter, t.probeAfter, t.offlineAfter, t.deadAfter)
108+ afterDecision := t.afterHealthDecision
109+ t.mu.Unlock()
110+
111+ if runHook {
112+ runHook = false
113+ if afterDecision != nil {
114+ afterDecision()
115+ }
116+ }
117+
118+ t.mu.Lock()
119+ if t.closed {
120+ t.mu.Unlock()
121+ return
122+ }
123+ if t.health.generation != generation {
124+ t.mu.Unlock()
125+ continue
126+ }
127+ changed, stateGeneration := t.setLinkStateLocked(state)
128+ afterStateCommit := t.afterLinkStateCommit
129+ closed := false
130+ if dead {
131+ closed = t.closeWithErrorLocked(ErrLinkDead)
132+ }
133+ t.mu.Unlock()
134+
135+ if changed {
136+ if afterStateCommit != nil {
137+ afterStateCommit(state)
138+ }
139+ var linkErr error
140+ if dead {
141+ linkErr = ErrLinkDead
142+ }
143+ t.publishLinkState(state, now, linkErr, stateGeneration)
144+ }
145+ if closed {
146+ t.broadcastClosed()
147+ return
148+ }
149+ if hop {
150+ t.hopPacketConnOnce(generation)
151+ }
152+ return
153+ }
154+}
155+
156+func (t *Transport) setLinkState(state ports.LinkState, err error) {
157+ now := t.clock.Now()
158+ t.mu.Lock()
159+ changed, stateGeneration := t.setLinkStateLocked(state)
160+ afterStateCommit := t.afterLinkStateCommit
161+ t.mu.Unlock()
162+ if changed {
163+ if afterStateCommit != nil {
164+ afterStateCommit(state)
165+ }
166+ t.publishLinkState(state, now, err, stateGeneration)
167+ }
168+}
169+
170+func (t *Transport) setLinkStateLocked(state ports.LinkState) (bool, uint64) {
171+ if t.linkState == state {
172+ return false, t.linkStateGeneration
173+ }
174+ t.linkState = state
175+ t.linkStateGeneration++
176+ if state == ports.LinkStateConnected {
177+ t.hoppedOffline = false
178+ t.hopGeneration = 0
179+ for _, p := range t.pending {
180+ if p != nil {
181+ p.attempts = 0
182+ }
183+ }
184+ }
185+ t.notifySendWaitersLocked()
186+ return true, t.linkStateGeneration
187+}
188+
189+func (t *Transport) publishLinkState(state ports.LinkState, now time.Time, err error, generation uint64) {
190+ t.linkEventMu.Lock()
191+ defer t.linkEventMu.Unlock()
192+ t.mu.Lock()
193+ if t.linkState != state || t.linkStateGeneration != generation {
194+ t.mu.Unlock()
195+ return
196+ }
197+ event := ports.LinkEvent{State: state, At: now, Err: err}
198+ select {
199+ case t.linkEvents <- event:
200+ default:
201+ }
202+ t.mu.Unlock()
203+ t.emitDiagnostic()
204+}
A internal/adapters/dgram/transport_health_test.go +731 -0
@@ -0,0 +1,731 @@
1+package dgram
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "net"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ pdgram "github.com/bnema/vev/pkg/dgram"
14+)
15+
16+func TestPortHopPreservesPendingReliableMessages(t *testing.T) {
17+ aPC, bPC := newPair()
18+ var hopped atomic.Bool
19+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
20+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
21+ ResendAfter: 20 * time.Millisecond,
22+ ProbeAfter: time.Millisecond,
23+ DeadAfter: time.Hour,
24+ RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
25+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
26+ bPC.peers["a2"] = newPC
27+ hopped.Store(true)
28+ return newPC, nil
29+ },
30+ })
31+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
32+ defer func() { _ = a.Close() }()
33+ defer func() { _ = b.Close() }()
34+
35+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("survive")}); err != nil {
36+ t.Fatal(err)
37+ }
38+ a.mu.Lock()
39+ a.health.lastPacket = time.Now().Add(-time.Second)
40+ a.mu.Unlock()
41+ a.checkSilence()
42+ if !hopped.Load() {
43+ t.Fatal("expected packet conn rebind")
44+ }
45+ aPC.drop = nil
46+ got := recvWithin(t, b, time.Second)
47+ if got.Type != ports.MsgInput || string(got.Payload) != "survive" {
48+ t.Fatalf("got=%+v, want pending reliable message", got)
49+ }
50+ select {
51+ case <-a.done:
52+ t.Fatal("transport closed during port hop")
53+ default:
54+ }
55+}
56+
57+func TestPortHopRetriesAfterRebindFailure(t *testing.T) {
58+ aPC, bPC := newPair()
59+ var attempts atomic.Int32
60+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
61+ ResendAfter: time.Hour,
62+ Heartbeat: time.Hour,
63+ ProbeAfter: time.Millisecond,
64+ DeadAfter: time.Hour,
65+ RebindPacketConn: func(old net.PacketConn) (net.PacketConn, error) {
66+ if attempts.Add(1) == 1 {
67+ return nil, errors.New("temporary rebind failure")
68+ }
69+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
70+ bPC.peers["a2"] = newPC
71+ return newPC, nil
72+ },
73+ })
74+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
75+ defer func() { _ = a.Close() }()
76+ defer func() { _ = b.Close() }()
77+
78+ a.mu.Lock()
79+ a.health.lastPacket = time.Now().Add(-time.Second)
80+ a.mu.Unlock()
81+ a.checkSilence()
82+ a.mu.Lock()
83+ firstPC := a.pc
84+ a.mu.Unlock()
85+ if attempts.Load() != 1 || firstPC != aPC {
86+ t.Fatalf("first failed hop attempts=%d pc=%v, want one attempt and original pc", attempts.Load(), firstPC.LocalAddr())
87+ }
88+
89+ a.checkSilence()
90+ a.mu.Lock()
91+ secondPC := a.pc
92+ a.mu.Unlock()
93+ if attempts.Load() != 2 || secondPC == aPC {
94+ t.Fatalf("second hop attempts=%d pc=%v, want retry with new pc", attempts.Load(), secondPC.LocalAddr())
95+ }
96+}
97+
98+func TestServerWithoutRebindDoesNotHopPorts(t *testing.T) {
99+ aPC, bPC := newPair()
100+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ProbeAfter: time.Millisecond, DeadAfter: time.Hour})
101+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
102+ defer func() { _ = a.Close() }()
103+ defer func() { _ = b.Close() }()
104+ a.mu.Lock()
105+ a.health.lastPacket = time.Now().Add(-time.Second)
106+ a.mu.Unlock()
107+ a.checkSilence()
108+ if a.pc != aPC {
109+ t.Fatal("server/proxy transport hopped without rebind hook")
110+ }
111+}
112+
113+func TestProbeTimeoutAndSuccess(t *testing.T) {
114+ aPC, bPC := newPair()
115+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
116+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
117+ defer func() { _ = a.Close() }()
118+ defer func() { _ = b.Close() }()
119+
120+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
121+ if err := a.Probe(ctx); err != nil {
122+ cancel()
123+ t.Fatalf("probe success: %v", err)
124+ }
125+ cancel()
126+
127+ bPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "a" }
128+ ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
129+ defer cancel()
130+ if err := a.Probe(ctx); !errors.Is(err, context.DeadlineExceeded) {
131+ t.Fatalf("probe timeout err=%v, want context deadline", err)
132+ }
133+}
134+
135+func TestRecvUnblocksWhenPeerDeadAfter(t *testing.T) {
136+ aPC, bPC := newPair()
137+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: 25 * time.Millisecond, DeadAfter: 75 * time.Millisecond})
138+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
139+ defer func() { _ = a.Close() }()
140+ defer func() { _ = b.Close() }()
141+ a.mu.Lock()
142+ a.health.lastPacket = time.Now().Add(-time.Second)
143+ a.mu.Unlock()
144+
145+ _, err := recvErrWithin(t, a, time.Second)
146+ if !errors.Is(err, ErrLinkDead) {
147+ t.Fatalf("Recv err=%v, want ErrLinkDead", err)
148+ }
149+}
150+
151+func TestPendingAttemptsResetWhenLinkRecovers(t *testing.T) {
152+ tr, _ := newResendTestTransport(t, 10)
153+ tr.linkState = ports.LinkStateDegraded
154+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 4}
155+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("stale")}, attempts: 7}
156+
157+ tr.setLinkState(ports.LinkStateConnected, nil)
158+
159+ for seq, p := range tr.pending {
160+ if p.attempts != 0 {
161+ t.Fatalf("pending[%d] attempts=%d, want reset on connected recovery", seq, p.attempts)
162+ }
163+ }
164+}
165+
166+func TestProbeReplyDoesNotBlockOnWriteMu(t *testing.T) {
167+ aPC, bPC := newPair()
168+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ defer func() { _ = tr.Close() }()
173+ defer func() { _ = bPC.Close() }()
174+
175+ tr.writeMu.Lock()
176+ done := make(chan struct{})
177+ go func() {
178+ for id := uint64(1); id <= probeReplyBufferSize*4; id++ {
179+ tr.handleRecord(probeRecord(recProbe, id))
180+ }
181+ close(done)
182+ }()
183+
184+ select {
185+ case <-done:
186+ case <-time.After(time.Second):
187+ tr.writeMu.Unlock()
188+ <-done
189+ t.Fatal("probe reply burst blocked behind writeMu")
190+ }
191+ tr.writeMu.Unlock()
192+}
193+
194+func TestPendingReliableQueueReturnsWhenLinkDegrades(t *testing.T) {
195+ aPC, bPC := newPair()
196+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
197+ clk := newManualClock(time.Now())
198+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: time.Hour, Clock: clk})
199+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
200+ defer func() { _ = a.Close() }()
201+ defer func() { _ = b.Close() }()
202+
203+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
204+ t.Fatal(err)
205+ }
206+ done := make(chan error, 2)
207+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
208+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("third")}) }()
209+ waitForManualTimers(t, clk, 2)
210+ a.setLinkState(ports.LinkStateDegraded, nil)
211+ for i := range 2 {
212+ select {
213+ case err := <-done:
214+ if !errors.Is(err, ErrPendingFull) {
215+ t.Fatalf("send %d err=%v, want ErrPendingFull", i, err)
216+ }
217+ case <-time.After(time.Second):
218+ t.Fatalf("send %d did not unblock after link degraded", i)
219+ }
220+ }
221+}
222+
223+func TestLinkStateTransitionsFromConfigurableSilence(t *testing.T) {
224+ aPC, bPC := newPair()
225+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: 4 * time.Second})
226+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
227+ defer func() { _ = a.Close() }()
228+ defer func() { _ = b.Close() }()
229+
230+ tests := []struct {
231+ name string
232+ age time.Duration
233+ want ports.LinkState
234+ }{
235+ {"degraded", time.Second, ports.LinkStateDegraded},
236+ {"probing", 2 * time.Second, ports.LinkStateProbing},
237+ {"offline", 3 * time.Second, ports.LinkStateOffline},
238+ }
239+ for _, tt := range tests {
240+ t.Run(tt.name, func(t *testing.T) {
241+ a.mu.Lock()
242+ staleAt := time.Now().Add(-tt.age)
243+ a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
244+ a.mu.Unlock()
245+ a.checkSilence()
246+ if got := a.LinkState(); got != tt.want {
247+ t.Fatalf("LinkState()=%v, want %v", got, tt.want)
248+ }
249+ select {
250+ case ev := <-a.LinkEvents():
251+ if ev.State != tt.want {
252+ t.Fatalf("event state=%v, want %v", ev.State, tt.want)
253+ }
254+ default:
255+ t.Fatalf("missing link event for %v", tt.want)
256+ }
257+ })
258+ }
259+}
260+
261+func TestLinkHealthSeparatesPacketContactFromUsefulProgress(t *testing.T) {
262+ now := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
263+ threshold := func(seconds int) time.Duration { return time.Duration(seconds) * time.Second }
264+ tests := []struct {
265+ name string
266+ packetAge time.Duration
267+ recordAge time.Duration
268+ ackAge time.Duration
269+ hasPending bool
270+ wantState ports.LinkState
271+ wantHop bool
272+ wantDead bool
273+ }{
274+ {name: "fresh contact without useful progress degrades", packetAge: 0, recordAge: threshold(10), ackAge: threshold(10), wantState: ports.LinkStateDegraded},
275+ {name: "pending round trip stall probes and hops", packetAge: 0, recordAge: threshold(20), ackAge: threshold(20), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
276+ {name: "fragment-only contact never becomes offline", packetAge: 0, recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateDegraded},
277+ {name: "packet silence probes and hops", packetAge: threshold(20), recordAge: threshold(20), ackAge: threshold(20), wantState: ports.LinkStateProbing, wantHop: true},
278+ {name: "packet silence becomes offline", packetAge: threshold(30), recordAge: threshold(30), ackAge: threshold(30), wantState: ports.LinkStateOffline},
279+ {name: "packet silence becomes dead", packetAge: threshold(60), recordAge: threshold(60), ackAge: threshold(60), wantState: ports.LinkStateDead, wantDead: true},
280+ {name: "complete record cannot mask pending stall", packetAge: 0, recordAge: 0, ackAge: threshold(30), hasPending: true, wantState: ports.LinkStateProbing, wantHop: true},
281+ {name: "complete record restores connected without pending data", packetAge: 0, recordAge: 0, ackAge: threshold(30), wantState: ports.LinkStateConnected},
282+ {name: "ack progress restores connected", packetAge: 0, recordAge: threshold(30), ackAge: 0, hasPending: true, wantState: ports.LinkStateConnected},
283+ }
284+
285+ for _, tt := range tests {
286+ t.Run(tt.name, func(t *testing.T) {
287+ health := healthTracker{
288+ lastPacket: now.Add(-tt.packetAge),
289+ lastRecord: now.Add(-tt.recordAge),
290+ lastProgress: now.Add(-tt.ackAge),
291+ }
292+ state, hop, dead := health.decide(now, tt.hasPending, threshold(10), threshold(20), threshold(30), threshold(60))
293+ if state != tt.wantState || hop != tt.wantHop || dead != tt.wantDead {
294+ t.Fatalf("decide()=(%v,%v,%v), want (%v,%v,%v)", state, hop, dead, tt.wantState, tt.wantHop, tt.wantDead)
295+ }
296+ })
297+ }
298+}
299+
300+func TestLinkHealthPendingStallWithFreshContactProbesOnceWithoutDying(t *testing.T) {
301+ aPC, bPC := newPair()
302+ var hops atomic.Int32
303+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
304+ ResendAfter: time.Hour, Heartbeat: time.Hour, ProbeAfter: 20 * time.Second, DeadAfter: 60 * time.Second,
305+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
306+ hops.Add(1)
307+ return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
308+ },
309+ })
310+ defer func() { _ = a.Close() }()
311+ defer func() { _ = bPC.Close() }()
312+
313+ now := time.Now()
314+ a.mu.Lock()
315+ a.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
316+ a.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, enqueued: now.Add(-time.Minute), attempts: 3}
317+ a.mu.Unlock()
318+
319+ a.checkSilence()
320+ a.checkSilence()
321+ if got := a.LinkState(); got != ports.LinkStateProbing {
322+ t.Fatalf("state=%v, want probing", got)
323+ }
324+ if got := hops.Load(); got != 1 {
325+ t.Fatalf("socket hops=%d, want one", got)
326+ }
327+ select {
328+ case <-a.done:
329+ t.Fatal("fresh authenticated contact must prevent pending-stall death")
330+ default:
331+ }
332+}
333+
334+func TestLinkHealthHeartbeatControlCannotMaskPendingACKStall(t *testing.T) {
335+ aPC, bPC := newPair()
336+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
337+ var hops atomic.Int32
338+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
339+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
340+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
341+ attempt := hops.Add(1)
342+ return &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
343+ },
344+ })
345+ if err != nil {
346+ t.Fatal(err)
347+ }
348+ defer func() { _ = tr.Close() }()
349+ defer func() { _ = bPC.Close() }()
350+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("unacked")}); err != nil {
351+ t.Fatal(err)
352+ }
353+
354+ codec, err := pdgram.NewCodec(key())
355+ if err != nil {
356+ t.Fatal(err)
357+ }
358+ authenticated := make(chan struct{}, 32)
359+ tr.mu.Lock()
360+ tr.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
361+ tr.mu.Unlock()
362+ var counter uint64
363+ sendHeartbeatPong := func() {
364+ t.Helper()
365+ counter++
366+ frag := pdgram.Fragment{Seq: counter, Index: 0, Count: 1, Data: probeRecord(recPong, counter)}
367+ raw, marshalErr := pdgram.MarshalFragment(frag)
368+ if marshalErr != nil {
369+ t.Fatal(marshalErr)
370+ }
371+ tr.mu.Lock()
372+ actual := tr.pc
373+ pc, ok := actual.(*fakePC)
374+ tr.mu.Unlock()
375+ if !ok {
376+ t.Fatalf("transport packet conn = %T, want *fakePC", actual)
377+ }
378+ pc.in <- packet{b: codec.Seal(2, counter, raw, nil), addr: testAddr("b")}
379+ select {
380+ case <-authenticated:
381+ case <-time.After(time.Second):
382+ t.Fatal("heartbeat pong was not authenticated")
383+ }
384+ }
385+
386+ for elapsed := 3 * time.Second; elapsed <= 66*time.Second; elapsed += 3 * time.Second {
387+ clk.advance(3 * time.Second)
388+ sendHeartbeatPong()
389+ eventually(t, time.Second, func() bool {
390+ tr.mu.Lock()
391+ defer tr.mu.Unlock()
392+ return tr.health.lastRecord.Equal(clk.Now())
393+ })
394+ if elapsed >= 20*time.Second {
395+ eventually(t, time.Second, func() bool {
396+ tr.mu.Lock()
397+ defer tr.mu.Unlock()
398+ return tr.pc != aPC
399+ })
400+ }
401+ }
402+ if got := tr.LinkState(); got != ports.LinkStateProbing {
403+ t.Fatalf("state=%v, want probing despite fresh heartbeat control traffic", got)
404+ }
405+ if got := hops.Load(); got != 1 {
406+ t.Fatalf("socket hops=%d, want one pending round-trip recovery hop", got)
407+ }
408+ select {
409+ case <-tr.done:
410+ t.Fatal("fresh authenticated control contact must prevent offline/dead closure")
411+ default:
412+ }
413+}
414+
415+func TestLinkHealthOnlyAckProgressThatRemovesPendingRestoresConnected(t *testing.T) {
416+ tr, _ := newResendTestTransport(t, 10)
417+ now := tr.clock.Now()
418+ tr.linkState = ports.LinkStateDegraded
419+ tr.health = healthTracker{lastPacket: now, lastRecord: now.Add(-time.Minute), lastProgress: now.Add(-time.Minute)}
420+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: now, last: now}
421+
422+ tr.handleRecord(ackRecord(1))
423+ if got := tr.LinkState(); got != ports.LinkStateConnected {
424+ t.Fatalf("state after advancing ACK=%v, want connected", got)
425+ }
426+ tr.mu.Lock()
427+ if tr.health.lastProgress.Before(now) {
428+ tr.mu.Unlock()
429+ t.Fatalf("last progress=%v, want at or after %v", tr.health.lastProgress, now)
430+ }
431+ tr.mu.Unlock()
432+
433+ tr.setLinkState(ports.LinkStateDegraded, nil)
434+ tr.handleRecord(ackRecord(1))
435+ if got := tr.LinkState(); got != ports.LinkStateDegraded {
436+ t.Fatalf("duplicate ACK state=%v, want degraded", got)
437+ }
438+}
439+
440+func TestLinkHealthFreshProgressInvalidatesStaleDecision(t *testing.T) {
441+ tests := []struct {
442+ name string
443+ staleAge time.Duration
444+ freshen func(*Transport)
445+ wantState ports.LinkState
446+ }{
447+ {
448+ name: "fresh packet contact prevents stale dead close",
449+ staleAge: time.Minute,
450+ freshen: func(tr *Transport) {
451+ tr.mu.Lock()
452+ now := tr.clock.Now()
453+ tr.health.authenticatedPacket(now)
454+ tr.lastAuthenticatedPacket = now
455+ tr.mu.Unlock()
456+ },
457+ wantState: ports.LinkStateDegraded,
458+ },
459+ {
460+ name: "complete record prevents stale probing state and hop",
461+ staleAge: 20 * time.Second,
462+ freshen: func(tr *Transport) {
463+ tr.mu.Lock()
464+ now := tr.clock.Now()
465+ tr.health.authenticatedPacket(now)
466+ tr.health.completeRecord(now)
467+ tr.lastAuthenticatedPacket = now
468+ tr.lastCompleteRecord = now
469+ tr.mu.Unlock()
470+ tr.setLinkState(ports.LinkStateConnected, nil)
471+ },
472+ wantState: ports.LinkStateConnected,
473+ },
474+ }
475+
476+ for _, tt := range tests {
477+ t.Run(tt.name, func(t *testing.T) {
478+ aPC, bPC := newPair()
479+ var hops atomic.Int32
480+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
481+ ResendAfter: time.Hour,
482+ Heartbeat: time.Hour,
483+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
484+ hops.Add(1)
485+ return &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}, nil
486+ },
487+ })
488+ if err != nil {
489+ t.Fatal(err)
490+ }
491+ defer func() { _ = tr.Close() }()
492+ defer func() { _ = bPC.Close() }()
493+
494+ now := tr.clock.Now()
495+ tr.mu.Lock()
496+ staleAt := now.Add(-tt.staleAge)
497+ tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
498+ decisionReady := make(chan struct{})
499+ resume := make(chan struct{})
500+ tr.afterHealthDecision = func() {
501+ close(decisionReady)
502+ <-resume
503+ }
504+ tr.mu.Unlock()
505+
506+ done := make(chan struct{})
507+ go func() {
508+ tr.checkSilence()
509+ close(done)
510+ }()
511+ select {
512+ case <-decisionReady:
513+ case <-time.After(time.Second):
514+ t.Fatal("health decision hook was not reached")
515+ }
516+ tt.freshen(tr)
517+ close(resume)
518+ select {
519+ case <-done:
520+ case <-time.After(time.Second):
521+ t.Fatal("health check did not finish")
522+ }
523+
524+ if got := tr.LinkState(); got != tt.wantState {
525+ t.Fatalf("state=%v, want %v after fresh health evidence", got, tt.wantState)
526+ }
527+ if got := hops.Load(); got != 0 {
528+ t.Fatalf("socket hops=%d, want none after recovery", got)
529+ }
530+ for len(tr.linkEvents) > 0 {
531+ ev := <-tr.linkEvents
532+ if ev.State == ports.LinkStateDead || ev.State == ports.LinkStateProbing {
533+ t.Fatalf("stale link event emitted after recovery: %v", ev.State)
534+ }
535+ }
536+ select {
537+ case <-tr.done:
538+ t.Fatal("transport closed from stale health decision")
539+ default:
540+ }
541+ })
542+ }
543+}
544+
545+func TestLinkHealthStaleRebindRestoresFutureHopAllowance(t *testing.T) {
546+ aPC, bPC := newPair()
547+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
548+ rebindStarted := make(chan struct{})
549+ resumeRebind := make(chan struct{})
550+ var rebinds atomic.Int32
551+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
552+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
553+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
554+ attempt := rebinds.Add(1)
555+ pc := &fakePC{addr: testAddr(fmt.Sprintf("a%d", attempt+1)), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
556+ if attempt == 1 {
557+ close(rebindStarted)
558+ <-resumeRebind
559+ }
560+ return pc, nil
561+ },
562+ })
563+ if err != nil {
564+ t.Fatal(err)
565+ }
566+ defer func() { _ = tr.Close() }()
567+ defer func() { _ = bPC.Close() }()
568+
569+ tr.mu.Lock()
570+ staleAt := clk.Now().Add(-20 * time.Second)
571+ tr.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
572+ tr.mu.Unlock()
573+ firstDone := make(chan struct{})
574+ go func() {
575+ tr.checkSilence()
576+ close(firstDone)
577+ }()
578+ select {
579+ case <-rebindStarted:
580+ case <-time.After(time.Second):
581+ t.Fatal("first rebind did not start")
582+ }
583+
584+ tr.mu.Lock()
585+ now := clk.Now()
586+ tr.health.authenticatedPacket(now)
587+ tr.lastAuthenticatedPacket = now
588+ tr.mu.Unlock()
589+ close(resumeRebind)
590+ select {
591+ case <-firstDone:
592+ case <-time.After(time.Second):
593+ t.Fatal("stale rebind did not finish")
594+ }
595+ tr.mu.Lock()
596+ latched := tr.hoppedOffline
597+ pcAfterStale := tr.pc
598+ tr.mu.Unlock()
599+ if latched {
600+ t.Fatal("stale rebind consumed the future hop allowance")
601+ }
602+ if pcAfterStale != aPC {
603+ t.Fatal("stale rebind replaced the active packet conn")
604+ }
605+
606+ clk.advance(20 * time.Second)
607+ tr.checkSilence()
608+ tr.mu.Lock()
609+ pcAfterRetry := tr.pc
610+ tr.mu.Unlock()
611+ if got := rebinds.Load(); got != 2 {
612+ t.Fatalf("rebind attempts=%d, want a later eligible retry", got)
613+ }
614+ if pcAfterRetry == aPC {
615+ t.Fatal("later stale interval did not hop the packet conn")
616+ }
617+}
618+
619+func TestLinkHealthStateEventsCannotPublishAfterNewerRecovery(t *testing.T) {
620+ aPC, bPC := newPair()
621+ tr, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{ResendAfter: time.Hour, Heartbeat: time.Hour})
622+ if err != nil {
623+ t.Fatal(err)
624+ }
625+ defer func() { _ = tr.Close() }()
626+ defer func() { _ = bPC.Close() }()
627+
628+ probingCommitted := make(chan struct{})
629+ resumeProbing := make(chan struct{})
630+ tr.mu.Lock()
631+ tr.linkState = ports.LinkStateDegraded
632+ tr.afterLinkStateCommit = func(state ports.LinkState) {
633+ if state != ports.LinkStateProbing {
634+ return
635+ }
636+ close(probingCommitted)
637+ <-resumeProbing
638+ }
639+ tr.mu.Unlock()
640+
641+ probingDone := make(chan struct{})
642+ go func() {
643+ tr.setLinkState(ports.LinkStateProbing, nil)
644+ close(probingDone)
645+ }()
646+ select {
647+ case <-probingCommitted:
648+ case <-time.After(time.Second):
649+ t.Fatal("probing commit hook was not reached")
650+ }
651+ tr.setLinkState(ports.LinkStateConnected, nil)
652+ close(resumeProbing)
653+ select {
654+ case <-probingDone:
655+ case <-time.After(time.Second):
656+ t.Fatal("probing publisher did not finish")
657+ }
658+
659+ if got := tr.LinkState(); got != ports.LinkStateConnected {
660+ t.Fatalf("state=%v, want connected", got)
661+ }
662+ select {
663+ case ev := <-tr.LinkEvents():
664+ if ev.State != ports.LinkStateConnected {
665+ t.Fatalf("first event=%v, want connected", ev.State)
666+ }
667+ default:
668+ t.Fatal("missing connected recovery event")
669+ }
670+ select {
671+ case ev := <-tr.LinkEvents():
672+ t.Fatalf("stale event published after recovery: %v", ev.State)
673+ default:
674+ }
675+}
676+
677+func TestHopFiresAtProbingNotDegraded(t *testing.T) {
678+ aPC, bPC := newPair()
679+ var hopped atomic.Bool
680+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
681+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
682+ DegradedAfter: time.Second, ProbeAfter: 2 * time.Second, OfflineAfter: 3 * time.Second, DeadAfter: time.Hour,
683+ RebindPacketConn: func(net.PacketConn) (net.PacketConn, error) {
684+ newPC := &fakePC{addr: testAddr("a2"), in: make(chan packet, 100), peers: map[string]*fakePC{"b": bPC}}
685+ bPC.peers["a2"] = newPC
686+ hopped.Store(true)
687+ return newPC, nil
688+ },
689+ })
690+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
691+ defer func() { _ = a.Close() }()
692+ defer func() { _ = b.Close() }()
693+
694+ a.mu.Lock()
695+ staleAt := time.Now().Add(-time.Second)
696+ a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
697+ a.mu.Unlock()
698+ a.checkSilence()
699+ if hopped.Load() {
700+ t.Fatal("socket hopped at degraded silence")
701+ }
702+
703+ a.mu.Lock()
704+ a.health.lastPacket = time.Now().Add(-2 * time.Second)
705+ a.mu.Unlock()
706+ a.checkSilence()
707+ if !hopped.Load() {
708+ t.Fatal("socket did not hop at probing silence")
709+ }
710+}
711+
712+func TestShortSilenceDoesNotKillTransport(t *testing.T) {
713+ aPC, bPC := newPair()
714+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{DegradedAfter: 50 * time.Millisecond, DeadAfter: time.Hour})
715+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
716+ defer func() { _ = a.Close() }()
717+ defer func() { _ = b.Close() }()
718+ a.mu.Lock()
719+ staleAt := time.Now().Add(-time.Second)
720+ a.health = healthTracker{lastPacket: staleAt, lastRecord: staleAt, lastProgress: staleAt}
721+ a.mu.Unlock()
722+ a.checkSilence()
723+ if got := a.LinkState(); got != ports.LinkStateDegraded {
724+ t.Fatalf("LinkState()=%v, want degraded", got)
725+ }
726+ select {
727+ case <-a.done:
728+ t.Fatal("transport closed before DeadAfter")
729+ default:
730+ }
731+}
A internal/adapters/dgram/transport_lifecycle.go +177 -0
@@ -0,0 +1,177 @@
1+package dgram
2+
3+import (
4+ "errors"
5+ "net"
6+ "sync"
7+
8+ "github.com/bnema/vev/internal/ports"
9+ pdgram "github.com/bnema/vev/pkg/dgram"
10+)
11+
12+func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, runtimeOpts ...RuntimeOption) (*Transport, error) {
13+ return NewTransportWithOptions(pc, peer, key, sendDir, recvDir, Options{}, runtimeOpts...)
14+}
15+
16+func NewTransportWithOptions(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32, opts Options, runtimeOpts ...RuntimeOption) (*Transport, error) {
17+ c, err := pdgram.NewCodec(key)
18+ if err != nil {
19+ return nil, err
20+ }
21+ opts = normalizeOptions(opts)
22+ t := &Transport{
23+ pc: pc,
24+ codec: c,
25+ sendDir: sendDir,
26+ recvDir: recvDir,
27+ mtu: opts.MTU,
28+ peer: peer,
29+ pending: make(map[uint64]*pending),
30+ replay: pdgram.NewReplayWindow(),
31+ reasm: pdgram.NewReassembler(),
32+ in: make(chan ports.Frame, 32),
33+ done: make(chan struct{}),
34+ health: newHealthTracker(opts.Clock.Now()),
35+ lastAuthenticatedPacket: opts.Clock.Now(),
36+ lastCompleteRecord: opts.Clock.Now(),
37+ lastACKProgress: opts.Clock.Now(),
38+ observe: opts.Observe,
39+ heartbeat: opts.Heartbeat,
40+ resendAfter: opts.ResendAfter,
41+ maxResendAfter: opts.MaxResendAfter,
42+ maxResendPerTick: opts.MaxResendPerTick,
43+ rto: opts.ResendAfter,
44+ writeTimeout: opts.WriteTimeout,
45+ degradedAfter: opts.DegradedAfter,
46+ probeAfter: opts.ProbeAfter,
47+ offlineAfter: opts.OfflineAfter,
48+ deadAfter: opts.DeadAfter,
49+ maxPending: opts.MaxPending,
50+ maxPendingWait: opts.MaxPendingWait,
51+ maxRecvBuffer: opts.MaxRecvBuffer,
52+ clock: opts.Clock,
53+ congestion: newCongestionController(opts.MTU),
54+ linkState: ports.LinkStateConnected,
55+ linkEvents: make(chan ports.LinkEvent, linkEventBufferSize),
56+ probeWait: make(map[uint64]chan struct{}),
57+ rebind: opts.RebindPacketConn,
58+ nextRecvSeq: 1,
59+ recvBuf: make(map[uint64]ports.Frame),
60+ }
61+ for _, opt := range runtimeOpts {
62+ if opt != nil {
63+ opt(t)
64+ }
65+ }
66+ t.sendWake = make(chan struct{})
67+ t.control = make(chan controlRecord, controlQueueSize)
68+ t.ackWake = make(chan struct{}, 1)
69+ t.ackSend = make(chan uint64, 1)
70+ t.outputWake = make(chan struct{})
71+ t.dataSend = make(chan dataSendJob, opts.MaxPending)
72+ t.writeDeadlines = newWriteDeadlineState(pc)
73+ // One queued batch bounds retransmit work while its sole sender is paced.
74+ t.retransmitWork = make(chan []retransmitRecord, 1)
75+ if t.observe != nil {
76+ t.diagnosticCh = make(chan Diagnostic, diagnosticBufferSize)
77+ go t.diagnosticLoop()
78+ }
79+ t.deliverCond = sync.NewCond(&t.deliverMu)
80+ go t.readLoop(pc)
81+ go t.retransmitLoop()
82+ go t.heartbeatLoop()
83+ go t.healthLoop()
84+ go t.dataSendLoop()
85+ go t.deliveryLoop()
86+ go t.controlLoop()
87+ go t.ackSendLoop()
88+ go t.outputPaceLoop()
89+ t.emitDiagnostic()
90+ return t, nil
91+}
92+
93+func (t *Transport) Recv() (ports.Frame, error) {
94+ end := t.beginRuntimeOperation(ports.RuntimeAdapterReceiveStart, 0)
95+ select {
96+ case f := <-t.in:
97+ end(true)
98+ return f, nil
99+ case <-t.done:
100+ t.mu.Lock()
101+ err := t.closeErr
102+ t.mu.Unlock()
103+ if err != nil {
104+ end(false)
105+ return ports.Frame{}, err
106+ }
107+ err = errors.New("dgram: closed")
108+ end(false)
109+ return ports.Frame{}, err
110+ }
111+}
112+
113+func (t *Transport) Close() error {
114+ done := t.beginShutdown()
115+ t.closeWithError(errors.New("dgram: closed"))
116+ t.mu.Lock()
117+ pc := t.pc
118+ t.mu.Unlock()
119+ err := pc.Close()
120+ if done != nil {
121+ <-done
122+ }
123+ return err
124+}
125+
126+func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
127+
128+func (t *Transport) LinkState() ports.LinkState {
129+ t.mu.Lock()
130+ defer t.mu.Unlock()
131+ return t.linkState
132+}
133+
134+func (t *Transport) LinkEvents() <-chan ports.LinkEvent { return t.linkEvents }
135+
136+func (t *Transport) closedError() error {
137+ t.mu.Lock()
138+ defer t.mu.Unlock()
139+ if t.closeErr != nil {
140+ return t.closeErr
141+ }
142+ return errors.New("dgram: closed")
143+}
144+
145+func (t *Transport) closeWithError(err error) {
146+ t.mu.Lock()
147+ closed := t.closeWithErrorLocked(err)
148+ t.mu.Unlock()
149+ if closed {
150+ t.broadcastClosed()
151+ }
152+}
153+
154+func (t *Transport) closeWithErrorLocked(err error) bool {
155+ if t.closed {
156+ return false
157+ }
158+ t.closed = true
159+ t.closeErr = err
160+ for _, q := range t.outputQueue {
161+ if q.done != nil {
162+ q.done <- err
163+ close(q.done)
164+ }
165+ }
166+ t.outputQueue = nil
167+ t.probeWait = make(map[uint64]chan struct{})
168+ close(t.done)
169+ t.notifySendWaitersLocked()
170+ return true
171+}
172+
173+func (t *Transport) broadcastClosed() {
174+ t.deliverMu.Lock()
175+ t.deliverCond.Broadcast()
176+ t.deliverMu.Unlock()
177+}
A internal/adapters/dgram/transport_receive.go +264 -0
@@ -0,0 +1,264 @@
1+package dgram
2+
3+import (
4+ "encoding/binary"
5+ "net"
6+
7+ "github.com/bnema/vev/internal/ports"
8+ pdgram "github.com/bnema/vev/pkg/dgram"
9+)
10+
11+func (t *Transport) readLoop(pc net.PacketConn) {
12+ buf := make([]byte, 64*1024)
13+ for {
14+ n, addr, err := pc.ReadFrom(buf)
15+ if err != nil {
16+ t.mu.Lock()
17+ current := t.pc == pc
18+ closed := t.closed
19+ t.mu.Unlock()
20+ if current && !closed {
21+ t.closeWithError(err)
22+ }
23+ return
24+ }
25+ t.recvMu.Lock()
26+ counter, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
27+ if err != nil {
28+ t.recvMu.Unlock()
29+ continue
30+ }
31+ frag, err := pdgram.UnmarshalFragment(pt)
32+ if err != nil {
33+ t.recvMu.Unlock()
34+ t.notifyMalformedFragment()
35+ continue
36+ }
37+ payload, complete, err := t.reasm.Add(frag)
38+ inflight := t.reasm.Inflight()
39+ // Keep the diagnostic mirror ordered with reassembly mutations. Rebind can
40+ // overlap old and new read loops, so recvMu must remain held through commit.
41+ t.mu.Lock()
42+ t.reassemblyInflight = inflight
43+ var afterAuthenticated func()
44+ if err == nil {
45+ now := t.clock.Now()
46+ t.health.authenticatedPacket(now)
47+ t.lastAuthenticatedPacket = now
48+ if !t.peerCounterSet || counter > t.peerCounter {
49+ t.peer = addr
50+ t.peerCounter = counter
51+ t.peerCounterSet = true
52+ }
53+ afterAuthenticated = t.afterAuthenticatedPacket
54+ }
55+ t.mu.Unlock()
56+ t.recvMu.Unlock()
57+ if afterAuthenticated != nil {
58+ afterAuthenticated()
59+ }
60+ if err != nil {
61+ t.notifyMalformedFragment()
62+ t.notifyPacketProcessed()
63+ continue
64+ }
65+ if !complete {
66+ t.notifyPacketProcessed()
67+ continue
68+ }
69+ t.recordCompleteRecord()
70+ t.handleRecord(payload)
71+ t.checkSilence()
72+ t.notifyPacketProcessed()
73+ }
74+}
75+
76+func (t *Transport) notifyPacketProcessed() {
77+ t.mu.Lock()
78+ afterPacketProcessed := t.afterPacketProcessed
79+ t.mu.Unlock()
80+ if afterPacketProcessed != nil {
81+ afterPacketProcessed()
82+ }
83+}
84+
85+func (t *Transport) notifyMalformedFragment() {
86+ t.mu.Lock()
87+ afterMalformed := t.afterMalformedFragment
88+ t.mu.Unlock()
89+ if afterMalformed != nil {
90+ afterMalformed()
91+ }
92+}
93+
94+func (t *Transport) recordCompleteRecord() {
95+ t.mu.Lock()
96+ now := t.clock.Now()
97+ t.health.completeRecord(now)
98+ t.lastCompleteRecord = now
99+ t.mu.Unlock()
100+}
101+
102+func (t *Transport) handleRecord(p []byte) {
103+ if len(p) < 1 {
104+ return
105+ }
106+ switch p[0] {
107+ case recAck:
108+ if len(p) != 9 {
109+ return
110+ }
111+ seq := binary.BigEndian.Uint64(p[1:])
112+ t.mu.Lock()
113+ acked := false
114+ ackedBytes := 0
115+ for pendingSeq, p := range t.pending {
116+ if pendingSeq > seq {
117+ continue
118+ }
119+ if pendingSeq == seq && p != nil && !p.retransmitted {
120+ t.updateRTTLocked(t.clock.Now().Sub(p.first))
121+ }
122+ if p != nil {
123+ ackedBytes += p.wireBytes
124+ }
125+ delete(t.pending, pendingSeq)
126+ acked = true
127+ }
128+ if acked {
129+ t.congestion.onACK(ackedBytes)
130+ now := t.clock.Now()
131+ t.health.ackProgress(now)
132+ if len(t.pending) == 0 {
133+ t.health.pendingCleared()
134+ }
135+ t.lastACKProgress = now
136+ t.notifySendWaitersLocked()
137+ }
138+ t.mu.Unlock()
139+ if acked {
140+ t.setLinkState(ports.LinkStateConnected, nil)
141+ t.emitDiagnostic()
142+ }
143+ case recProbe:
144+ if len(p) != 9 {
145+ return
146+ }
147+ t.queueControl(recPong, binary.BigEndian.Uint64(p[1:]))
148+ case recPong:
149+ if len(p) != 9 {
150+ return
151+ }
152+ id := binary.BigEndian.Uint64(p[1:])
153+ t.mu.Lock()
154+ ch := t.probeWait[id]
155+ if ch != nil {
156+ delete(t.probeWait, id)
157+ close(ch)
158+ }
159+ t.mu.Unlock()
160+ case recData:
161+ seq, reliable, f, ok := decodeData(p)
162+ if !ok {
163+ return
164+ }
165+ if reliable {
166+ ackSeq, ack, queued := t.enqueueReliable(seq, f)
167+ if ack {
168+ t.queueACK(ackSeq)
169+ }
170+ if queued {
171+ t.signalDelivery()
172+ }
173+ return
174+ }
175+ t.deliver(f)
176+ }
177+}
178+
179+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) (ackSeq uint64, ack bool, queued bool) {
180+ t.deliverMu.Lock()
181+ defer t.deliverMu.Unlock()
182+ if seq < t.nextRecvSeq {
183+ return t.highestContiguousRecvLocked(), true, false
184+ }
185+ if _, exists := t.recvBuf[seq]; exists {
186+ return t.highestContiguousRecvLocked(), true, false
187+ }
188+ if len(t.recvBuf) >= t.maxRecvBuffer && seq != t.nextRecvSeq {
189+ return 0, false, false
190+ }
191+ t.recvBuf[seq] = f
192+ return t.highestContiguousRecvLocked(), true, true
193+}
194+
195+func (t *Transport) highestContiguousRecvLocked() uint64 {
196+ seq := t.nextRecvSeq
197+ for {
198+ if _, ok := t.recvBuf[seq]; ok {
199+ seq++
200+ continue
201+ }
202+ break
203+ }
204+ return seq - 1
205+}
206+
207+func (t *Transport) signalDelivery() {
208+ t.deliverMu.Lock()
209+ t.deliverCond.Signal()
210+ t.deliverMu.Unlock()
211+}
212+
213+func (t *Transport) deliveryLoop() {
214+ for {
215+ t.deliverMu.Lock()
216+ f, ok := t.recvBuf[t.nextRecvSeq]
217+ for !ok {
218+ if t.isClosed() {
219+ t.deliverMu.Unlock()
220+ return
221+ }
222+ t.deliverCond.Wait()
223+ f, ok = t.recvBuf[t.nextRecvSeq]
224+ }
225+ delete(t.recvBuf, t.nextRecvSeq)
226+ t.nextRecvSeq++
227+ t.deliverMu.Unlock()
228+ t.deliver(f)
229+ }
230+}
231+
232+func (t *Transport) isClosed() bool {
233+ t.mu.Lock()
234+ defer t.mu.Unlock()
235+ return t.closed
236+}
237+
238+func (t *Transport) deliver(f ports.Frame) {
239+ select {
240+ case t.in <- f:
241+ case <-t.done:
242+ }
243+}
244+
245+func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
246+ b := make([]byte, dataRecordHeaderSize+len(f.Payload))
247+ b[0] = recData
248+ binary.BigEndian.PutUint64(b[1:9], seq)
249+ if reliable {
250+ b[9] = 1
251+ }
252+ b[10] = byte(f.Type)
253+ copy(b[dataRecordHeaderSize:], f.Payload)
254+ return b
255+}
256+func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
257+ if len(b) < dataRecordHeaderSize || b[0] != recData {
258+ return 0, false, ports.Frame{}, false
259+ }
260+ if b[11] != 0 {
261+ return 0, false, ports.Frame{}, false
262+ }
263+ return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[dataRecordHeaderSize:]...)}, true
264+}
A internal/adapters/dgram/transport_receive_test.go +324 -0
@@ -0,0 +1,324 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "net"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ pdgram "github.com/bnema/vev/pkg/dgram"
12+)
13+
14+func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
15+ aPC, bPC := newPair()
16+ var droppedAck atomic.Bool
17+ bPC.drop = func(_ []byte, addr net.Addr) bool {
18+ return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
19+ }
20+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
25+ if err != nil {
26+ t.Fatal(err)
27+ }
28+ defer func() {
29+ if err := a.Close(); err != nil {
30+ t.Errorf("close a: %v", err)
31+ }
32+ }()
33+ defer func() {
34+ if err := b.Close(); err != nil {
35+ t.Errorf("close b: %v", err)
36+ }
37+ }()
38+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
39+ t.Fatal(err)
40+ }
41+ got := recvWithin(t, b, time.Second)
42+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" {
43+ t.Fatalf("got=%+v", got)
44+ }
45+ eventually(t, time.Second, droppedAck.Load)
46+ if got, ok := recvMaybe(b, 3*defaultResend); ok {
47+ t.Fatalf("duplicate reliable frame delivered: %+v", got)
48+ }
49+}
50+
51+func TestAuthenticatedPeerChangeUpdatesPeerAndEmitsConnected(t *testing.T) {
52+ aPC, bPC := newPair()
53+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ defer func() {
58+ if err := a.Close(); err != nil {
59+ t.Errorf("close a: %v", err)
60+ }
61+ }()
62+ evilPC := &fakePC{addr: testAddr("evil"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
63+ aPC.peers["evil"] = evilPC
64+ _ = bPC
65+ old := a.Peer().String()
66+ a.setLinkState(ports.LinkStateOffline, nil)
67+ <-a.LinkEvents()
68+ aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
69+ if a.Peer().String() != old {
70+ t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
71+ }
72+ c, _ := pdgram.NewCodec(key())
73+ rec := encodeData(7, true, ports.Frame{Type: ports.MsgPing})
74+ frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
75+ raw, _ := pdgram.MarshalFragment(frags[0])
76+ aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
77+ waitPeer(t, a, "evil")
78+ select {
79+ case ev := <-a.LinkEvents():
80+ if ev.State != ports.LinkStateConnected {
81+ t.Fatalf("event state=%v, want connected", ev.State)
82+ }
83+ case <-time.After(time.Second):
84+ t.Fatal("missing connected event after authenticated rehome")
85+ }
86+ select {
87+ case pkt := <-evilPC.in:
88+ if pkt.addr.String() != "a" {
89+ t.Fatalf("ack source=%v, want a", pkt.addr)
90+ }
91+ case <-time.After(time.Second):
92+ t.Fatal("missing ACK to authenticated new peer")
93+ }
94+}
95+
96+func TestAuthenticatedFragmentSeparatesContactProgressAndPeerEligibility(t *testing.T) {
97+ aPC, bPC := newPair()
98+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
99+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
100+ Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour,
101+ })
102+ if err != nil {
103+ t.Fatal(err)
104+ }
105+ defer func() { _ = a.Close() }()
106+ defer func() { _ = bPC.Close() }()
107+
108+ a.setLinkState(ports.LinkStateDegraded, nil)
109+ <-a.LinkEvents()
110+ codec, err := pdgram.NewCodec(key())
111+ if err != nil {
112+ t.Fatal(err)
113+ }
114+ authenticated := make(chan struct{}, 8)
115+ malformed := make(chan struct{}, 8)
116+ a.mu.Lock()
117+ a.afterAuthenticatedPacket = func() { authenticated <- struct{}{} }
118+ a.afterMalformedFragment = func() { malformed <- struct{}{} }
119+ initialPacket := a.health.lastPacket
120+ initialRecord := a.health.lastRecord
121+ a.mu.Unlock()
122+
123+ sealFragment := func(counter uint64, frag pdgram.Fragment) []byte {
124+ t.Helper()
125+ raw, marshalErr := pdgram.MarshalFragment(frag)
126+ if marshalErr != nil {
127+ t.Fatal(marshalErr)
128+ }
129+ return codec.Seal(2, counter, raw, nil)
130+ }
131+ waitSignal := func(ch <-chan struct{}, what string) {
132+ t.Helper()
133+ select {
134+ case <-ch:
135+ case <-time.After(time.Second):
136+ t.Fatalf("timeout waiting for %s", what)
137+ }
138+ }
139+ assertHealth := func(wantPacket, wantRecord time.Time, wantPeer string, wantState ports.LinkState) {
140+ t.Helper()
141+ a.mu.Lock()
142+ gotPacket := a.health.lastPacket
143+ gotRecord := a.health.lastRecord
144+ a.mu.Unlock()
145+ if !gotPacket.Equal(wantPacket) || !gotRecord.Equal(wantRecord) {
146+ t.Fatalf("health packet/record=(%v,%v), want (%v,%v)", gotPacket, gotRecord, wantPacket, wantRecord)
147+ }
148+ if got := a.Peer().String(); got != wantPeer {
149+ t.Fatalf("peer=%s, want %s", got, wantPeer)
150+ }
151+ if got := a.LinkState(); got != wantState {
152+ t.Fatalf("state=%v, want %v", got, wantState)
153+ }
154+ }
155+
156+ // Neither failed authentication nor an authenticated but invalid fragment
157+ // crosses the contact or roaming trust boundary.
158+ badAEAD := codec.Seal(2, 1, []byte("not a fragment"), nil)
159+ badAEAD[len(badAEAD)-1] ^= 0xff
160+ aPC.in <- packet{b: badAEAD, addr: testAddr("roam1")}
161+ aPC.in <- packet{b: codec.Seal(2, 2, []byte("not a fragment"), nil), addr: testAddr("roam1")}
162+ waitSignal(malformed, "malformed fragment")
163+ assertHealth(initialPacket, initialRecord, "b", ports.LinkStateDegraded)
164+
165+ clk.advance(time.Second)
166+ partial := pdgram.Fragment{Seq: 100, Index: 0, Count: 2, Data: []byte("half")}
167+ acceptedPacket := clk.Now()
168+ acceptedRaw := sealFragment(10, partial)
169+ aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam1")}
170+ waitSignal(authenticated, "accepted incomplete fragment")
171+ assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
172+
173+ // A replay and an Add-time fragment inconsistency cannot refresh contact or
174+ // migrate the peer. Reassembly diagnostics still update on the Add error.
175+ aPC.in <- packet{b: acceptedRaw, addr: testAddr("roam2")}
176+ aPC.in <- packet{b: codec.Seal(2, 11, []byte("still not a fragment"), nil), addr: testAddr("roam2")}
177+ waitSignal(malformed, "replay barrier")
178+ assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
179+
180+ aPC.in <- packet{b: sealFragment(12, pdgram.Fragment{Seq: 100, Index: 1, Count: 3, Data: []byte("mismatch")}), addr: testAddr("roam2")}
181+ waitSignal(malformed, "reassembly rejection")
182+ assertHealth(acceptedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
183+
184+ // Fresh reordered packets refresh contact, but only the greatest accepted
185+ // AEAD counter is eligible to move the roaming peer. Contact alone must not
186+ // re-arm socket hopping while stream progress remains stalled.
187+ a.mu.Lock()
188+ a.hoppedOffline = true
189+ a.mu.Unlock()
190+ clk.advance(time.Second)
191+ reorderedPacket := clk.Now()
192+ aPC.in <- packet{b: sealFragment(9, pdgram.Fragment{Seq: 101, Index: 0, Count: 2, Data: []byte("older")}), addr: testAddr("roam2")}
193+ waitSignal(authenticated, "fresh reordered fragment")
194+ assertHealth(reorderedPacket, initialRecord, "roam1", ports.LinkStateDegraded)
195+ a.mu.Lock()
196+ if !a.hoppedOffline {
197+ a.mu.Unlock()
198+ t.Fatal("fragment-only contact re-armed socket hopping")
199+ }
200+ a.mu.Unlock()
201+
202+ clk.advance(time.Second)
203+ newestPacket := clk.Now()
204+ aPC.in <- packet{b: sealFragment(13, pdgram.Fragment{Seq: 102, Index: 0, Count: 2, Data: []byte("newer")}), addr: testAddr("roam2")}
205+ waitSignal(authenticated, "new greatest fragment")
206+ assertHealth(newestPacket, initialRecord, "roam2", ports.LinkStateDegraded)
207+
208+ clk.advance(time.Second)
209+ completeAt := clk.Now()
210+ aPC.in <- packet{b: sealFragment(14, pdgram.Fragment{Seq: 103, Index: 0, Count: 1, Data: probeRecord(recPong, 999)}), addr: testAddr("roam2")}
211+ waitSignal(authenticated, "complete record")
212+ eventually(t, time.Second, func() bool { return a.LinkState() == ports.LinkStateConnected })
213+ assertHealth(completeAt, completeAt, "roam2", ports.LinkStateConnected)
214+}
215+
216+func TestReliableReceiveBufferDoesNotDropAckedFutureFrame(t *testing.T) {
217+ tr := &Transport{maxRecvBuffer: 1, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
218+ _, ack, queued := tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("future")})
219+ if !ack || !queued {
220+ t.Fatalf("future enqueue ack=%v queued=%v, want true/true", ack, queued)
221+ }
222+ _, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
223+ if !ack || !queued {
224+ t.Fatalf("next enqueue ack=%v queued=%v, want true/true", ack, queued)
225+ }
226+ if _, ok := tr.recvBuf[2]; !ok {
227+ t.Fatal("acked future frame was dropped when next frame arrived")
228+ }
229+}
230+
231+func TestDataRecordWireEncoding(t *testing.T) {
232+ got := encodeData(5, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("x")})
233+ want := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 0, 'x'}
234+ if !bytes.Equal(got, want) {
235+ t.Fatalf("encoded data=%v, want %v", got, want)
236+ }
237+ seq, reliable, frame, ok := decodeData(got)
238+ if !ok || seq != 5 || !reliable || frame.Type != ports.MsgOutput || string(frame.Payload) != "x" {
239+ t.Fatalf("decoded seq=%d reliable=%v frame=%+v ok=%v", seq, reliable, frame, ok)
240+ }
241+}
242+
243+func TestDataRecordWireDecodeRejectsUnknownFlags(t *testing.T) {
244+ withTrailingGarbage := []byte{recData, 0, 0, 0, 0, 0, 0, 0, 5, 1, byte(ports.MsgOutput), 1, 'x'}
245+ if _, _, _, ok := decodeData(withTrailingGarbage); ok {
246+ t.Fatal("decodeData accepted unknown data-record flags with trailing garbage")
247+ }
248+}
249+
250+func TestReliableDeliveryPreservesInputSequenceOrder(t *testing.T) {
251+ aPC, bPC := newPair()
252+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
253+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
254+ defer func() { _ = a.Close() }()
255+ defer func() { _ = b.Close() }()
256+
257+ for i := range 100 {
258+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte{byte(i)}}); err != nil {
259+ t.Fatal(err)
260+ }
261+ }
262+ for i := range 100 {
263+ got := recvWithin(t, b, time.Second)
264+ if got.Type != ports.MsgInput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
265+ t.Fatalf("frame %d delivered out of order: %+v", i, got)
266+ }
267+ }
268+}
269+
270+func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
271+ aPC, bPC := newPair()
272+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
273+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
274+ defer func() { _ = a.Close() }()
275+ defer func() { _ = b.Close() }()
276+
277+ for i := range cap(b.in) + 20 {
278+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
279+ t.Fatal(err)
280+ }
281+ }
282+ deadline := time.Now().Add(time.Second)
283+ for time.Now().Before(deadline) {
284+ a.mu.Lock()
285+ pending := len(a.pending)
286+ a.mu.Unlock()
287+ if pending == 0 {
288+ return
289+ }
290+ time.Sleep(10 * time.Millisecond)
291+ }
292+ a.mu.Lock()
293+ pending := len(a.pending)
294+ a.mu.Unlock()
295+ t.Fatalf("acks blocked behind undrained consumer; pending=%d", pending)
296+}
297+
298+func TestReliableReceiverAcksOnlyHighestContiguousSequence(t *testing.T) {
299+ tr := &Transport{maxRecvBuffer: maxRecvBuffer, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
300+
301+ ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("third")})
302+ if !ack || !queued {
303+ t.Fatalf("out-of-order enqueue ack=%v queued=%v, want true/true", ack, queued)
304+ }
305+ if got, want := ackSeq, uint64(0); got != want {
306+ t.Fatalf("out-of-order ack=%d, want highest contiguous %d", got, want)
307+ }
308+
309+ ackSeq, ack, queued = tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("first")})
310+ if !ack || !queued {
311+ t.Fatalf("first enqueue ack=%v queued=%v, want true/true", ack, queued)
312+ }
313+ if got, want := ackSeq, uint64(1); got != want {
314+ t.Fatalf("first contiguous ack=%d, want %d", got, want)
315+ }
316+
317+ ackSeq, ack, queued = tr.enqueueReliable(2, ports.Frame{Type: ports.MsgOutput, Payload: []byte("second")})
318+ if !ack || !queued {
319+ t.Fatalf("gap fill enqueue ack=%v queued=%v, want true/true", ack, queued)
320+ }
321+ if got, want := ackSeq, uint64(3); got != want {
322+ t.Fatalf("filled gap ack=%d, want %d", got, want)
323+ }
324+}
A internal/adapters/dgram/transport_retransmit.go +303 -0
@@ -0,0 +1,303 @@
1+package dgram
2+
3+import (
4+ "errors"
5+ "net"
6+ "slices"
7+ "time"
8+
9+ pdgram "github.com/bnema/vev/pkg/dgram"
10+)
11+
12+func (t *Transport) retransmitLoop() {
13+ resend := t.clock.NewTimer(t.resendAfter)
14+ defer resend.Stop()
15+ for {
16+ select {
17+ case <-resend.C():
18+ t.queueRetransmits()
19+ resend.Reset(t.resendAfter)
20+ t.notifyTimerHook(func() func() { return t.afterRetransmitTimer })
21+ case <-t.done:
22+ return
23+ }
24+ }
25+}
26+
27+func (t *Transport) dataSendLoop() {
28+ pacer := bytePacer{clk: t.clock, afterTimer: func(deadline time.Time) {
29+ t.mu.Lock()
30+ hook := t.afterDataPaceWait
31+ t.mu.Unlock()
32+ if hook != nil {
33+ hook(deadline)
34+ }
35+ }}
36+ for {
37+ select {
38+ case job := <-t.dataSend:
39+ t.runDataSendJob(&pacer, job, true)
40+ case batch := <-t.retransmitWork:
41+ t.runRetransmitBatch(&pacer, batch)
42+ case <-t.done:
43+ return
44+ }
45+ }
46+}
47+
48+func (t *Transport) runDataSendJob(pacer *bytePacer, job dataSendJob, initial bool) {
49+ if initial {
50+ t.markPendingSent(job.seq, job.reliable)
51+ }
52+ _, err := t.writeDataJob(pacer, job, nil)
53+ if err != nil {
54+ if initial {
55+ t.removePending(job.seq, job.reliable)
56+ } else {
57+ t.markPendingReady(job.seq, job.reliable)
58+ }
59+ if !errors.Is(err, errPacerClosed) {
60+ t.closeWithError(err)
61+ }
62+ } else {
63+ t.markPendingReady(job.seq, job.reliable)
64+ }
65+ t.mu.Lock()
66+ afterDataJob := t.afterDataJob
67+ t.mu.Unlock()
68+ if afterDataJob != nil {
69+ afterDataJob()
70+ }
71+ if job.done != nil {
72+ if errors.Is(err, errPacerClosed) {
73+ err = t.closedError()
74+ }
75+ job.done <- err
76+ close(job.done)
77+ }
78+}
79+
80+func (t *Transport) writeDataJob(pacer *bytePacer, job dataSendJob, onFirstWrite func()) (bool, error) {
81+ if job.reliable && !t.pendingExists(job.seq) {
82+ return false, nil
83+ }
84+ payload := encodeData(job.seq, job.reliable, job.frame)
85+ frags, err := pdgram.FragmentPayload(t.nextCounter(), payload, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
86+ if err != nil {
87+ return false, err
88+ }
89+ t.mu.Lock()
90+ beforeDataPace := t.beforeDataPace
91+ t.mu.Unlock()
92+ if beforeDataPace != nil {
93+ beforeDataPace()
94+ }
95+ limits := func() (int, int) {
96+ t.mu.Lock()
97+ defer t.mu.Unlock()
98+ return t.congestion.bytesPerSecond(), t.congestion.burstBytes()
99+ }
100+ emitted := false
101+ wireBytes := 0
102+ for i, frag := range frags {
103+ if job.reliable && !t.pendingExists(job.seq) {
104+ return emitted, nil
105+ }
106+ raw, err := pdgram.MarshalFragment(frag)
107+ if err != nil {
108+ return emitted, err
109+ }
110+ pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
111+ wireBytes += len(pkt)
112+ if job.reliable {
113+ t.mu.Lock()
114+ if pending := t.pending[job.seq]; pending != nil && pending.wireBytes < wireBytes {
115+ pending.wireBytes = wireBytes
116+ }
117+ t.mu.Unlock()
118+ }
119+ if err := pacer.wait(t.done, len(pkt), limits); err != nil {
120+ return emitted, err
121+ }
122+ if job.reliable && !t.pendingExists(job.seq) {
123+ return emitted, nil
124+ }
125+ t.mu.Lock()
126+ peer := t.peer
127+ closed := t.closed
128+ closeErr := t.closeErr
129+ t.mu.Unlock()
130+ if closed {
131+ if closeErr != nil {
132+ return emitted, closeErr
133+ }
134+ return emitted, net.ErrClosed
135+ }
136+ if peer == nil {
137+ return emitted, errors.New("dgram: no peer")
138+ }
139+ if i == len(frags)-1 {
140+ t.markPendingFinalWrite(job.seq)
141+ }
142+ if err := t.writePacket(pkt, peer); err != nil {
143+ return emitted, err
144+ }
145+ if !emitted {
146+ emitted = true
147+ if onFirstWrite != nil {
148+ onFirstWrite()
149+ }
150+ }
151+ }
152+ return emitted, nil
153+}
154+
155+func (t *Transport) runRetransmitBatch(pacer *bytePacer, batch []retransmitRecord) {
156+ defer func() {
157+ t.mu.Lock()
158+ hook := t.afterDataJob
159+ t.mu.Unlock()
160+ if hook != nil {
161+ hook()
162+ }
163+ }()
164+ lossApplied := false
165+ onFirstWrite := func() {
166+ if lossApplied {
167+ return
168+ }
169+ t.mu.Lock()
170+ t.congestion.onLoss()
171+ t.mu.Unlock()
172+ lossApplied = true
173+ }
174+ for _, r := range batch {
175+ job := dataSendJob{seq: r.seq, reliable: true, frame: r.f}
176+ _, err := t.writeDataJob(pacer, job, onFirstWrite)
177+ if err == nil {
178+ t.markPendingReady(r.seq, true)
179+ continue
180+ }
181+ t.markPendingReady(r.seq, true)
182+ return
183+ }
184+}
185+
186+func (t *Transport) queueRetransmits() {
187+ // Do not mark records in-flight while the one-slot sender queue is full.
188+ // There is one producer and the sender only consumes, so capacity cannot be
189+ // lost after this check.
190+ if len(t.retransmitWork) == cap(t.retransmitWork) {
191+ return
192+ }
193+
194+ now := t.clock.Now()
195+ t.mu.Lock()
196+ resend, emitDiagnostic := t.selectRetransmitsLocked(now)
197+ t.mu.Unlock()
198+
199+ if len(resend) > 0 {
200+ select {
201+ case t.retransmitWork <- resend:
202+ case <-t.done:
203+ return
204+ }
205+ }
206+ if emitDiagnostic {
207+ t.emitDiagnostic()
208+ }
209+}
210+
211+// selectRetransmitsLocked deterministically reserves one bounded retransmit
212+// turn. The caller must hold t.mu and performs all pacing and writes after
213+// releasing it.
214+func (t *Transport) selectRetransmitsLocked(now time.Time) ([]retransmitRecord, bool) {
215+ resend := make([]retransmitRecord, 0, t.maxResendPerTick)
216+ seqs := make([]uint64, 0, len(t.pending))
217+ for seq := range t.pending {
218+ seqs = append(seqs, seq)
219+ }
220+ slices.Sort(seqs)
221+ limit := t.maxResendPerTick
222+ emitDiagnostic := false
223+ for _, seq := range seqs {
224+ if limit <= 0 {
225+ break
226+ }
227+ p := t.pending[seq]
228+ if p == nil || p.last.IsZero() || p.initialInFlight || now.Sub(p.last) < t.resendDelayLocked(p) {
229+ continue
230+ }
231+ p.initialInFlight = true
232+ p.attempts++
233+ p.retransmitted = true
234+ t.retransmits++
235+ emitDiagnostic = emitDiagnostic || t.retransmits%64 == 0
236+ resend = append(resend, retransmitRecord{seq: seq, f: p.frame})
237+ limit--
238+ }
239+ return resend, emitDiagnostic
240+}
241+
242+// resendPending remains a test hook for retransmission selection. Constructed
243+// transports transfer its batch to the sole data sender; small unit fixtures
244+// without transport goroutines execute the selected writes inline.
245+func (t *Transport) resendPending() {
246+ now := t.clock.Now()
247+ t.mu.Lock()
248+ resend, _ := t.selectRetransmitsLocked(now)
249+ t.mu.Unlock()
250+ if t.retransmitWork != nil {
251+ select {
252+ case t.retransmitWork <- resend:
253+ case <-t.done:
254+ }
255+ return
256+ }
257+ pacer := bytePacer{clk: t.clock}
258+ t.runRetransmitBatch(&pacer, resend)
259+}
260+
261+func (t *Transport) resendDelayLocked(p *pending) time.Duration {
262+ d := t.rto
263+ if d <= 0 {
264+ d = t.resendAfter
265+ }
266+ for i := 0; i < p.attempts && d < t.maxResendAfter; i++ {
267+ d *= 2
268+ if d > t.maxResendAfter {
269+ return t.maxResendAfter
270+ }
271+ }
272+ return d
273+}
274+
275+func (t *Transport) updateRTTLocked(sample time.Duration) {
276+ if sample <= 0 {
277+ return
278+ }
279+ if t.srtt <= 0 {
280+ t.srtt = sample
281+ t.rttvar = sample / 2
282+ } else {
283+ delta := t.srtt - sample
284+ if delta < 0 {
285+ delta = -delta
286+ }
287+ t.rttvar = (3*t.rttvar + delta) / 4
288+ t.srtt = (7*t.srtt + sample) / 8
289+ }
290+ variation := 4 * t.rttvar
291+ if variation < time.Millisecond {
292+ variation = time.Millisecond
293+ }
294+ rto := t.srtt + variation
295+ if rto < t.resendAfter {
296+ rto = t.resendAfter
297+ }
298+ if rto > t.maxResendAfter {
299+ rto = t.maxResendAfter
300+ }
301+ t.rto = rto
302+ t.congestion.onRTT(t.srtt)
303+}
A internal/adapters/dgram/transport_retransmit_test.go +353 -0
@@ -0,0 +1,353 @@
1+package dgram
2+
3+import (
4+ "net"
5+ "sync/atomic"
6+ "testing"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestResendSkipsQueuedOutputBeforeFirstWireAttempt(t *testing.T) {
13+ tr, writes := newResendTestTransport(t, 10)
14+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}}
15+
16+ tr.resendPending()
17+
18+ if got := writes.Load(); got != 0 {
19+ t.Fatalf("writes=%d, want queued output excluded from resend", got)
20+ }
21+ if !tr.pending[1].first.IsZero() || !tr.pending[1].last.IsZero() {
22+ t.Fatal("resend scan marked queued output as sent")
23+ }
24+}
25+
26+func TestResendSkipsOutputDuringFirstWireAttempt(t *testing.T) {
27+ tr, writes := newResendTestTransport(t, 10)
28+ now := tr.clock.Now()
29+ tr.pending[1] = &pending{
30+ frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("sending")},
31+ first: now.Add(-time.Second),
32+ last: now.Add(-time.Second),
33+ initialInFlight: true,
34+ }
35+
36+ tr.resendPending()
37+ if got := writes.Load(); got != 0 {
38+ t.Fatalf("writes=%d, want first wire attempt excluded from resend", got)
39+ }
40+ tr.pending[1].initialInFlight = false
41+ tr.resendPending()
42+ if got := writes.Load(); got != 1 {
43+ t.Fatalf("writes=%d, want resend after first wire attempt completed", got)
44+ }
45+}
46+
47+func TestReliableInputRetransmitsUntilAck(t *testing.T) {
48+ aPC, bPC := newPair()
49+ var dropped atomic.Bool
50+ aPC.drop = func(_ []byte, addr net.Addr) bool {
51+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
52+ }
53+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ defer func() {
62+ if err := a.Close(); err != nil {
63+ t.Errorf("close a: %v", err)
64+ }
65+ }()
66+ defer func() {
67+ if err := b.Close(); err != nil {
68+ t.Errorf("close b: %v", err)
69+ }
70+ }()
71+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
72+ t.Fatal(err)
73+ }
74+ got := recvWithin(t, b, time.Second)
75+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
76+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
77+ }
78+}
79+
80+func TestOutputRetransmitsUntilAck(t *testing.T) {
81+ aPC, bPC := newPair()
82+ var dropped atomic.Bool
83+ aPC.drop = func(_ []byte, addr net.Addr) bool {
84+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
85+ }
86+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
87+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
88+ defer func() {
89+ if err := a.Close(); err != nil {
90+ t.Errorf("close a: %v", err)
91+ }
92+ }()
93+ defer func() {
94+ if err := b.Close(); err != nil {
95+ t.Errorf("close b: %v", err)
96+ }
97+ }()
98+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil {
99+ t.Fatal(err)
100+ }
101+ got := recvWithin(t, b, time.Second)
102+ if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
103+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
104+ }
105+}
106+
107+func TestImmediateLoopbackAckSamplesStampedFinalWrite(t *testing.T) {
108+ aPC, bPC := newPair()
109+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
110+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
111+ defer func() { _ = a.Close() }()
112+ defer func() { _ = b.Close() }()
113+ aPC.afterWrite = func() {
114+ deadline := time.Now().Add(time.Second)
115+ for time.Now().Before(deadline) {
116+ a.mu.Lock()
117+ _, pending := a.pending[1]
118+ a.mu.Unlock()
119+ if !pending {
120+ return
121+ }
122+ time.Sleep(time.Microsecond)
123+ }
124+ t.Error("ACK did not retire pending frame before WriteTo returned")
125+ }
126+
127+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state")}); err != nil {
128+ t.Fatal(err)
129+ }
130+ eventually(t, time.Second, func() bool {
131+ a.mu.Lock()
132+ defer a.mu.Unlock()
133+ return a.srtt > 0
134+ })
135+ a.mu.Lock()
136+ srtt, rto := a.srtt, a.rto
137+ a.mu.Unlock()
138+ if srtt <= 0 || srtt > time.Second {
139+ t.Fatalf("srtt=%v, want finite immediate-loopback sample", srtt)
140+ }
141+ if rto < a.resendAfter || rto > a.maxResendAfter {
142+ t.Fatalf("rto=%v outside [%v,%v]", rto, a.resendAfter, a.maxResendAfter)
143+ }
144+}
145+
146+func TestOutputDependencyChainRetransmitsDroppedPredecessors(t *testing.T) {
147+ aPC, bPC := newPair()
148+ var sendAttempts atomic.Int32
149+ aPC.drop = func(_ []byte, addr net.Addr) bool {
150+ return addr.String() == "b" && sendAttempts.Add(1) <= 2
151+ }
152+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
153+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
154+ defer func() { _ = a.Close() }()
155+ defer func() { _ = b.Close() }()
156+
157+ for _, payload := range []string{"state1", "state2", "state3"} {
158+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
159+ t.Fatal(err)
160+ }
161+ }
162+
163+ for _, want := range []string{"state1", "state2", "state3"} {
164+ got := recvWithin(t, b, time.Second)
165+ if got.Type != ports.MsgOutput || string(got.Payload) != want {
166+ t.Fatalf("got=%+v, want ordered prerequisite output %q", got, want)
167+ }
168+ }
169+ if sendAttempts.Load() < 3 {
170+ t.Fatalf("send attempts=%d, want first two output sends dropped before newest arrived", sendAttempts.Load())
171+ }
172+}
173+
174+func TestResendPendingCapsBurst(t *testing.T) {
175+ tr, writes := newResendTestTransport(t, 2)
176+ now := tr.clock.Now()
177+ for i := range 5 {
178+ tr.pending[uint64(i+1)] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}, last: now.Add(-time.Second)}
179+ }
180+
181+ tr.resendPending()
182+ if got := writes.Load(); got != 2 {
183+ t.Fatalf("resend writes=%d, want capped burst of 2", got)
184+ }
185+}
186+
187+func TestRetransmitBatchReducesCongestionWindowOnce(t *testing.T) {
188+ tr, writes := newResendTestTransport(t, 10)
189+ now := tr.clock.Now()
190+ for seq := uint64(1); seq <= 3; seq++ {
191+ tr.pending[seq] = &pending{
192+ frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}},
193+ last: now.Add(-time.Second),
194+ }
195+ }
196+ initialCwnd := tr.congestion.cwndBytes
197+
198+ tr.resendPending()
199+
200+ if got := writes.Load(); got != 3 {
201+ t.Fatalf("retransmit writes=%d, want 3", got)
202+ }
203+ if got, want := tr.congestion.cwndBytes, initialCwnd/2; got != want {
204+ t.Fatalf("cwnd after one retransmit batch=%d, want %d", got, want)
205+ }
206+}
207+
208+func TestACKBeforeQueuedRetransmitDoesNotReduceCongestionWindow(t *testing.T) {
209+ tr, writes := newResendTestTransport(t, 1)
210+ now := tr.clock.Now()
211+ tr.pending[1] = &pending{
212+ frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("acked")},
213+ first: now.Add(-time.Second),
214+ last: now.Add(-time.Second),
215+ wireBytes: tr.mtu,
216+ }
217+ initialCwnd := tr.congestion.cwndBytes
218+ tr.mu.Lock()
219+ batch, _ := tr.selectRetransmitsLocked(now)
220+ tr.mu.Unlock()
221+ if len(batch) != 1 {
222+ t.Fatalf("selected retransmits=%d, want 1", len(batch))
223+ }
224+ tr.handleRecord(ackRecord(1))
225+ cwndAfterACK := initialCwnd + max(1, tr.mtu*tr.mtu/initialCwnd)
226+ counterAfterACK := tr.ctr
227+
228+ tr.runRetransmitBatch(&bytePacer{clk: tr.clock}, batch)
229+
230+ if got := tr.ctr; got != counterAfterACK {
231+ t.Fatalf("packet counter after ACK=%d, want unchanged %d", got, counterAfterACK)
232+ }
233+ if got := writes.Load(); got != 0 {
234+ t.Fatalf("writes after ACK=%d, want 0", got)
235+ }
236+ if got := tr.congestion.cwndBytes; got != cwndAfterACK {
237+ t.Fatalf("cwnd after ACK race=%d, want ACK-only growth %d", got, cwndAfterACK)
238+ }
239+}
240+
241+func TestOversizedRetransmitHonorsPacketBudget(t *testing.T) {
242+ tr, writes := newResendTestTransport(t, 1)
243+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
244+ tr.clock = clk
245+ tr.done = make(chan struct{})
246+ tr.pending[1] = &pending{
247+ frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*tr.mtu)},
248+ last: clk.now.Add(-time.Second),
249+ }
250+ done := make(chan struct{})
251+ go func() {
252+ tr.resendPending()
253+ close(done)
254+ }()
255+
256+ previous := int32(0)
257+ for tick := 0; tick < 128; tick++ {
258+ clk.advance(initialPacingRTT)
259+ time.Sleep(time.Millisecond)
260+ current := writes.Load()
261+ if delta := current - previous; delta > dataBurstPackets {
262+ t.Fatalf("tick %d retransmitted %d packets, burst %d", tick, delta, dataBurstPackets)
263+ }
264+ previous = current
265+ select {
266+ case <-done:
267+ return
268+ default:
269+ }
270+ }
271+ t.Fatal("oversized retransmit did not complete within pacing ticks")
272+}
273+
274+func TestResendPendingUsesExponentialBackoffAndMaxDelay(t *testing.T) {
275+ tr, writes := newResendTestTransport(t, 10)
276+ now := tr.clock.Now()
277+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("too-soon")}, last: now.Add(-150 * time.Millisecond), attempts: 1}
278+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("ready")}, last: now.Add(-250 * time.Millisecond), attempts: 1}
279+ tr.pending[3] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte("capped")}, last: now.Add(-time.Second), attempts: 10}
280+
281+ tr.resendPending()
282+ if got := writes.Load(); got != 2 {
283+ t.Fatalf("resend writes=%d, want ready and capped frames only", got)
284+ }
285+ if attempts := tr.pending[1].attempts; attempts != 1 {
286+ t.Fatalf("too-soon attempts=%d, want unchanged", attempts)
287+ }
288+ if attempts := tr.pending[2].attempts; attempts != 2 {
289+ t.Fatalf("ready attempts=%d, want incremented", attempts)
290+ }
291+ if attempts := tr.pending[3].attempts; attempts != 11 {
292+ t.Fatalf("capped attempts=%d, want incremented", attempts)
293+ }
294+}
295+
296+func TestCumulativeAckReleasesAllEarlierPendingFrames(t *testing.T) {
297+ tr, _ := newResendTestTransport(t, 10)
298+ now := tr.clock.Now()
299+ for seq := uint64(1); seq <= 3; seq++ {
300+ tr.pending[seq] = &pending{frame: ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(seq)}}, first: now, last: now}
301+ }
302+
303+ tr.handleRecord(ackRecord(3))
304+
305+ if got := len(tr.pending); got != 0 {
306+ t.Fatalf("pending after cumulative ack=%d, want 0", got)
307+ }
308+}
309+
310+func TestRTTEstimatorUpdatesRTOFromAckSample(t *testing.T) {
311+ clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
312+ tr, writes := newResendTestTransport(t, 10)
313+ tr.clock = clk
314+ tr.resendAfter = 100 * time.Millisecond
315+ tr.maxResendAfter = time.Second
316+ tr.rto = tr.resendAfter
317+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-200 * time.Millisecond)}
318+
319+ tr.handleRecord(ackRecord(1))
320+
321+ if tr.rto <= tr.resendAfter {
322+ t.Fatalf("rto=%v, want above initial resend after RTT sample", tr.rto)
323+ }
324+ tr.pending[2] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now, last: clk.now.Add(-(tr.rto - time.Millisecond))}
325+ tr.resendPending()
326+ if got := writes.Load(); got != 0 {
327+ t.Fatalf("writes before rto=%d, want 0", got)
328+ }
329+ tr.pending[2].last = clk.now.Add(-tr.rto)
330+ tr.resendPending()
331+ if got := writes.Load(); got != 1 {
332+ t.Fatalf("writes at rto=%d, want 1", got)
333+ }
334+}
335+
336+func TestRTTEstimatorIgnoresRetransmittedFramesAfterRecovery(t *testing.T) {
337+ clk := fixedClock{now: time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)}
338+ tr, _ := newResendTestTransport(t, 10)
339+ tr.clock = clk
340+ tr.resendAfter = 100 * time.Millisecond
341+ tr.maxResendAfter = time.Second
342+ tr.rto = tr.resendAfter
343+ tr.pending[1] = &pending{frame: ports.Frame{Type: ports.MsgOutput}, first: clk.now.Add(-200 * time.Millisecond), last: clk.now.Add(-tr.resendAfter)}
344+ tr.resendPending()
345+ tr.linkState = ports.LinkStateOffline
346+
347+ tr.setLinkState(ports.LinkStateConnected, nil)
348+ tr.handleRecord(ackRecord(1))
349+
350+ if tr.rto != tr.resendAfter || tr.srtt != 0 || tr.rttvar != 0 {
351+ t.Fatalf("rtt sampled retransmitted frame after recovery: rto=%v srtt=%v rttvar=%v", tr.rto, tr.srtt, tr.rttvar)
352+ }
353+}
A internal/adapters/dgram/transport_send.go +238 -0
@@ -0,0 +1,238 @@
1+package dgram
2+
3+import (
4+ "errors"
5+ "net"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+func (t *Transport) Send(f ports.Frame) error {
12+ end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
13+ err := t.send(f, false)
14+ end(err == nil)
15+ return err
16+}
17+
18+func (t *Transport) SendAsync(f ports.Frame) error {
19+ end := t.beginRuntimeOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
20+ err := t.send(f, true)
21+ end(err == nil)
22+ return err
23+}
24+
25+// SendSynchronous owns queue reservation, fragment pacing, per-write deadlines,
26+// and close cancellation. Callers must not layer a shorter preflight timer over it.
27+func (t *Transport) SendSynchronous(f ports.Frame) error { return t.Send(f) }
28+
29+func (t *Transport) send(f ports.Frame, async bool) error {
30+ reliable := true
31+ if err := t.lockOutboundSlot(reliable); err != nil {
32+ return err
33+ }
34+ unlockOutbound := true
35+ defer func() {
36+ if unlockOutbound {
37+ t.outboundMu.Unlock()
38+ }
39+ }()
40+ // lockOutboundSlot returns with both outboundMu and mu held.
41+
42+ t.seq++
43+ seq := t.seq
44+ if reliable {
45+ now := t.clock.Now()
46+ if len(t.pending) == 0 {
47+ t.health.pendingStarted(now)
48+ }
49+ t.pending[seq] = &pending{frame: f, enqueued: now}
50+ }
51+ var done chan error
52+ if !async {
53+ done = make(chan error, 1)
54+ }
55+ job := dataSendJob{seq: seq, reliable: reliable, frame: f, done: done}
56+ if shouldPaceOutput(f) || len(t.outputQueue) > 0 {
57+ wasEmpty := len(t.outputQueue) == 0
58+ t.outputQueue = append(t.outputQueue, queuedSend(job))
59+ if wasEmpty {
60+ t.notifyOutputPacerLocked()
61+ }
62+ t.mu.Unlock()
63+ t.outboundMu.Unlock()
64+ unlockOutbound = false
65+ if async {
66+ return nil
67+ }
68+ return t.waitQueuedSend(done)
69+ }
70+ t.mu.Unlock()
71+ if err := t.queueDataJob(job); err != nil {
72+ t.removePending(seq, reliable)
73+ return err
74+ }
75+ t.outboundMu.Unlock()
76+ unlockOutbound = false
77+ if async {
78+ return nil
79+ }
80+ return t.waitQueuedSend(done)
81+}
82+
83+func (t *Transport) queueDataJob(job dataSendJob) error {
84+ select {
85+ case t.dataSend <- job:
86+ return nil
87+ case <-t.done:
88+ return t.closedError()
89+ }
90+}
91+
92+func (t *Transport) waitQueuedSend(done <-chan error) error {
93+ select {
94+ case err := <-done:
95+ return err
96+ case <-t.done:
97+ t.mu.Lock()
98+ err := t.closeErr
99+ t.mu.Unlock()
100+ if err != nil {
101+ return err
102+ }
103+ return errors.New("dgram: closed")
104+ }
105+}
106+
107+func (t *Transport) lockOutboundSlot(reliable bool) error {
108+ var pendingWaitStarted time.Time
109+ for {
110+ t.outboundMu.Lock()
111+ t.mu.Lock()
112+ if t.closed {
113+ err := t.closeErr
114+ t.mu.Unlock()
115+ t.outboundMu.Unlock()
116+ if err != nil {
117+ return err
118+ }
119+ return errors.New("dgram: closed")
120+ }
121+ if !reliable || len(t.pending) < t.maxPending {
122+ return nil
123+ }
124+ if t.linkState != ports.LinkStateConnected {
125+ t.mu.Unlock()
126+ t.outboundMu.Unlock()
127+ return ErrPendingFull
128+ }
129+ if pendingWaitStarted.IsZero() {
130+ pendingWaitStarted = t.clock.Now()
131+ }
132+ remaining := t.maxPendingWait - t.clock.Now().Sub(pendingWaitStarted)
133+ if remaining <= 0 {
134+ t.mu.Unlock()
135+ t.outboundMu.Unlock()
136+ return ErrPendingFull
137+ }
138+ wake := t.sendWake
139+ timer := t.clock.NewTimer(remaining)
140+ t.mu.Unlock()
141+ t.outboundMu.Unlock()
142+ select {
143+ case <-timer.C():
144+ case <-wake:
145+ case <-t.done:
146+ }
147+ timer.Stop()
148+ }
149+}
150+
151+func (t *Transport) removePending(seq uint64, reliable bool) {
152+ if !reliable {
153+ return
154+ }
155+ t.mu.Lock()
156+ removed := t.removePendingLocked(seq)
157+ if removed {
158+ t.notifySendWaitersLocked()
159+ }
160+ t.mu.Unlock()
161+}
162+
163+func (t *Transport) removeQueuedPending(queued []queuedSend, err error) {
164+ t.mu.Lock()
165+ removed := false
166+ for _, q := range queued {
167+ if q.reliable {
168+ removed = t.removePendingLocked(q.seq) || removed
169+ }
170+ if q.done != nil {
171+ q.done <- err
172+ close(q.done)
173+ }
174+ }
175+ if removed {
176+ t.notifySendWaitersLocked()
177+ }
178+ t.mu.Unlock()
179+}
180+
181+func (t *Transport) removePendingLocked(seq uint64) bool {
182+ if _, ok := t.pending[seq]; !ok {
183+ return false
184+ }
185+ delete(t.pending, seq)
186+ if len(t.pending) == 0 {
187+ t.health.pendingCleared()
188+ }
189+ return true
190+}
191+
192+func (t *Transport) writePacket(pkt []byte, peer net.Addr) error {
193+ t.writeMu.Lock()
194+ defer t.writeMu.Unlock()
195+
196+ t.mu.Lock()
197+ pc := t.pc
198+ writeTimeout := t.writeTimeout
199+ writeDeadlines := t.writeDeadlines
200+ t.mu.Unlock()
201+
202+ return t.writeDatagram(pc, peer, pkt, writeDeadlines, writeTimeout)
203+}
204+
205+func (t *Transport) writeDatagram(pc net.PacketConn, peer net.Addr, pkt []byte, deadlines *writeDeadlineState, timeout time.Duration) error {
206+ if timeout <= 0 {
207+ _, err := pc.WriteTo(pkt, peer)
208+ return err
209+ }
210+ ownDeadline := t.clock.Now().Add(timeout)
211+ finish, err := deadlines.begin(ownDeadline)
212+ if err != nil {
213+ return err
214+ }
215+ defer finish()
216+ for {
217+ _, err = pc.WriteTo(pkt, peer)
218+ if err == nil {
219+ return nil
220+ }
221+ var timeoutErr interface{ Timeout() bool }
222+ now := t.clock.Now()
223+ if !errors.As(err, &timeoutErr) || !timeoutErr.Timeout() || !now.Before(ownDeadline) {
224+ return err
225+ }
226+ deadlines.expire(now)
227+ }
228+}
229+
230+func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr }
231+
232+func (t *Transport) notifySendWaitersLocked() {
233+ if t.sendWake == nil {
234+ return
235+ }
236+ close(t.sendWake)
237+ t.sendWake = make(chan struct{})
238+}
A internal/adapters/dgram/transport_send_test.go +537 -0
@@ -0,0 +1,537 @@
1+package dgram
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "net"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ pdgram "github.com/bnema/vev/pkg/dgram"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestPendingWireBytesMatchSealedDatagrams(t *testing.T) {
16+ const mtu = 128
17+ aPC, bPC := newPair()
18+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
19+ MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
20+ })
21+ if err != nil {
22+ t.Fatal(err)
23+ }
24+ defer func() { _ = a.Close() }()
25+
26+ maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead() - 16
27+ frame := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, maxFragmentData-dataRecordHeaderSize+1)}
28+ if err := a.Send(frame); err != nil {
29+ t.Fatal(err)
30+ }
31+
32+ want := 0
33+ for len(bPC.in) > 0 {
34+ want += len((<-bPC.in).b)
35+ }
36+ a.mu.Lock()
37+ got := a.pending[1].wireBytes
38+ initialCwnd := a.congestion.cwndBytes
39+ a.mu.Unlock()
40+ if got != want {
41+ t.Fatalf("pending wire bytes=%d, want sealed datagram bytes=%d", got, want)
42+ }
43+
44+ ack := make([]byte, 9)
45+ ack[0] = recAck
46+ binary.BigEndian.PutUint64(ack[1:], 1)
47+ a.handleRecord(ack)
48+ a.mu.Lock()
49+ gotCwnd := a.congestion.cwndBytes
50+ a.mu.Unlock()
51+ wantCwnd := initialCwnd + max(1, mtu*want/initialCwnd)
52+ if gotCwnd != wantCwnd {
53+ t.Fatalf("cwnd after ACK=%d, want %d from %d acknowledged sealed bytes", gotCwnd, wantCwnd, want)
54+ }
55+}
56+
57+func TestSendAsyncTransfersFragmentedRecordToBytePacedSender(t *testing.T) {
58+ const mtu = 128
59+ aPC, bPC := newPair()
60+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
61+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
62+ Clock: clk, MTU: mtu, ResendAfter: time.Hour, Heartbeat: time.Hour,
63+ })
64+ if err != nil {
65+ t.Fatal(err)
66+ }
67+ defer func() { _ = a.Close() }()
68+
69+ maxFragmentData := mtu - pdgram.HeaderSize - a.codec.Overhead()
70+ frame := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 2*maxFragmentData)}
71+ if err := a.SendAsync(frame); err != nil {
72+ t.Fatal(err)
73+ }
74+ eventuallyPacketCount(t, bPC, dataBurstPackets)
75+ if got := len(bPC.in); got != dataBurstPackets {
76+ t.Fatalf("immediate datagrams=%d, want %d MTU burst", got, dataBurstPackets)
77+ }
78+ awaitSignal(t, clk.timerCreated, "shared data sender pacing timer")
79+ clk.advance(initialPacingRTT)
80+ eventuallyPacketCount(t, bPC, 3)
81+}
82+
83+func TestSmallOutputsShareInitialByteBurst(t *testing.T) {
84+ aPC, bPC := newPair()
85+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
86+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
87+ if err != nil {
88+ t.Fatal(err)
89+ }
90+ defer func() { _ = a.Close() }()
91+
92+ for i := range 3 {
93+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
94+ t.Fatal(err)
95+ }
96+ }
97+ eventuallyPacketCount(t, bPC, 3)
98+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}); err != nil {
99+ t.Fatal(err)
100+ }
101+ eventuallyPacketCount(t, bPC, 4)
102+}
103+
104+func TestOutputPacingSendsAtMostOneOversizedFramePerTick(t *testing.T) {
105+ aPC, _ := newPair()
106+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
107+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
108+ if err != nil {
109+ t.Fatal(err)
110+ }
111+ defer func() { _ = a.Close() }()
112+
113+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("prime")}); err != nil {
114+ t.Fatal(err)
115+ }
116+ large := ports.MarshalOutput(ports.Output{BaseStateNum: 1, NewStateNum: 2, Data: make([]byte, 24*a.mtu)})
117+ for range 2 {
118+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: large}); err != nil {
119+ t.Fatal(err)
120+ }
121+ }
122+ waitForManualTimers(t, clk, 4)
123+ previousPackets := len(aPC.in)
124+ for tick := 0; tick < 128; tick++ {
125+ clk.advance(initialPacingRTT)
126+ time.Sleep(time.Millisecond)
127+ packets := len(aPC.in)
128+ if delta := packets - previousPackets; delta > initialCongestionPackets {
129+ t.Fatalf("tick %d emitted %d packets, cwnd packets %d", tick, delta, initialCongestionPackets)
130+ }
131+ previousPackets = packets
132+ a.mu.Lock()
133+ last := a.pending[3]
134+ done := len(a.outputQueue) == 0 && last != nil && !last.initialInFlight
135+ a.mu.Unlock()
136+ if done {
137+ return
138+ }
139+ }
140+ t.Fatal("oversized queued frames did not complete within pacing ticks")
141+}
142+
143+func TestPacedInitialSendTimestampsFinalFragmentCompletion(t *testing.T) {
144+ aPC, _ := newPair()
145+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
146+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
147+ Clock: clk, ResendAfter: 500 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
148+ })
149+ if err != nil {
150+ t.Fatal(err)
151+ }
152+ defer func() { _ = a.Close() }()
153+ large := ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 24*a.mtu)}
154+ done := make(chan error, 1)
155+ go func() { done <- a.Send(large) }()
156+ start := clk.Now()
157+ for tick := 0; tick < 128; tick++ {
158+ a.mu.Lock()
159+ p := a.pending[1]
160+ if p != nil && p.initialInFlight && !p.last.IsZero() {
161+ a.mu.Unlock()
162+ t.Fatal("pending last timestamp set before final fragment")
163+ }
164+ a.mu.Unlock()
165+ clk.advance(initialPacingRTT)
166+ time.Sleep(time.Millisecond)
167+ select {
168+ case err := <-done:
169+ if err != nil {
170+ t.Fatal(err)
171+ }
172+ a.mu.Lock()
173+ completed := a.pending[1].last
174+ a.mu.Unlock()
175+ if elapsed := completed.Sub(start); elapsed <= initialPacingRTT {
176+ t.Fatalf("large send completed too early after %v", elapsed)
177+ }
178+ before := len(aPC.in)
179+ clk.advance(499 * time.Millisecond)
180+ a.resendPending()
181+ if got := len(aPC.in); got != before {
182+ t.Fatalf("resend emitted before one RTO after final fragment: %d -> %d", before, got)
183+ }
184+ a.handleRecord(ackRecord(1))
185+ a.mu.Lock()
186+ srtt := a.srtt
187+ a.mu.Unlock()
188+ require.Equal(t, 499*time.Millisecond, srtt, "RTT sample must start at final-fragment completion")
189+ return
190+ default:
191+ }
192+ }
193+ t.Fatal("large initial send did not complete")
194+}
195+
196+func TestOwnedSynchronousMaxSideEffectCompletesThroughConcurrentPacedWork(t *testing.T) {
197+ aPC, bPC := newPair()
198+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
199+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{
200+ Clock: clk, ResendAfter: 250 * time.Millisecond, MaxResendAfter: time.Second, Heartbeat: time.Hour,
201+ })
202+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
203+ defer func() { _ = a.Close() }()
204+ defer func() { _ = b.Close() }()
205+ a.mu.Lock()
206+ a.srtt = 500 * time.Millisecond
207+ a.mu.Unlock()
208+ for i := range 8 {
209+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
210+ t.Fatal(err)
211+ }
212+ }
213+ now := clk.Now()
214+ a.mu.Lock()
215+ for _, seq := range []uint64{100, 101} {
216+ a.pending[seq] = &pending{
217+ frame: ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 20*a.mtu)},
218+ enqueued: now.Add(-time.Second), first: now.Add(-time.Second), last: now.Add(-time.Second),
219+ }
220+ }
221+ a.mu.Unlock()
222+ go a.resendPending()
223+ sideEffect := ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: make([]byte, 100*1024)})}
224+ done := make(chan error, 1)
225+ go func() { done <- a.SendSynchronous(sideEffect) }()
226+
227+ for tick := 0; tick < 512; tick++ {
228+ clk.advance(initialPacingRTT)
229+ time.Sleep(time.Millisecond)
230+ select {
231+ case err := <-done:
232+ if err != nil {
233+ t.Fatal(err)
234+ }
235+ if a.isClosed() {
236+ t.Fatal("healthy owned synchronous send force-closed transport")
237+ }
238+ return
239+ default:
240+ }
241+ }
242+ t.Fatal("maximum side effect did not complete through queued/retransmit pacing")
243+}
244+
245+func TestInputWaitsForDequeuedOutputBatch(t *testing.T) {
246+ aPC, bPC := newPair()
247+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
248+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
249+ if err != nil {
250+ t.Fatal(err)
251+ }
252+ defer func() { _ = a.Close() }()
253+
254+ a.writeMu.Lock()
255+ for _, payload := range []string{"first", "queued"} {
256+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte(payload)}); err != nil {
257+ t.Fatal(err)
258+ }
259+ }
260+ done := make(chan error, 1)
261+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
262+ select {
263+ case err := <-done:
264+ a.writeMu.Unlock()
265+ t.Fatalf("input completed before dequeued output batch: %v", err)
266+ case <-time.After(20 * time.Millisecond):
267+ }
268+ a.writeMu.Unlock()
269+ select {
270+ case err := <-done:
271+ if err != nil {
272+ t.Fatal(err)
273+ }
274+ case <-time.After(time.Second):
275+ t.Fatal("input did not complete after output batch was released")
276+ }
277+ if got := len(bPC.in); got != 3 {
278+ t.Fatalf("datagrams when input completed=%d, want output batch before input", got)
279+ }
280+}
281+
282+func TestPacedControlQueueFailureClosesTransport(t *testing.T) {
283+ aPC, bPC := newPair()
284+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
285+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
286+ if err != nil {
287+ t.Fatal(err)
288+ }
289+ defer func() { _ = a.Close() }()
290+
291+ if err := bPC.Close(); err != nil {
292+ t.Fatal(err)
293+ }
294+ inputDone := make(chan error, 1)
295+ go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
296+ if err := <-inputDone; err == nil {
297+ t.Fatal("data send succeeded after paced wire failure")
298+ }
299+ select {
300+ case <-a.done:
301+ case <-time.After(time.Second):
302+ t.Fatal("transport remained open after paced control queue write failed")
303+ }
304+}
305+
306+func TestRemoveQueuedPendingResolvesSynchronousWaiters(t *testing.T) {
307+ tr, _ := newResendTestTransport(t, 10)
308+ wantErr := errors.New("paced send failed")
309+ done := make(chan error, 1)
310+
311+ tr.mu.Lock()
312+ tr.pending[7] = &pending{}
313+ tr.mu.Unlock()
314+ tr.removeQueuedPending([]queuedSend{{seq: 7, reliable: true, done: done}}, wantErr)
315+
316+ require.ErrorIs(t, <-done, wantErr)
317+ _, ok := <-done
318+ require.False(t, ok, "completion channel must be closed")
319+ tr.mu.Lock()
320+ _, pending := tr.pending[7]
321+ tr.mu.Unlock()
322+ require.False(t, pending)
323+}
324+
325+func TestCloseUnblocksQueuedSynchronousFrames(t *testing.T) {
326+ frames := []struct {
327+ name string
328+ frame ports.Frame
329+ }{
330+ {name: "control", frame: ports.Frame{Type: ports.MsgPing}},
331+ {name: "terminal side effect", frame: ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: []byte("osc")})}},
332+ }
333+ for _, tt := range frames {
334+ t.Run(tt.name, func(t *testing.T) {
335+ aPC, _ := newPair()
336+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
337+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
338+ if err != nil {
339+ t.Fatal(err)
340+ }
341+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, 3*a.mtu)}); err != nil {
342+ t.Fatal(err)
343+ }
344+ done := make(chan error, 1)
345+ go func() { done <- a.Send(tt.frame) }()
346+ eventually(t, time.Second, func() bool {
347+ a.mu.Lock()
348+ defer a.mu.Unlock()
349+ return len(a.pending) == 2
350+ })
351+ _ = a.Close()
352+ if err := <-done; err == nil {
353+ t.Fatal("queued synchronous Send returned nil after close")
354+ }
355+ })
356+ }
357+}
358+
359+func TestOutputPaceFailureClosesTransportWithWriteError(t *testing.T) {
360+ aPC, bPC := newPair()
361+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
362+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
363+ if err != nil {
364+ t.Fatal(err)
365+ }
366+ defer func() { _ = a.Close() }()
367+
368+ if err := bPC.Close(); err != nil {
369+ t.Fatal(err)
370+ }
371+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("fails")}); err != nil {
372+ t.Fatal(err)
373+ }
374+
375+ select {
376+ case <-a.done:
377+ case <-time.After(time.Second):
378+ t.Fatal("transport remained open after paced output write failed")
379+ }
380+ if err := a.Send(ports.Frame{Type: ports.MsgInput}); err == nil || err.Error() != "peer closed" {
381+ t.Fatalf("send after paced write failure err=%v, want peer closed", err)
382+ }
383+}
384+
385+func TestInputAndControlRemainOrderedInBoundedPacedQueue(t *testing.T) {
386+ aPC, bPC := newPair()
387+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
388+ a, err := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{Clock: clk, ResendAfter: time.Hour, Heartbeat: time.Hour})
389+ if err != nil {
390+ t.Fatal(err)
391+ }
392+ defer func() { _ = a.Close() }()
393+
394+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("output")}); err != nil {
395+ t.Fatal(err)
396+ }
397+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued")}); err != nil {
398+ t.Fatal(err)
399+ }
400+ eventuallyPacketCount(t, bPC, 2)
401+ inputDone := make(chan error, 1)
402+ go func() { inputDone <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}) }()
403+ eventuallyPacketCount(t, bPC, 3)
404+ if err := <-inputDone; err != nil {
405+ t.Fatal(err)
406+ }
407+ if err := a.SendAsync(ports.Frame{Type: ports.MsgOutput, Payload: []byte("queued-before-control")}); err != nil {
408+ t.Fatal(err)
409+ }
410+ controlDone := make(chan error, 1)
411+ go func() { controlDone <- a.Send(ports.Frame{Type: ports.MsgPing}) }()
412+ eventuallyPacketCount(t, bPC, 5)
413+ if err := <-controlDone; err != nil {
414+ t.Fatal(err)
415+ }
416+}
417+
418+func TestSendWriteTimeoutBoundsBlockedPacketConn(t *testing.T) {
419+ pc := newBlockingWritePC()
420+ tr, err := NewTransportWithOptions(pc, testAddr("peer"), key(), 1, 2, Options{
421+ WriteTimeout: 10 * time.Millisecond,
422+ ResendAfter: time.Hour,
423+ Heartbeat: time.Hour,
424+ })
425+ if err != nil {
426+ t.Fatal(err)
427+ }
428+ defer func() { _ = tr.Close() }()
429+
430+ done := make(chan error, 1)
431+ go func() { done <- tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("blocked")}) }()
432+ select {
433+ case err := <-done:
434+ if err == nil {
435+ t.Fatal("Send returned nil, want write timeout")
436+ }
437+ var timeout interface{ Timeout() bool }
438+ if !errors.As(err, &timeout) || !timeout.Timeout() {
439+ t.Fatalf("Send err=%v, want timeout error", err)
440+ }
441+ tr.mu.Lock()
442+ pending := len(tr.pending)
443+ tr.mu.Unlock()
444+ if pending != 0 {
445+ t.Fatalf("pending after failed initial send=%d, want 0", pending)
446+ }
447+ case <-time.After(250 * time.Millisecond):
448+ t.Fatal("Send blocked without honoring WriteTimeout")
449+ }
450+}
451+
452+func TestPendingReliableQueueBackpressuresUntilAck(t *testing.T) {
453+ aPC, bPC := newPair()
454+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
455+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1})
456+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
457+ defer func() { _ = a.Close() }()
458+ defer func() { _ = b.Close() }()
459+
460+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
461+ t.Fatal(err)
462+ }
463+ done := make(chan error, 1)
464+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
465+
466+ select {
467+ case err := <-done:
468+ t.Fatalf("send returned before an ack released pending capacity: %v", err)
469+ case <-time.After(25 * time.Millisecond):
470+ }
471+
472+ a.handleRecord(ackRecord(1))
473+ select {
474+ case err := <-done:
475+ if err != nil {
476+ t.Fatalf("send after ack: %v", err)
477+ }
478+ case <-time.After(time.Second):
479+ t.Fatal("send did not resume after ack released pending capacity")
480+ }
481+ a.mu.Lock()
482+ pending := len(a.pending)
483+ a.mu.Unlock()
484+ if pending > 1 {
485+ t.Fatalf("pending=%d, want <= 1", pending)
486+ }
487+}
488+
489+func TestPendingReliableQueueReturnsErrPendingFullWhileConnected(t *testing.T) {
490+ aPC, bPC := newPair()
491+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
492+ clk := newManualClock(time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC))
493+ a, _ := NewTransportWithOptions(aPC, testAddr("b"), key(), 1, 2, Options{MaxPending: 1, MaxPendingWait: 50 * time.Millisecond, Clock: clk})
494+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
495+ defer func() { _ = a.Close() }()
496+ defer func() { _ = b.Close() }()
497+
498+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("first")}); err != nil {
499+ t.Fatal(err)
500+ }
501+ done := make(chan error, 1)
502+ go func() { done <- a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("second")}) }()
503+ waitForManualTimers(t, clk, 4)
504+
505+ clk.advance(50 * time.Millisecond)
506+ select {
507+ case err := <-done:
508+ if !errors.Is(err, ErrPendingFull) {
509+ t.Fatalf("send err=%v, want ErrPendingFull", err)
510+ }
511+ case <-time.After(time.Second):
512+ t.Fatal("send did not return after clock advanced past MaxPendingWait")
513+ }
514+}
515+
516+func TestWritePacketDeadlineUsesTransportClock(t *testing.T) {
517+ wantNow := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC)
518+ pc := &deadlineCapturePC{addr: testAddr("a"), done: make(chan struct{})}
519+ tr, err := NewTransportWithOptions(pc, testAddr("b"), key(), 1, 2, Options{
520+ Clock: fixedClock{now: wantNow},
521+ WriteTimeout: 250 * time.Millisecond,
522+ ResendAfter: time.Hour,
523+ Heartbeat: time.Hour,
524+ })
525+ if err != nil {
526+ t.Fatal(err)
527+ }
528+ defer func() { _ = tr.Close() }()
529+
530+ if err := tr.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("deadline")}); err != nil {
531+ t.Fatal(err)
532+ }
533+ got, _ := pc.deadline.Load().(time.Time)
534+ if want := wantNow.Add(250 * time.Millisecond); !got.Equal(want) {
535+ t.Fatalf("write deadline=%v, want transport clock deadline %v", got, want)
536+ }
537+}
D internal/adapters/dgram/transport_test.go +0 -2479 large diff (2479 lines changed): collapsed
A internal/adapters/dgram/transport_test_helpers_test.go +433 -0
@@ -0,0 +1,433 @@
1+package dgram
2+
3+import (
4+ "encoding/binary"
5+ "errors"
6+ "net"
7+ "sync"
8+ "sync/atomic"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ pdgram "github.com/bnema/vev/pkg/dgram"
14+)
15+
16+type timeoutErr struct{}
17+
18+func (timeoutErr) Error() string { return "i/o timeout" }
19+func (timeoutErr) Timeout() bool { return true }
20+
21+type blockingWritePC struct {
22+ addr net.Addr
23+
24+ mu sync.Mutex
25+ closed bool
26+ deadline time.Time
27+ done chan struct{}
28+}
29+
30+func newBlockingWritePC() *blockingWritePC {
31+ return &blockingWritePC{addr: testAddr("blocked"), done: make(chan struct{})}
32+}
33+
34+func (p *blockingWritePC) ReadFrom([]byte) (int, net.Addr, error) {
35+ <-p.done
36+ return 0, nil, errors.New("closed")
37+}
38+
39+func (p *blockingWritePC) WriteTo([]byte, net.Addr) (int, error) {
40+ for {
41+ p.mu.Lock()
42+ closed := p.closed
43+ deadline := p.deadline
44+ p.mu.Unlock()
45+ if closed {
46+ return 0, errors.New("closed")
47+ }
48+ if !deadline.IsZero() {
49+ wait := time.Until(deadline)
50+ if wait > 0 {
51+ timer := time.NewTimer(wait)
52+ select {
53+ case <-timer.C:
54+ case <-p.done:
55+ timer.Stop()
56+ return 0, errors.New("closed")
57+ }
58+ }
59+ return 0, timeoutErr{}
60+ }
61+ select {
62+ case <-time.After(time.Millisecond):
63+ case <-p.done:
64+ return 0, errors.New("closed")
65+ }
66+ }
67+}
68+
69+func (p *blockingWritePC) Close() error {
70+ p.mu.Lock()
71+ defer p.mu.Unlock()
72+ if !p.closed {
73+ p.closed = true
74+ close(p.done)
75+ }
76+ return nil
77+}
78+
79+func (p *blockingWritePC) LocalAddr() net.Addr { return p.addr }
80+func (p *blockingWritePC) SetDeadline(t time.Time) error {
81+ p.mu.Lock()
82+ p.deadline = t
83+ p.mu.Unlock()
84+ return nil
85+}
86+func (p *blockingWritePC) SetReadDeadline(time.Time) error { return nil }
87+func (p *blockingWritePC) SetWriteDeadline(t time.Time) error {
88+ p.mu.Lock()
89+ p.deadline = t
90+ p.mu.Unlock()
91+ return nil
92+}
93+
94+type fixedClock struct{ now time.Time }
95+
96+func (c fixedClock) Now() time.Time { return c.now }
97+func (c fixedClock) NewTimer(d time.Duration) ports.Timer {
98+ return realTimer{t: time.NewTimer(d)}
99+}
100+
101+type manualClock struct {
102+ mu sync.Mutex
103+ now time.Time
104+ timers map[*manualTimer]struct{}
105+ timerCreated chan struct{}
106+}
107+
108+func newManualClock(now time.Time) *manualClock {
109+ return &manualClock{
110+ now: now,
111+ timers: make(map[*manualTimer]struct{}),
112+ timerCreated: make(chan struct{}, 64),
113+ }
114+}
115+
116+func (c *manualClock) Now() time.Time {
117+ c.mu.Lock()
118+ defer c.mu.Unlock()
119+ return c.now
120+}
121+
122+func (c *manualClock) NewTimer(d time.Duration) ports.Timer {
123+ c.mu.Lock()
124+ tm := &manualTimer{clock: c, c: make(chan time.Time, 1), deadline: c.now.Add(d), active: true}
125+ c.timers[tm] = struct{}{}
126+ c.mu.Unlock()
127+ select {
128+ case c.timerCreated <- struct{}{}:
129+ default:
130+ }
131+ return tm
132+}
133+
134+func (c *manualClock) advance(d time.Duration) {
135+ c.mu.Lock()
136+ c.now = c.now.Add(d)
137+ now := c.now
138+ timers := make([]*manualTimer, 0, len(c.timers))
139+ for tm := range c.timers {
140+ timers = append(timers, tm)
141+ }
142+ c.mu.Unlock()
143+ for _, tm := range timers {
144+ tm.fireIfDue(now)
145+ }
146+}
147+
148+type manualTimer struct {
149+ clock *manualClock
150+ c chan time.Time
151+ deadline time.Time
152+ active bool
153+}
154+
155+func (t *manualTimer) C() <-chan time.Time { return t.c }
156+func (t *manualTimer) Reset(d time.Duration) bool {
157+ t.clock.mu.Lock()
158+ wasActive := t.active
159+ t.deadline = t.clock.now.Add(d)
160+ t.active = true
161+ t.clock.timers[t] = struct{}{}
162+ t.clock.mu.Unlock()
163+ return wasActive
164+}
165+func (t *manualTimer) Stop() bool {
166+ t.clock.mu.Lock()
167+ defer t.clock.mu.Unlock()
168+ wasActive := t.active
169+ t.active = false
170+ delete(t.clock.timers, t)
171+ return wasActive
172+}
173+func (t *manualTimer) fireIfDue(now time.Time) bool {
174+ t.clock.mu.Lock()
175+ if !t.active {
176+ delete(t.clock.timers, t)
177+ t.clock.mu.Unlock()
178+ return false
179+ }
180+ if now.Before(t.deadline) {
181+ t.clock.mu.Unlock()
182+ return false
183+ }
184+ t.active = false
185+ delete(t.clock.timers, t)
186+ t.clock.mu.Unlock()
187+ select {
188+ case t.c <- now:
189+ default:
190+ }
191+ return true
192+}
193+
194+func waitForManualTimers(t *testing.T, clk *manualClock, n int) {
195+ t.Helper()
196+ deadline := time.Now().Add(time.Second)
197+ for time.Now().Before(deadline) {
198+ clk.mu.Lock()
199+ active := len(clk.timers)
200+ clk.mu.Unlock()
201+ if active >= n {
202+ return
203+ }
204+ time.Sleep(time.Millisecond)
205+ }
206+ clk.mu.Lock()
207+ active := len(clk.timers)
208+ clk.mu.Unlock()
209+ t.Fatalf("manual timers=%d, want at least %d", active, n)
210+}
211+
212+type deadlineCapturePC struct {
213+ addr net.Addr
214+ deadline atomic.Value
215+ done chan struct{}
216+}
217+
218+func (p *deadlineCapturePC) ReadFrom([]byte) (int, net.Addr, error) {
219+ <-p.done
220+ return 0, nil, errors.New("closed")
221+}
222+func (p *deadlineCapturePC) WriteTo(b []byte, _ net.Addr) (int, error) { return len(b), nil }
223+func (p *deadlineCapturePC) Close() error {
224+ select {
225+ case <-p.done:
226+ default:
227+ close(p.done)
228+ }
229+ return nil
230+}
231+func (p *deadlineCapturePC) LocalAddr() net.Addr { return p.addr }
232+func (p *deadlineCapturePC) SetDeadline(time.Time) error { return nil }
233+func (p *deadlineCapturePC) SetReadDeadline(time.Time) error { return nil }
234+func (p *deadlineCapturePC) SetWriteDeadline(t time.Time) error {
235+ if !t.IsZero() {
236+ p.deadline.Store(t)
237+ }
238+ return nil
239+}
240+
241+func eventuallyPacketCount(t *testing.T, pc *fakePC, want int) {
242+ t.Helper()
243+ deadline := time.Now().Add(time.Second)
244+ for time.Now().Before(deadline) {
245+ if len(pc.in) >= want {
246+ return
247+ }
248+ time.Sleep(time.Millisecond)
249+ }
250+ t.Fatalf("datagrams=%d, want at least %d", len(pc.in), want)
251+}
252+
253+func eventually(t *testing.T, timeout time.Duration, condition func() bool) {
254+ t.Helper()
255+ deadline := time.Now().Add(timeout)
256+ for time.Now().Before(deadline) {
257+ if condition() {
258+ return
259+ }
260+ time.Sleep(time.Millisecond)
261+ }
262+ t.Fatal("condition was not met before timeout")
263+}
264+
265+func newResendTestTransport(t *testing.T, maxResendPerTick int) (*Transport, *atomic.Int32) {
266+ t.Helper()
267+ aPC, _ := newPair()
268+ codec, err := pdgram.NewCodec(key())
269+ if err != nil {
270+ t.Fatal(err)
271+ }
272+ var writes atomic.Int32
273+ aPC.drop = func(_ []byte, addr net.Addr) bool {
274+ if addr.String() == "b" {
275+ writes.Add(1)
276+ return true
277+ }
278+ return false
279+ }
280+ return &Transport{
281+ pc: aPC,
282+ peer: testAddr("b"),
283+ codec: codec,
284+ sendDir: 1,
285+ mtu: pdgram.DefaultMTU,
286+ pending: make(map[uint64]*pending),
287+ resendAfter: 100 * time.Millisecond,
288+ maxResendAfter: 500 * time.Millisecond,
289+ maxResendPerTick: maxResendPerTick,
290+ rto: 100 * time.Millisecond,
291+ clock: realClock{},
292+ congestion: newCongestionController(pdgram.DefaultMTU),
293+ done: make(chan struct{}),
294+ }, &writes
295+}
296+
297+func ackRecord(seq uint64) []byte {
298+ var b [9]byte
299+ b[0] = recAck
300+ binary.BigEndian.PutUint64(b[1:], seq)
301+ return b[:]
302+}
303+
304+func probeRecord(kind byte, id uint64) []byte {
305+ var b [9]byte
306+ b[0] = kind
307+ binary.BigEndian.PutUint64(b[1:], id)
308+ return b[:]
309+}
310+
311+func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
312+ tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, done: make(chan struct{})}
313+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
314+ for i := range maxRecvBuffer + 100 {
315+ tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}})
316+ }
317+ if got := len(tr.recvBuf); got != maxRecvBuffer {
318+ t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
319+ }
320+ tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
321+ if _, ok := tr.recvBuf[1]; !ok {
322+ t.Fatalf("next expected sequence was dropped when far-future buffer was full")
323+ }
324+ if got := len(tr.recvBuf); got > maxRecvBuffer+1 {
325+ t.Fatalf("recvBuf len=%d, want <= %d", got, maxRecvBuffer+1)
326+ }
327+}
328+
329+func TestOutputPrerequisitesRemainInFullReceiveBuffer(t *testing.T) {
330+ tr := &Transport{
331+ nextRecvSeq: 1,
332+ recvBuf: map[uint64]ports.Frame{2: {Type: ports.MsgOutput, Payload: []byte("stale")}},
333+ maxRecvBuffer: 1,
334+ done: make(chan struct{}),
335+ }
336+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
337+
338+ ackSeq, ack, queued := tr.enqueueReliable(3, ports.Frame{Type: ports.MsgOutput, Payload: []byte("replacement")})
339+ if ack || queued || ackSeq != 0 {
340+ t.Fatalf("replacement ackSeq=%d ack=%v queued=%v, want output rejected while prerequisite buffer is full", ackSeq, ack, queued)
341+ }
342+ if _, ok := tr.recvBuf[2]; !ok {
343+ t.Fatal("prerequisite output was discarded from full receive buffer")
344+ }
345+ if _, ok := tr.recvBuf[3]; ok {
346+ t.Fatal("far-future replacement bypassed full receive buffer")
347+ }
348+}
349+
350+func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
351+ aPC, bPC := newPair()
352+ codec, err := pdgram.NewCodec(key())
353+ if err != nil {
354+ t.Fatal(err)
355+ }
356+ tr := &Transport{pc: aPC, peer: testAddr("b"), codec: codec, sendDir: 1, mtu: pdgram.DefaultMTU, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), maxRecvBuffer: maxRecvBuffer, clock: realClock{}, done: make(chan struct{})}
357+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
358+ defer func() { _ = aPC.Close() }()
359+ defer func() { _ = bPC.Close() }()
360+
361+ for i := range maxRecvBuffer {
362+ tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
363+ }
364+ tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}))
365+ select {
366+ case pkt := <-bPC.in:
367+ t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
368+ case <-time.After(25 * time.Millisecond):
369+ }
370+
371+ tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}))
372+ select {
373+ case <-bPC.in:
374+ case <-time.After(time.Second):
375+ t.Fatal("expected ACK for buffered contiguous frame")
376+ }
377+}
378+
379+func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
380+ ch := make(chan ports.Frame, 1)
381+ go func() { f, _ := tr.Recv(); ch <- f }()
382+ select {
383+ case f := <-ch:
384+ return f, true
385+ case <-time.After(d):
386+ return ports.Frame{}, false
387+ }
388+}
389+
390+func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
391+ t.Helper()
392+ ch := make(chan ports.Frame, 1)
393+ go func() { f, _ := tr.Recv(); ch <- f }()
394+ select {
395+ case f := <-ch:
396+ return f
397+ case <-time.After(d):
398+ t.Fatal("timeout")
399+ return ports.Frame{}
400+ }
401+}
402+
403+func waitPeer(t *testing.T, tr *Transport, want string) {
404+ t.Helper()
405+ deadline := time.Now().Add(time.Second)
406+ for time.Now().Before(deadline) {
407+ if tr.Peer().String() == want {
408+ return
409+ }
410+ time.Sleep(time.Millisecond)
411+ }
412+ t.Fatalf("peer=%v, want %s", tr.Peer(), want)
413+}
414+
415+func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (ports.Frame, error) {
416+ t.Helper()
417+ type result struct {
418+ f ports.Frame
419+ err error
420+ }
421+ ch := make(chan result, 1)
422+ go func() {
423+ f, err := tr.Recv()
424+ ch <- result{f: f, err: err}
425+ }()
426+ select {
427+ case r := <-ch:
428+ return r.f, r.err
429+ case <-time.After(d):
430+ t.Fatal("timeout")
431+ return ports.Frame{}, nil
432+ }
433+}
M internal/app/run.go +37 -23
@@ -250,7 +250,13 @@ var (
250250 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
251251 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)
252252 }
253- runClientWithRuntimeObserver = client.RunWithRuntimeObserver
253+ runClientWithDeps runClientFunc = func(
254+ ctx context.Context,
255+ deps client.Dependencies,
256+ request client.AttachRequest,
257+ ) error {
258+ return client.NewRunner(deps).Run(ctx, request)
259+ }
254260 )
255261
256262 func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {
@@ -484,11 +490,10 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) (re
484490 },
485491 remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),
486492 selectedRemoteTransport: os.Getenv(envRemoteTransport),
487- runClient: func(ctx context.Context, dialer ports.Dialer, terminal ports.Terminal, behaviorClock ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error {
488- return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer)
489- },
490- createDetached: createDetachedLocalSession,
491- clipboard: clipboard.New(),
493+ runClient: runClientWithDeps,
494+ createDetached: createDetachedLocalSession,
495+ clipboard: clipboard.New(),
496+ runtimeObserver: observer,
492497 })
493498 }
494499
@@ -500,13 +505,15 @@ func defaultRemoteDialerFactory() ports.RemoteDialerFactory {
500505 return remoteadapter.NewDialerFactory()
501506 }
502507
508+type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error
509+
503510 type runAttachDeps struct {
504- attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
505511 localDialer func() ports.Dialer
506512 remoteDialerFactory ports.RemoteDialerFactory
507513 selectedRemoteTransport string
508- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
514+ runClient runClientFunc
509515 createDetached func(context.Context, string) error
516+ runtimeObserver ports.SerializedRuntimeObserver
510517 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
511518 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
512519 // Only used for the remote-dialer branch below; local attaches never
@@ -535,7 +542,7 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
535542
536543 runClient := deps.runClient
537544 if runClient == nil {
538- runClient = client.Run
545+ runClient = runClientWithDeps
539546 }
540547 if remoteTarget != "" {
541548 mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
@@ -553,26 +560,33 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
553560 if err != nil {
554561 return err
555562 }
556- return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log)
563+ return runClient(ctx, client.Dependencies{
564+ Dialer: dialer,
565+ Terminal: term.New(),
566+ Clock: clock.New(),
567+ Clipboard: deps.clipboard,
568+ Logger: log,
569+ RuntimeObserver: deps.runtimeObserver,
570+ }, client.AttachRequest{Intent: intent, SessionName: name, Remote: true})
557571 }
558572
559- attachLocal := deps.attachLocal
560- if attachLocal == nil {
561- localDialer := deps.localDialer
562- if localDialer == nil {
563- localDialer = defaultLocalDialer
564- }
565- attachLocal = func(ctx context.Context, intent uint8, name string, log *slog.Logger) error {
566- if log != nil {
567- log.Info("attaching to local session", "intent", intent, "name", name)
568- }
569- return runClient(ctx, localDialer(), term.New(), clock.New(), intent, name, false, nil, log)
570- }
573+ localDialer := deps.localDialer
574+ if localDialer == nil {
575+ localDialer = defaultLocalDialer
571576 }
572577 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
573578 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
574579 attach: func(ctx context.Context, intent uint8, name string) error {
575- return attachLocal(ctx, intent, name, log)
580+ if log != nil {
581+ log.Info("attaching to local session", "intent", intent, "name", name)
582+ }
583+ return runClient(ctx, client.Dependencies{
584+ Dialer: localDialer(),
585+ Terminal: term.New(),
586+ Clock: clock.New(),
587+ Logger: log,
588+ RuntimeObserver: deps.runtimeObserver,
589+ }, client.AttachRequest{Intent: intent, SessionName: name})
576590 },
577591 killDaemon: requestDaemonStop,
578592 settleAfterKill: waitForDaemonStop,
M internal/app/run_test.go +16 -25
@@ -5,7 +5,6 @@ import (
55 "context"
66 "errors"
77 "io"
8- "log/slog"
98 "net"
109 "os"
1110 "os/exec"
@@ -460,10 +459,6 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
460459 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
461460 called := false
462461 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
463- attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
464- called = true
465- return nil
466- },
467462 createDetached: func(context.Context, string) error {
468463 called = true
469464 return nil
@@ -483,10 +478,6 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
483478 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
484479 var gotName string
485480 err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
486- attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
487- t.Fatal("nested new should not attach to the session")
488- return nil
489- },
490481 createDetached: func(_ context.Context, name string) error {
491482 gotName = name
492483 return nil
@@ -548,16 +539,16 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
548539 remoteDialerFactory: factory,
549540 selectedRemoteTransport: tt.selectedTransport,
550541 clipboard: clip,
551- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
552- nd, ok := d.(namedDialer)
542+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
543+ nd, ok := deps.Dialer.(namedDialer)
553544 if !ok {
554- t.Fatalf("dialer type = %T, want namedDialer", d)
545+ t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
555546 }
556547 gotDialer = nd.name
557- gotRemote = remote
558- gotClipboard = clipboard
559- if intent != ports.IntentAttach || name != "work" {
560- t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
548+ gotRemote = request.Remote
549+ gotClipboard = deps.Clipboard
550+ if request.Intent != ports.IntentAttach || request.SessionName != "work" {
551+ t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
561552 }
562553 return nil
563554 },
@@ -585,7 +576,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
585576 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
586577 remoteDialerFactory: factory,
587578 selectedRemoteTransport: "serial",
588- runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
579+ runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
589580 runClientCalled = true
590581 return nil
591582 },
@@ -606,7 +597,7 @@ func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
606597
607598 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
608599 remoteDialerFactory: factory,
609- runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
600+ runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
610601 runClientCalled = true
611602 return nil
612603 },
@@ -628,16 +619,16 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
628619 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
629620 remoteDialerFactory: factory,
630621 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
631- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
632- nd, ok := d.(namedDialer)
622+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
623+ nd, ok := deps.Dialer.(namedDialer)
633624 if !ok {
634- t.Fatalf("dialer type = %T, want namedDialer", d)
625+ t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
635626 }
636627 gotDialer = nd.name
637- gotRemote = remote
638- gotClipboard = clipboard
639- if intent != ports.IntentEphemeral || name != "" {
640- t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
628+ gotRemote = request.Remote
629+ gotClipboard = deps.Clipboard
630+ if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
631+ t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
641632 }
642633 return nil
643634 },
M internal/app/transport_observability_test.go +8 -8
@@ -6,7 +6,6 @@ import (
66 "encoding/json"
77 "errors"
88 "io"
9- "log/slog"
109 "os"
1110 "path/filepath"
1211 "strconv"
@@ -16,6 +15,7 @@ import (
1615
1716 "github.com/bnema/vev/internal/ports"
1817 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/client"
1919 "github.com/stretchr/testify/mock"
2020 )
2121
@@ -229,17 +229,17 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
229229 }
230230 t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })
231231
232- originalRunClient := runClientWithRuntimeObserver
233- runClientWithRuntimeObserver = func(_ context.Context, gotDialer ports.Dialer, _ ports.Terminal, _ ports.Clock, _ uint8, _ string, _ bool, _ ports.ClipboardReader, _ *slog.Logger, got ports.SerializedRuntimeObserver) error {
234- if got != observer {
235- t.Fatalf("client transport observer = %v, want process observer %v", got, observer)
232+ originalRunClient := runClientWithDeps
233+ runClientWithDeps = func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
234+ if deps.RuntimeObserver != observer {
235+ t.Fatalf("client transport observer = %v, want process observer %v", deps.RuntimeObserver, observer)
236236 }
237- if gotDialer != (namedDialer{name: "remote"}) {
238- t.Fatalf("remote transport dialer = %v, want configured factory dialer", gotDialer)
237+ if deps.Dialer != (namedDialer{name: "remote"}) {
238+ t.Fatalf("remote transport dialer = %v, want configured factory dialer", deps.Dialer)
239239 }
240240 return nil
241241 }
242- t.Cleanup(func() { runClientWithRuntimeObserver = originalRunClient })
242+ t.Cleanup(func() { runClientWithDeps = originalRunClient })
243243
244244 if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil {
245245 t.Fatal(err)
A internal/ports/clipboard.go +23 -0
@@ -0,0 +1,23 @@
1+package ports
2+
3+import (
4+ "context"
5+ "errors"
6+)
7+
8+// ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the
9+// clipboard holds no image (or an unsupported type). Callers fall back to
10+// forwarding whatever keystroke triggered the read.
11+var ErrNoClipboardImage = errors.New("ports: no image on clipboard")
12+
13+// ClipboardReader reads an image off the CLIENT-side system clipboard, for
14+// forwarding to a remote session's focused pane. Implementations live in
15+// internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only
16+// see this interface. See
17+// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md.
18+type ClipboardReader interface {
19+ // ReadImage returns the clipboard's image bytes and MIME type. It returns
20+ // ErrNoClipboardImage when the clipboard holds no image (or an
21+ // unsupported type).
22+ ReadImage(ctx context.Context) (mime string, data []byte, err error)
23+}
A internal/ports/clock.go +16 -0
@@ -0,0 +1,16 @@
1+package ports
2+
3+import "time"
4+
5+// Clock abstracts time so usecases can be tested without real delays.
6+type Clock interface {
7+ Now() time.Time
8+ NewTimer(d time.Duration) Timer
9+}
10+
11+// Timer abstracts a cancellable, resettable time.Timer.
12+type Timer interface {
13+ C() <-chan time.Time
14+ Reset(d time.Duration) bool
15+ Stop() bool
16+}
A internal/ports/mocks/clipboard_mock.go +106 -0
@@ -0,0 +1,106 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "context"
9+
10+ mock "github.com/stretchr/testify/mock"
11+)
12+
13+// NewMockClipboardReader creates a new instance of MockClipboardReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
14+// The first argument is typically a *testing.T value.
15+func NewMockClipboardReader(t interface {
16+ mock.TestingT
17+ Cleanup(func())
18+}) *MockClipboardReader {
19+ mock := &MockClipboardReader{}
20+ mock.Mock.Test(t)
21+
22+ t.Cleanup(func() { mock.AssertExpectations(t) })
23+
24+ return mock
25+}
26+
27+// MockClipboardReader is an autogenerated mock type for the ClipboardReader type
28+type MockClipboardReader struct {
29+ mock.Mock
30+}
31+
32+type MockClipboardReader_Expecter struct {
33+ mock *mock.Mock
34+}
35+
36+func (_m *MockClipboardReader) EXPECT() *MockClipboardReader_Expecter {
37+ return &MockClipboardReader_Expecter{mock: &_m.Mock}
38+}
39+
40+// ReadImage provides a mock function for the type MockClipboardReader
41+func (_mock *MockClipboardReader) ReadImage(ctx context.Context) (string, []byte, error) {
42+ ret := _mock.Called(ctx)
43+
44+ if len(ret) == 0 {
45+ panic("no return value specified for ReadImage")
46+ }
47+
48+ var r0 string
49+ var r1 []byte
50+ var r2 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context) (string, []byte, error)); ok {
52+ return returnFunc(ctx)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context) string); ok {
55+ r0 = returnFunc(ctx)
56+ } else {
57+ r0 = ret.Get(0).(string)
58+ }
59+ if returnFunc, ok := ret.Get(1).(func(context.Context) []byte); ok {
60+ r1 = returnFunc(ctx)
61+ } else {
62+ if ret.Get(1) != nil {
63+ r1 = ret.Get(1).([]byte)
64+ }
65+ }
66+ if returnFunc, ok := ret.Get(2).(func(context.Context) error); ok {
67+ r2 = returnFunc(ctx)
68+ } else {
69+ r2 = ret.Error(2)
70+ }
71+ return r0, r1, r2
72+}
73+
74+// MockClipboardReader_ReadImage_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadImage'
75+type MockClipboardReader_ReadImage_Call struct {
76+ *mock.Call
77+}
78+
79+// ReadImage is a helper method to define mock.On call
80+// - ctx context.Context
81+func (_e *MockClipboardReader_Expecter) ReadImage(ctx any) *MockClipboardReader_ReadImage_Call {
82+ return &MockClipboardReader_ReadImage_Call{Call: _e.mock.On("ReadImage", ctx)}
83+}
84+
85+func (_c *MockClipboardReader_ReadImage_Call) Run(run func(ctx context.Context)) *MockClipboardReader_ReadImage_Call {
86+ _c.Call.Run(func(args mock.Arguments) {
87+ var arg0 context.Context
88+ if args[0] != nil {
89+ arg0 = args[0].(context.Context)
90+ }
91+ run(
92+ arg0,
93+ )
94+ })
95+ return _c
96+}
97+
98+func (_c *MockClipboardReader_ReadImage_Call) Return(mime string, data []byte, err error) *MockClipboardReader_ReadImage_Call {
99+ _c.Call.Return(mime, data, err)
100+ return _c
101+}
102+
103+func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.Context) (string, []byte, error)) *MockClipboardReader_ReadImage_Call {
104+ _c.Call.Return(run)
105+ return _c
106+}
A internal/ports/mocks/clock_mock.go +304 -0
@@ -0,0 +1,304 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "time"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockClock creates a new instance of MockClock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockClock(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockClock {
20+ mock := &MockClock{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockClock is an autogenerated mock type for the Clock type
29+type MockClock struct {
30+ mock.Mock
31+}
32+
33+type MockClock_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockClock) EXPECT() *MockClock_Expecter {
38+ return &MockClock_Expecter{mock: &_m.Mock}
39+}
40+
41+// NewTimer provides a mock function for the type MockClock
42+func (_mock *MockClock) NewTimer(d time.Duration) ports.Timer {
43+ ret := _mock.Called(d)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for NewTimer")
47+ }
48+
49+ var r0 ports.Timer
50+ if returnFunc, ok := ret.Get(0).(func(time.Duration) ports.Timer); ok {
51+ r0 = returnFunc(d)
52+ } else {
53+ if ret.Get(0) != nil {
54+ r0 = ret.Get(0).(ports.Timer)
55+ }
56+ }
57+ return r0
58+}
59+
60+// MockClock_NewTimer_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'NewTimer'
61+type MockClock_NewTimer_Call struct {
62+ *mock.Call
63+}
64+
65+// NewTimer is a helper method to define mock.On call
66+// - d time.Duration
67+func (_e *MockClock_Expecter) NewTimer(d any) *MockClock_NewTimer_Call {
68+ return &MockClock_NewTimer_Call{Call: _e.mock.On("NewTimer", d)}
69+}
70+
71+func (_c *MockClock_NewTimer_Call) Run(run func(d time.Duration)) *MockClock_NewTimer_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 time.Duration
74+ if args[0] != nil {
75+ arg0 = args[0].(time.Duration)
76+ }
77+ run(
78+ arg0,
79+ )
80+ })
81+ return _c
82+}
83+
84+func (_c *MockClock_NewTimer_Call) Return(timer ports.Timer) *MockClock_NewTimer_Call {
85+ _c.Call.Return(timer)
86+ return _c
87+}
88+
89+func (_c *MockClock_NewTimer_Call) RunAndReturn(run func(d time.Duration) ports.Timer) *MockClock_NewTimer_Call {
90+ _c.Call.Return(run)
91+ return _c
92+}
93+
94+// Now provides a mock function for the type MockClock
95+func (_mock *MockClock) Now() time.Time {
96+ ret := _mock.Called()
97+
98+ if len(ret) == 0 {
99+ panic("no return value specified for Now")
100+ }
101+
102+ var r0 time.Time
103+ if returnFunc, ok := ret.Get(0).(func() time.Time); ok {
104+ r0 = returnFunc()
105+ } else {
106+ r0 = ret.Get(0).(time.Time)
107+ }
108+ return r0
109+}
110+
111+// MockClock_Now_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Now'
112+type MockClock_Now_Call struct {
113+ *mock.Call
114+}
115+
116+// Now is a helper method to define mock.On call
117+func (_e *MockClock_Expecter) Now() *MockClock_Now_Call {
118+ return &MockClock_Now_Call{Call: _e.mock.On("Now")}
119+}
120+
121+func (_c *MockClock_Now_Call) Run(run func()) *MockClock_Now_Call {
122+ _c.Call.Run(func(args mock.Arguments) {
123+ run()
124+ })
125+ return _c
126+}
127+
128+func (_c *MockClock_Now_Call) Return(time1 time.Time) *MockClock_Now_Call {
129+ _c.Call.Return(time1)
130+ return _c
131+}
132+
133+func (_c *MockClock_Now_Call) RunAndReturn(run func() time.Time) *MockClock_Now_Call {
134+ _c.Call.Return(run)
135+ return _c
136+}
137+
138+// NewMockTimer creates a new instance of MockTimer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
139+// The first argument is typically a *testing.T value.
140+func NewMockTimer(t interface {
141+ mock.TestingT
142+ Cleanup(func())
143+}) *MockTimer {
144+ mock := &MockTimer{}
145+ mock.Mock.Test(t)
146+
147+ t.Cleanup(func() { mock.AssertExpectations(t) })
148+
149+ return mock
150+}
151+
152+// MockTimer is an autogenerated mock type for the Timer type
153+type MockTimer struct {
154+ mock.Mock
155+}
156+
157+type MockTimer_Expecter struct {
158+ mock *mock.Mock
159+}
160+
161+func (_m *MockTimer) EXPECT() *MockTimer_Expecter {
162+ return &MockTimer_Expecter{mock: &_m.Mock}
163+}
164+
165+// C provides a mock function for the type MockTimer
166+func (_mock *MockTimer) C() <-chan time.Time {
167+ ret := _mock.Called()
168+
169+ if len(ret) == 0 {
170+ panic("no return value specified for C")
171+ }
172+
173+ var r0 <-chan time.Time
174+ if returnFunc, ok := ret.Get(0).(func() <-chan time.Time); ok {
175+ r0 = returnFunc()
176+ } else {
177+ if ret.Get(0) != nil {
178+ r0 = ret.Get(0).(<-chan time.Time)
179+ }
180+ }
181+ return r0
182+}
183+
184+// MockTimer_C_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'C'
185+type MockTimer_C_Call struct {
186+ *mock.Call
187+}
188+
189+// C is a helper method to define mock.On call
190+func (_e *MockTimer_Expecter) C() *MockTimer_C_Call {
191+ return &MockTimer_C_Call{Call: _e.mock.On("C")}
192+}
193+
194+func (_c *MockTimer_C_Call) Run(run func()) *MockTimer_C_Call {
195+ _c.Call.Run(func(args mock.Arguments) {
196+ run()
197+ })
198+ return _c
199+}
200+
201+func (_c *MockTimer_C_Call) Return(timeCh <-chan time.Time) *MockTimer_C_Call {
202+ _c.Call.Return(timeCh)
203+ return _c
204+}
205+
206+func (_c *MockTimer_C_Call) RunAndReturn(run func() <-chan time.Time) *MockTimer_C_Call {
207+ _c.Call.Return(run)
208+ return _c
209+}
210+
211+// Reset provides a mock function for the type MockTimer
212+func (_mock *MockTimer) Reset(d time.Duration) bool {
213+ ret := _mock.Called(d)
214+
215+ if len(ret) == 0 {
216+ panic("no return value specified for Reset")
217+ }
218+
219+ var r0 bool
220+ if returnFunc, ok := ret.Get(0).(func(time.Duration) bool); ok {
221+ r0 = returnFunc(d)
222+ } else {
223+ r0 = ret.Get(0).(bool)
224+ }
225+ return r0
226+}
227+
228+// MockTimer_Reset_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Reset'
229+type MockTimer_Reset_Call struct {
230+ *mock.Call
231+}
232+
233+// Reset is a helper method to define mock.On call
234+// - d time.Duration
235+func (_e *MockTimer_Expecter) Reset(d any) *MockTimer_Reset_Call {
236+ return &MockTimer_Reset_Call{Call: _e.mock.On("Reset", d)}
237+}
238+
239+func (_c *MockTimer_Reset_Call) Run(run func(d time.Duration)) *MockTimer_Reset_Call {
240+ _c.Call.Run(func(args mock.Arguments) {
241+ var arg0 time.Duration
242+ if args[0] != nil {
243+ arg0 = args[0].(time.Duration)
244+ }
245+ run(
246+ arg0,
247+ )
248+ })
249+ return _c
250+}
251+
252+func (_c *MockTimer_Reset_Call) Return(b bool) *MockTimer_Reset_Call {
253+ _c.Call.Return(b)
254+ return _c
255+}
256+
257+func (_c *MockTimer_Reset_Call) RunAndReturn(run func(d time.Duration) bool) *MockTimer_Reset_Call {
258+ _c.Call.Return(run)
259+ return _c
260+}
261+
262+// Stop provides a mock function for the type MockTimer
263+func (_mock *MockTimer) Stop() bool {
264+ ret := _mock.Called()
265+
266+ if len(ret) == 0 {
267+ panic("no return value specified for Stop")
268+ }
269+
270+ var r0 bool
271+ if returnFunc, ok := ret.Get(0).(func() bool); ok {
272+ r0 = returnFunc()
273+ } else {
274+ r0 = ret.Get(0).(bool)
275+ }
276+ return r0
277+}
278+
279+// MockTimer_Stop_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Stop'
280+type MockTimer_Stop_Call struct {
281+ *mock.Call
282+}
283+
284+// Stop is a helper method to define mock.On call
285+func (_e *MockTimer_Expecter) Stop() *MockTimer_Stop_Call {
286+ return &MockTimer_Stop_Call{Call: _e.mock.On("Stop")}
287+}
288+
289+func (_c *MockTimer_Stop_Call) Run(run func()) *MockTimer_Stop_Call {
290+ _c.Call.Run(func(args mock.Arguments) {
291+ run()
292+ })
293+ return _c
294+}
295+
296+func (_c *MockTimer_Stop_Call) Return(b bool) *MockTimer_Stop_Call {
297+ _c.Call.Return(b)
298+ return _c
299+}
300+
301+func (_c *MockTimer_Stop_Call) RunAndReturn(run func() bool) *MockTimer_Stop_Call {
302+ _c.Call.Return(run)
303+ return _c
304+}
A internal/ports/mocks/link_mock.go +127 -0
@@ -0,0 +1,127 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "github.com/bnema/vev/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockLinkStateReporter creates a new instance of MockLinkStateReporter. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockLinkStateReporter(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockLinkStateReporter {
18+ mock := &MockLinkStateReporter{}
19+ mock.Mock.Test(t)
20+
21+ t.Cleanup(func() { mock.AssertExpectations(t) })
22+
23+ return mock
24+}
25+
26+// MockLinkStateReporter is an autogenerated mock type for the LinkStateReporter type
27+type MockLinkStateReporter struct {
28+ mock.Mock
29+}
30+
31+type MockLinkStateReporter_Expecter struct {
32+ mock *mock.Mock
33+}
34+
35+func (_m *MockLinkStateReporter) EXPECT() *MockLinkStateReporter_Expecter {
36+ return &MockLinkStateReporter_Expecter{mock: &_m.Mock}
37+}
38+
39+// LinkEvents provides a mock function for the type MockLinkStateReporter
40+func (_mock *MockLinkStateReporter) LinkEvents() <-chan ports.LinkEvent {
41+ ret := _mock.Called()
42+
43+ if len(ret) == 0 {
44+ panic("no return value specified for LinkEvents")
45+ }
46+
47+ var r0 <-chan ports.LinkEvent
48+ if returnFunc, ok := ret.Get(0).(func() <-chan ports.LinkEvent); ok {
49+ r0 = returnFunc()
50+ } else {
51+ if ret.Get(0) != nil {
52+ r0 = ret.Get(0).(<-chan ports.LinkEvent)
53+ }
54+ }
55+ return r0
56+}
57+
58+// MockLinkStateReporter_LinkEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinkEvents'
59+type MockLinkStateReporter_LinkEvents_Call struct {
60+ *mock.Call
61+}
62+
63+// LinkEvents is a helper method to define mock.On call
64+func (_e *MockLinkStateReporter_Expecter) LinkEvents() *MockLinkStateReporter_LinkEvents_Call {
65+ return &MockLinkStateReporter_LinkEvents_Call{Call: _e.mock.On("LinkEvents")}
66+}
67+
68+func (_c *MockLinkStateReporter_LinkEvents_Call) Run(run func()) *MockLinkStateReporter_LinkEvents_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ run()
71+ })
72+ return _c
73+}
74+
75+func (_c *MockLinkStateReporter_LinkEvents_Call) Return(linkEventCh <-chan ports.LinkEvent) *MockLinkStateReporter_LinkEvents_Call {
76+ _c.Call.Return(linkEventCh)
77+ return _c
78+}
79+
80+func (_c *MockLinkStateReporter_LinkEvents_Call) RunAndReturn(run func() <-chan ports.LinkEvent) *MockLinkStateReporter_LinkEvents_Call {
81+ _c.Call.Return(run)
82+ return _c
83+}
84+
85+// LinkState provides a mock function for the type MockLinkStateReporter
86+func (_mock *MockLinkStateReporter) LinkState() ports.LinkState {
87+ ret := _mock.Called()
88+
89+ if len(ret) == 0 {
90+ panic("no return value specified for LinkState")
91+ }
92+
93+ var r0 ports.LinkState
94+ if returnFunc, ok := ret.Get(0).(func() ports.LinkState); ok {
95+ r0 = returnFunc()
96+ } else {
97+ r0 = ret.Get(0).(ports.LinkState)
98+ }
99+ return r0
100+}
101+
102+// MockLinkStateReporter_LinkState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LinkState'
103+type MockLinkStateReporter_LinkState_Call struct {
104+ *mock.Call
105+}
106+
107+// LinkState is a helper method to define mock.On call
108+func (_e *MockLinkStateReporter_Expecter) LinkState() *MockLinkStateReporter_LinkState_Call {
109+ return &MockLinkStateReporter_LinkState_Call{Call: _e.mock.On("LinkState")}
110+}
111+
112+func (_c *MockLinkStateReporter_LinkState_Call) Run(run func()) *MockLinkStateReporter_LinkState_Call {
113+ _c.Call.Run(func(args mock.Arguments) {
114+ run()
115+ })
116+ return _c
117+}
118+
119+func (_c *MockLinkStateReporter_LinkState_Call) Return(linkState ports.LinkState) *MockLinkStateReporter_LinkState_Call {
120+ _c.Call.Return(linkState)
121+ return _c
122+}
123+
124+func (_c *MockLinkStateReporter_LinkState_Call) RunAndReturn(run func() ports.LinkState) *MockLinkStateReporter_LinkState_Call {
125+ _c.Call.Return(run)
126+ return _c
127+}
D internal/ports/mocks/mocks.go +0 -3198 large diff (3198 lines changed): collapsed
A internal/ports/mocks/process_mock.go +286 -0
@@ -0,0 +1,286 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ mock "github.com/stretchr/testify/mock"
9+)
10+
11+// NewMockProcessInspector creates a new instance of MockProcessInspector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12+// The first argument is typically a *testing.T value.
13+func NewMockProcessInspector(t interface {
14+ mock.TestingT
15+ Cleanup(func())
16+}) *MockProcessInspector {
17+ mock := &MockProcessInspector{}
18+ mock.Mock.Test(t)
19+
20+ t.Cleanup(func() { mock.AssertExpectations(t) })
21+
22+ return mock
23+}
24+
25+// MockProcessInspector is an autogenerated mock type for the ProcessInspector type
26+type MockProcessInspector struct {
27+ mock.Mock
28+}
29+
30+type MockProcessInspector_Expecter struct {
31+ mock *mock.Mock
32+}
33+
34+func (_m *MockProcessInspector) EXPECT() *MockProcessInspector_Expecter {
35+ return &MockProcessInspector_Expecter{mock: &_m.Mock}
36+}
37+
38+// Argv provides a mock function for the type MockProcessInspector
39+func (_mock *MockProcessInspector) Argv(pid int) ([]string, error) {
40+ ret := _mock.Called(pid)
41+
42+ if len(ret) == 0 {
43+ panic("no return value specified for Argv")
44+ }
45+
46+ var r0 []string
47+ var r1 error
48+ if returnFunc, ok := ret.Get(0).(func(int) ([]string, error)); ok {
49+ return returnFunc(pid)
50+ }
51+ if returnFunc, ok := ret.Get(0).(func(int) []string); ok {
52+ r0 = returnFunc(pid)
53+ } else {
54+ if ret.Get(0) != nil {
55+ r0 = ret.Get(0).([]string)
56+ }
57+ }
58+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
59+ r1 = returnFunc(pid)
60+ } else {
61+ r1 = ret.Error(1)
62+ }
63+ return r0, r1
64+}
65+
66+// MockProcessInspector_Argv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Argv'
67+type MockProcessInspector_Argv_Call struct {
68+ *mock.Call
69+}
70+
71+// Argv is a helper method to define mock.On call
72+// - pid int
73+func (_e *MockProcessInspector_Expecter) Argv(pid any) *MockProcessInspector_Argv_Call {
74+ return &MockProcessInspector_Argv_Call{Call: _e.mock.On("Argv", pid)}
75+}
76+
77+func (_c *MockProcessInspector_Argv_Call) Run(run func(pid int)) *MockProcessInspector_Argv_Call {
78+ _c.Call.Run(func(args mock.Arguments) {
79+ var arg0 int
80+ if args[0] != nil {
81+ arg0 = args[0].(int)
82+ }
83+ run(
84+ arg0,
85+ )
86+ })
87+ return _c
88+}
89+
90+func (_c *MockProcessInspector_Argv_Call) Return(strings []string, err error) *MockProcessInspector_Argv_Call {
91+ _c.Call.Return(strings, err)
92+ return _c
93+}
94+
95+func (_c *MockProcessInspector_Argv_Call) RunAndReturn(run func(pid int) ([]string, error)) *MockProcessInspector_Argv_Call {
96+ _c.Call.Return(run)
97+ return _c
98+}
99+
100+// Comm provides a mock function for the type MockProcessInspector
101+func (_mock *MockProcessInspector) Comm(pid int) (string, error) {
102+ ret := _mock.Called(pid)
103+
104+ if len(ret) == 0 {
105+ panic("no return value specified for Comm")
106+ }
107+
108+ var r0 string
109+ var r1 error
110+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
111+ return returnFunc(pid)
112+ }
113+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
114+ r0 = returnFunc(pid)
115+ } else {
116+ r0 = ret.Get(0).(string)
117+ }
118+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
119+ r1 = returnFunc(pid)
120+ } else {
121+ r1 = ret.Error(1)
122+ }
123+ return r0, r1
124+}
125+
126+// MockProcessInspector_Comm_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Comm'
127+type MockProcessInspector_Comm_Call struct {
128+ *mock.Call
129+}
130+
131+// Comm is a helper method to define mock.On call
132+// - pid int
133+func (_e *MockProcessInspector_Expecter) Comm(pid any) *MockProcessInspector_Comm_Call {
134+ return &MockProcessInspector_Comm_Call{Call: _e.mock.On("Comm", pid)}
135+}
136+
137+func (_c *MockProcessInspector_Comm_Call) Run(run func(pid int)) *MockProcessInspector_Comm_Call {
138+ _c.Call.Run(func(args mock.Arguments) {
139+ var arg0 int
140+ if args[0] != nil {
141+ arg0 = args[0].(int)
142+ }
143+ run(
144+ arg0,
145+ )
146+ })
147+ return _c
148+}
149+
150+func (_c *MockProcessInspector_Comm_Call) Return(s string, err error) *MockProcessInspector_Comm_Call {
151+ _c.Call.Return(s, err)
152+ return _c
153+}
154+
155+func (_c *MockProcessInspector_Comm_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Comm_Call {
156+ _c.Call.Return(run)
157+ return _c
158+}
159+
160+// Cwd provides a mock function for the type MockProcessInspector
161+func (_mock *MockProcessInspector) Cwd(pid int) (string, error) {
162+ ret := _mock.Called(pid)
163+
164+ if len(ret) == 0 {
165+ panic("no return value specified for Cwd")
166+ }
167+
168+ var r0 string
169+ var r1 error
170+ if returnFunc, ok := ret.Get(0).(func(int) (string, error)); ok {
171+ return returnFunc(pid)
172+ }
173+ if returnFunc, ok := ret.Get(0).(func(int) string); ok {
174+ r0 = returnFunc(pid)
175+ } else {
176+ r0 = ret.Get(0).(string)
177+ }
178+ if returnFunc, ok := ret.Get(1).(func(int) error); ok {
179+ r1 = returnFunc(pid)
180+ } else {
181+ r1 = ret.Error(1)
182+ }
183+ return r0, r1
184+}
185+
186+// MockProcessInspector_Cwd_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Cwd'
187+type MockProcessInspector_Cwd_Call struct {
188+ *mock.Call
189+}
190+
191+// Cwd is a helper method to define mock.On call
192+// - pid int
193+func (_e *MockProcessInspector_Expecter) Cwd(pid any) *MockProcessInspector_Cwd_Call {
194+ return &MockProcessInspector_Cwd_Call{Call: _e.mock.On("Cwd", pid)}
195+}
196+
197+func (_c *MockProcessInspector_Cwd_Call) Run(run func(pid int)) *MockProcessInspector_Cwd_Call {
198+ _c.Call.Run(func(args mock.Arguments) {
199+ var arg0 int
200+ if args[0] != nil {
201+ arg0 = args[0].(int)
202+ }
203+ run(
204+ arg0,
205+ )
206+ })
207+ return _c
208+}
209+
210+func (_c *MockProcessInspector_Cwd_Call) Return(s string, err error) *MockProcessInspector_Cwd_Call {
211+ _c.Call.Return(s, err)
212+ return _c
213+}
214+
215+func (_c *MockProcessInspector_Cwd_Call) RunAndReturn(run func(pid int) (string, error)) *MockProcessInspector_Cwd_Call {
216+ _c.Call.Return(run)
217+ return _c
218+}
219+
220+// GroupArgv provides a mock function for the type MockProcessInspector
221+func (_mock *MockProcessInspector) GroupArgv(pgid int, shellPid int) ([]string, error) {
222+ ret := _mock.Called(pgid, shellPid)
223+
224+ if len(ret) == 0 {
225+ panic("no return value specified for GroupArgv")
226+ }
227+
228+ var r0 []string
229+ var r1 error
230+ if returnFunc, ok := ret.Get(0).(func(int, int) ([]string, error)); ok {
231+ return returnFunc(pgid, shellPid)
232+ }
233+ if returnFunc, ok := ret.Get(0).(func(int, int) []string); ok {
234+ r0 = returnFunc(pgid, shellPid)
235+ } else {
236+ if ret.Get(0) != nil {
237+ r0 = ret.Get(0).([]string)
238+ }
239+ }
240+ if returnFunc, ok := ret.Get(1).(func(int, int) error); ok {
241+ r1 = returnFunc(pgid, shellPid)
242+ } else {
243+ r1 = ret.Error(1)
244+ }
245+ return r0, r1
246+}
247+
248+// MockProcessInspector_GroupArgv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GroupArgv'
249+type MockProcessInspector_GroupArgv_Call struct {
250+ *mock.Call
251+}
252+
253+// GroupArgv is a helper method to define mock.On call
254+// - pgid int
255+// - shellPid int
256+func (_e *MockProcessInspector_Expecter) GroupArgv(pgid any, shellPid any) *MockProcessInspector_GroupArgv_Call {
257+ return &MockProcessInspector_GroupArgv_Call{Call: _e.mock.On("GroupArgv", pgid, shellPid)}
258+}
259+
260+func (_c *MockProcessInspector_GroupArgv_Call) Run(run func(pgid int, shellPid int)) *MockProcessInspector_GroupArgv_Call {
261+ _c.Call.Run(func(args mock.Arguments) {
262+ var arg0 int
263+ if args[0] != nil {
264+ arg0 = args[0].(int)
265+ }
266+ var arg1 int
267+ if args[1] != nil {
268+ arg1 = args[1].(int)
269+ }
270+ run(
271+ arg0,
272+ arg1,
273+ )
274+ })
275+ return _c
276+}
277+
278+func (_c *MockProcessInspector_GroupArgv_Call) Return(strings []string, err error) *MockProcessInspector_GroupArgv_Call {
279+ _c.Call.Return(strings, err)
280+ return _c
281+}
282+
283+func (_c *MockProcessInspector_GroupArgv_Call) RunAndReturn(run func(pgid int, shellPid int) ([]string, error)) *MockProcessInspector_GroupArgv_Call {
284+ _c.Call.Return(run)
285+ return _c
286+}
A internal/ports/mocks/pty_mock.go +471 -0
@@ -0,0 +1,471 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ mock "github.com/stretchr/testify/mock"
13+)
14+
15+// NewMockPTY creates a new instance of MockPTY. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
16+// The first argument is typically a *testing.T value.
17+func NewMockPTY(t interface {
18+ mock.TestingT
19+ Cleanup(func())
20+}) *MockPTY {
21+ mock := &MockPTY{}
22+ mock.Mock.Test(t)
23+
24+ t.Cleanup(func() { mock.AssertExpectations(t) })
25+
26+ return mock
27+}
28+
29+// MockPTY is an autogenerated mock type for the PTY type
30+type MockPTY struct {
31+ mock.Mock
32+}
33+
34+type MockPTY_Expecter struct {
35+ mock *mock.Mock
36+}
37+
38+func (_m *MockPTY) EXPECT() *MockPTY_Expecter {
39+ return &MockPTY_Expecter{mock: &_m.Mock}
40+}
41+
42+// Close provides a mock function for the type MockPTY
43+func (_mock *MockPTY) Close() error {
44+ ret := _mock.Called()
45+
46+ if len(ret) == 0 {
47+ panic("no return value specified for Close")
48+ }
49+
50+ var r0 error
51+ if returnFunc, ok := ret.Get(0).(func() error); ok {
52+ r0 = returnFunc()
53+ } else {
54+ r0 = ret.Error(0)
55+ }
56+ return r0
57+}
58+
59+// MockPTY_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
60+type MockPTY_Close_Call struct {
61+ *mock.Call
62+}
63+
64+// Close is a helper method to define mock.On call
65+func (_e *MockPTY_Expecter) Close() *MockPTY_Close_Call {
66+ return &MockPTY_Close_Call{Call: _e.mock.On("Close")}
67+}
68+
69+func (_c *MockPTY_Close_Call) Run(run func()) *MockPTY_Close_Call {
70+ _c.Call.Run(func(args mock.Arguments) {
71+ run()
72+ })
73+ return _c
74+}
75+
76+func (_c *MockPTY_Close_Call) Return(err error) *MockPTY_Close_Call {
77+ _c.Call.Return(err)
78+ return _c
79+}
80+
81+func (_c *MockPTY_Close_Call) RunAndReturn(run func() error) *MockPTY_Close_Call {
82+ _c.Call.Return(run)
83+ return _c
84+}
85+
86+// ForegroundPgid provides a mock function for the type MockPTY
87+func (_mock *MockPTY) ForegroundPgid() (int, error) {
88+ ret := _mock.Called()
89+
90+ if len(ret) == 0 {
91+ panic("no return value specified for ForegroundPgid")
92+ }
93+
94+ var r0 int
95+ var r1 error
96+ if returnFunc, ok := ret.Get(0).(func() (int, error)); ok {
97+ return returnFunc()
98+ }
99+ if returnFunc, ok := ret.Get(0).(func() int); ok {
100+ r0 = returnFunc()
101+ } else {
102+ r0 = ret.Get(0).(int)
103+ }
104+ if returnFunc, ok := ret.Get(1).(func() error); ok {
105+ r1 = returnFunc()
106+ } else {
107+ r1 = ret.Error(1)
108+ }
109+ return r0, r1
110+}
111+
112+// MockPTY_ForegroundPgid_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ForegroundPgid'
113+type MockPTY_ForegroundPgid_Call struct {
114+ *mock.Call
115+}
116+
117+// ForegroundPgid is a helper method to define mock.On call
118+func (_e *MockPTY_Expecter) ForegroundPgid() *MockPTY_ForegroundPgid_Call {
119+ return &MockPTY_ForegroundPgid_Call{Call: _e.mock.On("ForegroundPgid")}
120+}
121+
122+func (_c *MockPTY_ForegroundPgid_Call) Run(run func()) *MockPTY_ForegroundPgid_Call {
123+ _c.Call.Run(func(args mock.Arguments) {
124+ run()
125+ })
126+ return _c
127+}
128+
129+func (_c *MockPTY_ForegroundPgid_Call) Return(n int, err error) *MockPTY_ForegroundPgid_Call {
130+ _c.Call.Return(n, err)
131+ return _c
132+}
133+
134+func (_c *MockPTY_ForegroundPgid_Call) RunAndReturn(run func() (int, error)) *MockPTY_ForegroundPgid_Call {
135+ _c.Call.Return(run)
136+ return _c
137+}
138+
139+// Pid provides a mock function for the type MockPTY
140+func (_mock *MockPTY) Pid() int {
141+ ret := _mock.Called()
142+
143+ if len(ret) == 0 {
144+ panic("no return value specified for Pid")
145+ }
146+
147+ var r0 int
148+ if returnFunc, ok := ret.Get(0).(func() int); ok {
149+ r0 = returnFunc()
150+ } else {
151+ r0 = ret.Get(0).(int)
152+ }
153+ return r0
154+}
155+
156+// MockPTY_Pid_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Pid'
157+type MockPTY_Pid_Call struct {
158+ *mock.Call
159+}
160+
161+// Pid is a helper method to define mock.On call
162+func (_e *MockPTY_Expecter) Pid() *MockPTY_Pid_Call {
163+ return &MockPTY_Pid_Call{Call: _e.mock.On("Pid")}
164+}
165+
166+func (_c *MockPTY_Pid_Call) Run(run func()) *MockPTY_Pid_Call {
167+ _c.Call.Run(func(args mock.Arguments) {
168+ run()
169+ })
170+ return _c
171+}
172+
173+func (_c *MockPTY_Pid_Call) Return(n int) *MockPTY_Pid_Call {
174+ _c.Call.Return(n)
175+ return _c
176+}
177+
178+func (_c *MockPTY_Pid_Call) RunAndReturn(run func() int) *MockPTY_Pid_Call {
179+ _c.Call.Return(run)
180+ return _c
181+}
182+
183+// Read provides a mock function for the type MockPTY
184+func (_mock *MockPTY) Read(p []byte) (int, error) {
185+ ret := _mock.Called(p)
186+
187+ if len(ret) == 0 {
188+ panic("no return value specified for Read")
189+ }
190+
191+ var r0 int
192+ var r1 error
193+ if returnFunc, ok := ret.Get(0).(func([]byte) (int, error)); ok {
194+ return returnFunc(p)
195+ }
196+ if returnFunc, ok := ret.Get(0).(func([]byte) int); ok {
197+ r0 = returnFunc(p)
198+ } else {
199+ r0 = ret.Get(0).(int)
200+ }
201+ if returnFunc, ok := ret.Get(1).(func([]byte) error); ok {
202+ r1 = returnFunc(p)
203+ } else {
204+ r1 = ret.Error(1)
205+ }
206+ return r0, r1
207+}
208+
209+// MockPTY_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read'
210+type MockPTY_Read_Call struct {
211+ *mock.Call
212+}
213+
214+// Read is a helper method to define mock.On call
215+// - p []byte
216+func (_e *MockPTY_Expecter) Read(p any) *MockPTY_Read_Call {
217+ return &MockPTY_Read_Call{Call: _e.mock.On("Read", p)}
218+}
219+
220+func (_c *MockPTY_Read_Call) Run(run func(p []byte)) *MockPTY_Read_Call {
221+ _c.Call.Run(func(args mock.Arguments) {
222+ var arg0 []byte
223+ if args[0] != nil {
224+ arg0 = args[0].([]byte)
225+ }
226+ run(
227+ arg0,
228+ )
229+ })
230+ return _c
231+}
232+
233+func (_c *MockPTY_Read_Call) Return(n int, err error) *MockPTY_Read_Call {
234+ _c.Call.Return(n, err)
235+ return _c
236+}
237+
238+func (_c *MockPTY_Read_Call) RunAndReturn(run func(p []byte) (int, error)) *MockPTY_Read_Call {
239+ _c.Call.Return(run)
240+ return _c
241+}
242+
243+// Resize provides a mock function for the type MockPTY
244+func (_mock *MockPTY) Resize(sz domain.Size) error {
245+ ret := _mock.Called(sz)
246+
247+ if len(ret) == 0 {
248+ panic("no return value specified for Resize")
249+ }
250+
251+ var r0 error
252+ if returnFunc, ok := ret.Get(0).(func(domain.Size) error); ok {
253+ r0 = returnFunc(sz)
254+ } else {
255+ r0 = ret.Error(0)
256+ }
257+ return r0
258+}
259+
260+// MockPTY_Resize_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Resize'
261+type MockPTY_Resize_Call struct {
262+ *mock.Call
263+}
264+
265+// Resize is a helper method to define mock.On call
266+// - sz domain.Size
267+func (_e *MockPTY_Expecter) Resize(sz any) *MockPTY_Resize_Call {
268+ return &MockPTY_Resize_Call{Call: _e.mock.On("Resize", sz)}
269+}
270+
271+func (_c *MockPTY_Resize_Call) Run(run func(sz domain.Size)) *MockPTY_Resize_Call {
272+ _c.Call.Run(func(args mock.Arguments) {
273+ var arg0 domain.Size
274+ if args[0] != nil {
275+ arg0 = args[0].(domain.Size)
276+ }
277+ run(
278+ arg0,
279+ )
280+ })
281+ return _c
282+}
283+
284+func (_c *MockPTY_Resize_Call) Return(err error) *MockPTY_Resize_Call {
285+ _c.Call.Return(err)
286+ return _c
287+}
288+
289+func (_c *MockPTY_Resize_Call) RunAndReturn(run func(sz domain.Size) error) *MockPTY_Resize_Call {
290+ _c.Call.Return(run)
291+ return _c
292+}
293+
294+// Write provides a mock function for the type MockPTY
295+func (_mock *MockPTY) Write(p []byte) (int, error) {
296+ ret := _mock.Called(p)
297+
298+ if len(ret) == 0 {
299+ panic("no return value specified for Write")
300+ }
301+
302+ var r0 int
303+ var r1 error
304+ if returnFunc, ok := ret.Get(0).(func([]byte) (int, error)); ok {
305+ return returnFunc(p)
306+ }
307+ if returnFunc, ok := ret.Get(0).(func([]byte) int); ok {
308+ r0 = returnFunc(p)
309+ } else {
310+ r0 = ret.Get(0).(int)
311+ }
312+ if returnFunc, ok := ret.Get(1).(func([]byte) error); ok {
313+ r1 = returnFunc(p)
314+ } else {
315+ r1 = ret.Error(1)
316+ }
317+ return r0, r1
318+}
319+
320+// MockPTY_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
321+type MockPTY_Write_Call struct {
322+ *mock.Call
323+}
324+
325+// Write is a helper method to define mock.On call
326+// - p []byte
327+func (_e *MockPTY_Expecter) Write(p any) *MockPTY_Write_Call {
328+ return &MockPTY_Write_Call{Call: _e.mock.On("Write", p)}
329+}
330+
331+func (_c *MockPTY_Write_Call) Run(run func(p []byte)) *MockPTY_Write_Call {
332+ _c.Call.Run(func(args mock.Arguments) {
333+ var arg0 []byte
334+ if args[0] != nil {
335+ arg0 = args[0].([]byte)
336+ }
337+ run(
338+ arg0,
339+ )
340+ })
341+ return _c
342+}
343+
344+func (_c *MockPTY_Write_Call) Return(n int, err error) *MockPTY_Write_Call {
345+ _c.Call.Return(n, err)
346+ return _c
347+}
348+
349+func (_c *MockPTY_Write_Call) RunAndReturn(run func(p []byte) (int, error)) *MockPTY_Write_Call {
350+ _c.Call.Return(run)
351+ return _c
352+}
353+
354+// NewMockPTYFactory creates a new instance of MockPTYFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
355+// The first argument is typically a *testing.T value.
356+func NewMockPTYFactory(t interface {
357+ mock.TestingT
358+ Cleanup(func())
359+}) *MockPTYFactory {
360+ mock := &MockPTYFactory{}
361+ mock.Mock.Test(t)
362+
363+ t.Cleanup(func() { mock.AssertExpectations(t) })
364+
365+ return mock
366+}
367+
368+// MockPTYFactory is an autogenerated mock type for the PTYFactory type
369+type MockPTYFactory struct {
370+ mock.Mock
371+}
372+
373+type MockPTYFactory_Expecter struct {
374+ mock *mock.Mock
375+}
376+
377+func (_m *MockPTYFactory) EXPECT() *MockPTYFactory_Expecter {
378+ return &MockPTYFactory_Expecter{mock: &_m.Mock}
379+}
380+
381+// Open provides a mock function for the type MockPTYFactory
382+func (_mock *MockPTYFactory) Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error) {
383+ ret := _mock.Called(ctx, cmd, args, env, dir, sz)
384+
385+ if len(ret) == 0 {
386+ panic("no return value specified for Open")
387+ }
388+
389+ var r0 ports.PTY
390+ var r1 error
391+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) (ports.PTY, error)); ok {
392+ return returnFunc(ctx, cmd, args, env, dir, sz)
393+ }
394+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, []string, []string, string, domain.Size) ports.PTY); ok {
395+ r0 = returnFunc(ctx, cmd, args, env, dir, sz)
396+ } else {
397+ if ret.Get(0) != nil {
398+ r0 = ret.Get(0).(ports.PTY)
399+ }
400+ }
401+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, []string, []string, string, domain.Size) error); ok {
402+ r1 = returnFunc(ctx, cmd, args, env, dir, sz)
403+ } else {
404+ r1 = ret.Error(1)
405+ }
406+ return r0, r1
407+}
408+
409+// MockPTYFactory_Open_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Open'
410+type MockPTYFactory_Open_Call struct {
411+ *mock.Call
412+}
413+
414+// Open is a helper method to define mock.On call
415+// - ctx context.Context
416+// - cmd string
417+// - args []string
418+// - env []string
419+// - dir string
420+// - sz domain.Size
421+func (_e *MockPTYFactory_Expecter) Open(ctx any, cmd any, args any, env any, dir any, sz any) *MockPTYFactory_Open_Call {
422+ return &MockPTYFactory_Open_Call{Call: _e.mock.On("Open", ctx, cmd, args, env, dir, sz)}
423+}
424+
425+func (_c *MockPTYFactory_Open_Call) Run(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size)) *MockPTYFactory_Open_Call {
426+ _c.Call.Run(func(args mock.Arguments) {
427+ var arg0 context.Context
428+ if args[0] != nil {
429+ arg0 = args[0].(context.Context)
430+ }
431+ var arg1 string
432+ if args[1] != nil {
433+ arg1 = args[1].(string)
434+ }
435+ var arg2 []string
436+ if args[2] != nil {
437+ arg2 = args[2].([]string)
438+ }
439+ var arg3 []string
440+ if args[3] != nil {
441+ arg3 = args[3].([]string)
442+ }
443+ var arg4 string
444+ if args[4] != nil {
445+ arg4 = args[4].(string)
446+ }
447+ var arg5 domain.Size
448+ if args[5] != nil {
449+ arg5 = args[5].(domain.Size)
450+ }
451+ run(
452+ arg0,
453+ arg1,
454+ arg2,
455+ arg3,
456+ arg4,
457+ arg5,
458+ )
459+ })
460+ return _c
461+}
462+
463+func (_c *MockPTYFactory_Open_Call) Return(pTY ports.PTY, err error) *MockPTYFactory_Open_Call {
464+ _c.Call.Return(pTY, err)
465+ return _c
466+}
467+
468+func (_c *MockPTYFactory_Open_Call) RunAndReturn(run func(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (ports.PTY, error)) *MockPTYFactory_Open_Call {
469+ _c.Call.Return(run)
470+ return _c
471+}
A internal/ports/mocks/remote_mock.go +119 -0
@@ -0,0 +1,119 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "log/slog"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockRemoteDialerFactory(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockRemoteDialerFactory {
20+ mock := &MockRemoteDialerFactory{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type
29+type MockRemoteDialerFactory struct {
30+ mock.Mock
31+}
32+
33+type MockRemoteDialerFactory_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter {
38+ return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock}
39+}
40+
41+// DialerForRemote provides a mock function for the type MockRemoteDialerFactory
42+func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
43+ ret := _mock.Called(target, session, mode, log)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for DialerForRemote")
47+ }
48+
49+ var r0 ports.Dialer
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok {
52+ return returnFunc(target, session, mode, log)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok {
55+ r0 = returnFunc(target, session, mode, log)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(ports.Dialer)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok {
62+ r1 = returnFunc(target, session, mode, log)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote'
70+type MockRemoteDialerFactory_DialerForRemote_Call struct {
71+ *mock.Call
72+}
73+
74+// DialerForRemote is a helper method to define mock.On call
75+// - target string
76+// - session string
77+// - mode ports.RemoteTransportMode
78+// - log *slog.Logger
79+func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call {
80+ return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)}
81+}
82+
83+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call {
84+ _c.Call.Run(func(args mock.Arguments) {
85+ var arg0 string
86+ if args[0] != nil {
87+ arg0 = args[0].(string)
88+ }
89+ var arg1 string
90+ if args[1] != nil {
91+ arg1 = args[1].(string)
92+ }
93+ var arg2 ports.RemoteTransportMode
94+ if args[2] != nil {
95+ arg2 = args[2].(ports.RemoteTransportMode)
96+ }
97+ var arg3 *slog.Logger
98+ if args[3] != nil {
99+ arg3 = args[3].(*slog.Logger)
100+ }
101+ run(
102+ arg0,
103+ arg1,
104+ arg2,
105+ arg3,
106+ )
107+ })
108+ return _c
109+}
110+
111+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call {
112+ _c.Call.Return(dialer, err)
113+ return _c
114+}
115+
116+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call {
117+ _c.Call.Return(run)
118+ return _c
119+}
A internal/ports/mocks/runtime_observer_mock.go +210 -0
@@ -0,0 +1,210 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "github.com/bnema/vev/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockRuntimeObserver creates a new instance of MockRuntimeObserver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockRuntimeObserver(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockRuntimeObserver {
18+ mock := &MockRuntimeObserver{}
19+ mock.Mock.Test(t)
20+
21+ t.Cleanup(func() { mock.AssertExpectations(t) })
22+
23+ return mock
24+}
25+
26+// MockRuntimeObserver is an autogenerated mock type for the RuntimeObserver type
27+type MockRuntimeObserver struct {
28+ mock.Mock
29+}
30+
31+type MockRuntimeObserver_Expecter struct {
32+ mock *mock.Mock
33+}
34+
35+func (_m *MockRuntimeObserver) EXPECT() *MockRuntimeObserver_Expecter {
36+ return &MockRuntimeObserver_Expecter{mock: &_m.Mock}
37+}
38+
39+// ObserveRuntime provides a mock function for the type MockRuntimeObserver
40+func (_mock *MockRuntimeObserver) ObserveRuntime(runtimeMark ports.RuntimeMark) {
41+ _mock.Called(runtimeMark)
42+ return
43+}
44+
45+// MockRuntimeObserver_ObserveRuntime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveRuntime'
46+type MockRuntimeObserver_ObserveRuntime_Call struct {
47+ *mock.Call
48+}
49+
50+// ObserveRuntime is a helper method to define mock.On call
51+// - runtimeMark ports.RuntimeMark
52+func (_e *MockRuntimeObserver_Expecter) ObserveRuntime(runtimeMark any) *MockRuntimeObserver_ObserveRuntime_Call {
53+ return &MockRuntimeObserver_ObserveRuntime_Call{Call: _e.mock.On("ObserveRuntime", runtimeMark)}
54+}
55+
56+func (_c *MockRuntimeObserver_ObserveRuntime_Call) Run(run func(runtimeMark ports.RuntimeMark)) *MockRuntimeObserver_ObserveRuntime_Call {
57+ _c.Call.Run(func(args mock.Arguments) {
58+ var arg0 ports.RuntimeMark
59+ if args[0] != nil {
60+ arg0 = args[0].(ports.RuntimeMark)
61+ }
62+ run(
63+ arg0,
64+ )
65+ })
66+ return _c
67+}
68+
69+func (_c *MockRuntimeObserver_ObserveRuntime_Call) Return() *MockRuntimeObserver_ObserveRuntime_Call {
70+ _c.Call.Return()
71+ return _c
72+}
73+
74+func (_c *MockRuntimeObserver_ObserveRuntime_Call) RunAndReturn(run func(runtimeMark ports.RuntimeMark)) *MockRuntimeObserver_ObserveRuntime_Call {
75+ _c.Run(run)
76+ return _c
77+}
78+
79+// NewMockSerializedRuntimeObserver creates a new instance of MockSerializedRuntimeObserver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
80+// The first argument is typically a *testing.T value.
81+func NewMockSerializedRuntimeObserver(t interface {
82+ mock.TestingT
83+ Cleanup(func())
84+}) *MockSerializedRuntimeObserver {
85+ mock := &MockSerializedRuntimeObserver{}
86+ mock.Mock.Test(t)
87+
88+ t.Cleanup(func() { mock.AssertExpectations(t) })
89+
90+ return mock
91+}
92+
93+// MockSerializedRuntimeObserver is an autogenerated mock type for the SerializedRuntimeObserver type
94+type MockSerializedRuntimeObserver struct {
95+ mock.Mock
96+}
97+
98+type MockSerializedRuntimeObserver_Expecter struct {
99+ mock *mock.Mock
100+}
101+
102+func (_m *MockSerializedRuntimeObserver) EXPECT() *MockSerializedRuntimeObserver_Expecter {
103+ return &MockSerializedRuntimeObserver_Expecter{mock: &_m.Mock}
104+}
105+
106+// Close provides a mock function for the type MockSerializedRuntimeObserver
107+func (_mock *MockSerializedRuntimeObserver) Close() {
108+ _mock.Called()
109+ return
110+}
111+
112+// MockSerializedRuntimeObserver_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
113+type MockSerializedRuntimeObserver_Close_Call struct {
114+ *mock.Call
115+}
116+
117+// Close is a helper method to define mock.On call
118+func (_e *MockSerializedRuntimeObserver_Expecter) Close() *MockSerializedRuntimeObserver_Close_Call {
119+ return &MockSerializedRuntimeObserver_Close_Call{Call: _e.mock.On("Close")}
120+}
121+
122+func (_c *MockSerializedRuntimeObserver_Close_Call) Run(run func()) *MockSerializedRuntimeObserver_Close_Call {
123+ _c.Call.Run(func(args mock.Arguments) {
124+ run()
125+ })
126+ return _c
127+}
128+
129+func (_c *MockSerializedRuntimeObserver_Close_Call) Return() *MockSerializedRuntimeObserver_Close_Call {
130+ _c.Call.Return()
131+ return _c
132+}
133+
134+func (_c *MockSerializedRuntimeObserver_Close_Call) RunAndReturn(run func()) *MockSerializedRuntimeObserver_Close_Call {
135+ _c.Run(run)
136+ return _c
137+}
138+
139+// Flush provides a mock function for the type MockSerializedRuntimeObserver
140+func (_mock *MockSerializedRuntimeObserver) Flush() {
141+ _mock.Called()
142+ return
143+}
144+
145+// MockSerializedRuntimeObserver_Flush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Flush'
146+type MockSerializedRuntimeObserver_Flush_Call struct {
147+ *mock.Call
148+}
149+
150+// Flush is a helper method to define mock.On call
151+func (_e *MockSerializedRuntimeObserver_Expecter) Flush() *MockSerializedRuntimeObserver_Flush_Call {
152+ return &MockSerializedRuntimeObserver_Flush_Call{Call: _e.mock.On("Flush")}
153+}
154+
155+func (_c *MockSerializedRuntimeObserver_Flush_Call) Run(run func()) *MockSerializedRuntimeObserver_Flush_Call {
156+ _c.Call.Run(func(args mock.Arguments) {
157+ run()
158+ })
159+ return _c
160+}
161+
162+func (_c *MockSerializedRuntimeObserver_Flush_Call) Return() *MockSerializedRuntimeObserver_Flush_Call {
163+ _c.Call.Return()
164+ return _c
165+}
166+
167+func (_c *MockSerializedRuntimeObserver_Flush_Call) RunAndReturn(run func()) *MockSerializedRuntimeObserver_Flush_Call {
168+ _c.Run(run)
169+ return _c
170+}
171+
172+// ObserveRuntime provides a mock function for the type MockSerializedRuntimeObserver
173+func (_mock *MockSerializedRuntimeObserver) ObserveRuntime(runtimeMark ports.RuntimeMark) {
174+ _mock.Called(runtimeMark)
175+ return
176+}
177+
178+// MockSerializedRuntimeObserver_ObserveRuntime_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ObserveRuntime'
179+type MockSerializedRuntimeObserver_ObserveRuntime_Call struct {
180+ *mock.Call
181+}
182+
183+// ObserveRuntime is a helper method to define mock.On call
184+// - runtimeMark ports.RuntimeMark
185+func (_e *MockSerializedRuntimeObserver_Expecter) ObserveRuntime(runtimeMark any) *MockSerializedRuntimeObserver_ObserveRuntime_Call {
186+ return &MockSerializedRuntimeObserver_ObserveRuntime_Call{Call: _e.mock.On("ObserveRuntime", runtimeMark)}
187+}
188+
189+func (_c *MockSerializedRuntimeObserver_ObserveRuntime_Call) Run(run func(runtimeMark ports.RuntimeMark)) *MockSerializedRuntimeObserver_ObserveRuntime_Call {
190+ _c.Call.Run(func(args mock.Arguments) {
191+ var arg0 ports.RuntimeMark
192+ if args[0] != nil {
193+ arg0 = args[0].(ports.RuntimeMark)
194+ }
195+ run(
196+ arg0,
197+ )
198+ })
199+ return _c
200+}
201+
202+func (_c *MockSerializedRuntimeObserver_ObserveRuntime_Call) Return() *MockSerializedRuntimeObserver_ObserveRuntime_Call {
203+ _c.Call.Return()
204+ return _c
205+}
206+
207+func (_c *MockSerializedRuntimeObserver_ObserveRuntime_Call) RunAndReturn(run func(runtimeMark ports.RuntimeMark)) *MockSerializedRuntimeObserver_ObserveRuntime_Call {
208+ _c.Run(run)
209+ return _c
210+}
A internal/ports/mocks/shell_mock.go +107 -0
@@ -0,0 +1,107 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockShellCommandRunner creates a new instance of MockShellCommandRunner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockShellCommandRunner(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockShellCommandRunner {
20+ mock := &MockShellCommandRunner{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockShellCommandRunner is an autogenerated mock type for the ShellCommandRunner type
29+type MockShellCommandRunner struct {
30+ mock.Mock
31+}
32+
33+type MockShellCommandRunner_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockShellCommandRunner) EXPECT() *MockShellCommandRunner_Expecter {
38+ return &MockShellCommandRunner_Expecter{mock: &_m.Mock}
39+}
40+
41+// Run provides a mock function for the type MockShellCommandRunner
42+func (_mock *MockShellCommandRunner) Run(ctx context.Context, spec ports.CommandSpec) ([]byte, error) {
43+ ret := _mock.Called(ctx, spec)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Run")
47+ }
48+
49+ var r0 []byte
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) ([]byte, error)); ok {
52+ return returnFunc(ctx, spec)
53+ }
54+ if returnFunc, ok := ret.Get(0).(func(context.Context, ports.CommandSpec) []byte); ok {
55+ r0 = returnFunc(ctx, spec)
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).([]byte)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func(context.Context, ports.CommandSpec) error); ok {
62+ r1 = returnFunc(ctx, spec)
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockShellCommandRunner_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
70+type MockShellCommandRunner_Run_Call struct {
71+ *mock.Call
72+}
73+
74+// Run is a helper method to define mock.On call
75+// - ctx context.Context
76+// - spec ports.CommandSpec
77+func (_e *MockShellCommandRunner_Expecter) Run(ctx any, spec any) *MockShellCommandRunner_Run_Call {
78+ return &MockShellCommandRunner_Run_Call{Call: _e.mock.On("Run", ctx, spec)}
79+}
80+
81+func (_c *MockShellCommandRunner_Run_Call) Run(run func(ctx context.Context, spec ports.CommandSpec)) *MockShellCommandRunner_Run_Call {
82+ _c.Call.Run(func(args mock.Arguments) {
83+ var arg0 context.Context
84+ if args[0] != nil {
85+ arg0 = args[0].(context.Context)
86+ }
87+ var arg1 ports.CommandSpec
88+ if args[1] != nil {
89+ arg1 = args[1].(ports.CommandSpec)
90+ }
91+ run(
92+ arg0,
93+ arg1,
94+ )
95+ })
96+ return _c
97+}
98+
99+func (_c *MockShellCommandRunner_Run_Call) Return(bytes []byte, err error) *MockShellCommandRunner_Run_Call {
100+ _c.Call.Return(bytes, err)
101+ return _c
102+}
103+
104+func (_c *MockShellCommandRunner_Run_Call) RunAndReturn(run func(ctx context.Context, spec ports.CommandSpec) ([]byte, error)) *MockShellCommandRunner_Run_Call {
105+ _c.Call.Return(run)
106+ return _c
107+}
A internal/ports/mocks/store_mock.go +525 -0
@@ -0,0 +1,525 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "github.com/bnema/vev/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockStore creates a new instance of MockStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockStore(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockStore {
18+ mock := &MockStore{}
19+ mock.Mock.Test(t)
20+
21+ t.Cleanup(func() { mock.AssertExpectations(t) })
22+
23+ return mock
24+}
25+
26+// MockStore is an autogenerated mock type for the Store type
27+type MockStore struct {
28+ mock.Mock
29+}
30+
31+type MockStore_Expecter struct {
32+ mock *mock.Mock
33+}
34+
35+func (_m *MockStore) EXPECT() *MockStore_Expecter {
36+ return &MockStore_Expecter{mock: &_m.Mock}
37+}
38+
39+// Close provides a mock function for the type MockStore
40+func (_mock *MockStore) Close() error {
41+ ret := _mock.Called()
42+
43+ if len(ret) == 0 {
44+ panic("no return value specified for Close")
45+ }
46+
47+ var r0 error
48+ if returnFunc, ok := ret.Get(0).(func() error); ok {
49+ r0 = returnFunc()
50+ } else {
51+ r0 = ret.Error(0)
52+ }
53+ return r0
54+}
55+
56+// MockStore_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
57+type MockStore_Close_Call struct {
58+ *mock.Call
59+}
60+
61+// Close is a helper method to define mock.On call
62+func (_e *MockStore_Expecter) Close() *MockStore_Close_Call {
63+ return &MockStore_Close_Call{Call: _e.mock.On("Close")}
64+}
65+
66+func (_c *MockStore_Close_Call) Run(run func()) *MockStore_Close_Call {
67+ _c.Call.Run(func(args mock.Arguments) {
68+ run()
69+ })
70+ return _c
71+}
72+
73+func (_c *MockStore_Close_Call) Return(err error) *MockStore_Close_Call {
74+ _c.Call.Return(err)
75+ return _c
76+}
77+
78+func (_c *MockStore_Close_Call) RunAndReturn(run func() error) *MockStore_Close_Call {
79+ _c.Call.Return(run)
80+ return _c
81+}
82+
83+// Delete provides a mock function for the type MockStore
84+func (_mock *MockStore) Delete(key []byte) error {
85+ ret := _mock.Called(key)
86+
87+ if len(ret) == 0 {
88+ panic("no return value specified for Delete")
89+ }
90+
91+ var r0 error
92+ if returnFunc, ok := ret.Get(0).(func([]byte) error); ok {
93+ r0 = returnFunc(key)
94+ } else {
95+ r0 = ret.Error(0)
96+ }
97+ return r0
98+}
99+
100+// MockStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
101+type MockStore_Delete_Call struct {
102+ *mock.Call
103+}
104+
105+// Delete is a helper method to define mock.On call
106+// - key []byte
107+func (_e *MockStore_Expecter) Delete(key any) *MockStore_Delete_Call {
108+ return &MockStore_Delete_Call{Call: _e.mock.On("Delete", key)}
109+}
110+
111+func (_c *MockStore_Delete_Call) Run(run func(key []byte)) *MockStore_Delete_Call {
112+ _c.Call.Run(func(args mock.Arguments) {
113+ var arg0 []byte
114+ if args[0] != nil {
115+ arg0 = args[0].([]byte)
116+ }
117+ run(
118+ arg0,
119+ )
120+ })
121+ return _c
122+}
123+
124+func (_c *MockStore_Delete_Call) Return(err error) *MockStore_Delete_Call {
125+ _c.Call.Return(err)
126+ return _c
127+}
128+
129+func (_c *MockStore_Delete_Call) RunAndReturn(run func(key []byte) error) *MockStore_Delete_Call {
130+ _c.Call.Return(run)
131+ return _c
132+}
133+
134+// Get provides a mock function for the type MockStore
135+func (_mock *MockStore) Get(key []byte) ([]byte, bool) {
136+ ret := _mock.Called(key)
137+
138+ if len(ret) == 0 {
139+ panic("no return value specified for Get")
140+ }
141+
142+ var r0 []byte
143+ var r1 bool
144+ if returnFunc, ok := ret.Get(0).(func([]byte) ([]byte, bool)); ok {
145+ return returnFunc(key)
146+ }
147+ if returnFunc, ok := ret.Get(0).(func([]byte) []byte); ok {
148+ r0 = returnFunc(key)
149+ } else {
150+ if ret.Get(0) != nil {
151+ r0 = ret.Get(0).([]byte)
152+ }
153+ }
154+ if returnFunc, ok := ret.Get(1).(func([]byte) bool); ok {
155+ r1 = returnFunc(key)
156+ } else {
157+ r1 = ret.Get(1).(bool)
158+ }
159+ return r0, r1
160+}
161+
162+// MockStore_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
163+type MockStore_Get_Call struct {
164+ *mock.Call
165+}
166+
167+// Get is a helper method to define mock.On call
168+// - key []byte
169+func (_e *MockStore_Expecter) Get(key any) *MockStore_Get_Call {
170+ return &MockStore_Get_Call{Call: _e.mock.On("Get", key)}
171+}
172+
173+func (_c *MockStore_Get_Call) Run(run func(key []byte)) *MockStore_Get_Call {
174+ _c.Call.Run(func(args mock.Arguments) {
175+ var arg0 []byte
176+ if args[0] != nil {
177+ arg0 = args[0].([]byte)
178+ }
179+ run(
180+ arg0,
181+ )
182+ })
183+ return _c
184+}
185+
186+func (_c *MockStore_Get_Call) Return(bytes []byte, b bool) *MockStore_Get_Call {
187+ _c.Call.Return(bytes, b)
188+ return _c
189+}
190+
191+func (_c *MockStore_Get_Call) RunAndReturn(run func(key []byte) ([]byte, bool)) *MockStore_Get_Call {
192+ _c.Call.Return(run)
193+ return _c
194+}
195+
196+// Range provides a mock function for the type MockStore
197+func (_mock *MockStore) Range(fn func(k []byte, v []byte) bool) {
198+ _mock.Called(fn)
199+ return
200+}
201+
202+// MockStore_Range_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Range'
203+type MockStore_Range_Call struct {
204+ *mock.Call
205+}
206+
207+// Range is a helper method to define mock.On call
208+// - fn func(k []byte, v []byte) bool
209+func (_e *MockStore_Expecter) Range(fn any) *MockStore_Range_Call {
210+ return &MockStore_Range_Call{Call: _e.mock.On("Range", fn)}
211+}
212+
213+func (_c *MockStore_Range_Call) Run(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
214+ _c.Call.Run(func(args mock.Arguments) {
215+ var arg0 func(k []byte, v []byte) bool
216+ if args[0] != nil {
217+ arg0 = args[0].(func(k []byte, v []byte) bool)
218+ }
219+ run(
220+ arg0,
221+ )
222+ })
223+ return _c
224+}
225+
226+func (_c *MockStore_Range_Call) Return() *MockStore_Range_Call {
227+ _c.Call.Return()
228+ return _c
229+}
230+
231+func (_c *MockStore_Range_Call) RunAndReturn(run func(fn func(k []byte, v []byte) bool)) *MockStore_Range_Call {
232+ _c.Run(run)
233+ return _c
234+}
235+
236+// Set provides a mock function for the type MockStore
237+func (_mock *MockStore) Set(key []byte, val []byte) error {
238+ ret := _mock.Called(key, val)
239+
240+ if len(ret) == 0 {
241+ panic("no return value specified for Set")
242+ }
243+
244+ var r0 error
245+ if returnFunc, ok := ret.Get(0).(func([]byte, []byte) error); ok {
246+ r0 = returnFunc(key, val)
247+ } else {
248+ r0 = ret.Error(0)
249+ }
250+ return r0
251+}
252+
253+// MockStore_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
254+type MockStore_Set_Call struct {
255+ *mock.Call
256+}
257+
258+// Set is a helper method to define mock.On call
259+// - key []byte
260+// - val []byte
261+func (_e *MockStore_Expecter) Set(key any, val any) *MockStore_Set_Call {
262+ return &MockStore_Set_Call{Call: _e.mock.On("Set", key, val)}
263+}
264+
265+func (_c *MockStore_Set_Call) Run(run func(key []byte, val []byte)) *MockStore_Set_Call {
266+ _c.Call.Run(func(args mock.Arguments) {
267+ var arg0 []byte
268+ if args[0] != nil {
269+ arg0 = args[0].([]byte)
270+ }
271+ var arg1 []byte
272+ if args[1] != nil {
273+ arg1 = args[1].([]byte)
274+ }
275+ run(
276+ arg0,
277+ arg1,
278+ )
279+ })
280+ return _c
281+}
282+
283+func (_c *MockStore_Set_Call) Return(err error) *MockStore_Set_Call {
284+ _c.Call.Return(err)
285+ return _c
286+}
287+
288+func (_c *MockStore_Set_Call) RunAndReturn(run func(key []byte, val []byte) error) *MockStore_Set_Call {
289+ _c.Call.Return(run)
290+ return _c
291+}
292+
293+// Sync provides a mock function for the type MockStore
294+func (_mock *MockStore) Sync() error {
295+ ret := _mock.Called()
296+
297+ if len(ret) == 0 {
298+ panic("no return value specified for Sync")
299+ }
300+
301+ var r0 error
302+ if returnFunc, ok := ret.Get(0).(func() error); ok {
303+ r0 = returnFunc()
304+ } else {
305+ r0 = ret.Error(0)
306+ }
307+ return r0
308+}
309+
310+// MockStore_Sync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Sync'
311+type MockStore_Sync_Call struct {
312+ *mock.Call
313+}
314+
315+// Sync is a helper method to define mock.On call
316+func (_e *MockStore_Expecter) Sync() *MockStore_Sync_Call {
317+ return &MockStore_Sync_Call{Call: _e.mock.On("Sync")}
318+}
319+
320+func (_c *MockStore_Sync_Call) Run(run func()) *MockStore_Sync_Call {
321+ _c.Call.Run(func(args mock.Arguments) {
322+ run()
323+ })
324+ return _c
325+}
326+
327+func (_c *MockStore_Sync_Call) Return(err error) *MockStore_Sync_Call {
328+ _c.Call.Return(err)
329+ return _c
330+}
331+
332+func (_c *MockStore_Sync_Call) RunAndReturn(run func() error) *MockStore_Sync_Call {
333+ _c.Call.Return(run)
334+ return _c
335+}
336+
337+// NewMockSnapshotStore creates a new instance of MockSnapshotStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
338+// The first argument is typically a *testing.T value.
339+func NewMockSnapshotStore(t interface {
340+ mock.TestingT
341+ Cleanup(func())
342+}) *MockSnapshotStore {
343+ mock := &MockSnapshotStore{}
344+ mock.Mock.Test(t)
345+
346+ t.Cleanup(func() { mock.AssertExpectations(t) })
347+
348+ return mock
349+}
350+
351+// MockSnapshotStore is an autogenerated mock type for the SnapshotStore type
352+type MockSnapshotStore struct {
353+ mock.Mock
354+}
355+
356+type MockSnapshotStore_Expecter struct {
357+ mock *mock.Mock
358+}
359+
360+func (_m *MockSnapshotStore) EXPECT() *MockSnapshotStore_Expecter {
361+ return &MockSnapshotStore_Expecter{mock: &_m.Mock}
362+}
363+
364+// Delete provides a mock function for the type MockSnapshotStore
365+func (_mock *MockSnapshotStore) Delete(name string) error {
366+ ret := _mock.Called(name)
367+
368+ if len(ret) == 0 {
369+ panic("no return value specified for Delete")
370+ }
371+
372+ var r0 error
373+ if returnFunc, ok := ret.Get(0).(func(string) error); ok {
374+ r0 = returnFunc(name)
375+ } else {
376+ r0 = ret.Error(0)
377+ }
378+ return r0
379+}
380+
381+// MockSnapshotStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
382+type MockSnapshotStore_Delete_Call struct {
383+ *mock.Call
384+}
385+
386+// Delete is a helper method to define mock.On call
387+// - name string
388+func (_e *MockSnapshotStore_Expecter) Delete(name any) *MockSnapshotStore_Delete_Call {
389+ return &MockSnapshotStore_Delete_Call{Call: _e.mock.On("Delete", name)}
390+}
391+
392+func (_c *MockSnapshotStore_Delete_Call) Run(run func(name string)) *MockSnapshotStore_Delete_Call {
393+ _c.Call.Run(func(args mock.Arguments) {
394+ var arg0 string
395+ if args[0] != nil {
396+ arg0 = args[0].(string)
397+ }
398+ run(
399+ arg0,
400+ )
401+ })
402+ return _c
403+}
404+
405+func (_c *MockSnapshotStore_Delete_Call) Return(err error) *MockSnapshotStore_Delete_Call {
406+ _c.Call.Return(err)
407+ return _c
408+}
409+
410+func (_c *MockSnapshotStore_Delete_Call) RunAndReturn(run func(name string) error) *MockSnapshotStore_Delete_Call {
411+ _c.Call.Return(run)
412+ return _c
413+}
414+
415+// Load provides a mock function for the type MockSnapshotStore
416+func (_mock *MockSnapshotStore) Load() ([]ports.SnapshotBlob, error) {
417+ ret := _mock.Called()
418+
419+ if len(ret) == 0 {
420+ panic("no return value specified for Load")
421+ }
422+
423+ var r0 []ports.SnapshotBlob
424+ var r1 error
425+ if returnFunc, ok := ret.Get(0).(func() ([]ports.SnapshotBlob, error)); ok {
426+ return returnFunc()
427+ }
428+ if returnFunc, ok := ret.Get(0).(func() []ports.SnapshotBlob); ok {
429+ r0 = returnFunc()
430+ } else {
431+ if ret.Get(0) != nil {
432+ r0 = ret.Get(0).([]ports.SnapshotBlob)
433+ }
434+ }
435+ if returnFunc, ok := ret.Get(1).(func() error); ok {
436+ r1 = returnFunc()
437+ } else {
438+ r1 = ret.Error(1)
439+ }
440+ return r0, r1
441+}
442+
443+// MockSnapshotStore_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load'
444+type MockSnapshotStore_Load_Call struct {
445+ *mock.Call
446+}
447+
448+// Load is a helper method to define mock.On call
449+func (_e *MockSnapshotStore_Expecter) Load() *MockSnapshotStore_Load_Call {
450+ return &MockSnapshotStore_Load_Call{Call: _e.mock.On("Load")}
451+}
452+
453+func (_c *MockSnapshotStore_Load_Call) Run(run func()) *MockSnapshotStore_Load_Call {
454+ _c.Call.Run(func(args mock.Arguments) {
455+ run()
456+ })
457+ return _c
458+}
459+
460+func (_c *MockSnapshotStore_Load_Call) Return(snapshotBlobs []ports.SnapshotBlob, err error) *MockSnapshotStore_Load_Call {
461+ _c.Call.Return(snapshotBlobs, err)
462+ return _c
463+}
464+
465+func (_c *MockSnapshotStore_Load_Call) RunAndReturn(run func() ([]ports.SnapshotBlob, error)) *MockSnapshotStore_Load_Call {
466+ _c.Call.Return(run)
467+ return _c
468+}
469+
470+// Write provides a mock function for the type MockSnapshotStore
471+func (_mock *MockSnapshotStore) Write(name string, data []byte) error {
472+ ret := _mock.Called(name, data)
473+
474+ if len(ret) == 0 {
475+ panic("no return value specified for Write")
476+ }
477+
478+ var r0 error
479+ if returnFunc, ok := ret.Get(0).(func(string, []byte) error); ok {
480+ r0 = returnFunc(name, data)
481+ } else {
482+ r0 = ret.Error(0)
483+ }
484+ return r0
485+}
486+
487+// MockSnapshotStore_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write'
488+type MockSnapshotStore_Write_Call struct {
489+ *mock.Call
490+}
491+
492+// Write is a helper method to define mock.On call
493+// - name string
494+// - data []byte
495+func (_e *MockSnapshotStore_Expecter) Write(name any, data any) *MockSnapshotStore_Write_Call {
496+ return &MockSnapshotStore_Write_Call{Call: _e.mock.On("Write", name, data)}
497+}
498+
499+func (_c *MockSnapshotStore_Write_Call) Run(run func(name string, data []byte)) *MockSnapshotStore_Write_Call {
500+ _c.Call.Run(func(args mock.Arguments) {
501+ var arg0 string
502+ if args[0] != nil {
503+ arg0 = args[0].(string)
504+ }
505+ var arg1 []byte
506+ if args[1] != nil {
507+ arg1 = args[1].([]byte)
508+ }
509+ run(
510+ arg0,
511+ arg1,
512+ )
513+ })
514+ return _c
515+}
516+
517+func (_c *MockSnapshotStore_Write_Call) Return(err error) *MockSnapshotStore_Write_Call {
518+ _c.Call.Return(err)
519+ return _c
520+}
521+
522+func (_c *MockSnapshotStore_Write_Call) RunAndReturn(run func(name string, data []byte) error) *MockSnapshotStore_Write_Call {
523+ _c.Call.Return(run)
524+ return _c
525+}
A internal/ports/mocks/terminal_mock.go +373 -0
@@ -0,0 +1,373 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "io"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockTerminal creates a new instance of MockTerminal. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockTerminal(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockTerminal {
20+ mock := &MockTerminal{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockTerminal is an autogenerated mock type for the Terminal type
29+type MockTerminal struct {
30+ mock.Mock
31+}
32+
33+type MockTerminal_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockTerminal) EXPECT() *MockTerminal_Expecter {
38+ return &MockTerminal_Expecter{mock: &_m.Mock}
39+}
40+
41+// EnterRaw provides a mock function for the type MockTerminal
42+func (_mock *MockTerminal) EnterRaw() (func() error, error) {
43+ ret := _mock.Called()
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for EnterRaw")
47+ }
48+
49+ var r0 func() error
50+ var r1 error
51+ if returnFunc, ok := ret.Get(0).(func() (func() error, error)); ok {
52+ return returnFunc()
53+ }
54+ if returnFunc, ok := ret.Get(0).(func() func() error); ok {
55+ r0 = returnFunc()
56+ } else {
57+ if ret.Get(0) != nil {
58+ r0 = ret.Get(0).(func() error)
59+ }
60+ }
61+ if returnFunc, ok := ret.Get(1).(func() error); ok {
62+ r1 = returnFunc()
63+ } else {
64+ r1 = ret.Error(1)
65+ }
66+ return r0, r1
67+}
68+
69+// MockTerminal_EnterRaw_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'EnterRaw'
70+type MockTerminal_EnterRaw_Call struct {
71+ *mock.Call
72+}
73+
74+// EnterRaw is a helper method to define mock.On call
75+func (_e *MockTerminal_Expecter) EnterRaw() *MockTerminal_EnterRaw_Call {
76+ return &MockTerminal_EnterRaw_Call{Call: _e.mock.On("EnterRaw")}
77+}
78+
79+func (_c *MockTerminal_EnterRaw_Call) Run(run func()) *MockTerminal_EnterRaw_Call {
80+ _c.Call.Run(func(args mock.Arguments) {
81+ run()
82+ })
83+ return _c
84+}
85+
86+func (_c *MockTerminal_EnterRaw_Call) Return(restore func() error, err error) *MockTerminal_EnterRaw_Call {
87+ _c.Call.Return(restore, err)
88+ return _c
89+}
90+
91+func (_c *MockTerminal_EnterRaw_Call) RunAndReturn(run func() (func() error, error)) *MockTerminal_EnterRaw_Call {
92+ _c.Call.Return(run)
93+ return _c
94+}
95+
96+// Flush provides a mock function for the type MockTerminal
97+func (_mock *MockTerminal) Flush() error {
98+ ret := _mock.Called()
99+
100+ if len(ret) == 0 {
101+ panic("no return value specified for Flush")
102+ }
103+
104+ var r0 error
105+ if returnFunc, ok := ret.Get(0).(func() error); ok {
106+ r0 = returnFunc()
107+ } else {
108+ r0 = ret.Error(0)
109+ }
110+ return r0
111+}
112+
113+// MockTerminal_Flush_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Flush'
114+type MockTerminal_Flush_Call struct {
115+ *mock.Call
116+}
117+
118+// Flush is a helper method to define mock.On call
119+func (_e *MockTerminal_Expecter) Flush() *MockTerminal_Flush_Call {
120+ return &MockTerminal_Flush_Call{Call: _e.mock.On("Flush")}
121+}
122+
123+func (_c *MockTerminal_Flush_Call) Run(run func()) *MockTerminal_Flush_Call {
124+ _c.Call.Run(func(args mock.Arguments) {
125+ run()
126+ })
127+ return _c
128+}
129+
130+func (_c *MockTerminal_Flush_Call) Return(err error) *MockTerminal_Flush_Call {
131+ _c.Call.Return(err)
132+ return _c
133+}
134+
135+func (_c *MockTerminal_Flush_Call) RunAndReturn(run func() error) *MockTerminal_Flush_Call {
136+ _c.Call.Return(run)
137+ return _c
138+}
139+
140+// In provides a mock function for the type MockTerminal
141+func (_mock *MockTerminal) In() io.Reader {
142+ ret := _mock.Called()
143+
144+ if len(ret) == 0 {
145+ panic("no return value specified for In")
146+ }
147+
148+ var r0 io.Reader
149+ if returnFunc, ok := ret.Get(0).(func() io.Reader); ok {
150+ r0 = returnFunc()
151+ } else {
152+ if ret.Get(0) != nil {
153+ r0 = ret.Get(0).(io.Reader)
154+ }
155+ }
156+ return r0
157+}
158+
159+// MockTerminal_In_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'In'
160+type MockTerminal_In_Call struct {
161+ *mock.Call
162+}
163+
164+// In is a helper method to define mock.On call
165+func (_e *MockTerminal_Expecter) In() *MockTerminal_In_Call {
166+ return &MockTerminal_In_Call{Call: _e.mock.On("In")}
167+}
168+
169+func (_c *MockTerminal_In_Call) Run(run func()) *MockTerminal_In_Call {
170+ _c.Call.Run(func(args mock.Arguments) {
171+ run()
172+ })
173+ return _c
174+}
175+
176+func (_c *MockTerminal_In_Call) Return(reader io.Reader) *MockTerminal_In_Call {
177+ _c.Call.Return(reader)
178+ return _c
179+}
180+
181+func (_c *MockTerminal_In_Call) RunAndReturn(run func() io.Reader) *MockTerminal_In_Call {
182+ _c.Call.Return(run)
183+ return _c
184+}
185+
186+// Out provides a mock function for the type MockTerminal
187+func (_mock *MockTerminal) Out() io.Writer {
188+ ret := _mock.Called()
189+
190+ if len(ret) == 0 {
191+ panic("no return value specified for Out")
192+ }
193+
194+ var r0 io.Writer
195+ if returnFunc, ok := ret.Get(0).(func() io.Writer); ok {
196+ r0 = returnFunc()
197+ } else {
198+ if ret.Get(0) != nil {
199+ r0 = ret.Get(0).(io.Writer)
200+ }
201+ }
202+ return r0
203+}
204+
205+// MockTerminal_Out_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Out'
206+type MockTerminal_Out_Call struct {
207+ *mock.Call
208+}
209+
210+// Out is a helper method to define mock.On call
211+func (_e *MockTerminal_Expecter) Out() *MockTerminal_Out_Call {
212+ return &MockTerminal_Out_Call{Call: _e.mock.On("Out")}
213+}
214+
215+func (_c *MockTerminal_Out_Call) Run(run func()) *MockTerminal_Out_Call {
216+ _c.Call.Run(func(args mock.Arguments) {
217+ run()
218+ })
219+ return _c
220+}
221+
222+func (_c *MockTerminal_Out_Call) Return(writer io.Writer) *MockTerminal_Out_Call {
223+ _c.Call.Return(writer)
224+ return _c
225+}
226+
227+func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTerminal_Out_Call {
228+ _c.Call.Return(run)
229+ return _c
230+}
231+
232+// QueryColors provides a mock function for the type MockTerminal
233+func (_mock *MockTerminal) QueryColors() error {
234+ ret := _mock.Called()
235+
236+ if len(ret) == 0 {
237+ panic("no return value specified for QueryColors")
238+ }
239+
240+ var r0 error
241+ if returnFunc, ok := ret.Get(0).(func() error); ok {
242+ r0 = returnFunc()
243+ } else {
244+ r0 = ret.Error(0)
245+ }
246+ return r0
247+}
248+
249+// MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors'
250+type MockTerminal_QueryColors_Call struct {
251+ *mock.Call
252+}
253+
254+// QueryColors is a helper method to define mock.On call
255+func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call {
256+ return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")}
257+}
258+
259+func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call {
260+ _c.Call.Run(func(args mock.Arguments) {
261+ run()
262+ })
263+ return _c
264+}
265+
266+func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call {
267+ _c.Call.Return(err)
268+ return _c
269+}
270+
271+func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call {
272+ _c.Call.Return(run)
273+ return _c
274+}
275+
276+// ResizeEvents provides a mock function for the type MockTerminal
277+func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {
278+ ret := _mock.Called()
279+
280+ if len(ret) == 0 {
281+ panic("no return value specified for ResizeEvents")
282+ }
283+
284+ var r0 <-chan domain.Size
285+ if returnFunc, ok := ret.Get(0).(func() <-chan domain.Size); ok {
286+ r0 = returnFunc()
287+ } else {
288+ if ret.Get(0) != nil {
289+ r0 = ret.Get(0).(<-chan domain.Size)
290+ }
291+ }
292+ return r0
293+}
294+
295+// MockTerminal_ResizeEvents_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResizeEvents'
296+type MockTerminal_ResizeEvents_Call struct {
297+ *mock.Call
298+}
299+
300+// ResizeEvents is a helper method to define mock.On call
301+func (_e *MockTerminal_Expecter) ResizeEvents() *MockTerminal_ResizeEvents_Call {
302+ return &MockTerminal_ResizeEvents_Call{Call: _e.mock.On("ResizeEvents")}
303+}
304+
305+func (_c *MockTerminal_ResizeEvents_Call) Run(run func()) *MockTerminal_ResizeEvents_Call {
306+ _c.Call.Run(func(args mock.Arguments) {
307+ run()
308+ })
309+ return _c
310+}
311+
312+func (_c *MockTerminal_ResizeEvents_Call) Return(sizeCh <-chan domain.Size) *MockTerminal_ResizeEvents_Call {
313+ _c.Call.Return(sizeCh)
314+ return _c
315+}
316+
317+func (_c *MockTerminal_ResizeEvents_Call) RunAndReturn(run func() <-chan domain.Size) *MockTerminal_ResizeEvents_Call {
318+ _c.Call.Return(run)
319+ return _c
320+}
321+
322+// Size provides a mock function for the type MockTerminal
323+func (_mock *MockTerminal) Size() (domain.Size, error) {
324+ ret := _mock.Called()
325+
326+ if len(ret) == 0 {
327+ panic("no return value specified for Size")
328+ }
329+
330+ var r0 domain.Size
331+ var r1 error
332+ if returnFunc, ok := ret.Get(0).(func() (domain.Size, error)); ok {
333+ return returnFunc()
334+ }
335+ if returnFunc, ok := ret.Get(0).(func() domain.Size); ok {
336+ r0 = returnFunc()
337+ } else {
338+ r0 = ret.Get(0).(domain.Size)
339+ }
340+ if returnFunc, ok := ret.Get(1).(func() error); ok {
341+ r1 = returnFunc()
342+ } else {
343+ r1 = ret.Error(1)
344+ }
345+ return r0, r1
346+}
347+
348+// MockTerminal_Size_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Size'
349+type MockTerminal_Size_Call struct {
350+ *mock.Call
351+}
352+
353+// Size is a helper method to define mock.On call
354+func (_e *MockTerminal_Expecter) Size() *MockTerminal_Size_Call {
355+ return &MockTerminal_Size_Call{Call: _e.mock.On("Size")}
356+}
357+
358+func (_c *MockTerminal_Size_Call) Run(run func()) *MockTerminal_Size_Call {
359+ _c.Call.Run(func(args mock.Arguments) {
360+ run()
361+ })
362+ return _c
363+}
364+
365+func (_c *MockTerminal_Size_Call) Return(size domain.Size, err error) *MockTerminal_Size_Call {
366+ _c.Call.Return(size, err)
367+ return _c
368+}
369+
370+func (_c *MockTerminal_Size_Call) RunAndReturn(run func() (domain.Size, error)) *MockTerminal_Size_Call {
371+ _c.Call.Return(run)
372+ return _c
373+}
A internal/ports/mocks/transport_mock.go +810 -0
@@ -0,0 +1,810 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockTransport creates a new instance of MockTransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockTransport(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockTransport {
20+ mock := &MockTransport{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockTransport is an autogenerated mock type for the Transport type
29+type MockTransport struct {
30+ mock.Mock
31+}
32+
33+type MockTransport_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockTransport) EXPECT() *MockTransport_Expecter {
38+ return &MockTransport_Expecter{mock: &_m.Mock}
39+}
40+
41+// Close provides a mock function for the type MockTransport
42+func (_mock *MockTransport) Close() error {
43+ ret := _mock.Called()
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Close")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func() error); ok {
51+ r0 = returnFunc()
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockTransport_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
59+type MockTransport_Close_Call struct {
60+ *mock.Call
61+}
62+
63+// Close is a helper method to define mock.On call
64+func (_e *MockTransport_Expecter) Close() *MockTransport_Close_Call {
65+ return &MockTransport_Close_Call{Call: _e.mock.On("Close")}
66+}
67+
68+func (_c *MockTransport_Close_Call) Run(run func()) *MockTransport_Close_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ run()
71+ })
72+ return _c
73+}
74+
75+func (_c *MockTransport_Close_Call) Return(err error) *MockTransport_Close_Call {
76+ _c.Call.Return(err)
77+ return _c
78+}
79+
80+func (_c *MockTransport_Close_Call) RunAndReturn(run func() error) *MockTransport_Close_Call {
81+ _c.Call.Return(run)
82+ return _c
83+}
84+
85+// Recv provides a mock function for the type MockTransport
86+func (_mock *MockTransport) Recv() (ports.Frame, error) {
87+ ret := _mock.Called()
88+
89+ if len(ret) == 0 {
90+ panic("no return value specified for Recv")
91+ }
92+
93+ var r0 ports.Frame
94+ var r1 error
95+ if returnFunc, ok := ret.Get(0).(func() (ports.Frame, error)); ok {
96+ return returnFunc()
97+ }
98+ if returnFunc, ok := ret.Get(0).(func() ports.Frame); ok {
99+ r0 = returnFunc()
100+ } else {
101+ r0 = ret.Get(0).(ports.Frame)
102+ }
103+ if returnFunc, ok := ret.Get(1).(func() error); ok {
104+ r1 = returnFunc()
105+ } else {
106+ r1 = ret.Error(1)
107+ }
108+ return r0, r1
109+}
110+
111+// MockTransport_Recv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Recv'
112+type MockTransport_Recv_Call struct {
113+ *mock.Call
114+}
115+
116+// Recv is a helper method to define mock.On call
117+func (_e *MockTransport_Expecter) Recv() *MockTransport_Recv_Call {
118+ return &MockTransport_Recv_Call{Call: _e.mock.On("Recv")}
119+}
120+
121+func (_c *MockTransport_Recv_Call) Run(run func()) *MockTransport_Recv_Call {
122+ _c.Call.Run(func(args mock.Arguments) {
123+ run()
124+ })
125+ return _c
126+}
127+
128+func (_c *MockTransport_Recv_Call) Return(frame ports.Frame, err error) *MockTransport_Recv_Call {
129+ _c.Call.Return(frame, err)
130+ return _c
131+}
132+
133+func (_c *MockTransport_Recv_Call) RunAndReturn(run func() (ports.Frame, error)) *MockTransport_Recv_Call {
134+ _c.Call.Return(run)
135+ return _c
136+}
137+
138+// Send provides a mock function for the type MockTransport
139+func (_mock *MockTransport) Send(frame ports.Frame) error {
140+ ret := _mock.Called(frame)
141+
142+ if len(ret) == 0 {
143+ panic("no return value specified for Send")
144+ }
145+
146+ var r0 error
147+ if returnFunc, ok := ret.Get(0).(func(ports.Frame) error); ok {
148+ r0 = returnFunc(frame)
149+ } else {
150+ r0 = ret.Error(0)
151+ }
152+ return r0
153+}
154+
155+// MockTransport_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send'
156+type MockTransport_Send_Call struct {
157+ *mock.Call
158+}
159+
160+// Send is a helper method to define mock.On call
161+// - frame ports.Frame
162+func (_e *MockTransport_Expecter) Send(frame any) *MockTransport_Send_Call {
163+ return &MockTransport_Send_Call{Call: _e.mock.On("Send", frame)}
164+}
165+
166+func (_c *MockTransport_Send_Call) Run(run func(frame ports.Frame)) *MockTransport_Send_Call {
167+ _c.Call.Run(func(args mock.Arguments) {
168+ var arg0 ports.Frame
169+ if args[0] != nil {
170+ arg0 = args[0].(ports.Frame)
171+ }
172+ run(
173+ arg0,
174+ )
175+ })
176+ return _c
177+}
178+
179+func (_c *MockTransport_Send_Call) Return(err error) *MockTransport_Send_Call {
180+ _c.Call.Return(err)
181+ return _c
182+}
183+
184+func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) error) *MockTransport_Send_Call {
185+ _c.Call.Return(run)
186+ return _c
187+}
188+
189+// NewMockDatagramTransport creates a new instance of MockDatagramTransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
190+// The first argument is typically a *testing.T value.
191+func NewMockDatagramTransport(t interface {
192+ mock.TestingT
193+ Cleanup(func())
194+}) *MockDatagramTransport {
195+ mock := &MockDatagramTransport{}
196+ mock.Mock.Test(t)
197+
198+ t.Cleanup(func() { mock.AssertExpectations(t) })
199+
200+ return mock
201+}
202+
203+// MockDatagramTransport is an autogenerated mock type for the DatagramTransport type
204+type MockDatagramTransport struct {
205+ mock.Mock
206+}
207+
208+type MockDatagramTransport_Expecter struct {
209+ mock *mock.Mock
210+}
211+
212+func (_m *MockDatagramTransport) EXPECT() *MockDatagramTransport_Expecter {
213+ return &MockDatagramTransport_Expecter{mock: &_m.Mock}
214+}
215+
216+// Close provides a mock function for the type MockDatagramTransport
217+func (_mock *MockDatagramTransport) Close() error {
218+ ret := _mock.Called()
219+
220+ if len(ret) == 0 {
221+ panic("no return value specified for Close")
222+ }
223+
224+ var r0 error
225+ if returnFunc, ok := ret.Get(0).(func() error); ok {
226+ r0 = returnFunc()
227+ } else {
228+ r0 = ret.Error(0)
229+ }
230+ return r0
231+}
232+
233+// MockDatagramTransport_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
234+type MockDatagramTransport_Close_Call struct {
235+ *mock.Call
236+}
237+
238+// Close is a helper method to define mock.On call
239+func (_e *MockDatagramTransport_Expecter) Close() *MockDatagramTransport_Close_Call {
240+ return &MockDatagramTransport_Close_Call{Call: _e.mock.On("Close")}
241+}
242+
243+func (_c *MockDatagramTransport_Close_Call) Run(run func()) *MockDatagramTransport_Close_Call {
244+ _c.Call.Run(func(args mock.Arguments) {
245+ run()
246+ })
247+ return _c
248+}
249+
250+func (_c *MockDatagramTransport_Close_Call) Return(err error) *MockDatagramTransport_Close_Call {
251+ _c.Call.Return(err)
252+ return _c
253+}
254+
255+func (_c *MockDatagramTransport_Close_Call) RunAndReturn(run func() error) *MockDatagramTransport_Close_Call {
256+ _c.Call.Return(run)
257+ return _c
258+}
259+
260+// DatagramTransport provides a mock function for the type MockDatagramTransport
261+func (_mock *MockDatagramTransport) DatagramTransport() {
262+ _mock.Called()
263+ return
264+}
265+
266+// MockDatagramTransport_DatagramTransport_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DatagramTransport'
267+type MockDatagramTransport_DatagramTransport_Call struct {
268+ *mock.Call
269+}
270+
271+// DatagramTransport is a helper method to define mock.On call
272+func (_e *MockDatagramTransport_Expecter) DatagramTransport() *MockDatagramTransport_DatagramTransport_Call {
273+ return &MockDatagramTransport_DatagramTransport_Call{Call: _e.mock.On("DatagramTransport")}
274+}
275+
276+func (_c *MockDatagramTransport_DatagramTransport_Call) Run(run func()) *MockDatagramTransport_DatagramTransport_Call {
277+ _c.Call.Run(func(args mock.Arguments) {
278+ run()
279+ })
280+ return _c
281+}
282+
283+func (_c *MockDatagramTransport_DatagramTransport_Call) Return() *MockDatagramTransport_DatagramTransport_Call {
284+ _c.Call.Return()
285+ return _c
286+}
287+
288+func (_c *MockDatagramTransport_DatagramTransport_Call) RunAndReturn(run func()) *MockDatagramTransport_DatagramTransport_Call {
289+ _c.Run(run)
290+ return _c
291+}
292+
293+// Recv provides a mock function for the type MockDatagramTransport
294+func (_mock *MockDatagramTransport) Recv() (ports.Frame, error) {
295+ ret := _mock.Called()
296+
297+ if len(ret) == 0 {
298+ panic("no return value specified for Recv")
299+ }
300+
301+ var r0 ports.Frame
302+ var r1 error
303+ if returnFunc, ok := ret.Get(0).(func() (ports.Frame, error)); ok {
304+ return returnFunc()
305+ }
306+ if returnFunc, ok := ret.Get(0).(func() ports.Frame); ok {
307+ r0 = returnFunc()
308+ } else {
309+ r0 = ret.Get(0).(ports.Frame)
310+ }
311+ if returnFunc, ok := ret.Get(1).(func() error); ok {
312+ r1 = returnFunc()
313+ } else {
314+ r1 = ret.Error(1)
315+ }
316+ return r0, r1
317+}
318+
319+// MockDatagramTransport_Recv_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Recv'
320+type MockDatagramTransport_Recv_Call struct {
321+ *mock.Call
322+}
323+
324+// Recv is a helper method to define mock.On call
325+func (_e *MockDatagramTransport_Expecter) Recv() *MockDatagramTransport_Recv_Call {
326+ return &MockDatagramTransport_Recv_Call{Call: _e.mock.On("Recv")}
327+}
328+
329+func (_c *MockDatagramTransport_Recv_Call) Run(run func()) *MockDatagramTransport_Recv_Call {
330+ _c.Call.Run(func(args mock.Arguments) {
331+ run()
332+ })
333+ return _c
334+}
335+
336+func (_c *MockDatagramTransport_Recv_Call) Return(frame ports.Frame, err error) *MockDatagramTransport_Recv_Call {
337+ _c.Call.Return(frame, err)
338+ return _c
339+}
340+
341+func (_c *MockDatagramTransport_Recv_Call) RunAndReturn(run func() (ports.Frame, error)) *MockDatagramTransport_Recv_Call {
342+ _c.Call.Return(run)
343+ return _c
344+}
345+
346+// Send provides a mock function for the type MockDatagramTransport
347+func (_mock *MockDatagramTransport) Send(frame ports.Frame) error {
348+ ret := _mock.Called(frame)
349+
350+ if len(ret) == 0 {
351+ panic("no return value specified for Send")
352+ }
353+
354+ var r0 error
355+ if returnFunc, ok := ret.Get(0).(func(ports.Frame) error); ok {
356+ r0 = returnFunc(frame)
357+ } else {
358+ r0 = ret.Error(0)
359+ }
360+ return r0
361+}
362+
363+// MockDatagramTransport_Send_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Send'
364+type MockDatagramTransport_Send_Call struct {
365+ *mock.Call
366+}
367+
368+// Send is a helper method to define mock.On call
369+// - frame ports.Frame
370+func (_e *MockDatagramTransport_Expecter) Send(frame any) *MockDatagramTransport_Send_Call {
371+ return &MockDatagramTransport_Send_Call{Call: _e.mock.On("Send", frame)}
372+}
373+
374+func (_c *MockDatagramTransport_Send_Call) Run(run func(frame ports.Frame)) *MockDatagramTransport_Send_Call {
375+ _c.Call.Run(func(args mock.Arguments) {
376+ var arg0 ports.Frame
377+ if args[0] != nil {
378+ arg0 = args[0].(ports.Frame)
379+ }
380+ run(
381+ arg0,
382+ )
383+ })
384+ return _c
385+}
386+
387+func (_c *MockDatagramTransport_Send_Call) Return(err error) *MockDatagramTransport_Send_Call {
388+ _c.Call.Return(err)
389+ return _c
390+}
391+
392+func (_c *MockDatagramTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) error) *MockDatagramTransport_Send_Call {
393+ _c.Call.Return(run)
394+ return _c
395+}
396+
397+// NewMockAsyncTransport creates a new instance of MockAsyncTransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
398+// The first argument is typically a *testing.T value.
399+func NewMockAsyncTransport(t interface {
400+ mock.TestingT
401+ Cleanup(func())
402+}) *MockAsyncTransport {
403+ mock := &MockAsyncTransport{}
404+ mock.Mock.Test(t)
405+
406+ t.Cleanup(func() { mock.AssertExpectations(t) })
407+
408+ return mock
409+}
410+
411+// MockAsyncTransport is an autogenerated mock type for the AsyncTransport type
412+type MockAsyncTransport struct {
413+ mock.Mock
414+}
415+
416+type MockAsyncTransport_Expecter struct {
417+ mock *mock.Mock
418+}
419+
420+func (_m *MockAsyncTransport) EXPECT() *MockAsyncTransport_Expecter {
421+ return &MockAsyncTransport_Expecter{mock: &_m.Mock}
422+}
423+
424+// SendAsync provides a mock function for the type MockAsyncTransport
425+func (_mock *MockAsyncTransport) SendAsync(frame ports.Frame) error {
426+ ret := _mock.Called(frame)
427+
428+ if len(ret) == 0 {
429+ panic("no return value specified for SendAsync")
430+ }
431+
432+ var r0 error
433+ if returnFunc, ok := ret.Get(0).(func(ports.Frame) error); ok {
434+ r0 = returnFunc(frame)
435+ } else {
436+ r0 = ret.Error(0)
437+ }
438+ return r0
439+}
440+
441+// MockAsyncTransport_SendAsync_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendAsync'
442+type MockAsyncTransport_SendAsync_Call struct {
443+ *mock.Call
444+}
445+
446+// SendAsync is a helper method to define mock.On call
447+// - frame ports.Frame
448+func (_e *MockAsyncTransport_Expecter) SendAsync(frame any) *MockAsyncTransport_SendAsync_Call {
449+ return &MockAsyncTransport_SendAsync_Call{Call: _e.mock.On("SendAsync", frame)}
450+}
451+
452+func (_c *MockAsyncTransport_SendAsync_Call) Run(run func(frame ports.Frame)) *MockAsyncTransport_SendAsync_Call {
453+ _c.Call.Run(func(args mock.Arguments) {
454+ var arg0 ports.Frame
455+ if args[0] != nil {
456+ arg0 = args[0].(ports.Frame)
457+ }
458+ run(
459+ arg0,
460+ )
461+ })
462+ return _c
463+}
464+
465+func (_c *MockAsyncTransport_SendAsync_Call) Return(err error) *MockAsyncTransport_SendAsync_Call {
466+ _c.Call.Return(err)
467+ return _c
468+}
469+
470+func (_c *MockAsyncTransport_SendAsync_Call) RunAndReturn(run func(frame ports.Frame) error) *MockAsyncTransport_SendAsync_Call {
471+ _c.Call.Return(run)
472+ return _c
473+}
474+
475+// NewMockOwnedSynchronousTransport creates a new instance of MockOwnedSynchronousTransport. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
476+// The first argument is typically a *testing.T value.
477+func NewMockOwnedSynchronousTransport(t interface {
478+ mock.TestingT
479+ Cleanup(func())
480+}) *MockOwnedSynchronousTransport {
481+ mock := &MockOwnedSynchronousTransport{}
482+ mock.Mock.Test(t)
483+
484+ t.Cleanup(func() { mock.AssertExpectations(t) })
485+
486+ return mock
487+}
488+
489+// MockOwnedSynchronousTransport is an autogenerated mock type for the OwnedSynchronousTransport type
490+type MockOwnedSynchronousTransport struct {
491+ mock.Mock
492+}
493+
494+type MockOwnedSynchronousTransport_Expecter struct {
495+ mock *mock.Mock
496+}
497+
498+func (_m *MockOwnedSynchronousTransport) EXPECT() *MockOwnedSynchronousTransport_Expecter {
499+ return &MockOwnedSynchronousTransport_Expecter{mock: &_m.Mock}
500+}
501+
502+// SendSynchronous provides a mock function for the type MockOwnedSynchronousTransport
503+func (_mock *MockOwnedSynchronousTransport) SendSynchronous(frame ports.Frame) error {
504+ ret := _mock.Called(frame)
505+
506+ if len(ret) == 0 {
507+ panic("no return value specified for SendSynchronous")
508+ }
509+
510+ var r0 error
511+ if returnFunc, ok := ret.Get(0).(func(ports.Frame) error); ok {
512+ r0 = returnFunc(frame)
513+ } else {
514+ r0 = ret.Error(0)
515+ }
516+ return r0
517+}
518+
519+// MockOwnedSynchronousTransport_SendSynchronous_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendSynchronous'
520+type MockOwnedSynchronousTransport_SendSynchronous_Call struct {
521+ *mock.Call
522+}
523+
524+// SendSynchronous is a helper method to define mock.On call
525+// - frame ports.Frame
526+func (_e *MockOwnedSynchronousTransport_Expecter) SendSynchronous(frame any) *MockOwnedSynchronousTransport_SendSynchronous_Call {
527+ return &MockOwnedSynchronousTransport_SendSynchronous_Call{Call: _e.mock.On("SendSynchronous", frame)}
528+}
529+
530+func (_c *MockOwnedSynchronousTransport_SendSynchronous_Call) Run(run func(frame ports.Frame)) *MockOwnedSynchronousTransport_SendSynchronous_Call {
531+ _c.Call.Run(func(args mock.Arguments) {
532+ var arg0 ports.Frame
533+ if args[0] != nil {
534+ arg0 = args[0].(ports.Frame)
535+ }
536+ run(
537+ arg0,
538+ )
539+ })
540+ return _c
541+}
542+
543+func (_c *MockOwnedSynchronousTransport_SendSynchronous_Call) Return(err error) *MockOwnedSynchronousTransport_SendSynchronous_Call {
544+ _c.Call.Return(err)
545+ return _c
546+}
547+
548+func (_c *MockOwnedSynchronousTransport_SendSynchronous_Call) RunAndReturn(run func(frame ports.Frame) error) *MockOwnedSynchronousTransport_SendSynchronous_Call {
549+ _c.Call.Return(run)
550+ return _c
551+}
552+
553+// NewMockDialer creates a new instance of MockDialer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
554+// The first argument is typically a *testing.T value.
555+func NewMockDialer(t interface {
556+ mock.TestingT
557+ Cleanup(func())
558+}) *MockDialer {
559+ mock := &MockDialer{}
560+ mock.Mock.Test(t)
561+
562+ t.Cleanup(func() { mock.AssertExpectations(t) })
563+
564+ return mock
565+}
566+
567+// MockDialer is an autogenerated mock type for the Dialer type
568+type MockDialer struct {
569+ mock.Mock
570+}
571+
572+type MockDialer_Expecter struct {
573+ mock *mock.Mock
574+}
575+
576+func (_m *MockDialer) EXPECT() *MockDialer_Expecter {
577+ return &MockDialer_Expecter{mock: &_m.Mock}
578+}
579+
580+// Dial provides a mock function for the type MockDialer
581+func (_mock *MockDialer) Dial(ctx context.Context) (ports.Transport, error) {
582+ ret := _mock.Called(ctx)
583+
584+ if len(ret) == 0 {
585+ panic("no return value specified for Dial")
586+ }
587+
588+ var r0 ports.Transport
589+ var r1 error
590+ if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.Transport, error)); ok {
591+ return returnFunc(ctx)
592+ }
593+ if returnFunc, ok := ret.Get(0).(func(context.Context) ports.Transport); ok {
594+ r0 = returnFunc(ctx)
595+ } else {
596+ if ret.Get(0) != nil {
597+ r0 = ret.Get(0).(ports.Transport)
598+ }
599+ }
600+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
601+ r1 = returnFunc(ctx)
602+ } else {
603+ r1 = ret.Error(1)
604+ }
605+ return r0, r1
606+}
607+
608+// MockDialer_Dial_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dial'
609+type MockDialer_Dial_Call struct {
610+ *mock.Call
611+}
612+
613+// Dial is a helper method to define mock.On call
614+// - ctx context.Context
615+func (_e *MockDialer_Expecter) Dial(ctx any) *MockDialer_Dial_Call {
616+ return &MockDialer_Dial_Call{Call: _e.mock.On("Dial", ctx)}
617+}
618+
619+func (_c *MockDialer_Dial_Call) Run(run func(ctx context.Context)) *MockDialer_Dial_Call {
620+ _c.Call.Run(func(args mock.Arguments) {
621+ var arg0 context.Context
622+ if args[0] != nil {
623+ arg0 = args[0].(context.Context)
624+ }
625+ run(
626+ arg0,
627+ )
628+ })
629+ return _c
630+}
631+
632+func (_c *MockDialer_Dial_Call) Return(transport ports.Transport, err error) *MockDialer_Dial_Call {
633+ _c.Call.Return(transport, err)
634+ return _c
635+}
636+
637+func (_c *MockDialer_Dial_Call) RunAndReturn(run func(ctx context.Context) (ports.Transport, error)) *MockDialer_Dial_Call {
638+ _c.Call.Return(run)
639+ return _c
640+}
641+
642+// NewMockListener creates a new instance of MockListener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
643+// The first argument is typically a *testing.T value.
644+func NewMockListener(t interface {
645+ mock.TestingT
646+ Cleanup(func())
647+}) *MockListener {
648+ mock := &MockListener{}
649+ mock.Mock.Test(t)
650+
651+ t.Cleanup(func() { mock.AssertExpectations(t) })
652+
653+ return mock
654+}
655+
656+// MockListener is an autogenerated mock type for the Listener type
657+type MockListener struct {
658+ mock.Mock
659+}
660+
661+type MockListener_Expecter struct {
662+ mock *mock.Mock
663+}
664+
665+func (_m *MockListener) EXPECT() *MockListener_Expecter {
666+ return &MockListener_Expecter{mock: &_m.Mock}
667+}
668+
669+// Accept provides a mock function for the type MockListener
670+func (_mock *MockListener) Accept() (ports.Transport, error) {
671+ ret := _mock.Called()
672+
673+ if len(ret) == 0 {
674+ panic("no return value specified for Accept")
675+ }
676+
677+ var r0 ports.Transport
678+ var r1 error
679+ if returnFunc, ok := ret.Get(0).(func() (ports.Transport, error)); ok {
680+ return returnFunc()
681+ }
682+ if returnFunc, ok := ret.Get(0).(func() ports.Transport); ok {
683+ r0 = returnFunc()
684+ } else {
685+ if ret.Get(0) != nil {
686+ r0 = ret.Get(0).(ports.Transport)
687+ }
688+ }
689+ if returnFunc, ok := ret.Get(1).(func() error); ok {
690+ r1 = returnFunc()
691+ } else {
692+ r1 = ret.Error(1)
693+ }
694+ return r0, r1
695+}
696+
697+// MockListener_Accept_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Accept'
698+type MockListener_Accept_Call struct {
699+ *mock.Call
700+}
701+
702+// Accept is a helper method to define mock.On call
703+func (_e *MockListener_Expecter) Accept() *MockListener_Accept_Call {
704+ return &MockListener_Accept_Call{Call: _e.mock.On("Accept")}
705+}
706+
707+func (_c *MockListener_Accept_Call) Run(run func()) *MockListener_Accept_Call {
708+ _c.Call.Run(func(args mock.Arguments) {
709+ run()
710+ })
711+ return _c
712+}
713+
714+func (_c *MockListener_Accept_Call) Return(transport ports.Transport, err error) *MockListener_Accept_Call {
715+ _c.Call.Return(transport, err)
716+ return _c
717+}
718+
719+func (_c *MockListener_Accept_Call) RunAndReturn(run func() (ports.Transport, error)) *MockListener_Accept_Call {
720+ _c.Call.Return(run)
721+ return _c
722+}
723+
724+// Addr provides a mock function for the type MockListener
725+func (_mock *MockListener) Addr() string {
726+ ret := _mock.Called()
727+
728+ if len(ret) == 0 {
729+ panic("no return value specified for Addr")
730+ }
731+
732+ var r0 string
733+ if returnFunc, ok := ret.Get(0).(func() string); ok {
734+ r0 = returnFunc()
735+ } else {
736+ r0 = ret.Get(0).(string)
737+ }
738+ return r0
739+}
740+
741+// MockListener_Addr_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Addr'
742+type MockListener_Addr_Call struct {
743+ *mock.Call
744+}
745+
746+// Addr is a helper method to define mock.On call
747+func (_e *MockListener_Expecter) Addr() *MockListener_Addr_Call {
748+ return &MockListener_Addr_Call{Call: _e.mock.On("Addr")}
749+}
750+
751+func (_c *MockListener_Addr_Call) Run(run func()) *MockListener_Addr_Call {
752+ _c.Call.Run(func(args mock.Arguments) {
753+ run()
754+ })
755+ return _c
756+}
757+
758+func (_c *MockListener_Addr_Call) Return(s string) *MockListener_Addr_Call {
759+ _c.Call.Return(s)
760+ return _c
761+}
762+
763+func (_c *MockListener_Addr_Call) RunAndReturn(run func() string) *MockListener_Addr_Call {
764+ _c.Call.Return(run)
765+ return _c
766+}
767+
768+// Close provides a mock function for the type MockListener
769+func (_mock *MockListener) Close() error {
770+ ret := _mock.Called()
771+
772+ if len(ret) == 0 {
773+ panic("no return value specified for Close")
774+ }
775+
776+ var r0 error
777+ if returnFunc, ok := ret.Get(0).(func() error); ok {
778+ r0 = returnFunc()
779+ } else {
780+ r0 = ret.Error(0)
781+ }
782+ return r0
783+}
784+
785+// MockListener_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
786+type MockListener_Close_Call struct {
787+ *mock.Call
788+}
789+
790+// Close is a helper method to define mock.On call
791+func (_e *MockListener_Expecter) Close() *MockListener_Close_Call {
792+ return &MockListener_Close_Call{Call: _e.mock.On("Close")}
793+}
794+
795+func (_c *MockListener_Close_Call) Run(run func()) *MockListener_Close_Call {
796+ _c.Call.Run(func(args mock.Arguments) {
797+ run()
798+ })
799+ return _c
800+}
801+
802+func (_c *MockListener_Close_Call) Return(err error) *MockListener_Close_Call {
803+ _c.Call.Return(err)
804+ return _c
805+}
806+
807+func (_c *MockListener_Close_Call) RunAndReturn(run func() error) *MockListener_Close_Call {
808+ _c.Call.Return(run)
809+ return _c
810+}
D internal/ports/ports.go +0 -155
@@ -1,155 +0,0 @@
1-package ports
2-
3-import (
4- "context"
5- "errors"
6- "io"
7- "time"
8-
9- "github.com/bnema/vev/internal/domain"
10-)
11-
12-// ErrNoClipboardImage is returned by ClipboardReader.ReadImage when the
13-// clipboard holds no image (or an unsupported type). Callers fall back to
14-// forwarding whatever keystroke triggered the read.
15-var ErrNoClipboardImage = errors.New("ports: no image on clipboard")
16-
17-// PTY is a running pseudo-terminal-backed child process.
18-type PTY interface {
19- io.ReadWriteCloser // Read = child output, Write = child input
20- Resize(sz domain.Size) error
21- Pid() int
22- ForegroundPgid() (int, error)
23-}
24-
25-// PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
26-type PTYFactory interface {
27- // Open creates a PTY while honoring ctx cancellation. Implementations must
28- // return promptly once ctx is cancelled and must not leave a child running.
29- Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
30-}
31-
32-// Terminal is the CLIENT-side controlling terminal.
33-type Terminal interface {
34- EnterRaw() (restore func() error, err error)
35- Size() (domain.Size, error)
36- ResizeEvents() <-chan domain.Size
37- QueryColors() error
38- In() io.Reader
39- Out() io.Writer
40- Flush() error
41-}
42-
43-// Transport is a framed message channel over a single connection. Close must
44-// be safe to call concurrently with Send and Recv, and must unblock active
45-// Send and Recv calls.
46-type Transport interface {
47- Send(Frame) error
48- Recv() (Frame, error) // blocking; io.EOF on close
49- Close() error
50-}
51-
52-// DatagramTransport marks transports backed by a datagram link. It lets
53-// usecases negotiate a conservative output window without importing adapters.
54-type DatagramTransport interface {
55- Transport
56- DatagramTransport()
57-}
58-
59-// AsyncTransport accepts frames for ordered background transmission. SendAsync
60-// returns once the adapter owns the frame; Send retains its synchronous wire-
61-// attempt contract. Daemon paint output may use this capability to pipeline.
62-type AsyncTransport interface {
63- SendAsync(Frame) error
64-}
65-
66-// OwnedSynchronousTransport owns the complete bounded synchronous operation,
67-// including adapter queues, pacing, write deadlines, and close cancellation.
68-type OwnedSynchronousTransport interface {
69- SendSynchronous(Frame) error
70-}
71-
72-// Dialer establishes outbound Transport connections.
73-type Dialer interface {
74- Dial(ctx context.Context) (Transport, error)
75-}
76-
77-// Listener accepts incoming Transport connections.
78-type Listener interface {
79- Accept() (Transport, error)
80- Close() error
81- Addr() string
82-}
83-
84-// Store is a small byte-key/value persistence port.
85-//
86-// Implementations may buffer writes; Sync is the durability barrier.
87-type Store interface {
88- Get(key []byte) ([]byte, bool)
89- Set(key, val []byte) error
90- Delete(key []byte) error
91- // Range iterates key/value pairs; fn returning false stops iteration early.
92- Range(fn func(k, v []byte) bool)
93- Sync() error
94- Close() error
95-}
96-
97-// SnapshotBlob is a durable named session snapshot payload.
98-type SnapshotBlob struct {
99- Name string
100- Data []byte
101-}
102-
103-// SnapshotStore persists encoded session snapshots by name.
104-type SnapshotStore interface {
105- Write(name string, data []byte) error
106- Load() ([]SnapshotBlob, error)
107- Delete(name string) error
108-}
109-
110-// Clock abstracts time so usecases can be tested without real delays.
111-type Clock interface {
112- Now() time.Time
113- NewTimer(d time.Duration) Timer
114-}
115-
116-// Timer abstracts a cancellable, resettable time.Timer.
117-type Timer interface {
118- C() <-chan time.Time
119- Reset(d time.Duration) bool
120- Stop() bool
121-}
122-
123-// CommandSpec describes a bounded shell command execution request.
124-type CommandSpec struct {
125- Command string
126- Env []string
127- Timeout time.Duration
128- StdoutLimit int
129-}
130-
131-// ShellCommandRunner runs shell commands and returns bounded stdout bytes.
132-type ShellCommandRunner interface {
133- Run(ctx context.Context, spec CommandSpec) ([]byte, error)
134-}
135-
136-// ProcessInspector reads process metadata for daemon snapshot/persistence use.
137-// Implementations may live in platform packages; usecases depend on this port.
138-type ProcessInspector interface {
139- Cwd(pid int) (string, error)
140- Comm(pid int) (string, error)
141- Argv(pid int) ([]string, error)
142- GroupArgv(pgid int, shellPid int) ([]string, error)
143-}
144-
145-// ClipboardReader reads an image off the CLIENT-side system clipboard, for
146-// forwarding to a remote session's focused pane. Implementations live in
147-// internal/adapters (e.g. a wl-paste-backed one for Wayland); usecases only
148-// see this interface. See
149-// docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md.
150-type ClipboardReader interface {
151- // ReadImage returns the clipboard's image bytes and MIME type. It returns
152- // ErrNoClipboardImage when the clipboard holds no image (or an
153- // unsupported type).
154- ReadImage(ctx context.Context) (mime string, data []byte, err error)
155-}
A internal/ports/process.go +10 -0
@@ -0,0 +1,10 @@
1+package ports
2+
3+// ProcessInspector reads process metadata for daemon snapshot/persistence use.
4+// Implementations may live in platform packages; usecases depend on this port.
5+type ProcessInspector interface {
6+ Cwd(pid int) (string, error)
7+ Comm(pid int) (string, error)
8+ Argv(pid int) ([]string, error)
9+ GroupArgv(pgid int, shellPid int) ([]string, error)
10+}
A internal/ports/pty.go +23 -0
@@ -0,0 +1,23 @@
1+package ports
2+
3+import (
4+ "context"
5+ "io"
6+
7+ "github.com/bnema/vev/internal/domain"
8+)
9+
10+// PTY is a running pseudo-terminal-backed child process.
11+type PTY interface {
12+ io.ReadWriteCloser // Read = child output, Write = child input
13+ Resize(sz domain.Size) error
14+ Pid() int
15+ ForegroundPgid() (int, error)
16+}
17+
18+// PTYFactory creates PTYs by spawning a command attached to a new pseudo-terminal.
19+type PTYFactory interface {
20+ // Open creates a PTY while honoring ctx cancellation. Implementations must
21+ // return promptly once ctx is cancelled and must not leave a child running.
22+ Open(ctx context.Context, cmd string, args []string, env []string, dir string, sz domain.Size) (PTY, error)
23+}
A internal/ports/shell.go +19 -0
@@ -0,0 +1,19 @@
1+package ports
2+
3+import (
4+ "context"
5+ "time"
6+)
7+
8+// CommandSpec describes a bounded shell command execution request.
9+type CommandSpec struct {
10+ Command string
11+ Env []string
12+ Timeout time.Duration
13+ StdoutLimit int
14+}
15+
16+// ShellCommandRunner runs shell commands and returns bounded stdout bytes.
17+type ShellCommandRunner interface {
18+ Run(ctx context.Context, spec CommandSpec) ([]byte, error)
19+}
A internal/ports/store.go +31 -0
@@ -0,0 +1,31 @@
1+package ports
2+
3+// Store is a small byte-key/value persistence port.
4+//
5+// Implementations may buffer writes; Sync is the durability barrier.
6+type Store interface {
7+ // Get returns a stable copy of the value for key. The caller owns the
8+ // returned slice and may retain or mutate it.
9+ Get(key []byte) ([]byte, bool)
10+ Set(key, val []byte) error
11+ Delete(key []byte) error
12+ // Range iterates key/value pairs; fn returning false stops iteration early.
13+ // Each key and value is a stable copy owned by the caller, which may retain
14+ // or mutate either slice after Range returns.
15+ Range(fn func(k, v []byte) bool)
16+ Sync() error
17+ Close() error
18+}
19+
20+// SnapshotBlob is a durable named session snapshot payload.
21+type SnapshotBlob struct {
22+ Name string
23+ Data []byte
24+}
25+
26+// SnapshotStore persists encoded session snapshots by name.
27+type SnapshotStore interface {
28+ Write(name string, data []byte) error
29+ Load() ([]SnapshotBlob, error)
30+ Delete(name string) error
31+}
A internal/ports/terminal.go +18 -0
@@ -0,0 +1,18 @@
1+package ports
2+
3+import (
4+ "io"
5+
6+ "github.com/bnema/vev/internal/domain"
7+)
8+
9+// Terminal is the CLIENT-side controlling terminal.
10+type Terminal interface {
11+ EnterRaw() (restore func() error, err error)
12+ Size() (domain.Size, error)
13+ ResizeEvents() <-chan domain.Size
14+ QueryColors() error
15+ In() io.Reader
16+ Out() io.Writer
17+ Flush() error
18+}
A internal/ports/transport.go +44 -0
@@ -0,0 +1,44 @@
1+package ports
2+
3+import "context"
4+
5+// Transport is a framed message channel over a single connection. Close must
6+// be safe to call concurrently with Send and Recv, and must unblock active
7+// Send and Recv calls.
8+type Transport interface {
9+ Send(Frame) error
10+ Recv() (Frame, error) // blocking; io.EOF on close
11+ Close() error
12+}
13+
14+// DatagramTransport marks transports backed by a datagram link. It lets
15+// usecases negotiate a conservative output window without importing adapters.
16+type DatagramTransport interface {
17+ Transport
18+ DatagramTransport()
19+}
20+
21+// AsyncTransport accepts frames for ordered background transmission. SendAsync
22+// returns once the adapter owns the frame; Send retains its synchronous wire-
23+// attempt contract. Daemon paint output may use this capability to pipeline.
24+type AsyncTransport interface {
25+ SendAsync(Frame) error
26+}
27+
28+// OwnedSynchronousTransport owns the complete bounded synchronous operation,
29+// including adapter queues, pacing, write deadlines, and close cancellation.
30+type OwnedSynchronousTransport interface {
31+ SendSynchronous(Frame) error
32+}
33+
34+// Dialer establishes outbound Transport connections.
35+type Dialer interface {
36+ Dial(ctx context.Context) (Transport, error)
37+}
38+
39+// Listener accepts incoming Transport connections.
40+type Listener interface {
41+ Accept() (Transport, error)
42+ Close() error
43+ Addr() string
44+}
M internal/usecase/client/client.go +211 -146
@@ -143,47 +143,58 @@ const (
143143 statusClear = "\r\x1b[2K"
144144 )
145145
146-// Run connects via dialer and runs the attach client. It owns the terminal
147-// lifecycle above attach attempts so raw mode remains active while a live
148-// client process redials a lost link.
149-//
150-// remote and clipboard together gate the clipboard-image-transfer feature
151-// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md):
152-// Ctrl+V is only intercepted on a remote attach (remote true) with a
153-// ClipboardReader configured (clipboard non-nil) — local attaches forward
154-// 0x16 untouched so the locally running agent's own clipboard handling
155-// applies.
156-// Option configures opt-in client runtime observation.
157-type Option func(*runtimeOptions)
158-
159-type runtimeOptions struct {
160- observer ports.SerializedRuntimeObserver
161-}
162-
163-// WithRuntimeObserver accepts only a composition-root serialized observer, so
164-// terminal and transport progress can never wait for a raw trace sink.
165-func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
166- return func(opts *runtimeOptions) { opts.observer = observer }
167-}
168-
169-func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger) error {
170- return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, nil)
171-}
172-
173-// RunWithRuntimeObserver is the application wiring entry point.
174-func RunWithRuntimeObserver(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) error {
175- return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer)
176-}
177-
178-func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observer ports.SerializedRuntimeObserver) (retErr error) {
146+// Dependencies supplies the collaborators required by a Runner.
147+type Dependencies struct {
148+ Dialer ports.Dialer
149+ Terminal ports.Terminal
150+ Clock ports.Clock
151+ Clipboard ports.ClipboardReader
152+ Logger *slog.Logger
153+ RuntimeObserver ports.SerializedRuntimeObserver
154+}
155+
156+// AttachRequest identifies the session and transport mode for one client run.
157+type AttachRequest struct {
158+ Intent uint8
159+ SessionName string
160+ Remote bool
161+}
162+
163+// Runner owns the client lifecycle across one or more attachment attempts.
164+type Runner struct {
165+ dialer ports.Dialer
166+ term ports.Terminal
167+ clock ports.Clock
168+ clipboard ports.ClipboardReader
169+ logger *slog.Logger
170+ runtimeObserver ports.SerializedRuntimeObserver
171+}
172+
173+// NewRunner constructs a client runner. A nil logger uses the process default.
174+func NewRunner(deps Dependencies) *Runner {
175+ log := deps.Logger
179176 if log == nil {
180177 log = slog.Default()
181178 }
179+ return &Runner{
180+ dialer: deps.Dialer,
181+ term: deps.Terminal,
182+ clock: deps.Clock,
183+ clipboard: deps.Clipboard,
184+ logger: log,
185+ runtimeObserver: deps.RuntimeObserver,
186+ }
187+}
188+
189+// Run connects and runs the attach client. It owns the terminal lifecycle
190+// above attach attempts so raw mode remains active while a live client process
191+// redials a lost link.
192+func (r *Runner) Run(ctx context.Context, request AttachRequest) (retErr error) {
182193 ms := milestones{}
183194
184195 defer func() {
185196 if retErr != nil {
186- log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())
197+ r.logger.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())
187198 }
188199 }()
189200
@@ -201,7 +212,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
201212 return nil
202213 }
203214 var err error
204- restore, err = term.EnterRaw()
215+ restore, err = r.term.EnterRaw()
205216 if err != nil {
206217 return fmt.Errorf("vev: entering raw mode: %w", err)
207218 }
@@ -211,87 +222,31 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
211222 }
212223
213224 resumeToken := uint64(0)
214- attemptIntent := intent
215- attemptName := name
225+ attemptRequest := request
216226 backoff := defaultReconnectBackoff.initial
217- showingStatus := false
218- var reconnectToastRect domain.Rect
219227 themeState := &terminalThemeState{}
220- statusStage := reconnectStageOfflineRetrying
221- redrawRemoteStatus := func(size domain.Size) {
222- if !rawEntered || !remote {
223- return
224- }
225- if showingStatus {
226- _ = clearReconnectToast(term.Out(), reconnectToastRect)
227- }
228- drawnRect, _ := drawReconnectToastStage(term.Out(), size, statusStage)
229- reconnectToastRect = drawnRect
230- _ = term.Flush()
231- showingStatus = true
232- }
233- var drawStatus func()
234- drawStatusStage := func(stage reconnectStage) {
235- statusStage = stage
236- if showingStatus && remote {
237- size, err := term.Size()
238- if err == nil {
239- redrawRemoteStatus(size)
240- }
241- return
242- }
243- drawStatus()
244- }
245- drawStatus = func() {
246- if !rawEntered || showingStatus {
247- return
248- }
249- if remote {
250- size, err := term.Size()
251- if err != nil {
252- return
253- }
254- redrawRemoteStatus(size)
255- } else {
256- _, _ = term.Out().Write([]byte(statusReconnect))
257- _ = term.Flush()
258- showingStatus = true
259- }
260- }
261- clearStatus := func() {
262- if !showingStatus {
263- return
264- }
265- if remote {
266- _ = clearReconnectToast(term.Out(), reconnectToastRect)
267- } else {
268- _, _ = term.Out().Write([]byte(statusClear))
269- }
270- _ = term.Flush()
271- showingStatus = false
272- }
273- sleepWhileReconnecting := func(d time.Duration) bool {
274- if remote && showingStatus {
275- return reconnectSleepWithResize(ctx, clk, d, term.ResizeEvents(), redrawRemoteStatus)
276- }
277- return reconnectSleep(ctx, clk, d)
228+ reconnect := &reconnectUI{
229+ term: r.term,
230+ remote: request.Remote,
231+ rawEntered: &rawEntered,
232+ stage: reconnectStageOfflineRetrying,
278233 }
279234
280235 for {
281- transport, err := dialer.Dial(ctx)
236+ transport, err := r.dialer.Dial(ctx)
282237 if err != nil {
283238 if resumeToken == 0 || ctx.Err() != nil {
284- clearStatus()
239+ reconnect.clear()
285240 return err
286241 }
287- log.Warn("reconnect dial failed", "err", err, "backoff", backoff)
288- drawStatus()
289- if !sleepWhileReconnecting(backoff) {
290- clearStatus()
242+ r.logger.Warn("reconnect dial failed", "err", err, "backoff", backoff)
243+ reconnect.draw()
244+ if !reconnect.sleep(ctx, r.clock, backoff) {
245+ reconnect.clear()
291246 return ctx.Err()
292247 }
293248 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
294- attemptIntent = ports.IntentResume
249+ attemptRequest.Intent = ports.IntentResume
295250 continue
296251 }
297252 ms.dialed = true
@@ -300,7 +255,18 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
300255 if reporter, ok := transport.(ports.LinkStateReporter); ok {
301256 linkEvents = reporter.LinkEvents()
302257 }
303- result := attachOnce(ctx, transport, term, clk, attemptIntent, attemptName, resumeToken, processClientID, &ms, themeState, enterRaw, clearStatus, drawStatusStage, linkEvents, remote, clipboard, log, observer)
258+ result := (&attachAttempt{
259+ runner: r,
260+ transport: transport,
261+ request: attemptRequest,
262+ resumeToken: resumeToken,
263+ clientID: processClientID,
264+ milestones: &ms,
265+ themeState: themeState,
266+ enterRaw: enterRaw,
267+ reconnect: reconnect,
268+ linkEvents: linkEvents,
269+ }).run(ctx)
304270 if result.welcomed {
305271 backoff = defaultReconnectBackoff.initial
306272 }
@@ -311,27 +277,101 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
311277 resumeToken = result.resumeToken
312278 }
313279 if result.sessionName != "" {
314- attemptName = result.sessionName
280+ attemptRequest.SessionName = result.sessionName
315281 }
316282 if result.err == nil {
317- clearStatus()
283+ reconnect.clear()
318284 return nil
319285 }
320286 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
321- clearStatus()
287+ reconnect.clear()
322288 return result.err
323289 }
324- log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
325- drawStatusStage(reconnectStageSSH)
326- if !sleepWhileReconnecting(backoff) {
327- clearStatus()
290+ r.logger.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
291+ reconnect.drawStage(reconnectStageSSH)
292+ if !reconnect.sleep(ctx, r.clock, backoff) {
293+ reconnect.clear()
328294 return ctx.Err()
329295 }
330296 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
331- attemptIntent = ports.IntentResume
297+ attemptRequest.Intent = ports.IntentResume
332298 }
333299 }
334300
301+// reconnectUI owns reconnect presentation while Runner owns the lifecycle.
302+// Its methods run only on Runner's goroutine, preserving the terminal's
303+// single-writer rule.
304+type reconnectUI struct {
305+ term ports.Terminal
306+ remote bool
307+ rawEntered *bool
308+ showing bool
309+ rect domain.Rect
310+ stage reconnectStage
311+}
312+
313+func (u *reconnectUI) redraw(size domain.Size) {
314+ if !*u.rawEntered || !u.remote {
315+ return
316+ }
317+ if u.showing {
318+ _ = clearReconnectToast(u.term.Out(), u.rect)
319+ }
320+ drawnRect, _ := drawReconnectToastStage(u.term.Out(), size, u.stage)
321+ u.rect = drawnRect
322+ _ = u.term.Flush()
323+ u.showing = true
324+}
325+
326+func (u *reconnectUI) drawStage(stage reconnectStage) {
327+ u.stage = stage
328+ if u.showing && u.remote {
329+ size, err := u.term.Size()
330+ if err == nil {
331+ u.redraw(size)
332+ }
333+ return
334+ }
335+ u.draw()
336+}
337+
338+func (u *reconnectUI) draw() {
339+ if !*u.rawEntered || u.showing {
340+ return
341+ }
342+ if u.remote {
343+ size, err := u.term.Size()
344+ if err != nil {
345+ return
346+ }
347+ u.redraw(size)
348+ return
349+ }
350+ _, _ = u.term.Out().Write([]byte(statusReconnect))
351+ _ = u.term.Flush()
352+ u.showing = true
353+}
354+
355+func (u *reconnectUI) clear() {
356+ if !u.showing {
357+ return
358+ }
359+ if u.remote {
360+ _ = clearReconnectToast(u.term.Out(), u.rect)
361+ } else {
362+ _, _ = u.term.Out().Write([]byte(statusClear))
363+ }
364+ _ = u.term.Flush()
365+ u.showing = false
366+}
367+
368+func (u *reconnectUI) sleep(ctx context.Context, clk ports.Clock, d time.Duration) bool {
369+ if u.remote && u.showing {
370+ return reconnectSleepWithResize(ctx, clk, d, u.term.ResizeEvents(), u.redraw)
371+ }
372+ return reconnectSleep(ctx, clk, d)
373+}
374+
335375 func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {
336376 t := clk.NewTimer(d)
337377 defer t.Stop()
@@ -364,25 +404,17 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
364404 }
365405 }
366406
367-// Attach connects an already-dialed transport to the controlling terminal
368-// and runs the attach loop until the session detaches, the daemon
369-// disappears, or the context is cancelled.
370-//
371-// It is kept as a compatibility wrapper for callers that still own dialing.
372-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, options ...Option) error {
373- var opts runtimeOptions
374- for _, option := range options {
375- if option != nil {
376- option(&opts)
377- }
378- }
379- return run(ctx, singleTransportDialer{transport: transport}, term, clk, intent, name, false, nil, slog.Default(), opts.observer)
380-}
381-
382-type singleTransportDialer struct{ transport ports.Transport }
383-
384-func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
385- return d.transport, nil
407+type attachAttempt struct {
408+ runner *Runner
409+ transport ports.Transport
410+ request AttachRequest
411+ resumeToken uint64
412+ clientID [16]byte
413+ milestones *milestones
414+ themeState *terminalThemeState
415+ enterRaw func() error
416+ reconnect *reconnectUI
417+ linkEvents <-chan ports.LinkEvent
386418 }
387419
388420 type attachResult struct {
@@ -452,11 +484,23 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
452484 return 8
453485 }
454486
455-func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, themeState *terminalThemeState, enterRaw func() error, clearStatus func(), drawStatusStage func(reconnectStage), linkEvents <-chan ports.LinkEvent, remote bool, clipboard ports.ClipboardReader, log *slog.Logger, observers ...ports.RuntimeObserver) attachResult {
456- var observer ports.RuntimeObserver
457- if len(observers) != 0 {
458- observer = observers[0]
459- }
487+func (a *attachAttempt) run(ctx context.Context) attachResult {
488+ transport := a.transport
489+ term := a.runner.term
490+ clk := a.runner.clock
491+ intent := a.request.Intent
492+ name := a.request.SessionName
493+ resumeToken := a.resumeToken
494+ clientID := a.clientID
495+ ms := a.milestones
496+ themeState := a.themeState
497+ enterRaw := a.enterRaw
498+ reconnect := a.reconnect
499+ linkEvents := a.linkEvents
500+ remote := a.request.Remote
501+ clipboard := a.runner.clipboard
502+ log := a.runner.logger
503+ observer := a.runner.runtimeObserver
460504 // 1. Handshake: send Hello with our size and TERM.
461505 size, err := term.Size()
462506 if err != nil {
@@ -528,7 +572,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
528572 if err := enterRaw(); err != nil {
529573 return welcomedResult(err)
530574 }
531- clearStatus()
575+ reconnect.clear()
532576 reportedTheme, restoreTheme := themeState.reportedTheme()
533577
534578 // 4. Derive a cancellable context so the pumps always unwind when the
@@ -562,7 +606,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
562606 clip = clipboard
563607 }
564608 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
565- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, themeState, requestColors, clip, log)
609+ go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors}).run()
566610 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
567611
568612 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -586,15 +630,15 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
586630 continue
587631 }
588632 if ev.State == ports.LinkStateConnected {
589- clearStatus()
633+ reconnect.clear()
590634 continue
591635 }
592636 if ev.State == ports.LinkStateDegraded {
593637 log.Warn("UDP link degraded")
594- clearStatus()
638+ reconnect.clear()
595639 continue
596640 }
597- drawStatusStage(stageForLinkState(ev.State))
641+ reconnect.drawStage(stageForLinkState(ev.State))
598642 if ev.State == ports.LinkStateOffline {
599643 // Stop waiting for the transport to reach Dead at 60s: exit with
600644 // a retryable error so Run re-dials over ssh with the resume
@@ -752,17 +796,38 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
752796 }
753797 }
754798
755-// runStdin pumps terminal input to the daemon as Input frames. A read error
799+// stdinPump pumps terminal input to the daemon as Input frames. A read error
756800 // or EOF is treated as a detach: it best-effort sends a Detach frame and
757801 // cancels the loop.
758802 //
759803 // Known MVP limitation: a bare io.Reader cannot be unblocked from outside,
760804 // so on shutdown initiated elsewhere (daemon detach, transport loss) this
761805 // goroutine stays parked in Read until the next byte arrives or the process
762-// exits. That is harmless here — Attach has already returned and restored
763-// the terminal — and matches the standard pattern for stdin pumps; a
806+// exits. That is harmless here — Run has already returned and restored the
807+// terminal — and matches the standard pattern for stdin pumps; a
764808 // closable stdin duplicate could lift it later if ever needed.
765-func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, clk ports.Clock, themeState *terminalThemeState, requestColors func(), clipboard ports.ClipboardReader, log *slog.Logger) {
809+type stdinPump struct {
810+ ctx context.Context
811+ cancel context.CancelFunc
812+ in io.Reader
813+ out chan<- ports.Frame
814+ clock ports.Clock
815+ themeState *terminalThemeState
816+ clipboard ports.ClipboardReader
817+ logger *slog.Logger
818+ requestColors func()
819+}
820+
821+func (p *stdinPump) run() {
822+ ctx := p.ctx
823+ cancel := p.cancel
824+ in := p.in
825+ out := p.out
826+ clk := p.clock
827+ themeState := p.themeState
828+ requestColors := p.requestColors
829+ clipboard := p.clipboard
830+ log := p.logger
766831 defer log.Debug("stdin pump exited")
767832 buf := make([]byte, stdinBufSize)
768833 var scanner theme.Scanner
M internal/usecase/client/client_test.go +45 -19
@@ -156,6 +156,32 @@ func isType(typ ports.MsgType) any {
156156 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
157157 }
158158
159+// transportDialer adapts one already-open test transport to the Runner API.
160+type transportDialer struct{ transport ports.Transport }
161+
162+func (d transportDialer) Dial(context.Context) (ports.Transport, error) {
163+ return d.transport, nil
164+}
165+
166+func runTestClient(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
167+ return client.NewRunner(deps).Run(ctx, request)
168+}
169+
170+func testDependencies(dialer ports.Dialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies {
171+ return client.Dependencies{
172+ Dialer: dialer,
173+ Terminal: terminal,
174+ Clock: clock,
175+ Clipboard: clipboard,
176+ Logger: slog.New(slog.DiscardHandler),
177+ RuntimeObserver: observer,
178+ }
179+}
180+
181+func attachTestDependencies(transport ports.Transport, terminal ports.Terminal, clock ports.Clock) client.Dependencies {
182+ return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)
183+}
184+
159185 const testPreWelcomeTimeout = 15 * time.Second
160186
161187 func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {
@@ -227,7 +253,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
227253
228254 errCh := make(chan error, 1)
229255 go func() {
230- errCh <- client.Run(ctx, dialer, term, clk, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
256+ errCh <- runTestClient(ctx, testDependencies(dialer, term, clk, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
231257 }()
232258
233259 var timerC chan time.Time
@@ -295,7 +321,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
295321 defer unblock()
296322 tr.EXPECT().Close().Return(nil).Once()
297323
298- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
324+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
299325 require.NoError(t, err)
300326
301327 select {
@@ -359,7 +385,7 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
359385 defer unblock()
360386 tr.EXPECT().Close().Return(nil).Once()
361387
362- require.NoError(t, client.Attach(context.Background(), markedDatagramTransport{tr}, tm, realClock{}, ports.IntentEphemeral, ""))
388+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(markedDatagramTransport{tr}, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
363389 }
364390
365391 func TestAttachHappyPath(t *testing.T) {
@@ -379,7 +405,7 @@ func TestAttachHappyPath(t *testing.T) {
379405 defer unblock()
380406 tr.EXPECT().Close().Return(nil).Once()
381407
382- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
408+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
383409 require.NoError(t, err)
384410 require.Equal(t, "hello world", out.String())
385411 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -398,7 +424,7 @@ func TestAttachVersionMismatch(t *testing.T) {
398424 ).Once()
399425 tr.EXPECT().Close().Return(nil).Once()
400426
401- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")
427+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main"})
402428 require.Error(t, err)
403429 var pe *client.ProtocolError
404430 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -422,7 +448,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
422448 defer unblock()
423449 tr.EXPECT().Close().Return(nil).Once()
424450
425- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
451+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
426452 require.Error(t, err)
427453 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
428454 }
@@ -443,7 +469,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
443469 defer unblock()
444470 tr.EXPECT().Close().Return(nil).Once()
445471
446- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
472+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
447473 require.Error(t, err)
448474 require.ErrorIs(t, err, io.EOF)
449475 require.Equal(t, int32(1), restoreCount.Load())
@@ -515,7 +541,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
515541 }).Maybe()
516542 tr.EXPECT().Close().Return(nil).Once()
517543
518- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
544+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
519545 require.NoError(t, err)
520546 require.Equal(t, int32(1), restoreCount.Load())
521547
@@ -594,7 +620,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
594620 }).Maybe()
595621 tr.EXPECT().Close().Return(nil).Once()
596622
597- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
623+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
598624 require.NoError(t, err)
599625 select {
600626 case th := <-gotTheme:
@@ -654,7 +680,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
654680 }).Maybe()
655681 tr.EXPECT().Close().Return(nil).Once()
656682
657- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
683+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
658684 require.NoError(t, err)
659685 select {
660686 case got := <-gotInput:
@@ -713,7 +739,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
713739 }).Maybe()
714740 tr.EXPECT().Close().Return(nil).Once()
715741
716- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
742+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
717743 require.NoError(t, err)
718744 select {
719745 case got := <-gotInput:
@@ -773,7 +799,7 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
773799 }).Maybe()
774800 tr.EXPECT().Close().Return(nil).Once()
775801
776- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
802+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
777803 require.NoError(t, err)
778804 select {
779805 case got := <-gotInput:
@@ -836,7 +862,7 @@ func TestAttachForwardsResize(t *testing.T) {
836862 resizeCh <- domain.Size{Cols: 120, Rows: 40}
837863 }()
838864
839- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")
865+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
840866 require.NoError(t, err)
841867 select {
842868 case r := <-gotResize:
@@ -865,9 +891,9 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
865891 d := portsmocks.NewMockDialer(t)
866892 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
867893
868- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
894+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: false})
869895 require.Error(t, err)
870- require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
896+ require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attach attempt errors")
871897 }
872898
873899 func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
@@ -885,7 +911,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
885911 d := portsmocks.NewMockDialer(t)
886912 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
887913
888- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
914+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
889915 require.Error(t, err)
890916 var pe *client.ProtocolError
891917 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -897,7 +923,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
897923 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
898924 tm := portsmocks.NewMockTerminal(t)
899925
900- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))
926+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: false})
901927 require.ErrorIs(t, err, dialErr)
902928 }
903929
@@ -987,7 +1013,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
9871013 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
9881014 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
9891015
990- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
1016+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
9911017 require.NoError(t, err)
9921018 require.Equal(t, int32(3), d.calls.Load())
9931019 require.Equal(t, int32(1), term.rawCount.Load())
@@ -1014,7 +1040,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
10141040 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
10151041 d := &sequenceDialer{trs: []ports.Transport{tr}}
10161042
1017- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))
1043+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
10181044 require.Error(t, err)
10191045 var de *client.DetachedError
10201046 require.True(t, errors.As(err, &de))
M internal/usecase/client/clipboard_intercept_test.go +1 -2
@@ -4,7 +4,6 @@ import (
44 "bytes"
55 "context"
66 "io"
7- "log/slog"
87 "testing"
98 "time"
109
@@ -86,7 +85,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
8685 d := portsmocks.NewMockDialer(t)
8786 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
8887
89- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", remote, clip, slog.New(slog.DiscardHandler))
88+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, clip, nil), client.AttachRequest{Intent: ports.IntentEphemeral, Remote: remote})
9089 require.NoError(t, err)
9190 return gotInput, gotImage
9291 }
M internal/usecase/client/reconnect.go +1 -1
@@ -14,7 +14,7 @@ type reconnectBackoff struct {
1414
1515 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
1616
17-// errLinkOffline is returned by attachOnce when the transport link reaches the
17+// errLinkOffline is returned by an attach attempt when the transport link reaches the
1818 // Offline state. It is retryable so Run re-dials over ssh with the resume token
1919 // at ~30s of silence instead of waiting the full Dead timeout at 60s.
2020 var errLinkOffline = errors.New("vev: link offline; reconnecting")
M internal/usecase/client/reconnect_toast_test.go +86 -46
@@ -8,6 +8,7 @@ import (
88 "io"
99 "log/slog"
1010 "strings"
11+ "sync"
1112 "testing"
1213 "time"
1314
@@ -71,6 +72,34 @@ func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *re
7172
7273 func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
7374
75+type reconnectToastOutputRecorder struct {
76+ mu sync.Mutex
77+ pending strings.Builder
78+ completed chan string
79+}
80+
81+func newReconnectToastOutputRecorder() *reconnectToastOutputRecorder {
82+ return &reconnectToastOutputRecorder{completed: make(chan string, 3)}
83+}
84+
85+func (r *reconnectToastOutputRecorder) Write(p []byte) (int, error) {
86+ const restoreCursor = "\x1b[0m\x1b[u"
87+
88+ r.mu.Lock()
89+ r.pending.Write(p)
90+ var output string
91+ if string(p) == restoreCursor {
92+ output = r.pending.String()
93+ r.pending.Reset()
94+ }
95+ r.mu.Unlock()
96+
97+ if output != "" {
98+ r.completed <- output
99+ }
100+ return len(p), nil
101+}
102+
74103 func reconnectToastWelcome(token uint64) ports.Frame {
75104 return reconnectToastWelcomeNamed("", token)
76105 }
@@ -191,66 +220,81 @@ func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock {
191220 return clk
192221 }
193222
223+func newReconnectAttachAttempt(term ports.Terminal, transport ports.Transport, clock ports.Clock, request AttachRequest, resumeToken uint64, state *terminalThemeState, linkEvents <-chan ports.LinkEvent, ms *milestones) *attachAttempt {
224+ rawEntered := false
225+ enterRaw := func() error {
226+ if rawEntered {
227+ return nil
228+ }
229+ _, err := term.EnterRaw()
230+ if err == nil {
231+ rawEntered = true
232+ ms.rawEntered = true
233+ }
234+ return err
235+ }
236+ runner := &Runner{term: term, clock: clock, logger: slog.New(slog.DiscardHandler)}
237+ return &attachAttempt{
238+ runner: runner,
239+ transport: transport,
240+ request: request,
241+ resumeToken: resumeToken,
242+ clientID: [16]byte{1},
243+ milestones: ms,
244+ themeState: state,
245+ enterRaw: enterRaw,
246+ reconnect: &reconnectUI{
247+ term: term,
248+ remote: request.Remote,
249+ rawEntered: &rawEntered,
250+ stage: reconnectStageOfflineRetrying,
251+ },
252+ linkEvents: linkEvents,
253+ }
254+}
255+
194256 func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
195- term := newReconnectToastTerminalHarness(t)
257+ out := newReconnectToastOutputRecorder()
258+ term := newReconnectToastTerminalHarnessWithOutput(t, out)
196259 defer term.closeInput()
197260 tr := newReconnectToastLinkTransport()
198261 tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}
199262
200- stages := make(chan reconnectStage, 4)
201- clears := make(chan struct{}, 4)
202263 resultCh := make(chan attachResult, 1)
203264 ms := milestones{}
204- go func() {
205- resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error {
206- _, err := term.term.EnterRaw()
207- return err
208- }, func() { clears <- struct{}{} }, func(stage reconnectStage) {
209- stages <- stage
210- }, tr.LinkEvents(), true, nil, slog.New(slog.DiscardHandler))
211- }()
265+ attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms)
266+ go func() { resultCh <- attempt.run(context.Background()) }()
267+
268+ tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
269+ probed := <-out.completed
270+ require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
212271
213- require.Eventually(t, func() bool { return len(clears) == 1 }, time.Second, time.Millisecond)
214- <-clears // Welcome clears any stale status from an earlier attempt.
215272 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}
216- select {
217- case <-clears:
218- case <-time.After(time.Second):
219- t.Fatal("degraded link did not clear stale reconnect modal")
220- }
221- select {
222- case stage := <-stages:
223- t.Fatalf("degraded link drew modal stage %v", stage)
224- case <-time.After(25 * time.Millisecond):
225- }
273+ cleared := <-out.completed
274+ assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
275+
226276 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
227- require.Equal(t, reconnectStageProbingUDP, <-stages)
277+ probed = <-out.completed
278+ require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
279+
228280 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
229281 result := <-resultCh
230282 require.NoError(t, result.err)
231283 require.True(t, result.welcomed)
232284 }
233285
234-func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {
286+func TestAttachAttemptOfflineLinkEventReturnsReconnectableError(t *testing.T) {
235287 term := newReconnectToastTerminalHarness(t)
236288 defer term.closeInput()
237289 tr := newReconnectToastLinkTransport()
238290 tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}
239291
240- stages := make(chan reconnectStage, 4)
241292 resultCh := make(chan attachResult, 1)
242293 ms := milestones{}
243- go func() {
244- resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error {
245- _, err := term.term.EnterRaw()
246- return err
247- }, func() {}, func(stage reconnectStage) {
248- stages <- stage
249- }, tr.LinkEvents(), true, nil, slog.New(slog.DiscardHandler))
250- }()
294+ attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms)
295+ go func() { resultCh <- attempt.run(context.Background()) }()
251296
252297 tr.events <- ports.LinkEvent{State: ports.LinkStateOffline}
253- require.Equal(t, reconnectStageOfflineRetrying, <-stages)
254298 result := <-resultCh
255299 require.ErrorIs(t, result.err, errLinkOffline)
256300 require.True(t, result.welcomed)
@@ -412,7 +456,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
412456 }}
413457 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
414458
415- err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
459+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
416460 require.ErrorIs(t, err, context.Canceled)
417461 require.True(t, out.failed)
418462 assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
@@ -447,7 +491,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
447491 }}
448492 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
449493
450- err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
494+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
451495 require.NoError(t, err)
452496 require.True(t, tr1.closed)
453497 require.True(t, tr2.closed)
@@ -565,12 +609,8 @@ func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) {
565609 ctx, cancel := context.WithCancel(context.Background())
566610 resultCh := make(chan attachResult, 1)
567611 ms := milestones{}
568- go func() {
569- resultCh <- attachOnce(ctx, transport, term.term, newReconnectHandshakeClock(t), ports.IntentResume, "main", 44, [16]byte{1}, &ms, themeState, func() error {
570- _, err := term.term.EnterRaw()
571- return err
572- }, func() {}, func(reconnectStage) {}, nil, true, nil, slog.New(slog.DiscardHandler))
573- }()
612+ attempt := newReconnectAttachAttempt(term.term, transport, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentResume, SessionName: "main", Remote: true}, 44, themeState, nil, &ms)
613+ go func() { resultCh <- attempt.run(ctx) }()
574614
575615 select {
576616 case <-transport.started:
@@ -615,7 +655,7 @@ func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) {
615655 tr2 := newReconnectThemeTransport(55, false)
616656 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
617657
618- err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
658+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
619659 require.NoError(t, err)
620660 require.True(t, tr2.observedTheme.HasForeground)
621661 require.True(t, tr2.observedTheme.HasBackground)
@@ -649,7 +689,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
649689 }}
650690 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
651691
652- err := Run(context.Background(), dialer, term.term, newReconnectHandshakeClock(t), ports.IntentEphemeral, "", true, nil, slog.New(slog.DiscardHandler))
692+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: true})
653693 require.NoError(t, err)
654694 require.Equal(t, 2, dialer.calls)
655695
@@ -734,7 +774,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
734774 dialer := portsmocks.NewMockDialer(t)
735775 tt.configure(t, dialer)
736776
737- err := Run(ctx, dialer, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
777+ err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(ctx, AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
738778 tt.wantErr(t, err)
739779 out := term.out.String()
740780 require.Contains(t, out, reconnectToastMessage)
M internal/usecase/client/transport_observability_test.go +7 -7
@@ -17,11 +17,11 @@ import (
1717 "github.com/bnema/vev/internal/usecase/client"
1818 )
1919
20-func TestRuntimeObserverOptionRequiresSerializedContract(t *testing.T) {
21- got := reflect.TypeOf(client.WithRuntimeObserver).In(0)
22- want := reflect.TypeFor[ports.SerializedRuntimeObserver]()
23- if got != want {
24- t.Fatalf("WithRuntimeObserver accepts %v, want %v; raw blocking sinks must not enter the hot path", got, want)
20+func TestRuntimeObserverDependencyUsesSerializedContract(t *testing.T) {
21+ deps := reflect.TypeFor[client.Dependencies]()
22+ field, ok := deps.FieldByName("RuntimeObserver")
23+ if !ok || field.Type != reflect.TypeFor[ports.SerializedRuntimeObserver]() {
24+ t.Fatalf("Dependencies.RuntimeObserver is %v, want %v; raw blocking sinks must not enter the hot path", field.Type, reflect.TypeFor[ports.SerializedRuntimeObserver]())
2525 }
2626 }
2727
@@ -57,7 +57,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
5757 ctx, cancel := context.WithCancel(context.Background())
5858 result := make(chan error, 1)
5959 go func() {
60- result <- client.Attach(ctx, transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))
60+ result <- runTestClient(ctx, testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})
6161 }()
6262 awaitClientRuntime(t, observer.entered, "blocking observer")
6363 awaitClientRuntime(t, flushed, "terminal flush")
@@ -95,7 +95,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
9595 transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()
9696 transport.EXPECT().Close().Return(nil).Once()
9797
98- err := client.Attach(context.Background(), transport, term, realClock{}, ports.IntentEphemeral, "", client.WithRuntimeObserver(reporter))
98+ err := runTestClient(context.Background(), testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})
9999 require.NoError(t, err)
100100 reporter.Flush()
101101 require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")
M internal/usecase/daemon/attention_test.go +35 -12
@@ -14,6 +14,7 @@ import (
1414 "github.com/bnema/vev/internal/ports"
1515 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1616 "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
1718 "github.com/bnema/vev/internal/usecase/ui"
1819 "github.com/bnema/vev/pkg/renderer"
1920 )
@@ -366,11 +367,10 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
366367 require.NotContains(t, string(data), "MIDSCREENMARKER")
367368 }
368369
369-// TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars pins the
370-// same invariant at the composeClientFrameWithState level: with a warm
371-// barCache and no screen damage, changing only bars.attentionFrame must
372-// produce damage confined to row 0 (top bar) and the last row (bottom bar).
373-func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
370+// TestComposeFrameAttentionFrameChangeDamagesOnlyBars pins the same invariant
371+// through the production composition pipeline: with a warm cache and no pane
372+// damage, changing only bars.attentionFrame damages only the chrome rows.
373+func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
374374 d, sess, _, _, releases := newManualTabSession(t, 2)
375375 defer releases[0]()
376376 defer releases[1]()
@@ -381,16 +381,39 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi
381381 sess.mu.Unlock()
382382
383383 tb := sess.tabs[0]
384- var cache barCache
384+ pane := tb.focusedPane()
385+ pane.screen.Write([]byte("ATTENTION-PANE-CONTENT"))
385386 bars := d.barStateFor(sess, "")
386- _, damage := composeClientFrameWithState(bars, tb, true, &cache)
387- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
388- tb.focusedPane().screen.ClearDamage()
389-
387+ area := domain.Rect{Width: pane.screen.Frame.Width, Height: pane.screen.Frame.Height}
388+ placement := layout.Placement{ID: pane.id, Content: area}
389+ state := capturedRenderState{
390+ reset: true,
391+ layout: capturedTabLayout{area: area, focus: pane.id, placements: []layout.Placement{placement}, valid: true},
392+ panes: []capturedPaneRenderState{{
393+ id: pane.id, frame: pane.screen.Frame.Clone(), placement: placement, focused: true,
394+ damage: []renderer.Damage{renderer.FullRedraw()},
395+ }},
396+ bars: bars,
397+ }
398+ out := composeFrame(state, composeCacheInput{})
399+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
400+ require.Len(t, state.panes, 1)
401+ require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT")
402+ firstContent := rowText(out.frame.Row(1))
403+
404+ // The pane was included in the warmed cache; it has no new damage when the
405+ // attention pulse advances, so the subsequent compose must retain it.
390406 bars.attentionFrame++
391- _, damage = composeClientFrameWithState(bars, tb, false, &cache)
407+ state.reset, state.bars = false, bars
408+ state.panes[0].damage = nil
409+ out = composeFrame(state, out.cache)
410+ damage := out.damage
411+ require.Len(t, state.panes, 1, "pane state must remain present when it has no damage")
412+ require.Empty(t, state.panes[0].damage)
413+ require.Equal(t, firstContent, rowText(out.frame.Row(1)), "unchanged pane content must remain in the composed frame")
414+ require.Contains(t, rowText(out.frame.Row(1)), "ATTENTION-PANE-CONTENT")
392415 require.NotEmpty(t, damage)
393- lastRow := tb.focusedPane().screen.Frame.Height + 1
416+ lastRow := pane.screen.Frame.Height + 1
394417 for _, dmg := range damage {
395418 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
396419 }
M internal/usecase/daemon/copymode_test.go +9 -1
@@ -151,7 +151,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
151151 mode := scopy.NewMode(snap)
152152
153153 bars := barState{status: sess.statusSegments(true)}
154- base, _ := composeClientFrameWithState(bars, tb, true)
154+ content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
155+ base := composeFrame(capturedRenderState{
156+ reset: true,
157+ layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
158+ panes: []capturedPaneRenderState{{
159+ id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
160+ }},
161+ bars: bars,
162+ }, composeCacheInput{}).frame
155163 frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
156164
157165 require.Equal(t, 80, frame.Width)
M internal/usecase/daemon/floating_render.go +25 -5
@@ -9,7 +9,7 @@ import (
99
1010 // floatingGeometry describes a popup's outer frame and terminal content area.
1111 // Bounds and Inner always share the same coordinate space. Committed pane
12-// geometry is origin-zero and tab-content-relative; composeFloatingFrame
12+// geometry is origin-zero and tab-content-relative; composeCapturedFloatingFrame
1313 // translates both fields and returns/caches frame-absolute geometry. Callers
1414 // must not translate a composed or cached value again.
1515 type floatingGeometry struct {
@@ -85,12 +85,32 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
8585 }
8686 }
8787
88-func composeCapturedFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, floating capturedFloatingRenderState, content domain.Rect, layoutSnap capturedTabLayout, theme themeui.Theme, cache composeCacheInput, full bool) (renderer.Frame, []renderer.Damage) {
88+type floatingComposeInput struct {
89+ baseFrame renderer.Frame
90+ baseDamage []renderer.Damage
91+ floating capturedFloatingRenderState
92+ content domain.Rect
93+ layout capturedTabLayout
94+ theme themeui.Theme
95+ cache composeCacheInput
96+ full bool
97+}
98+
99+func composeCapturedFloatingFrame(input floatingComposeInput) (renderer.Frame, []renderer.Damage) {
100+ base := input.baseFrame
101+ baseDamage := input.baseDamage
102+ floating := input.floating
103+ content := input.content
104+ layoutSnap := input.layout
105+ theme := input.theme
106+ cache := input.cache
107+ full := input.full
108+
89109 frame := base.Clone()
90- legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
91- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)
110+ layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
111+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme)
92112 geometry := floating.geometry.translate(content.X, content.Y)
93- blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, theme)
113+ blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))
94114 damage := append([]renderer.Damage(nil), baseDamage...)
95115 for _, d := range floating.pane.damage {
96116 damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
D internal/usecase/daemon/floating_render_legacy_test.go +0 -72
@@ -1,72 +0,0 @@
1-package daemon
2-
3-import (
4- "github.com/bnema/vev/internal/domain"
5- themeui "github.com/bnema/vev/internal/usecase/theme"
6- "github.com/bnema/vev/pkg/renderer"
7-)
8-
9-// composeFloatingFrame applies the popup to a copy of the normal composed
10-// destination. In particular it never writes the VT screen frame: that frame
11-// is shared with the PTY reader and is the source for future renders.
12-func composeFloatingFrame(base renderer.Frame, baseDamage []renderer.Damage, p *pane, generation uint64, content domain.Rect, desired floatingGeometry, layoutSnap tabLayoutSnapshot, theme themeui.Theme, cache *composedFrameCache, full bool) (renderer.Frame, []renderer.Damage, floatingGeometry) {
13- var frame renderer.Frame
14- if cache != nil && cache.floatingFrame.Width == base.Width && cache.floatingFrame.Height == base.Height {
15- frame = cache.floatingFrame
16- for y := 0; y < base.Height; y++ {
17- copy(frame.Row(y), base.Row(y))
18- }
19- } else {
20- frame = base.Clone()
21- if cache != nil {
22- cache.floatingFrame = frame
23- }
24- }
25- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnap, theme)
26- // The PTY reader mutates both the frame and its damage under p.mu. Keep
27- // every read of them, including the blit, in the same critical section.
28- // In particular, Frame is not safe to retain as an alias after unlocking.
29- p.mu.Lock()
30- // Committed popup geometry is content-relative; translate it exactly once
31- // while holding the same lock that protects the committed state and frame.
32- geometry := p.committedFloatingGeometryLocked(desired).translate(content.X, content.Y)
33- previousCapture := capturedPaneRenderState{}
34- if cache != nil {
35- previousCapture = cache.floatingCaptured
36- }
37- captured := capturePaneRenderStateLockedInto(p, geometry.Inner, previousCapture)
38- // This retained legacy test helper models its historical synchronous
39- // consume path; production commits receipts in emitFrame instead.
40- p.screen.AcknowledgeDamage(captured.damageGeneration)
41- if cache != nil {
42- cache.floatingCaptured = captured
43- }
44- p.mu.Unlock()
45- title := captured.title
46- titleGeneration := captured.titleGeneration
47- blitPaneFrame(frame, geometry.Inner, captured.frame, false, theme)
48- damage := append([]renderer.Damage(nil), baseDamage...)
49- for _, d := range captured.damage {
50- damage = append(damage, translatePaneDamage(d, geometry.Inner, content)...)
51- }
52-
53- // The committed geometry, rather than the requested config geometry, is
54- // what was actually rendered. Track it so movement (including a same-size
55- // resize) cannot reuse a cached popup at its former position.
56- popupChanged := cache == nil || cache.floating != p || cache.floatingGeneration != generation || cache.floatingGeometry != geometry
57- titleChanged := popupChanged || cache == nil || cache.floatingTitleGeneration != titleGeneration
58- drawFloatingBorder(frame, geometry.Bounds, title, newThemeStyles(theme).border)
59- if cache != nil {
60- cache.floating = p
61- cache.floatingGeneration = generation
62- cache.floatingGeometry = geometry
63- cache.floatingTitleGeneration = titleGeneration
64- }
65- if full || popupChanged {
66- return frame, []renderer.Damage{renderer.FullRedraw()}, geometry
67- }
68- if titleChanged && geometry.Bounds.Height >= 3 {
69- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: geometry.Bounds.X, Y: geometry.Bounds.Y, Width: geometry.Bounds.Width, Height: 1})
70- }
71- return frame, damage, geometry
72-}
M internal/usecase/daemon/floating_render_test.go +142 -63
@@ -128,35 +128,56 @@ func TestFloatingAxisGeometryEndpointsAndTinyBorders(t *testing.T) {
128128 }
129129 }
130130
131-func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
132- p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
133- p.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'F'})
134- p.title.processName, p.title.terminalTitle, p.title.generation = "nvim", "project/main.go", 1
131+func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {
135132 base := renderer.NewFrame(40, 12)
136133 base.Set(2, 2, renderer.Cell{Rune: 'B'})
137134 content := domain.Rect{Y: 1, Width: 40, Height: 10}
138- cache := &composedFrameCache{}
139- frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
135+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
136+ paneFrame := renderer.NewFrame(6, 3)
137+ paneFrame.Set(0, 0, renderer.Cell{Rune: 'F'})
138+ floating := capturedFloatingRenderState{
139+ visible: true,
140+ pane: capturedPaneRenderState{frame: paneFrame, title: "nvim: project/main.go", titleGeneration: 1},
141+ geometry: geometry,
142+ title: "nvim: project/main.go",
143+ generation: 1,
144+ titleGeneration: 1,
145+ }
146+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
147+ baseFrame: base,
148+ floating: floating,
149+ content: content,
150+ theme: themeui.Theme{},
151+ })
140152 require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination")
141- geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}).translate(content.X, content.Y)
142- require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune)
143- require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune)
153+ frameGeometry := geometry.translate(content.X, content.Y)
154+ require.Equal(t, '┌', frame.At(frameGeometry.Bounds.X, frameGeometry.Bounds.Y).Rune)
155+ require.Equal(t, 'F', frame.At(frameGeometry.Inner.X, frameGeometry.Inner.Y).Rune)
144156 var gotTitle strings.Builder
145- for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
146- gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
157+ for x := frameGeometry.Bounds.X + 2; x < frameGeometry.Bounds.X+frameGeometry.Bounds.Width-2; x++ {
158+ gotTitle.WriteRune(frame.At(x, frameGeometry.Bounds.Y).Rune)
147159 }
148160 require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─"))
149161
150- p.mu.Lock()
151- p.title.generation++
152- p.mu.Unlock()
153- _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80}), tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
162+ floating.titleGeneration++
163+ _, damage := composeCapturedFloatingFrame(floatingComposeInput{
164+ baseFrame: base,
165+ floating: floating,
166+ content: content,
167+ theme: themeui.Theme{},
168+ cache: composeCacheInput{
169+ valid: true,
170+ floatingGeneration: 1,
171+ floatingGeometry: frameGeometry,
172+ floatingTitleGeneration: 1,
173+ },
174+ })
154175 require.Len(t, damage, 1)
155- require.Equal(t, geometry.Bounds.Y, damage[0].Y)
176+ require.Equal(t, frameGeometry.Bounds.Y, damage[0].Y)
156177 require.Equal(t, 1, damage[0].Height)
157178 }
158179
159-func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
180+func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
160181 initial := floatingGeometry{
161182 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},
162183 Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3},
@@ -176,20 +197,27 @@ func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
176197 }
177198 for _, tt := range tests {
178199 t.Run(tt.name, func(t *testing.T) {
179- p := newPane("floating", nil, domain.Size{Cols: 6, Rows: 3})
180- p.rect, p.popupGeometry = initial.Inner, initial
181- cache := &composedFrameCache{}
182200 base := renderer.NewFrame(30, 12)
183201 content := domain.Rect{Y: 1, Width: 30, Height: 10}
184- _, damage, _ := composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
202+ pane := capturedPaneRenderState{frame: renderer.NewFrame(6, 3), titleGeneration: 1}
203+ initialInput := floatingComposeInput{
204+ baseFrame: base,
205+ floating: capturedFloatingRenderState{visible: true, pane: pane, geometry: initial, generation: 1, titleGeneration: 1},
206+ content: content,
207+ theme: themeui.Theme{},
208+ }
209+ _, damage := composeCapturedFloatingFrame(initialInput)
185210 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
186211
187- p.mu.Lock()
188- p.rect, p.popupGeometry = tt.next.Inner, tt.next
189- p.mu.Unlock()
190- _, damage, _ = composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
212+ initialInput.floating.geometry = tt.next
213+ initialInput.cache = composeCacheInput{
214+ valid: true,
215+ floatingGeneration: 1,
216+ floatingGeometry: initial.translate(content.X, content.Y),
217+ floatingTitleGeneration: 1,
218+ }
219+ _, damage = composeCapturedFloatingFrame(initialInput)
191220 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
192- require.Equal(t, tt.next.translate(content.X, content.Y), cache.floatingGeometry)
193221 })
194222 }
195223 }
@@ -239,8 +267,17 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
239267 base := renderer.NewFrame(40, 12)
240268 content := domain.Rect{Y: 1, Width: 40, Height: 10}
241269 cfg := domain.FloatingConfig{Width: 80, Height: 80}
242- frame, _, _ := composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, false)
243- geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg).translate(content.X, content.Y)
270+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
271+ captured := capturedPaneRenderState{frame: renderer.NewFrame(geometry.Inner.Width, geometry.Inner.Height), title: "btop", titleGeneration: 1}
272+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
273+ baseFrame: base,
274+ floating: capturedFloatingRenderState{
275+ visible: true, pane: captured, geometry: geometry, title: captured.title, generation: 1, titleGeneration: captured.titleGeneration,
276+ },
277+ content: content,
278+ theme: themeui.Theme{},
279+ })
280+ geometry = geometry.translate(content.X, content.Y)
244281 var gotTitle strings.Builder
245282 for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
246283 gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
@@ -248,10 +285,14 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
248285 require.Equal(t, "btop", strings.TrimRight(gotTitle.String(), "─"))
249286 }
250287
251-func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
288+func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
252289 p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
253- base := renderer.NewFrame(80, 24)
254- content := domain.Rect{Width: 80, Height: 24}
290+ tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
291+ installTestFloating(tb, p, true)
292+ ac := &attachedClient{}
293+ ac.initOverlays()
294+ sess := &session{tabs: []*tab{tb}, client: ac}
295+ base := barState{}
255296 cfg := domain.FloatingConfig{Width: 100, Height: 100}
256297
257298 start := make(chan struct{})
@@ -261,7 +302,6 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
261302 defer wg.Done()
262303 <-start
263304 for range 500 {
264- // This is the same pane lock used by ptyReader around Screen.Write.
265305 p.mu.Lock()
266306 p.screen.Write([]byte("\x1b[Hreader"))
267307 p.mu.Unlock()
@@ -270,9 +310,14 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
270310 go func() {
271311 defer wg.Done()
272312 <-start
273- cache := &composedFrameCache{}
313+ cache := composeCacheInput{}
274314 for range 500 {
275- composeFloatingFrame(base, nil, p, 1, content, calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
315+ ac.sendMu.Lock()
316+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: base, floatingCfg: cfg})
317+ ac.sendMu.Unlock()
318+ require.True(t, ok)
319+ out := composeFrame(*state, cache)
320+ cache = out.cache
276321 }
277322 }()
278323 close(start)
@@ -287,39 +332,59 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
287332 }
288333 }
289334
290-func BenchmarkComposeFloatingFrameCached(b *testing.B) {
291- p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
292- p.title.displayFallback = "float"
335+func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
293336 base := renderer.NewFrame(80, 24)
294337 content := domain.Rect{Y: 1, Width: 80, Height: 22}
295- cfg := domain.FloatingConfig{Width: 80, Height: 80}
296- desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
297- cache := &composedFrameCache{}
298- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
299- p.screen.ClearDamage()
300-
301- b.ReportAllocs()
302- b.ResetTimer()
303- for b.Loop() {
304- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
338+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
339+ input := floatingComposeInput{
340+ baseFrame: base,
341+ floating: capturedFloatingRenderState{
342+ visible: true,
343+ pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
344+ geometry: geometry, title: "float", generation: 1, titleGeneration: 1,
345+ },
346+ content: content,
347+ cache: composeCacheInput{
348+ valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1,
349+ },
305350 }
351+
352+ allocs := testing.AllocsPerRun(100, func() { composeCapturedFloatingFrame(input) })
353+ // The production entry point deliberately clones Frame.Cells and its row
354+ // offsets to keep the cached base immutable; those are its two unavoidable
355+ // allocations. A higher count would reintroduce avoidable cache churn.
356+ require.LessOrEqual(t, allocs, float64(2))
306357 }
307358
308-func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) {
309- p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
310- p.title.displayFallback = "float"
359+func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
311360 base := renderer.NewFrame(80, 24)
312361 content := domain.Rect{Y: 1, Width: 80, Height: 22}
313- cfg := domain.FloatingConfig{Width: 80, Height: 80}
314- desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
315- cache := &composedFrameCache{}
316- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
317- p.screen.ClearDamage()
362+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
363+ input := floatingComposeInput{
364+ baseFrame: base,
365+ floating: capturedFloatingRenderState{
366+ visible: true,
367+ pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
368+ geometry: geometry,
369+ title: "float",
370+ generation: 1,
371+ titleGeneration: 1,
372+ },
373+ content: content,
374+ theme: themeui.Theme{},
375+ cache: composeCacheInput{
376+ valid: true,
377+ floatingGeneration: 1,
378+ floatingGeometry: geometry.translate(content.X, content.Y),
379+ floatingTitleGeneration: 1,
380+ },
381+ }
318382
319- allocs := testing.AllocsPerRun(100, func() {
320- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)
321- })
322- require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes")
383+ b.ReportAllocs()
384+ b.ResetTimer()
385+ for b.Loop() {
386+ composeCapturedFloatingFrame(input)
387+ }
323388 }
324389
325390 func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
@@ -368,7 +433,7 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
368433
369434 func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
370435 cfg := domain.FloatingConfig{Width: 50, Height: 50}
371- oldContent := domain.Rect{Y: 1, Width: 80, Height: 24}
436+ oldContent := domain.Rect{Width: 80, Height: 24}
372437 newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
373438 oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg)
374439 newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg)
@@ -384,14 +449,28 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
384449 require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
385450 tb.mu.Lock()
386451 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
387- copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
388452 tb.mu.Unlock()
389453 require.True(t, visible)
390454 require.Equal(t, oldGeometry, inputGeometry)
391- require.Equal(t, oldGeometry.translate(newContent.X, newContent.Y).Inner, copyRect)
392455
456+ p.mu.Lock()
457+ captured := capturePaneRenderStateLocked(p, oldGeometry.Inner)
458+ p.mu.Unlock()
393459 base := renderer.NewFrame(newContent.Width, newContent.Height+2)
394- frame, _, _ := composeFloatingFrame(base, nil, p, 1, newContent, calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg), tabLayoutSnapshot{}, themeui.Theme{}, &composedFrameCache{}, true)
460+ frame, _ := composeCapturedFloatingFrame(floatingComposeInput{
461+ baseFrame: base,
462+ floating: capturedFloatingRenderState{
463+ visible: true,
464+ pane: captured,
465+ geometry: oldGeometry,
466+ title: captured.title,
467+ generation: 1,
468+ titleGeneration: captured.titleGeneration,
469+ },
470+ content: newContent,
471+ theme: themeui.Theme{},
472+ full: true,
473+ })
395474 oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y)
396475 newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y)
397476 require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune)
M internal/usecase/daemon/overlay_backdrop.go +2 -1
@@ -36,6 +36,7 @@ func (b overlayBackdrop) apply(frame renderer.Frame, contentArea domain.Rect, la
3636 }
3737
3838 func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
39+ dimmer := themeui.NewDimmer(theme)
3940 x0 := max(rect.X, 0)
4041 y0 := max(rect.Y, 0)
4142 x1 := min(rect.X+rect.Width, frame.Width)
@@ -43,7 +44,7 @@ func dimFrameRect(frame renderer.Frame, rect domain.Rect, theme themeui.Theme) {
4344 for y := y0; y < y1; y++ {
4445 for x := x0; x < x1; x++ {
4546 cell := frame.At(x, y)
46- cell.Style = themeui.DimStyle(cell.Style, theme)
47+ cell.Style = dimmer.Dim(cell.Style)
4748 frame.Set(x, y, cell)
4849 }
4950 }
M internal/usecase/daemon/overlay_backdrop_test.go +1 -1
@@ -71,7 +71,7 @@ func TestOverlayBackdropClipsPlacementsToFrame(t *testing.T) {
7171 (overlayBackdrop{DimPaneContents: true}).apply(frame, domain.Rect{Width: 4, Height: 4}, snap, backdropTheme())
7272
7373 for _, point := range tt.affected {
74- require.Equal(t, themeui.DimStyle(original, backdropTheme()), frame.At(point[0], point[1]).Style)
74+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(original), frame.At(point[0], point[1]).Style)
7575 }
7676 for _, point := range tt.unaffected {
7777 require.Equal(t, original, frame.At(point[0], point[1]).Style)
M internal/usecase/daemon/pane_actions_test.go +10 -1
@@ -16,6 +16,7 @@ import (
1616 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1717 scopy "github.com/bnema/vev/internal/usecase/copy"
1818 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/pkg/renderer"
1920 )
2021
2122 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
@@ -309,7 +310,15 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
309310 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
310311 require.Equal(t, []byte("Z"), <-writes)
311312 tb.focusedPane().screen.Write([]byte("survivor"))
312- frame, _ := composeClientFrame(sess, tb, false, "")
313+ content := domain.Rect{Width: tb.size.Cols, Height: tb.size.Rows}
314+ frame := composeFrame(capturedRenderState{
315+ reset: true,
316+ layout: capturedTabLayout{area: content, focus: tb.focusedPane().id, valid: true},
317+ panes: []capturedPaneRenderState{{
318+ id: tb.focusedPane().id, frame: tb.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: tb.focusedPane().id, Content: content}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
319+ }},
320+ bars: barState{status: sess.statusSegments(true)},
321+ }, composeCacheInput{}).frame
313322 require.Contains(t, frameRowString(frame, 1), "survivor")
314323 }
315324
M internal/usecase/daemon/render.go +10 -3
@@ -325,7 +325,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
325325 copyFeedback: overlays.copyFeedback,
326326 }
327327 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)
328- state, ok := capturePrimaryRenderState(sess, ac, bars, capturedOverlays, preview, floatingCfg, reset, lease)
328+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
329+ bars: bars,
330+ overlays: capturedOverlays,
331+ preview: preview,
332+ floatingCfg: floatingCfg,
333+ reset: reset,
334+ lease: lease,
335+ })
329336 endCapture(0, ok)
330337 if !ok {
331338 ac.sendMu.Unlock()
@@ -423,14 +430,14 @@ func offsetPlacement(pl layout.Placement, dx, dy int) layout.Placement {
423430 return pl
424431 }
425432
426-func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, theme themeui.Theme) {
433+func blitPaneFrame(dst renderer.Frame, r domain.Rect, src renderer.Frame, dim bool, dimmer themeui.Dimmer) {
427434 rows := min(r.Height, src.Height)
428435 cols := min(r.Width, src.Width)
429436 for y := range rows {
430437 for x := range cols {
431438 cell := src.At(x, y)
432439 if dim {
433- cell.Style = themeui.DimStyle(cell.Style, theme)
440+ cell.Style = dimmer.Dim(cell.Style)
434441 }
435442 dst.Set(r.X+x, r.Y+y, cell)
436443 }
M internal/usecase/daemon/render_capture.go +20 -1
@@ -166,11 +166,30 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
166166 return false
167167 }
168168
169+type primaryCaptureRequest struct {
170+ bars barState
171+ overlays capturedOverlayRenderState
172+ preview picker.Preview
173+ floatingCfg domain.FloatingConfig
174+ reset bool
175+ lease *attachmentLease
176+}
177+
169178 // capturePrimaryRenderState is the ownership boundary for a primary render
170179 // transaction. Callers hold attachment sendMu; this function then follows
171180 // session -> tab -> pane lock order. ACK-blocked capture returns before touching
172181 // VT damage, and every captured pane records a receipt for successful emission.
173-func capturePrimaryRenderState(sess *session, ac *attachedClient, bars barState, overlays capturedOverlayRenderState, preview picker.Preview, floatingCfg domain.FloatingConfig, reset bool, lease *attachmentLease) (*capturedRenderState, bool) {
182+func capturePrimaryRenderState(
183+ sess *session,
184+ ac *attachedClient,
185+ request primaryCaptureRequest,
186+) (*capturedRenderState, bool) {
187+ bars := request.bars
188+ overlays := request.overlays
189+ preview := request.preview
190+ floatingCfg := request.floatingCfg
191+ reset := request.reset
192+ lease := request.lease
174193 if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
175194 return nil, false
176195 }
M internal/usecase/daemon/render_damage_receipt_test.go +47 -2
@@ -5,6 +5,7 @@ import (
55
66 "github.com/bnema/vev/internal/domain"
77 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/layout"
89 "github.com/bnema/vev/internal/usecase/picker"
910 "github.com/bnema/vev/pkg/renderer"
1011 "github.com/stretchr/testify/require"
@@ -26,7 +27,14 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
2627 p.mu.Unlock()
2728
2829 ac.sendMu.Lock()
29- state, ok := capturePrimaryRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, nil)
30+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
31+ bars: barState{},
32+ overlays: capturedOverlayRenderState{},
33+ preview: pickerPreviewEmpty(),
34+ floatingCfg: domain.FloatingConfig{},
35+ reset: false,
36+ lease: nil,
37+ })
3038 ac.sendMu.Unlock()
3139 require.True(t, ok)
3240 require.Len(t, state.receipts, 1)
@@ -38,6 +46,36 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
3846
3947 // These are deliberately end-to-end pipeline tests: the pending damage comes
4048 // from a real pane VT Write, not a hand-built FullRedraw.
49+func TestCaptureComposeEmitAcknowledgesCollapsedPaneDamageOnlyAfterEmission(t *testing.T) {
50+ d, sess, ac, sends := newManualSessionWithPTYs(t, nil)
51+ tb := sess.tabs[0]
52+ visible := tb.focusedPane()
53+ collapsed := newPane("collapsed", nil, domain.Size{Cols: 80, Rows: 23})
54+ tb.panes[collapsed.id] = collapsed
55+ tb.tree = &layout.Tree{
56+ Root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(visible.id), layout.NewLeaf(collapsed.id)}, Expanded: visible.id},
57+ Focus: visible.id,
58+ }
59+ visible.screen.ClearDamage()
60+ collapsed.screen.ClearDamage()
61+ collapsed.screen.Write([]byte("hidden damage"))
62+
63+ ac.sendMu.Lock()
64+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{bars: barState{}})
65+ require.True(t, ok)
66+ collapsedReceipt := false
67+ for _, receipt := range state.receipts {
68+ collapsedReceipt = collapsedReceipt || receipt.pane == collapsed
69+ }
70+ require.True(t, collapsedReceipt, "capture must retain a receipt for the collapsed pane")
71+ require.NotEmpty(t, collapsed.screen.Damage(), "capture alone must not acknowledge collapsed-pane damage")
72+ composed := composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
73+ require.True(t, d.emitFrame(sess, ac, state, composed))
74+
75+ <-sends
76+ require.Empty(t, collapsed.screen.Damage(), "successful emission must acknowledge the collapsed-pane receipt")
77+}
78+
4179 func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
4280 for _, tt := range []struct {
4381 name string
@@ -120,7 +158,14 @@ func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
120158 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
121159 t.Helper()
122160 ac.sendMu.Lock() // emitFrame releases the transaction lock.
123- state, ok := capturePrimaryRenderState(sess, ac, barState{}, capturedOverlayRenderState{}, pickerPreviewEmpty(), domain.FloatingConfig{}, false, nil)
161+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
162+ bars: barState{},
163+ overlays: capturedOverlayRenderState{},
164+ preview: pickerPreviewEmpty(),
165+ floatingCfg: domain.FloatingConfig{},
166+ reset: false,
167+ lease: nil,
168+ })
124169 require.True(t, ok)
125170 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
126171 }
D internal/usecase/daemon/render_legacy_test.go +0 -279
@@ -1,279 +0,0 @@
1-package daemon
2-
3-import (
4- "github.com/bnema/vev/internal/domain"
5- "github.com/bnema/vev/internal/usecase/layout"
6- themeui "github.com/bnema/vev/internal/usecase/theme"
7- "github.com/bnema/vev/internal/usecase/ui"
8- "github.com/bnema/vev/pkg/renderer"
9-)
10-
11-// Legacy composition adapters are test-only. Production rendering is owned by
12-// capturePrimaryRenderState -> composeFrame -> emitFrame.
13-// copyTargetRectLocked maps a captured copy source into the already-composed
14-// client frame. The caller holds tb.mu, preserving the layout/floating
15-// snapshot while the rectangle is chosen.
16-func copyTargetRectLocked(layoutSnap tabLayoutSnapshot, contentArea domain.Rect, p, floating *pane, hasFloating bool, floatingFrameGeometry floatingGeometry) domain.Rect {
17- if p == nil {
18- return domain.Rect{}
19- }
20- if hasFloating && p == floating {
21- return floatingFrameGeometry.Inner
22- }
23- for _, placement := range layoutSnap.placements {
24- if placement.ID == p.id && !placement.Collapsed {
25- r := placement.Content
26- r.X += contentArea.X
27- r.Y += contentArea.Y
28- return r
29- }
30- }
31- p.mu.Lock()
32- width, height := p.screen.Frame.Width, p.screen.Frame.Height
33- p.mu.Unlock()
34- return domain.Rect{X: contentArea.X, Y: contentArea.Y, Width: min(width, contentArea.Width), Height: min(height, contentArea.Height)}
35-}
36-
37-type composedFrameCache struct {
38- frame renderer.Frame
39- valid bool
40- layoutSnap tabLayoutSnapshot
41- titleGenerations map[layout.PaneID]uint64
42- floating *pane
43- floatingFrame renderer.Frame
44- floatingGeneration uint64
45- floatingGeometry floatingGeometry
46- floatingTitleGeneration uint64
47- floatingCaptured capturedPaneRenderState
48-}
49-
50-func composeClientFrame(sess *session, tb *tab, full bool, rightStatus string, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
51- return composeClientFrameWithState(barState{status: sess.statusSegments(true), copyFeedback: rightStatus}, tb, full, caches...)
52-}
53-
54-func composeClientFrameWithState(bars barState, tb *tab, full bool, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
55- return composeClientFrameWithLayout(bars, tb, full, solveTabLayoutLocked(tb), caches...)
56-}
57-
58-func composeClientFrameWithLayout(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, caches ...*barCache) (renderer.Frame, []renderer.Damage) {
59- var cache *barCache
60- if len(caches) > 0 {
61- cache = caches[0]
62- }
63- return composeClientFrameWithLayoutCached(bars, tb, full, layoutSnap, cache, nil)
64-}
65-
66-func composeClientFrameWithLayoutCached(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
67- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, false)
68-}
69-
70-func composeClientFrameWithLayoutCachedConsumeDamage(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache) (renderer.Frame, []renderer.Damage) {
71- return composeClientFrameWithLayoutCachedOptions(bars, tb, full, layoutSnap, cache, composed, true)
72-}
73-
74-func composeClientFrameWithLayoutCachedOptions(bars barState, tb *tab, full bool, layoutSnap tabLayoutSnapshot, cache *barCache, composed *composedFrameCache, consumeDamage bool) (renderer.Frame, []renderer.Damage) {
75- p := tb.focusedPane()
76- styles := newThemeStyles(bars.theme)
77- if p == nil {
78- width, screenRows := tb.size.Cols, tb.size.Rows
79- if width <= 0 || screenRows <= 0 {
80- return renderer.NewFrame(0, 0), nil
81- }
82- return renderer.NewFrame(width, screenRows+2), nil
83- }
84- p.mu.Lock()
85- width, screenRows := p.screen.Frame.Width, p.screen.Frame.Height
86- p.mu.Unlock()
87- if tb.size.Valid() {
88- width, screenRows = tb.size.Cols, tb.size.Rows
89- }
90- cacheValid := composed != nil && composed.valid && composed.frame.Width == width && composed.frame.Height == screenRows+2
91- layoutSame := composed == nil || (cacheValid && sameTabLayoutSnapshot(composed.layoutSnap, layoutSnap))
92- var frame renderer.Frame
93- if cacheValid {
94- frame = composed.frame
95- } else {
96- frame = renderer.NewFrame(width, screenRows+2)
97- if composed != nil {
98- full = true
99- }
100- }
101- if !layoutSame {
102- full = true
103- }
104- contentArea := domain.Rect{Y: 1, Width: width, Height: screenRows}
105- if cacheValid && !layoutSame {
106- clearFrameRect(frame, contentArea)
107- }
108- topBar := frame.Row(0)
109- drawTopBarSnapshot(topBar, bars.status, bars.attentionFrame, bars.topRight, styles)
110- var titleGenerations map[layout.PaneID]uint64
111- if composed != nil {
112- if composed.titleGenerations == nil {
113- composed.titleGenerations = make(map[layout.PaneID]uint64)
114- }
115- titleGenerations = composed.titleGenerations
116- }
117- contentDamage := composeTabFrameIntoWithLayoutOptions(tb, frame, contentArea, bars.theme, layoutSnap, cacheValid && layoutSame, consumeDamage, titleGenerations)
118- bottomBar := frame.Row(screenRows + 1)
119- drawStatusBarState(bottomBar, bars, styles)
120- if composed != nil {
121- composed.frame = frame
122- composed.valid = true
123- composed.layoutSnap = layoutSnap
124- }
125- if full {
126- if cache != nil {
127- cache.capture(topBar, bottomBar)
128- }
129- return frame, []renderer.Damage{renderer.FullRedraw()}
130- }
131- damage := translateDamage(contentDamage, 0, 1)
132- if cache == nil || !sameCells(cache.top, topBar) {
133- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: width, Height: 1})
134- }
135- if cache == nil || !sameCells(cache.bottom, bottomBar) {
136- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: screenRows + 1, Width: width, Height: 1})
137- }
138- if cache != nil {
139- cache.capture(topBar, bottomBar)
140- }
141- return frame, damage
142-}
143-
144-func composeTabFrame(tb *tab, area domain.Rect, theme themeui.Theme) (renderer.Frame, []renderer.Damage) {
145- return composeTabFrameWithLayout(tb, area, theme, tabLayoutSnapshot{})
146-}
147-
148-func composeTabFrameWithLayout(tb *tab, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot) (renderer.Frame, []renderer.Damage) {
149- frame := renderer.NewFrame(area.Width, area.Height)
150- damage := composeTabFrameIntoWithLayout(tb, frame, area, theme, layoutSnap, false)
151- return frame, damage
152-}
153-
154-func composeTabFrameIntoWithLayout(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool) []renderer.Damage {
155- return composeTabFrameIntoWithLayoutOptions(tb, frame, area, theme, layoutSnap, cacheValid, false, nil)
156-}
157-
158-func composeTabFrameIntoWithLayoutOptions(tb *tab, frame renderer.Frame, area domain.Rect, theme themeui.Theme, layoutSnap tabLayoutSnapshot, cacheValid bool, consumeDamage bool, titleGenerations map[layout.PaneID]uint64) []renderer.Damage {
159- contentArea := domain.Rect{Width: area.Width, Height: area.Height}
160- root := tb.tree.Root
161- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
162- if !ok {
163- placements, ok = layout.Solve(root, contentArea)
164- }
165- var fallback *pane
166- if !ok {
167- fallback = tb.focusedPane()
168- if fallback == nil {
169- return nil
170- }
171- placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
172- }
173- // A valid cache has the same layout, so its title IDs remain valid. Reset
174- // the existing cache only when rebuilding after layout or frame churn.
175- if titleGenerations != nil && !cacheValid {
176- clear(titleGenerations)
177- }
178- if ok && !cacheValid {
179- drawDividers(frame, root, area, themeui.DimStyle(newThemeStyles(theme).border, theme))
180- }
181- var damage []renderer.Damage
182- for _, pl := range placements {
183- p := tb.panes[pl.ID]
184- if p == nil && fallback != nil && pl.ID == fallback.id {
185- p = fallback
186- }
187- if p == nil {
188- continue
189- }
190- focused := tb.tree.Focus == pl.ID
191- pl = offsetPlacement(pl, area.X, area.Y)
192- if pl.TitleBar.Height > 0 {
193- generation := drawPaneTitleBar(frame, pl, p, focused, theme)
194- if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
195- titleDamage := pl.TitleBar
196- titleDamage.X -= area.X
197- titleDamage.Y -= area.Y
198- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
199- }
200- if titleGenerations != nil {
201- titleGenerations[pl.ID] = generation
202- }
203- }
204- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
205- if consumeDamage {
206- p.mu.Lock()
207- captured := capturePaneRenderStateLocked(p, domain.Rect{})
208- p.screen.AcknowledgeDamage(captured.damageGeneration)
209- p.mu.Unlock()
210- }
211- continue
212- }
213- var paneFrame renderer.Frame
214- var paneDamage []renderer.Damage
215- if consumeDamage {
216- p.mu.Lock()
217- captured := capturePaneRenderStateLocked(p, pl.Content)
218- p.screen.AcknowledgeDamage(captured.damageGeneration)
219- p.mu.Unlock()
220- paneFrame, paneDamage = captured.frame, captured.damage
221- } else {
222- p.mu.Lock()
223- paneDamage = p.screen.Damage()
224- if !cacheValid || len(paneDamage) > 0 {
225- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, theme)
226- }
227- for _, d := range paneDamage {
228- localContent := pl.Content
229- localContent.X -= area.X
230- localContent.Y -= area.Y
231- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
232- }
233- p.mu.Unlock()
234- continue
235- }
236- if !cacheValid || len(paneDamage) > 0 {
237- blitPaneFrame(frame, pl.Content, paneFrame, !focused, theme)
238- }
239- for _, d := range paneDamage {
240- localContent := pl.Content
241- localContent.X -= area.X
242- localContent.Y -= area.Y
243- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
244- }
245- }
246- return damage
247-}
248-
249-func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
250- return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
251-}
252-
253-func clearFrameRect(frame renderer.Frame, r domain.Rect) {
254- blank := renderer.BlankCell()
255- for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
256- for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
257- frame.Set(x, y, blank)
258- }
259- }
260-}
261-
262-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, theme themeui.Theme) uint64 {
263- styles := newThemeStyles(theme)
264- style := styles.border
265- if focused {
266- style = styles.statusBar
267- } else {
268- style = themeui.DimStyle(style, theme)
269- }
270- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
271- frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
272- }
273- p.mu.Lock()
274- title := p.displayTitleLocked()
275- generation := p.title.generation
276- p.mu.Unlock()
277- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
278- return generation
279-}
M internal/usecase/daemon/render_pipeline.go +22 -9
@@ -52,6 +52,8 @@ type composedRenderFrame struct {
5252 // composeFrame is pure with respect to daemon ownership: it consumes only the
5353 // capture, the last committed cache, and an independent attachment-owned
5454 // scratch cache. It returns a replacement cache without mutating committed.
55+const inactivePaneForegroundDimming = 55
56+
5557 func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...composeCacheInput) composedRenderFrame {
5658 scratch := composeCacheInput{}
5759 if len(scratchIn) > 0 {
@@ -74,11 +76,13 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
7476 }
7577 }
7678 styles := newThemeStyles(state.theme)
79+ defaultDimmer := themeui.NewDimmer(state.theme)
80+ inactivePaneDimmer := themeui.NewDimmer(state.theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
7781 drawTopBarSnapshot(frame.Row(0), state.bars.status, state.bars.attentionFrame, state.bars.topRight, styles)
7882 drawStatusBarState(frame.Row(rows+1), state.bars, styles)
7983 content := domain.Rect{Y: 1, Width: width, Height: rows}
8084 if state.layout.valid && state.layout.root != nil {
81- drawDividers(frame, state.layout.root, content, themeui.DimStyle(styles.border, state.theme))
85+ drawDividers(frame, state.layout.root, content, defaultDimmer.Dim(styles.border))
8286 }
8387
8488 full := state.reset || !in.valid || in.frame.Width != width || in.frame.Height != rows+2 || in.layoutFingerprint != state.layout.fingerprint || in.theme != state.theme || in.floatingVisible != state.floating.visible
@@ -86,18 +90,19 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
8690 if titles == nil {
8791 titles = make(map[layout.PaneID]uint64, len(state.panes))
8892 }
93+ clear(titles)
8994 damage := scratch.damage[:0]
9095 for _, pane := range state.panes {
9196 pl := offsetPlacement(pane.placement, 0, 1)
9297 if pl.TitleBar.Height > 0 {
93- drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, state.theme)
98+ drawCapturedPaneTitleBar(frame, pl, pane.title, pane.focused, styles, defaultDimmer)
9499 if !full && in.titleGenerations[pane.id] != pane.titleGeneration {
95100 damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: pl.TitleBar.X, Y: pl.TitleBar.Y, Width: pl.TitleBar.Width, Height: pl.TitleBar.Height})
96101 }
97102 titles[pane.id] = pane.titleGeneration
98103 }
99104 if !pl.Collapsed && pl.Content.Width > 0 && pl.Content.Height > 0 && (full || len(pane.damage) > 0) {
100- blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, state.theme)
105+ blitPaneFrame(frame, pl.Content, pane.frame, !pane.focused, inactivePaneDimmer)
101106 }
102107 for _, d := range pane.damage {
103108 if d.Kind != renderer.DamageFullRedraw {
@@ -111,7 +116,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
111116 baseFrame := frame
112117 if state.floating.visible {
113118 var floatingDamage []renderer.Damage
114- frame, floatingDamage = composeCapturedFloatingFrame(baseFrame, damage, state.floating, content, state.layout, state.theme, in, full || state.overlays.active())
119+ frame, floatingDamage = composeCapturedFloatingFrame(floatingComposeInput{
120+ baseFrame: baseFrame,
121+ baseDamage: damage,
122+ floating: state.floating,
123+ content: content,
124+ layout: state.layout,
125+ theme: state.theme,
126+ cache: in,
127+ full: full || state.overlays.active(),
128+ })
115129 damage = floatingDamage
116130 }
117131 if !full {
@@ -141,13 +155,12 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
141155 return composedRenderFrame{frame: frame, damage: damage, cursor: cursor, cache: outCache, reset: state.reset || state.overlays.active()}
142156 }
143157
144-func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, theme themeui.Theme) {
145- styles := newThemeStyles(theme)
158+func drawCapturedPaneTitleBar(frame renderer.Frame, pl layout.Placement, title string, focused bool, styles themeStyles, dimmer themeui.Dimmer) {
146159 style := styles.border
147160 if focused {
148161 style = styles.statusBar
149162 } else {
150- style = themeui.DimStyle(style, theme)
163+ style = dimmer.Dim(style)
151164 }
152165 for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
153166 frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
@@ -225,9 +238,9 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
225238 }
226239 frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)
227240 }
228- legacy := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
241+ layoutSnapshot := tabLayoutSnapshot{placements: state.layout.placements, area: state.layout.area, focus: state.layout.focus, ok: state.layout.valid}
229242 if o.paletteActive && !state.floating.visible {
230- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacy, state.theme)
243+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)
231244 }
232245 for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {
233246 if modal.inner.Width == 0 && modal.inner.Height == 0 {
M internal/usecase/daemon/render_pipeline_cache_test.go +165 -3
@@ -3,11 +3,13 @@ package daemon
33 import (
44 "errors"
55 "io"
6+ "maps"
67 "testing"
78
89 "github.com/bnema/vev/internal/domain"
910 "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/usecase/layout"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
1113 "github.com/bnema/vev/pkg/renderer"
1214 "github.com/stretchr/testify/require"
1315 )
@@ -86,6 +88,168 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
8688 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)
8789 }
8890
91+func TestComposeFrameCacheSkipsUndamagedBlitsAndInvalidatesFocusAndLayout(t *testing.T) {
92+ theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
93+ initial := cachedSplitState("horizontal-left", "left", layout.Horizontal, theme)
94+ committed := composeFrame(initial, composeCacheInput{})
95+ require.Equal(t, '│', committed.frame.At(20, 1).Rune)
96+ focusedStyle := committed.frame.At(0, 1).Style
97+ dimmedStyle := committed.frame.At(21, 1).Style
98+ require.Equal(t, renderer.RGB{R: 96, G: 96, B: 96}, dimmedStyle.ForegroundRGB, "inactive panes should use stronger foreground dimming")
99+
100+ undamaged := initial
101+ undamaged.reset = false
102+ undamaged.panes[0].frame.Set(0, 0, renderer.Cell{Rune: 'Z', Style: renderer.DefaultStyle()})
103+ undamaged.panes[0].damage = nil
104+ undamaged.panes[1].frame.Set(0, 0, renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()})
105+ undamaged.panes[1].damage = []renderer.Damage{{Kind: renderer.DamageText, X: 21, Width: 1, Height: 1}}
106+ out := composeFrame(undamaged, committed.cache, composeCacheInput{})
107+ require.Equal(t, 'L', out.frame.At(0, 1).Rune, "an undamaged pane must retain the committed blit")
108+ require.Equal(t, 'x', out.frame.At(21, 1).Rune)
109+ require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 21, Y: 1, Width: 1, Height: 1})
110+
111+ focusChanged := cachedSplitState("horizontal-right", "right", layout.Horizontal, theme)
112+ focusChanged.reset = false
113+ focusChanged.panes[0].damage, focusChanged.panes[1].damage = nil, nil
114+ out = composeFrame(focusChanged, out.cache, committed.cache)
115+ require.Equal(t, dimmedStyle, out.frame.At(0, 1).Style, "the old focus must be re-blitted dimmed")
116+ require.Equal(t, focusedStyle, out.frame.At(21, 1).Style, "the new focus must be re-blitted undimmed")
117+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
118+
119+ vertical := cachedSplitState("vertical-right", "right", layout.Vertical, theme)
120+ vertical.reset = false
121+ vertical.panes[0].damage, vertical.panes[1].damage = nil, nil
122+ out = composeFrame(vertical, out.cache, committed.cache)
123+ require.NotEqual(t, '│', out.frame.At(20, 1).Rune, "layout invalidation must clear the old vertical divider")
124+ require.Equal(t, '─', out.frame.At(20, 3).Rune)
125+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, out.damage)
126+}
127+
128+func TestComposeFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
129+ state := cachedStackTitleState("collapsed", 1, true)
130+ state.theme = themeui.Theme{
131+ Known: true,
132+ TrueColor: true,
133+ HasFG: true,
134+ HasBG: true,
135+ Foreground: renderer.RGB{R: 220, G: 210, B: 200},
136+ Background: renderer.RGB{R: 20, G: 30, B: 40},
137+ }
138+ before := cloneCapturedStackState(state)
139+
140+ out := composeFrame(state, composeCacheInput{})
141+
142+ collapsedTitle := out.frame.At(0, 1)
143+ require.Equal(t, 'c', collapsedTitle.Rune)
144+ require.True(t, collapsedTitle.Style.HasForegroundRGB, "collapsed title bar must use dimmed chrome")
145+ focusedTitle := out.frame.At(0, 2)
146+ require.Equal(t, 's', focusedTitle.Rune)
147+ require.True(t, focusedTitle.Style.Inverse || focusedTitle.Style.HasBackgroundRGB, "focused title bar must use accent chrome")
148+ require.Equal(t, 'E', out.frame.At(0, 3).Rune, "expanded pane content must be visible below its title bar")
149+ require.Equal(t, before, state, "composition must not mutate the captured source frame")
150+}
151+
152+func TestComposeFrameCacheRefreshesStackTitlesAndBars(t *testing.T) {
153+ initial := cachedStackTitleState("one", 1, true)
154+ committed := composeFrame(initial, composeCacheInput{})
155+
156+ renamed := initial
157+ renamed.reset = false
158+ renamed.panes[0].title, renamed.panes[0].titleGeneration = "renamed", 2
159+ out := composeFrame(renamed, committed.cache, composeCacheInput{})
160+ require.Equal(t, "renamed", rowText(out.frame.Row(1))[:7])
161+ require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
162+
163+ unchanged := composeFrame(renamed, out.cache, committed.cache)
164+ require.NotContains(t, unchanged.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
165+
166+ bars := cacheBarState("one", true)
167+ committed = composeFrame(bars, composeCacheInput{})
168+ bars.reset = false
169+ bars.panes[0].damage = []renderer.Damage{{Kind: renderer.DamageText, Width: 1, Height: 1}}
170+ out = composeFrame(bars, committed.cache, composeCacheInput{})
171+ for _, damage := range out.damage {
172+ require.NotEqual(t, 0, damage.Y, "unchanged top bar must remain cached")
173+ require.NotEqual(t, 2, damage.Y, "unchanged bottom bar must remain cached")
174+ }
175+ bars.panes[0].damage = nil
176+ bars.bars.bottomRight = "two"
177+ out = composeFrame(bars, out.cache, committed.cache)
178+ require.Contains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 2, Width: 6, Height: 1})
179+ require.NotContains(t, out.damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 6, Height: 1})
180+}
181+
182+func TestComposeFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
183+ state := cachedStackTitleState("title", 1, true)
184+ committed, scratch := composeCacheInput{}, composeCacheInput{}
185+ compose := func() {
186+ out := composeFrame(state, committed, scratch)
187+ scratch, committed = committed, out.cache
188+ state.reset = false
189+ }
190+ compose()
191+ compose()
192+ compose()
193+
194+ allocs := testing.AllocsPerRun(100, compose)
195+ require.Zero(t, allocs, "warm production title composition must reuse the committed and scratch cache backing storage")
196+}
197+
198+func cachedSplitState(fingerprint string, focus layout.PaneID, direction layout.SplitDir, theme themeui.Theme) capturedRenderState {
199+ left, right := layout.PaneID("left"), layout.PaneID("right")
200+ placements := []layout.Placement{{ID: left, Content: domain.Rect{Width: 20, Height: 5}}, {ID: right, Content: domain.Rect{X: 21, Width: 20, Height: 5}}}
201+ leftFrame, rightFrame := cachePaneFrame(20, 5, 'L'), cachePaneFrame(20, 5, 'R')
202+ if direction == layout.Vertical {
203+ placements = []layout.Placement{{ID: left, Content: domain.Rect{Width: 41, Height: 2}}, {ID: right, Content: domain.Rect{Y: 3, Width: 41, Height: 2}}}
204+ leftFrame, rightFrame = cachePaneFrame(41, 2, 'L'), cachePaneFrame(41, 2, 'R')
205+ }
206+ return capturedRenderState{
207+ reset: true,
208+ layout: capturedTabLayout{root: &layout.Node{Kind: layout.Split, Dir: direction, Children: []*layout.Node{layout.NewLeaf(left), layout.NewLeaf(right)}}, area: domain.Rect{Width: 41, Height: 5}, focus: focus, placements: placements, fingerprint: fingerprint, valid: true},
209+ panes: []capturedPaneRenderState{{id: left, frame: leftFrame, placement: placements[0], focused: focus == left, damage: []renderer.Damage{renderer.FullRedraw()}}, {id: right, frame: rightFrame, placement: placements[1], focused: focus == right, damage: []renderer.Damage{renderer.FullRedraw()}}},
210+ theme: theme,
211+ }
212+}
213+
214+func cachedStackTitleState(title string, generation uint64, reset bool) capturedRenderState {
215+ first, second := layout.PaneID("first"), layout.PaneID("second")
216+ placements := []layout.Placement{
217+ {ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true, InStack: true},
218+ {ID: second, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Content: domain.Rect{Y: 2, Width: 20, Height: 3}, InStack: true},
219+ }
220+ return capturedRenderState{
221+ reset: reset,
222+ layout: capturedTabLayout{root: &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(first), layout.NewLeaf(second)}, Expanded: second}, area: domain.Rect{Width: 20, Height: 5}, focus: second, placements: placements, fingerprint: "stack", valid: true},
223+ panes: []capturedPaneRenderState{{id: first, title: title, titleGeneration: generation, placement: placements[0]}, {id: second, frame: cachePaneFrame(20, 3, 'E'), title: "second", titleGeneration: 1, placement: placements[1], focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}},
224+ }
225+}
226+
227+func cloneCapturedStackState(in capturedRenderState) capturedRenderState {
228+ out := in
229+ out.layout.placements = append([]layout.Placement(nil), in.layout.placements...)
230+ out.panes = append([]capturedPaneRenderState(nil), in.panes...)
231+ for i := range out.panes {
232+ if in.panes[i].frame.Cells != nil {
233+ out.panes[i].frame = in.panes[i].frame.Clone()
234+ }
235+ out.panes[i].rawDamage = append([]renderer.Damage(nil), in.panes[i].rawDamage...)
236+ out.panes[i].damage = append([]renderer.Damage(nil), in.panes[i].damage...)
237+ }
238+ return out
239+}
240+
241+func cacheBarState(bottom string, reset bool) capturedRenderState {
242+ pane := cachePaneFrame(6, 1, 'P')
243+ placement := layout.Placement{ID: "pane", Content: domain.Rect{Width: 6, Height: 1}}
244+ return capturedRenderState{reset: reset, layout: capturedTabLayout{area: domain.Rect{Width: 6, Height: 1}, focus: "pane", placements: []layout.Placement{placement}, fingerprint: "bar", valid: true}, panes: []capturedPaneRenderState{{id: "pane", frame: pane, placement: placement, focused: true, damage: []renderer.Damage{renderer.FullRedraw()}}}, bars: barState{bottomRight: bottom}}
245+}
246+
247+func cachePaneFrame(width, height int, r rune) renderer.Frame {
248+ frame := renderer.NewFrame(width, height)
249+ frame.Set(0, 0, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
250+ return frame
251+}
252+
89253 func cacheState(title string, generation uint64) capturedRenderState {
90254 pane := renderer.NewFrame(6, 1)
91255 for x, r := range title[:min(len(title), 6)] {
@@ -106,9 +270,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput {
106270 out := in
107271 out.frame = in.frame.Clone()
108272 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
109- for id, generation := range in.titleGenerations {
110- out.titleGenerations[id] = generation
111- }
273+ maps.Copy(out.titleGenerations, in.titleGenerations)
112274 out.damage = append([]renderer.Damage(nil), in.damage...)
113275 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
114276 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
M internal/usecase/daemon/render_pipeline_test.go +18 -7
@@ -1,6 +1,9 @@
11 package daemon
22
33 import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
47 "io"
58 "os"
69 "path/filepath"
@@ -163,14 +166,22 @@ func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
163166 }
164167
165168 func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
166- for _, name := range []string{"render.go", "floating_render.go", "picker.go"} {
167- data, err := os.ReadFile(filepath.Join(".", name))
169+ files, err := filepath.Glob("*.go")
170+ require.NoError(t, err)
171+ for _, name := range files {
172+ parsed, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
168173 require.NoError(t, err)
169- source := string(data)
170- for _, forbidden := range []string{
171- "func composeClientFrame", "func composeTabFrame", "func composeFloatingFrame",
172- } {
173- require.NotContains(t, source, forbidden, "%s retains duplicate production composition path", name)
174+ for _, declaration := range parsed.Decls {
175+ switch declaration := declaration.(type) {
176+ case *ast.FuncDecl:
177+ forbidden := declaration.Name.Name == "composeFloatingFrame" || declaration.Name.Name == "copyTargetRectLocked" || strings.HasPrefix(declaration.Name.Name, "composeClientFrame") || strings.HasPrefix(declaration.Name.Name, "composeTabFrame")
178+ require.False(t, forbidden, "%s declares forbidden legacy render helper %s", name, declaration.Name.Name)
179+ case *ast.GenDecl:
180+ for _, spec := range declaration.Specs {
181+ typeSpec, ok := spec.(*ast.TypeSpec)
182+ require.False(t, ok && typeSpec.Name.Name == "composedFrameCache", "%s declares forbidden legacy render cache", name)
183+ }
184+ }
174185 }
175186 }
176187 data, err := os.ReadFile("render_pipeline.go")
M internal/usecase/daemon/render_test.go +3 -310
@@ -46,7 +46,7 @@ func TestPaletteBackdropDimsSimultaneousCopyMode(t *testing.T) {
4646 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
4747 dimmed := client.Frame.At(0, 1)
4848 require.Equal(t, undimmed.Rune, dimmed.Rune, "palette backdrop must preserve copy content")
49- require.Equal(t, themeui.DimStyle(undimmed.Style, theme), dimmed.Style, "palette backdrop must dim the composed copy frame")
49+ require.Equal(t, themeui.NewDimmer(theme).Dim(undimmed.Style), dimmed.Style, "palette backdrop must dim the composed copy frame")
5050 require.Equal(t, copyBar, client.Frame.Row(client.Frame.Height-1), "copy status bar must remain crisp")
5151 paletteVisible := false
5252 for y := range client.Frame.Height {
@@ -110,7 +110,7 @@ func TestPaletteBackdropKeepsSimultaneousPickerCrisp(t *testing.T) {
110110 d.enterPalette(sess, ac)
111111 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
112112 require.Equal(t, pickerTitle, client.Frame.At(36, 2), "picker composed with palette must remain crisp")
113- require.Equal(t, themeui.DimStyle(undimmedPane.Style, backdropTheme()), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
113+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmedPane.Style), client.Frame.At(0, 1).Style, "pane content outside overlays must use the theme dim style")
114114 }
115115
116116 func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
@@ -131,7 +131,7 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
131131 mustApplyOutput(t, client, awaitFrame(t, sends, ports.MsgOutput))
132132 dimmed := client.Frame.At(0, 1)
133133 require.Equal(t, 'X', dimmed.Rune)
134- require.Equal(t, themeui.DimStyle(undimmed.Style, backdropTheme()), dimmed.Style, "open palette must use the theme dim style")
134+ require.Equal(t, themeui.NewDimmer(backdropTheme()).Dim(undimmed.Style), dimmed.Style, "open palette must use the theme dim style")
135135 require.Equal(t, topBar, client.Frame.At(0, 0), "top chrome remains crisp")
136136 require.Equal(t, bottomBar, client.Frame.At(0, 24), "bottom chrome remains crisp")
137137
@@ -802,228 +802,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
802802 }
803803 }
804804
805-func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
806- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
807- left := win.focusedPane()
808- left.screen.ClearDamage()
809- left.screen.Write([]byte("L"))
810- left.screen.ClearDamage()
811-
812- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
813- right.screen.Write([]byte("R"))
814- right.screen.ClearDamage()
815- right.screen.Write([]byte("x"))
816-
817- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
818- win.tree.Focus = right.id
819- win.panes[right.id] = right
820-
821- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
822- frame, damage := composeTabFrame(win, domain.Rect{Width: 41, Height: 4}, theme)
823-
824- require.Equal(t, 'L', frame.At(0, 0).Rune)
825- require.Equal(t, '│', frame.At(20, 0).Rune)
826- require.Equal(t, 'R', frame.At(21, 0).Rune)
827- require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "unfocused left pane should be dimmed during blit")
828- require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen")
829- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
830-}
831-
832-func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
833- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
834- left := win.focusedPane()
835- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
836- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
837- win.tree.Focus = right.id
838- win.panes[right.id] = right
839- left.screen.Write([]byte("L"))
840- right.screen.Write([]byte("R"))
841- var bars barCache
842- var composed composedFrameCache
843- sess := &session{id: "s", name: "work", tabs: []*tab{win}}
844- composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, true, solveTabLayoutLocked(win), &bars, &composed)
845- left.screen.ClearDamage()
846- right.screen.ClearDamage()
847- left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
848- right.screen.Write([]byte("x"))
849-
850- frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, false, solveTabLayoutLocked(win), &bars, &composed)
851-
852- require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
853- require.Equal(t, 'x', frame.At(22, 1).Rune)
854- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1})
855-}
856-
857-func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
858- win := newTab(nil, domain.Size{Cols: 20, Rows: 4})
859- p := win.focusedPane()
860- p.screen.Write([]byte("old"))
861- var bars barCache
862- var composed composedFrameCache
863- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
864-
865- _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
866- require.NotEmpty(t, damage)
867- require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame")
868-
869- p.screen.Write([]byte("new"))
870- require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render")
871-
872- _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
873- require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming")
874-}
875-
876-func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
877- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
878- p1 := win.focusedPane()
879- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
880- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
881- win.tree.Focus = p2.id
882- win.panes[p2.id] = p2
883- p1.screen.Write([]byte("hidden"))
884- p2.screen.Write([]byte("shown"))
885- var bars barCache
886- var composed composedFrameCache
887- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
888-
889- _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
890-
891- require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars")
892- require.Empty(t, p2.screen.Damage())
893-}
894-
895-func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
896- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
897- left := win.focusedPane()
898- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
899- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
900- win.panes[right.id] = right
901- left.screen.Write([]byte("L"))
902- right.screen.Write([]byte("R"))
903- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
904- var bars barCache
905- var composed composedFrameCache
906- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true), theme: theme}
907- win.tree.Focus = left.id
908- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
909- left.screen.ClearDamage()
910- right.screen.ClearDamage()
911- leftWasFocused := composed.frame.At(0, 1).Style
912- rightWasDimmed := composed.frame.At(21, 1).Style
913-
914- win.tree.Focus = right.id
915- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
916-
917- require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed")
918- require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed")
919- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
920-}
921-
922-func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
923- win := newTab(nil, domain.Size{Cols: 41, Rows: 5})
924- left := win.focusedPane()
925- right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5})
926- win.panes[right.id] = right
927- left.screen.Write([]byte("L"))
928- right.screen.Write([]byte("R"))
929- var bars barCache
930- var composed composedFrameCache
931- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
932- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
933- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
934- require.Equal(t, '│', composed.frame.At(20, 1).Rune)
935- left.screen.ClearDamage()
936- right.screen.ClearDamage()
937-
938- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
939- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
940-
941- require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes")
942- require.Equal(t, '─', frame.At(20, 3).Rune)
943- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
944-}
945-
946-func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
947- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
948- p := win.focusedPane()
949- p.title.processName = "shell"
950- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
951- layoutSnap := solveTabLayoutLocked(win)
952- frame := renderer.NewFrame(20, 5)
953- titleGenerations := map[layout.PaneID]uint64{p.id: p.title.generation}
954- p.screen.ClearDamage()
955-
956- composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
957- allocs := testing.AllocsPerRun(100, func() {
958- composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
959- })
960- require.Zero(t, allocs, "valid cached title-bar renders must not allocate")
961-}
962-
963-func TestComposeTabFrameStackUsesShellFallback(t *testing.T) {
964- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
965- p := win.focusedPane()
966- p.title.displayFallback = "fish"
967- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
968-
969- frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, themeui.Theme{})
970-
971- require.Equal(t, "fish", rowText(frame.Row(0))[:4])
972-}
973-
974-func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
975- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
976- p1 := win.focusedPane()
977- p1.title.processName = "one"
978- p1.screen.ClearDamage()
979- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
980- p2.title.processName = "two"
981- p2.screen.Write([]byte("T"))
982- p2.screen.ClearDamage()
983-
984- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
985- win.tree.Focus = p2.id
986- win.panes[p2.id] = p2
987-
988- theme := themeui.Theme{Known: true, TrueColor: true, HasFG: true, HasBG: true, Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 10, B: 10}}
989- frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, theme)
990-
991- require.Equal(t, "one", rowText(frame.Row(0))[:3])
992- require.Equal(t, "two", rowText(frame.Row(1))[:3])
993- require.Equal(t, 'T', frame.At(0, 2).Rune)
994- require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "collapsed title bar should use dimmed chrome")
995- require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
996-}
997-
998-func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
999- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
1000- p1 := win.focusedPane()
1001- p1.title.processName = "one"
1002- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
1003- p2.title.processName = "two"
1004- p2.screen.Write([]byte("T"))
1005- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
1006- win.tree.Focus = p2.id
1007- win.panes[p2.id] = p2
1008- var bars barCache
1009- var composed composedFrameCache
1010- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
1011- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
1012- p1.screen.ClearDamage()
1013- p2.screen.ClearDamage()
1014-
1015- p1.title.processName = "renamed"
1016- p1.title.generation++
1017- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
1018-
1019- require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
1020- require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
1021- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
1022-
1023- _, damage = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
1024- require.NotContains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}, "unchanged title must not redraw its row")
1025-}
1026-
1027805 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
1028806 tests := []struct {
1029807 name string
@@ -1170,56 +948,6 @@ func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
1170948 }
1171949 }
1172950
1173-func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
1174- sess, win := newBarCacheTestSession()
1175- var cache barCache
1176-
1177- _, damage := composeClientFrame(sess, win, true, "", &cache)
1178- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1179- win.focusedPane().screen.ClearDamage()
1180- win.focusedPane().screen.Write([]byte("x"))
1181-
1182- _, damage = composeClientFrame(sess, win, false, "", &cache)
1183-
1184- require.NotEmpty(t, damage)
1185- for _, d := range damage {
1186- require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")
1187- require.NotEqual(t, win.focusedPane().screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
1188- }
1189-}
1190-
1191-func TestComposeClientFrameBarCacheDamagesChangedBottomOnly(t *testing.T) {
1192- sess, win := newBarCacheTestSession()
1193- var cache barCache
1194- composeClientFrame(sess, win, true, "", &cache)
1195- win.focusedPane().screen.ClearDamage()
1196-
1197- sess.name = "renamed"
1198- _, damage := composeClientFrame(sess, win, false, "", &cache)
1199-
1200- require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: win.focusedPane().screen.Frame.Height + 1, Width: win.focusedPane().screen.Frame.Width, Height: 1}}, damage)
1201-}
1202-
1203-func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
1204- sess, win := newBarCacheTestSession()
1205- var cache barCache
1206- cache.top = []renderer.Cell{renderer.BlankCell()}
1207- cache.bottom = []renderer.Cell{renderer.BlankCell()}
1208-
1209- _, damage := composeClientFrame(sess, win, true, "", &cache)
1210- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1211- win.focusedPane().screen.ClearDamage()
1212-
1213- _, damage = composeClientFrame(sess, win, false, "", &cache)
1214- require.Empty(t, damage)
1215-}
1216-
1217-func newBarCacheTestSession() (*session, *tab) {
1218- win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 3})
1219- sess := &session{id: "s", name: "work", tabs: []*tab{win}}
1220- return sess, win
1221-}
1222-
1223951 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1224952 newSize := domain.Size{Cols: 100, Rows: 30}
1225953
@@ -1464,41 +1192,6 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
14641192 require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")
14651193 }
14661194
1467-func TestCopyTargetRectLocked(t *testing.T) {
1468- tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
1469- cfg := domain.Defaults().Floating
1470- contentArea := domain.Rect{Y: 1, Width: 80, Height: 23}
1471- fp := newPane("floating", nil, domain.Size{Cols: 10, Rows: 4})
1472- stray := newPane("stray", nil, domain.Size{Cols: 10, Rows: 4})
1473- tb.mu.Lock()
1474- defer tb.mu.Unlock()
1475- layoutSnap := solveTabLayoutLocked(tb)
1476- main := tb.focusedPane()
1477- fp.popupGeometry = calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg)
1478- floatingFrameGeometry := fp.popupGeometry.translate(contentArea.X, contentArea.Y)
1479-
1480- cases := []struct {
1481- name string
1482- pane *pane
1483- floating *pane
1484- hasFloating bool
1485- want domain.Rect
1486- }{
1487- {name: "floating source targets committed frame-absolute popup inner", pane: fp, floating: fp, hasFloating: true, want: calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg).translate(contentArea.X, contentArea.Y).Inner},
1488- {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}},
1489- {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}},
1490- }
1491- for _, tc := range cases {
1492- t.Run(tc.name, func(t *testing.T) {
1493- got := copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, floatingFrameGeometry)
1494- require.Equal(t, tc.want, got)
1495- if tc.hasFloating {
1496- require.NotEqual(t, fp.popupGeometry.Inner, got, "content-relative geometry must not target the client frame directly")
1497- }
1498- })
1499- }
1500-}
1501-
15021195 func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
15031196 normal, releaseNormal := newBlockingPTY(t)
15041197 floatingPTY, releaseFloating := newBlockingPTY(t)
M internal/usecase/daemon/status_test.go +37 -9
@@ -87,7 +87,16 @@ func TestStatusCompositionGolden(t *testing.T) {
8787 win.size = domain.Size{Cols: 12, Rows: 2}
8888 win.focusedPane().screen.Write([]byte("hello"))
8989
90- frame, damage := composeClientFrame(sess, win, true, "")
90+ frameState := capturedRenderState{
91+ reset: true,
92+ layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, focus: win.focusedPane().id, valid: true},
93+ panes: []capturedPaneRenderState{{
94+ id: win.focusedPane().id, frame: win.focusedPane().screen.Frame.Clone(), placement: layout.Placement{ID: win.focusedPane().id, Content: domain.Rect{Width: 12, Height: 2}}, focused: true, damage: []renderer.Damage{renderer.FullRedraw()},
95+ }},
96+ bars: barState{status: sess.statusSegments(true)},
97+ }
98+ composed := composeFrame(frameState, composeCacheInput{})
99+ frame, damage := composed.frame, composed.damage
91100
92101 require.Equal(t, 12, frame.Width)
93102 require.Equal(t, 4, frame.Height)
@@ -409,8 +418,10 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
409418 })
410419
411420 bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}
412- frame, damage := composeClientFrameWithState(bars, win, true)
413- out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)
421+ composed := composeFrame(capturedRenderState{
422+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: bars, theme: bars.theme,
423+ }, composeCacheInput{})
424+ out, err := renderer.New(renderer.Capabilities{}).Draw(composed.frame, composed.damage)
414425
415426 require.NoError(t, err)
416427 require.Contains(t, string(out), ";48;2;")
@@ -788,7 +799,9 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
788799 win.focusedPane().screen.Resize(12, 2)
789800 win.size = domain.Size{Cols: 12, Rows: 2}
790801
791- frame, _ := composeClientFrame(sess, win, true, "")
802+ frame := composeFrame(capturedRenderState{
803+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 12, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true)},
804+ }, composeCacheInput{}).frame
792805
793806 require.Equal(t, " 1 (sh) ", rowText(frame.Row(0)))
794807 require.Equal(t, " 0* ", rowText(frame.Row(3)))
@@ -853,13 +866,19 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
853866 win.focusedPane().screen.Resize(30, 2)
854867 win.size = domain.Size{Cols: 30, Rows: 2}
855868
856- frame, _ := composeClientFrame(sess, win, true, "ok")
869+ frame := composeFrame(capturedRenderState{
870+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "ok"},
871+ }, composeCacheInput{}).frame
857872 require.Equal(t, " work ok", rowText(frame.Row(3)))
858873
859- frame, _ = composeClientFrame(sess, win, true, "1234567890123456789")
874+ frame = composeFrame(capturedRenderState{
875+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "1234567890123456789"},
876+ }, composeCacheInput{}).frame
860877 require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))
861878
862- frame, _ = composeClientFrame(sess, win, true, "selection too large to copy")
879+ frame = composeFrame(capturedRenderState{
880+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 30, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), copyFeedback: "selection too large to copy"},
881+ }, composeCacheInput{}).frame
863882 require.Equal(t, " work ", rowText(frame.Row(3)))
864883 }
865884
@@ -923,7 +942,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
923942 sess.tabs[1].attentionAt = time.Unix(10, 0)
924943 sess.mu.Unlock()
925944
926- frame, _ := composeClientFrameWithState(barState{status: sess.statusSegments(true), attentionFrame: 1}, win, true)
945+ frame := composeFrame(capturedRenderState{
946+ reset: true, layout: capturedTabLayout{area: domain.Rect{Width: 18, Height: 2}, valid: true}, bars: barState{status: sess.statusSegments(true), attentionFrame: 1},
947+ }, composeCacheInput{}).frame
927948
928949 // Tab 2's label is enriched with its focused pane's title ("sh", the
929950 // default shell fallback) and truncated to fit alongside the bell. The
@@ -1040,7 +1061,14 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
10401061 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
10411062 capture := func(bars barState) capturedRenderState {
10421063 t.Helper()
1043- state, ok := capturePrimaryRenderState(sess, ac, bars, capturedOverlayRenderState{}, picker.Preview{}, domain.FloatingConfig{}, false, nil)
1064+ state, ok := capturePrimaryRenderState(sess, ac, primaryCaptureRequest{
1065+ bars: bars,
1066+ overlays: capturedOverlayRenderState{},
1067+ preview: picker.Preview{},
1068+ floatingCfg: domain.FloatingConfig{},
1069+ reset: false,
1070+ lease: nil,
1071+ })
10441072 require.True(t, ok)
10451073 return *state
10461074 }
M internal/usecase/daemon/title_cache_regression_test.go +16 -20
@@ -10,28 +10,24 @@ import (
1010 "github.com/bnema/vev/internal/usecase/layout"
1111 )
1212
13-func TestComposeClientFrameCachePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {
14- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
15- first := win.focusedPane()
16- var bars barCache
17- var composed composedFrameCache
18- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
13+func TestComposeFramePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {
14+ committed := composeCacheInput{}
15+ scratch := composeCacheInput{}
16+ first := layout.PaneID("pane-1")
1917
20- for i := 0; i < 8; i++ {
18+ for i := range 8 {
2119 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))
22- replacement := newPane(id, nil, domain.Size{Cols: 20, Rows: 3})
23- win.panes = map[layout.PaneID]*pane{first.id: first, id: replacement}
24- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{
25- layout.NewLeaf(first.id),
26- layout.NewLeaf(id),
27- }, Expanded: id}
28- win.tree.Focus = id
20+ state := capturedRenderState{
21+ reset: i == 0,
22+ layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, fingerprint: fmt.Sprintf("layout-%d", i), valid: true},
23+ panes: []capturedPaneRenderState{
24+ {id: first, title: "first", titleGeneration: 1, placement: layout.Placement{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true}},
25+ {id: id, title: "replacement", titleGeneration: 1, placement: layout.Placement{ID: id, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Collapsed: true}},
26+ },
27+ }
28+ out := composeFrame(state, committed, scratch)
29+ scratch, committed = committed, out.cache
2930
30- composeClientFrameWithLayoutCached(state, win, i == 0, solveTabLayoutLocked(win), &bars, &composed)
31-
32- require.Equal(t, map[layout.PaneID]uint64{
33- first.id: composed.titleGenerations[first.id],
34- id: composed.titleGenerations[id],
35- }, composed.titleGenerations)
31+ require.Equal(t, map[layout.PaneID]uint64{first: 1, id: 1}, committed.titleGenerations)
3632 }
3733 }
M internal/usecase/theme/theme.go +117 -14
@@ -373,28 +373,131 @@ func MutedVariantStyle(base renderer.Style, t Theme) renderer.Style {
373373 return out
374374 }
375375
376-func DimStyle(style renderer.Style, t Theme) renderer.Style {
377- if !usable(t) {
376+const defaultDimmingPercent = 35
377+
378+// Dimmer transforms resolved terminal colors for subdued UI states. Construct
379+// it once and reuse it while rendering cells with the same theme and policy.
380+type Dimmer struct {
381+ theme Theme
382+ backgroundPercent int
383+ foregroundPercent int
384+}
385+
386+type dimmingTarget uint8
387+
388+const (
389+ dimBackground dimmingTarget = iota
390+ dimForeground
391+)
392+
393+// DimmerOption customizes a Dimmer without allocating a closure.
394+type DimmerOption struct {
395+ target dimmingTarget
396+ percent int
397+}
398+
399+// WithBackgroundDimming overrides the default 35 percent background fade.
400+func WithBackgroundDimming(percent int) DimmerOption {
401+ return DimmerOption{target: dimBackground, percent: percent}
402+}
403+
404+// WithForegroundDimming overrides the default 35 percent foreground fade.
405+func WithForegroundDimming(percent int) DimmerOption {
406+ return DimmerOption{target: dimForeground, percent: percent}
407+}
408+
409+// NewDimmer returns a reusable dimmer with optional per-channel overrides.
410+func NewDimmer(t Theme, opts ...DimmerOption) Dimmer {
411+ d := Dimmer{
412+ theme: t,
413+ backgroundPercent: defaultDimmingPercent,
414+ foregroundPercent: defaultDimmingPercent,
415+ }
416+ for _, opt := range opts {
417+ switch opt.target {
418+ case dimBackground:
419+ d.backgroundPercent = opt.percent
420+ case dimForeground:
421+ d.foregroundPercent = opt.percent
422+ }
423+ }
424+ d.backgroundPercent = clampPercent(d.backgroundPercent)
425+ d.foregroundPercent = clampPercent(d.foregroundPercent)
426+ return d
427+}
428+
429+// Dim fades a style's resolved background toward the terminal background,
430+// then fades its foreground and custom underline toward that dimmed background.
431+func (d Dimmer) Dim(style renderer.Style) renderer.Style {
432+ if !usable(d.theme) {
378433 return style
379434 }
435+
436+ foreground := resolveForeground(style, d.theme)
437+ background := resolveBackground(style, d.theme)
438+ if style.Inverse {
439+ foreground, background = background, foreground
440+ }
441+ background = blendPercent(background, d.theme.Background, d.backgroundPercent)
442+
380443 out := style
444+ out.Inverse = false
381445 out.HasForegroundRGB = true
446+ out.ForegroundRGB = blendPercent(foreground, background, d.foregroundPercent)
447+ out.HasBackgroundRGB = true
448+ out.BackgroundRGB = background
449+ if style.HasUnderlineColorRGB || style.HasUnderlineColor {
450+ underline := style.UnderlineColorRGB
451+ if !style.HasUnderlineColorRGB {
452+ underline = xterm256Color(style.UnderlineColor)
453+ }
454+ out.HasUnderlineColor = false
455+ out.HasUnderlineColorRGB = true
456+ out.UnderlineColorRGB = blendPercent(underline, background, d.foregroundPercent)
457+ }
458+ return out
459+}
460+
461+func resolveForeground(style renderer.Style, t Theme) renderer.RGB {
382462 if style.HasForegroundRGB {
383- out.ForegroundRGB = Blend(style.ForegroundRGB, t.Background, 0.35)
384- } else if style.Foreground >= 0 {
385- out.ForegroundRGB = Blend(xterm256Color(style.Foreground), t.Background, 0.35)
386- } else {
387- out.ForegroundRGB = Blend(t.Foreground, t.Background, 0.35)
463+ return style.ForegroundRGB
388464 }
389- out.HasBackgroundRGB = true
465+ if style.Foreground >= 0 {
466+ return xterm256Color(style.Foreground)
467+ }
468+ return t.Foreground
469+}
470+
471+func resolveBackground(style renderer.Style, t Theme) renderer.RGB {
390472 if style.HasBackgroundRGB {
391- out.BackgroundRGB = Blend(style.BackgroundRGB, t.Background, 0.35)
392- } else if style.Background >= 0 {
393- out.BackgroundRGB = Blend(xterm256Color(style.Background), t.Background, 0.35)
394- } else {
395- out.BackgroundRGB = Blend(t.Background, t.Background, 0.35)
473+ return style.BackgroundRGB
396474 }
397- return out
475+ if style.Background >= 0 {
476+ return xterm256Color(style.Background)
477+ }
478+ return t.Background
479+}
480+
481+func blendPercent(a, b renderer.RGB, percent int) renderer.RGB {
482+ return renderer.RGB{
483+ R: blendBytePercent(a.R, b.R, percent),
484+ G: blendBytePercent(a.G, b.G, percent),
485+ B: blendBytePercent(a.B, b.B, percent),
486+ }
487+}
488+
489+func blendBytePercent(a, b uint8, percent int) uint8 {
490+ return uint8((int(a)*(100-percent) + int(b)*percent + 50) / 100)
491+}
492+
493+func clampPercent(percent int) int {
494+ if percent < 0 {
495+ return 0
496+ }
497+ if percent > 100 {
498+ return 100
499+ }
500+ return percent
398501 }
399502
400503 var (
M internal/usecase/theme/theme_test.go +84 -7
@@ -6,6 +6,7 @@ import (
66 "testing"
77
88 "github.com/bnema/vev/pkg/renderer"
9+ "github.com/stretchr/testify/require"
910 )
1011
1112 func TestParseXColor(t *testing.T) {
@@ -261,7 +262,83 @@ func TestScannerFlushesOverflowingPartialQueue(t *testing.T) {
261262 }
262263 }
263264
264-func TestDimStyle(t *testing.T) {
265+func TestDimmer(t *testing.T) {
266+ theme := Theme{
267+ Foreground: renderer.RGB{R: 240, G: 240, B: 240},
268+ Background: renderer.RGB{R: 10, G: 20, B: 30},
269+ HasFG: true, HasBG: true, Known: true, TrueColor: true,
270+ }
271+ dimmer := NewDimmer(theme, WithForegroundDimming(55))
272+
273+ t.Run("fades foreground toward the faded cell background", func(t *testing.T) {
274+ style := renderer.Style{
275+ Foreground: -1, Background: -1,
276+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
277+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
278+ }
279+
280+ got := dimmer.Dim(style)
281+
282+ require.Equal(t, renderer.RGB{R: 36, G: 105, B: 173}, got.BackgroundRGB)
283+ require.Equal(t, renderer.RGB{R: 132, G: 103, B: 118}, got.ForegroundRGB)
284+ })
285+
286+ t.Run("resolves inverse and custom underline colors before fading", func(t *testing.T) {
287+ style := renderer.Style{
288+ Bold: true, Italic: true, Inverse: true, Attrs: renderer.AttrUnderline,
289+ Foreground: -1, Background: -1,
290+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
291+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
292+ HasUnderlineColorRGB: true, UnderlineColorRGB: renderer.RGB{R: 200, G: 10, B: 100},
293+ }
294+
295+ got := dimmer.Dim(style)
296+
297+ require.False(t, got.Inverse)
298+ require.Equal(t, renderer.RGB{R: 166, G: 72, B: 43}, got.BackgroundRGB)
299+ require.Equal(t, renderer.RGB{R: 114, G: 107, B: 136}, got.ForegroundRGB)
300+ require.Equal(t, renderer.RGB{R: 181, G: 44, B: 69}, got.UnderlineColorRGB)
301+ require.True(t, got.Bold)
302+ require.True(t, got.Italic)
303+ require.Equal(t, renderer.AttrUnderline, got.Attrs)
304+ })
305+
306+ t.Run("resolves indexed and default colors", func(t *testing.T) {
307+ style := renderer.Style{
308+ Foreground: 196,
309+ Background: -1,
310+ HasUnderlineColor: true,
311+ UnderlineColor: 46,
312+ }
313+
314+ got := dimmer.Dim(style)
315+
316+ require.Equal(t, renderer.RGB{R: 10, G: 20, B: 30}, got.BackgroundRGB)
317+ require.Equal(t, renderer.RGB{R: 120, G: 11, B: 17}, got.ForegroundRGB)
318+ require.True(t, got.HasUnderlineColorRGB)
319+ require.Equal(t, renderer.RGB{R: 6, G: 126, B: 17}, got.UnderlineColorRGB)
320+ })
321+
322+ t.Run("clamps percentages", func(t *testing.T) {
323+ style := renderer.Style{
324+ Foreground: -1, Background: -1,
325+ HasForegroundRGB: true, ForegroundRGB: renderer.RGB{R: 250, G: 100, B: 50},
326+ HasBackgroundRGB: true, BackgroundRGB: renderer.RGB{R: 50, G: 150, B: 250},
327+ }
328+
329+ got := NewDimmer(theme, WithBackgroundDimming(-1), WithForegroundDimming(101)).Dim(style)
330+
331+ require.Equal(t, style.BackgroundRGB, got.BackgroundRGB)
332+ require.Equal(t, style.BackgroundRGB, got.ForegroundRGB)
333+ })
334+
335+ t.Run("leaves style unchanged without a usable truecolor theme", func(t *testing.T) {
336+ style := renderer.Style{Foreground: 196, Background: 21, Inverse: true}
337+ require.Equal(t, style, NewDimmer(Theme{}, WithForegroundDimming(55)).Dim(style))
338+ })
339+}
340+
341+func TestDimmerDefaults(t *testing.T) {
265342 theme := Theme{Foreground: renderer.RGB{R: 200, G: 200, B: 200}, Background: renderer.RGB{R: 10, G: 20, B: 30}, HasFG: true, HasBG: true, Known: true, TrueColor: true}
266343 tests := []struct {
267344 name string
@@ -311,9 +388,9 @@ func TestDimStyle(t *testing.T) {
311388 Foreground: -1,
312389 Background: 21,
313390 HasForegroundRGB: true,
314- ForegroundRGB: Blend(theme.Foreground, theme.Background, 0.35),
391+ ForegroundRGB: renderer.RGB{R: 131, G: 132, B: 192},
315392 HasBackgroundRGB: true,
316- BackgroundRGB: Blend(renderer.RGB{R: 0, G: 0, B: 255}, theme.Background, 0.35),
393+ BackgroundRGB: renderer.RGB{R: 4, G: 7, B: 176},
317394 },
318395 },
319396 {
@@ -330,18 +407,18 @@ func TestDimStyle(t *testing.T) {
330407 Foreground: -1,
331408 Background: -1,
332409 HasForegroundRGB: true,
333- ForegroundRGB: Blend(renderer.RGB{R: 100, G: 50, B: 25}, theme.Background, 0.35),
410+ ForegroundRGB: renderer.RGB{R: 71, G: 58, B: 61},
334411 HasBackgroundRGB: true,
335- BackgroundRGB: Blend(renderer.RGB{R: 20, G: 100, B: 180}, theme.Background, 0.35),
412+ BackgroundRGB: renderer.RGB{R: 17, G: 72, B: 128},
336413 },
337414 },
338415 }
339416
340417 for _, tt := range tests {
341418 t.Run(tt.name, func(t *testing.T) {
342- got := DimStyle(tt.in, theme)
419+ got := NewDimmer(theme).Dim(tt.in)
343420 if !got.Equal(tt.want) {
344- t.Fatalf("DimStyle()=%+v want %+v", got, tt.want)
421+ t.Fatalf("Dimmer.Dim()=%+v want %+v", got, tt.want)
345422 }
346423 })
347424 }
M pkg/vt/history_test.go +45 -0
@@ -147,6 +147,51 @@ func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) {
147147 }
148148 }
149149
150+func TestHistoryRecordsOnlyTopEdgeScrollEvictions(t *testing.T) {
151+ tests := []struct {
152+ name string
153+ top int
154+ bottom int
155+ wantRows int
156+ wantEvents int
157+ }{
158+ {
159+ name: "interior scroll region does not enter global history",
160+ top: 1,
161+ bottom: 3,
162+ wantRows: 0,
163+ wantEvents: 0,
164+ },
165+ {
166+ name: "top-edge scroll enters global history",
167+ top: 0,
168+ bottom: 3,
169+ wantRows: 1,
170+ wantEvents: 1,
171+ },
172+ }
173+
174+ for _, tt := range tests {
175+ t.Run(tt.name, func(t *testing.T) {
176+ s := NewScreenWithHistory(4, 4, HistoryConfig{MaxRows: 8, ChunkRows: 2})
177+ for y := range s.Frame.Height {
178+ copy(s.Frame.Row(y), historyRow(string([]byte{byte('A' + y), byte('A' + y), byte('A' + y), byte('A' + y)})))
179+ }
180+ events := 0
181+ s.OnLineEvicted = func([]renderer.Cell) { events++ }
182+
183+ s.scrollUpRegion(tt.top, tt.bottom, 1)
184+
185+ if got := s.History().View().Len(); got != tt.wantRows {
186+ t.Errorf("history rows = %d, want %d", got, tt.wantRows)
187+ }
188+ if events != tt.wantEvents {
189+ t.Errorf("eviction events = %d, want %d", events, tt.wantEvents)
190+ }
191+ })
192+ }
193+}
194+
150195 func historyRow(text string) []renderer.Cell {
151196 row := make([]renderer.Cell, len(text))
152197 for i, r := range text {
M pkg/vt/screen.go +0 -1521
@@ -1,44 +1,17 @@
11 package vt
22
33 import (
4- "strconv"
5- "strings"
64 "unicode/utf8"
75
86 "github.com/bnema/vev/pkg/renderer"
97 )
108
11-type cursorState struct {
12- row int
13- col int
14- style renderer.Style
15- originMode bool
16- insertMode bool
17- saved bool
18-}
19-
20-type screenState struct {
21- frame renderer.Frame
22- row int
23- col int
24- style renderer.Style
25- scrollTop int
26- scrollBottom int
27- savedCursor cursorState
28- originMode bool
29- insertMode bool
30-}
31-
329 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
3310 // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
3411 // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
3512 // so keep this value in sync if that payload cap changes.
3613 const maxEscapeBufferLen = 128 * 1024
3714
38-// maxPendingDamage bounds metadata retained while no render transaction can
39-// acknowledge a screen. Saturation falls back to one exact full redraw.
40-const maxPendingDamage = 1024
41-
4215 const (
4316 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
4417 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -46,14 +19,6 @@ const (
4619 ColorSchemeReportLight = "\x1b[?997;2n"
4720 )
4821
49-const (
50- progressStateClear = iota
51- progressStateNormal
52- progressStateError
53- progressStateIndeterminate
54- progressStatePaused
55-)
56-
5722 type Screen struct {
5823 Frame renderer.Frame
5924 Row int
@@ -143,130 +108,6 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
143108 // configured with NewScreenWithHistory.
144109 func (s *Screen) History() *History { return s.history }
145110
146-func (s *Screen) Resize(width, height int) {
147- if width == s.Frame.Width && height == s.Frame.Height {
148- return
149- }
150- evict := s.recordEvicted
151-
152- if s.alternate != nil {
153- var shift int
154- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil)
155- s.Row = clamp(s.Row-shift, 0, height-1)
156- s.Col = clamp(s.Col, 0, width-1)
157- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
158- s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict)
159- s.alternate.row = clamp(s.alternate.row-shift, 0, height-1)
160- s.alternate.col = clamp(s.alternate.col, 0, width-1)
161- s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height)
162- s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1)
163- s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1)
164- if s.alternate.scrollTop >= s.alternate.scrollBottom {
165- s.alternate.scrollTop = 0
166- s.alternate.scrollBottom = max(height-1, 0)
167- }
168- } else {
169- var shift int
170- s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict)
171- s.Row = clamp(s.Row-shift, 0, height-1)
172- s.Col = clamp(s.Col, 0, width-1)
173- s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
174- }
175-
176- // A resize can split an in-flight escape sequence from the terminal state it
177- // was meant to mutate; keep the durable child state but discard partial bytes.
178- s.escapeBuf = s.escapeBuf[:0]
179- s.resetScrollRegion()
180- s.fullRedraw()
181-}
182-
183-// DamageCapture is an immutable copy of pending damage at one screen generation.
184-type DamageCapture struct {
185- Damage []renderer.Damage
186- Generation uint64
187-}
188-
189-// Damage returns the current damage list. The caller must not modify the
190-// returned slice; ClearDamage must be called after the damage is consumed.
191-func (s *Screen) Damage() []renderer.Damage { return s.damage }
192-func (s *Screen) ClearDamage() {
193- s.damage = s.damage[:0]
194- s.damageSaturated = false
195- s.damageFullRedrawSticky = false
196-}
197-
198-// CaptureDamage snapshots pending damage without consuming it.
199-func (s *Screen) CaptureDamage() DamageCapture {
200- return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
201-}
202-
203-// AcknowledgeDamage consumes a capture only if no screen mutation occurred
204-// since it was taken. A stale acknowledgement conservatively requests a full
205-// redraw, ensuring intervening writes remain visible to the next capture.
206-func (s *Screen) AcknowledgeDamage(generation uint64) bool {
207- if generation != s.damageGeneration {
208- s.fullRedraw()
209- return false
210- }
211- s.ClearDamage()
212- return true
213-}
214-
215-// SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
216-// is currently enabled by the child process.
217-func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
218-
219-// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
220-// by the child process.
221-func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
222-
223-// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
224-func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
225-
226-// SetColorScheme updates the host color scheme and notifies subscribed child apps.
227-func (s *Screen) SetColorScheme(light bool) {
228- if s.colorSchemeSet && s.colorSchemeLight == light {
229- return
230- }
231- s.colorSchemeSet = true
232- s.colorSchemeLight = light
233- if s.colorSchemeMode {
234- s.respond(s.colorSchemeReport())
235- }
236-}
237-
238-// ClearColorScheme marks the host color scheme as unknown. Future child color
239-// scheme queries are silent until SetColorScheme supplies a known value again.
240-func (s *Screen) ClearColorScheme() {
241- s.colorSchemeSet = false
242-}
243-
244-// ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
245-// Hosts use this as a safety valve if a child enters synchronized update mode
246-// and never sends the matching end sequence.
247-func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
248-
249-// SetDefaultColors sets the terminal default foreground/background colors used
250-// to answer child OSC 10/11 color queries. Passing ok=false makes color queries
251-// silent until known colors are supplied again.
252-func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) {
253- s.defaultFG = fg
254- s.defaultBG = bg
255- s.defaultColorsKnown = ok
256-}
257-
258-func (s *Screen) CursorRow() int { return s.Row }
259-func (s *Screen) CursorCol() int { return s.Col }
260-func (s *Screen) CursorVisible() bool {
261- return s.cursorVisible
262-}
263-func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet }
264-func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
265-func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
266-
267-// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
268-func (s *Screen) TerminalTitle() string { return s.terminalTitle }
269-
270111 func (s *Screen) Write(data []byte) {
271112 if len(s.escapeBuf) > 0 {
272113 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -300,393 +141,6 @@ func (s *Screen) Write(data []byte) {
300141 }
301142 }
302143
303-func (s *Screen) putRune(r rune) {
304- switch r {
305- case '\a':
306- if s.OnBell != nil {
307- s.OnBell()
308- }
309- return
310- case 0x00, 0x0e, 0x0f, 0x7f:
311- return
312- case '\r':
313- s.Col = 0
314- case '\n', '\v', '\f':
315- if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
316- s.Col = s.Frame.Width - 1
317- }
318- s.index()
319- case '\b':
320- if s.Col > 0 {
321- s.Col--
322- }
323- case '\t':
324- spaces := 8 - (s.Col % 8)
325- for range spaces {
326- s.putPrintable(' ')
327- }
328- default:
329- // Drop C1 control characters (U+0080-U+009F).
330- if r >= 0x80 && r <= 0x9F {
331- return
332- }
333- if r >= 0x20 {
334- s.putPrintable(r)
335- }
336- }
337-}
338-
339-func (s *Screen) putPrintable(r rune) {
340- w := renderer.RuneWidth(r)
341- // Skip combining marks and zero-width characters.
342- if w == 0 {
343- return
344- }
345- if s.Frame.Width == 0 || s.Frame.Height == 0 {
346- return
347- }
348- // Deferred wrap: cursor sits past the last column.
349- if s.Col >= s.Frame.Width {
350- s.Col = 0
351- s.index()
352- }
353- if s.Row >= s.Frame.Height {
354- s.Row = s.Frame.Height - 1
355- }
356-
357- // A wide rune must never straddle the right edge. If it does not fit on the
358- // current line, clear the abandoned last cell and wrap to the next line.
359- if w == 2 && s.Col+1 >= s.Frame.Width {
360- if s.Frame.Width < 2 {
361- // The screen is too narrow to ever hold a wide rune; degrade to a
362- // single narrow cell so the cursor stays aligned and we never write
363- // a continuation cell out of bounds.
364- w = 1
365- } else {
366- // The abandoned cell may itself be the continuation of a pair whose
367- // left half sits one column back; report damage over the full span.
368- cx := s.Col
369- if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
370- cx = s.Col - 1
371- }
372- s.clearWidePairAt(s.Col, s.Row)
373- s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
374- s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
375- s.Col = 0
376- s.index()
377- if s.Row >= s.Frame.Height {
378- s.Row = s.Frame.Height - 1
379- }
380- }
381- }
382-
383- insertDamageX := s.Col
384- insertDamageWidth := 0
385- if s.insertMode {
386- row := s.Frame.Row(s.Row)
387- leftSplit := s.Col > 0 && row[s.Col].Continuation
388- for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
389- row[x] = row[x-w]
390- }
391- for x := s.Col; x < s.Col+w; x++ {
392- row[x] = renderer.BlankCell()
393- }
394- s.repairRow(s.Row)
395- if leftSplit {
396- insertDamageX = s.Col - 1
397- }
398- insertDamageWidth = s.Frame.Width - insertDamageX
399- }
400-
401- // Determine the range of cells actually modified, extending over any wide
402- // pair the write lands on so no orphaned half is left behind.
403- lo, hi := s.Col, s.Col+w-1
404- if s.Frame.At(s.Col, s.Row).Continuation {
405- lo = s.Col - 1
406- }
407- if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
408- hi = right
409- }
410- for x := lo; x <= hi; x++ {
411- s.Frame.Set(x, s.Row, renderer.BlankCell())
412- }
413- s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
414- if w == 2 {
415- s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
416- }
417- if insertDamageWidth > 0 {
418- lo = min(lo, insertDamageX)
419- hi = max(hi, insertDamageX+insertDamageWidth-1)
420- }
421- s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
422- s.Col += w
423-}
424-
425-// clearWidePairAt blanks both halves of a wide-character pair when the cell at
426-// (x,y) is either the left (wide) half or the right (continuation) half. It is
427-// O(1) and assumes the pair invariant (a continuation is preceded by its wide
428-// left half) holds — which the writer maintains.
429-func (s *Screen) clearWidePairAt(x, y int) {
430- if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
431- return
432- }
433- if s.Frame.At(x, y).Continuation {
434- if x-1 >= 0 {
435- s.Frame.Set(x-1, y, renderer.BlankCell())
436- }
437- s.Frame.Set(x, y, renderer.BlankCell())
438- return
439- }
440- if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
441- s.Frame.Set(x, y, renderer.BlankCell())
442- s.Frame.Set(x+1, y, renderer.BlankCell())
443- }
444-}
445-
446-func (s *Screen) eraseCell() renderer.Cell {
447- style := renderer.DefaultStyle()
448- if s.Style.HasBackgroundRGB {
449- style.HasBackgroundRGB = true
450- style.BackgroundRGB = s.Style.BackgroundRGB
451- } else {
452- style.Background = s.Style.Background
453- }
454- return renderer.Cell{Rune: ' ', Style: style}
455-}
456-
457-// clearRow blanks cells [x0,x1) on row y, extending the range to swallow either
458-// half of a wide pair that straddles a boundary so no orphan half is left. It
459-// returns the actual modified span [start, start+width).
460-func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
461- if y < 0 || y >= s.Frame.Height {
462- return x0, 0
463- }
464- if x0 < 0 {
465- x0 = 0
466- }
467- if x1 > s.Frame.Width {
468- x1 = s.Frame.Width
469- }
470- if x0 >= x1 {
471- return x0, 0
472- }
473- // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
474- if x0 > 0 && s.Frame.At(x0, y).Continuation {
475- x0--
476- }
477- // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
478- // inside the range and will be blanked, so swallow the continuation too.
479- if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
480- x1++
481- }
482- blank := s.eraseCell()
483- for x := x0; x < x1; x++ {
484- s.Frame.Set(x, y, blank)
485- }
486- return x0, x1 - x0
487-}
488-
489-// repairRow blanks any orphaned wide-character halves on row y: a continuation
490-// cell not preceded by its wide left half, or a wide rune not followed by its
491-// continuation. Used after erase/insert/delete operations that may split a pair
492-// at a boundary or by shifting cells.
493-func (s *Screen) repairRow(y int) {
494- repairFrameRow(s.Frame, y)
495-}
496-
497-func repairFrameRow(frame renderer.Frame, y int) {
498- if y < 0 || y >= frame.Height {
499- return
500- }
501- w := frame.Width
502- for x := range w {
503- c := frame.At(x, y)
504- if c.Continuation {
505- if x == 0 {
506- frame.Set(x, y, renderer.BlankCell())
507- continue
508- }
509- left := frame.At(x-1, y)
510- if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
511- frame.Set(x, y, renderer.BlankCell())
512- }
513- continue
514- }
515- if renderer.RuneWidth(c.Rune) == 2 {
516- if x+1 >= w || !frame.At(x+1, y).Continuation {
517- frame.Set(x, y, renderer.BlankCell())
518- }
519- }
520- }
521-}
522-
523-func (s *Screen) index() {
524- if s.Frame.Height == 0 {
525- return
526- }
527- if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
528- s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
529- return
530- }
531- if s.Row+1 < s.Frame.Height {
532- s.Row++
533- return
534- }
535- s.Row = s.Frame.Height - 1
536-}
537-
538-func (s *Screen) nextLine() {
539- s.Col = 0
540- s.index()
541-}
542-
543-func (s *Screen) reverseIndex() {
544- if s.Frame.Height == 0 {
545- return
546- }
547- if s.Row == s.scrollTop && s.Row <= s.scrollBottom {
548- s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1)
549- return
550- }
551- if s.Row > 0 {
552- s.Row--
553- }
554-}
555-
556-// scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU).
557-// The cursor position is preserved.
558-func (s *Screen) scrollUpBy(n int) {
559- if n <= 0 {
560- return
561- }
562- s.scrollUpRegion(s.scrollTop, s.scrollBottom, n)
563-}
564-
565-func (s *Screen) scrollDownBy(n int) {
566- if n <= 0 {
567- return
568- }
569- s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)
570-}
571-
572-func (s *Screen) scrollUpRegion(top, bottom, n int) {
573- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
574- return
575- }
576- top, bottom, ok := s.normalizedRegion(top, bottom)
577- if !ok {
578- return
579- }
580- w := s.Frame.Width
581- height := bottom - top + 1
582- if n > height {
583- n = height
584- }
585- // VT scroll regions always span the full frame width, so we rotate the
586- // frame's line offsets (recycling and blanking the evicted rows in place)
587- // instead of copying cells. See renderer.Frame.ScrollUp.
588- s.emitLineEvicted(top, n)
589- s.Frame.ScrollUp(top, bottom, n)
590- s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
591- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
592-}
593-
594-func (s *Screen) emitLineEvicted(top, n int) {
595- if s.alternate != nil {
596- return
597- }
598- for y := top; y < top+n; y++ {
599- s.recordEvicted(s.Frame.Row(y))
600- }
601-}
602-
603-func (s *Screen) recordEvicted(row []renderer.Cell) {
604- if s.history != nil {
605- s.history.Append(row)
606- }
607- if s.OnLineEvicted != nil {
608- s.OnLineEvicted(append([]renderer.Cell(nil), row...))
609- }
610-}
611-
612-func (s *Screen) respond(b []byte) {
613- if s.OnResponse != nil {
614- s.OnResponse(b)
615- }
616-}
617-
618-func (s *Screen) colorSchemeReport() []byte {
619- if !s.colorSchemeSet {
620- return nil
621- }
622- if s.colorSchemeLight {
623- return []byte(ColorSchemeReportLight)
624- }
625- return []byte(ColorSchemeReportDark)
626-}
627-
628-func (s *Screen) scrollDownRegion(top, bottom, n int) {
629- if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
630- return
631- }
632- top, bottom, ok := s.normalizedRegion(top, bottom)
633- if !ok {
634- return
635- }
636- w := s.Frame.Width
637- height := bottom - top + 1
638- if n > height {
639- n = height
640- }
641- // Full-width region: rotate line offsets instead of copying cells.
642- s.Frame.ScrollDown(top, bottom, n)
643- s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
644-}
645-
646-func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
647- if s.Frame.Height == 0 || top > bottom {
648- return 0, 0, false
649- }
650- top = clamp(top, 0, s.Frame.Height-1)
651- bottom = clamp(bottom, 0, s.Frame.Height-1)
652- return top, bottom, top <= bottom
653-}
654-
655-func (s *Screen) record(d renderer.Damage) {
656- s.damageGeneration++
657- if s.damageSaturated || s.damageFullRedrawSticky {
658- return
659- }
660- if len(s.damage) >= maxPendingDamage {
661- s.damage = []renderer.Damage{renderer.FullRedraw()}
662- s.damageSaturated = true
663- return
664- }
665- // Replace FullRedraw with the first concrete damage item.
666- if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
667- s.damage[0] = d
668- return
669- }
670- // Coalesce adjacent single-cell text damage on the same line.
671- if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 {
672- last := &s.damage[len(s.damage)-1]
673- if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 {
674- last.Width++
675- return
676- }
677- }
678- s.damage = append(s.damage, d)
679-}
680-
681-func (s *Screen) fullRedraw() {
682- s.damageGeneration++
683- s.damage = []renderer.Damage{renderer.FullRedraw()}
684- // A structural frame replacement (such as DEC 1049 exit) cannot be
685- // represented by the following text damage alone. Retain the redraw until
686- // the render owner acknowledges this screen generation.
687- s.damageFullRedrawSticky = true
688-}
689-
690144 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
691145 if len(data) < 2 {
692146 return 0, true
@@ -737,164 +191,6 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
737191 }
738192 }
739193
740-func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
741- end := -1
742- for i := 2; i < len(data); i++ {
743- if data[i] >= '@' && data[i] <= '~' {
744- end = i
745- break
746- }
747- }
748- if end == -1 {
749- return 0, true
750- }
751- params := string(data[2:end])
752- cmd := data[end]
753- s.applyCSI(params, cmd)
754- return end + 1, false
755-}
756-
757-func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
758- for i := 2; i < len(data); i++ {
759- switch data[i] {
760- case 0x07:
761- s.applyOSC(data[2:i], []byte{0x07})
762- s.handleOSC(data[2:i])
763- return i + 1, false
764- case 0x1b:
765- if i+1 < len(data) && data[i+1] == '\\' {
766- s.applyOSC(data[2:i], []byte{0x1b, '\\'})
767- s.handleOSC(data[2:i])
768- return i + 2, false
769- }
770- }
771- }
772- return 0, true
773-}
774-
775-func (s *Screen) applyOSC(payload, terminator []byte) {
776- if !s.defaultColorsKnown {
777- return
778- }
779-
780- var color renderer.RGB
781- switch string(payload) {
782- case "10;?":
783- color = s.defaultFG
784- case "11;?":
785- color = s.defaultBG
786- default:
787- return
788- }
789-
790- resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator))
791- resp = append(resp, "\x1b]"...)
792- resp = append(resp, payload[:2]...)
793- resp = append(resp, ";rgb:"...)
794- resp = appendOSCColorComponent(resp, color.R)
795- resp = append(resp, '/')
796- resp = appendOSCColorComponent(resp, color.G)
797- resp = append(resp, '/')
798- resp = appendOSCColorComponent(resp, color.B)
799- resp = append(resp, terminator...)
800- s.respond(resp)
801-}
802-
803-func appendOSCColorComponent(dst []byte, c uint8) []byte {
804- const hex = "0123456789abcdef"
805- v := uint16(c)<<8 | uint16(c)
806- return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
807-}
808-
809-// handleOSC inspects a complete OSC payload (between "ESC ]" and its
810-// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
811-// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
812-// is discarded.
813-func (s *Screen) handleOSC(payload []byte) {
814- if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
815- s.handleOSC52(string(payload[len("52;"):]))
816- return
817- }
818- p := string(payload)
819- if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
820- s.terminalTitle = title
821- return
822- }
823- if p == "9;4" || strings.HasPrefix(p, "9;4;") {
824- s.handleProgress(p[len("9;4"):])
825- return
826- }
827- if s.OnNotify == nil {
828- return
829- }
830- switch {
831- case strings.HasPrefix(p, "9;"):
832- s.OnNotify("", p[len("9;"):])
833- case strings.HasPrefix(p, "777;"):
834- parts := strings.SplitN(p[len("777;"):], ";", 3)
835- if parts[0] != "notify" {
836- return
837- }
838- var title, body string
839- if len(parts) > 1 {
840- title = parts[1]
841- }
842- if len(parts) > 2 {
843- body = parts[2]
844- }
845- s.OnNotify(title, body)
846- }
847-}
848-
849-func (s *Screen) handleProgress(rest string) {
850- rest = strings.TrimPrefix(rest, ";")
851- if rest == "" {
852- return
853- }
854- token, _, _ := strings.Cut(rest, ";")
855- state, err := strconv.Atoi(token)
856- if err != nil {
857- return
858- }
859- switch state {
860- case progressStateNormal, progressStateIndeterminate, progressStatePaused:
861- s.progressState = state
862- case progressStateClear:
863- previous := s.progressState
864- s.progressState = state
865- if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused {
866- if s.OnProgress != nil {
867- s.OnProgress(false)
868- }
869- }
870- case progressStateError:
871- previous := s.progressState
872- s.progressState = state
873- if previous != progressStateError {
874- if s.OnProgress != nil {
875- s.OnProgress(true)
876- }
877- }
878- }
879-}
880-
881-// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
882-// clipboard payload (selection may be empty; split on the first ";"). A
883-// clipboard query (data == "?") is always ignored — vev never answers
884-// clipboard queries. A payload with no second ";" is malformed and ignored.
885-func (s *Screen) handleOSC52(rest string) {
886- _, data, ok := strings.Cut(rest, ";")
887- if !ok {
888- return
889- }
890- if data == "?" {
891- return
892- }
893- if s.OnClipboard != nil {
894- s.OnClipboard(data)
895- }
896-}
897-
898194 func consumeSTString(data []byte) (consumed int, partial bool) {
899195 for i := 2; i < len(data); i++ {
900196 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -903,820 +199,3 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
903199 }
904200 return 0, true
905201 }
906-
907-func (s *Screen) applyCSI(params string, cmd byte) {
908- private := strings.HasPrefix(params, "?")
909- parts := s.parseCSIInts(params)
910- switch cmd {
911- case 'c':
912- switch params {
913- case "", "0":
914- s.respond([]byte("\x1b[?6c"))
915- case ">", ">0":
916- s.respond([]byte("\x1b[>0;0;0c"))
917- }
918- case 'n':
919- switch firstPositive(parts, 0) {
920- case 5:
921- if !private {
922- s.respond([]byte("\x1b[0n"))
923- }
924- case 996:
925- if private {
926- if report := s.colorSchemeReport(); report != nil {
927- s.respond(report)
928- }
929- }
930- case 6:
931- resp := make([]byte, 0, 16)
932- resp = append(resp, "\x1b["...)
933- if private {
934- resp = append(resp, '?')
935- }
936- resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
937- resp = append(resp, ';')
938- resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
939- resp = append(resp, 'R')
940- s.respond(resp)
941- }
942- case 'p':
943- if modeText, ok := strings.CutSuffix(params, "$"); ok {
944- if private {
945- modeText = strings.TrimPrefix(modeText, "?")
946- }
947- mode, err := strconv.Atoi(modeText)
948- if err != nil {
949- return
950- }
951- state := s.modeReportState(private, mode)
952- resp := make([]byte, 0, 16)
953- resp = append(resp, "\x1b["...)
954- if private {
955- resp = append(resp, '?')
956- }
957- resp = strconv.AppendInt(resp, int64(mode), 10)
958- resp = append(resp, ';')
959- resp = strconv.AppendInt(resp, int64(state), 10)
960- resp = append(resp, "$y"...)
961- s.respond(resp)
962- }
963- case 'm':
964- if strings.HasPrefix(params, ">") {
965- return
966- }
967- s.applySGR(params)
968- case 'q':
969- s.applyCursorStyle(params)
970- case 'J':
971- mode := 0
972- if len(parts) > 0 {
973- mode = parts[0]
974- }
975- s.clearScreenMode(mode)
976- case 'K':
977- mode := 0
978- if len(parts) > 0 {
979- mode = parts[0]
980- }
981- s.clearLineMode(mode)
982- case 'S':
983- s.scrollUpBy(firstPositive(parts, 1))
984- case 'T':
985- s.scrollDownBy(firstPositive(parts, 1))
986- case 'H', 'f':
987- row, col := 1, 1
988- if len(parts) > 0 && parts[0] > 0 {
989- row = parts[0]
990- }
991- if len(parts) > 1 && parts[1] > 0 {
992- col = parts[1]
993- }
994- s.addressCursor(row, col)
995- case 'A':
996- s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
997- case 'B':
998- s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
999- case 'C':
1000- s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
1001- case 'D':
1002- s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
1003- case 'E':
1004- s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
1005- s.Col = 0
1006- case 'F':
1007- s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
1008- s.Col = 0
1009- case 'G':
1010- col := firstPositive(parts, 1)
1011- s.Col = clamp(col-1, 0, s.Frame.Width-1)
1012- case 'd':
1013- row := firstPositive(parts, 1)
1014- s.Row = s.addressedRow(row)
1015- case '@':
1016- s.insertChars(firstPositive(parts, 1))
1017- case 'P':
1018- s.deleteChars(firstPositive(parts, 1))
1019- case 'L':
1020- s.insertLines(firstPositive(parts, 1))
1021- case 'M':
1022- s.deleteLines(firstPositive(parts, 1))
1023- case 'r':
1024- s.setScrollRegion(parts)
1025- case 's':
1026- s.saveCursor()
1027- case 'u':
1028- if params == "" {
1029- s.restoreCursor()
1030- }
1031- case 'h':
1032- s.setMode(private, parts, true)
1033- case 'l':
1034- s.setMode(private, parts, false)
1035- }
1036-}
1037-
1038-func firstPositive(parts []int, fallback int) int {
1039- if len(parts) == 0 || parts[0] <= 0 {
1040- return fallback
1041- }
1042- return parts[0]
1043-}
1044-
1045-func (s *Screen) cursorReportRow() int {
1046- if s.originMode {
1047- return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1
1048- }
1049- return s.Row + 1
1050-}
1051-
1052-func (s *Screen) modeReportState(private bool, mode int) int {
1053- if private {
1054- switch mode {
1055- case 6:
1056- return boolModeReportState(s.originMode)
1057- case 2026:
1058- return boolModeReportState(s.syncUpdateActive)
1059- case 2031:
1060- return boolModeReportState(s.colorSchemeMode)
1061- }
1062- return 0
1063- }
1064- if mode == 4 {
1065- return boolModeReportState(s.insertMode)
1066- }
1067- return 0
1068-}
1069-
1070-func boolModeReportState(enabled bool) int {
1071- if enabled {
1072- return 1
1073- }
1074- return 2
1075-}
1076-
1077-func (s *Screen) cursorMinRow() int {
1078- if s.originMode {
1079- return clamp(s.scrollTop, 0, s.Frame.Height-1)
1080- }
1081- return 0
1082-}
1083-
1084-func (s *Screen) cursorMaxRow() int {
1085- if s.originMode {
1086- return clamp(s.scrollBottom, 0, s.Frame.Height-1)
1087- }
1088- return max(s.Frame.Height-1, 0)
1089-}
1090-
1091-func (s *Screen) addressedRow(row int) int {
1092- if s.originMode {
1093- return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
1094- }
1095- return clamp(row-1, 0, s.Frame.Height-1)
1096-}
1097-
1098-func (s *Screen) addressCursor(row, col int) {
1099- s.Row = s.addressedRow(row)
1100- s.Col = clamp(col-1, 0, s.Frame.Width-1)
1101-}
1102-
1103-func (s *Screen) homeCursor() {
1104- s.addressCursor(1, 1)
1105-}
1106-
1107-func (s *Screen) applyCursorStyle(params string) {
1108- if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
1109- return
1110- }
1111- styleParam := strings.TrimSuffix(params, " ")
1112- style := 0
1113- if styleParam != "" {
1114- v, err := strconv.Atoi(styleParam)
1115- if err != nil {
1116- return
1117- }
1118- style = v
1119- }
1120- if style < 0 || style > 6 {
1121- return
1122- }
1123- s.cursorStyle = style
1124- s.cursorStyleSet = true
1125-}
1126-
1127-const sgrUnderlineStyleMarker = -1000
1128-
1129-func (s *Screen) applySGR(params string) {
1130- if params == "" {
1131- s.Style = renderer.DefaultStyle()
1132- return
1133- }
1134- parts := s.parseSGRInts(params)
1135- for i := 0; i < len(parts); i++ {
1136- switch parts[i] {
1137- case 0:
1138- s.Style = renderer.DefaultStyle()
1139- case 1:
1140- s.Style.Bold = true
1141- case 2:
1142- s.Style.Attrs |= renderer.AttrDim
1143- case 3:
1144- s.Style.Italic = true
1145- case 4:
1146- s.Style.Attrs |= renderer.AttrUnderline
1147- s.Style.UnderlineStyle = renderer.UnderlineSingle
1148- if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
1149- underlineStyle := sgrUnderlineStyle(parts[i+2])
1150- if underlineStyle == renderer.UnderlineNone {
1151- s.Style.Attrs &^= renderer.AttrUnderline
1152- } else {
1153- s.Style.Attrs |= renderer.AttrUnderline
1154- }
1155- s.Style.UnderlineStyle = underlineStyle
1156- i += 2
1157- }
1158- case 5, 6:
1159- s.Style.Attrs |= renderer.AttrBlink
1160- case 7:
1161- s.Style.Inverse = true
1162- case 9:
1163- s.Style.Attrs |= renderer.AttrStrikethrough
1164- case 21:
1165- s.Style.Attrs |= renderer.AttrUnderline
1166- s.Style.UnderlineStyle = renderer.UnderlineDouble
1167- case 22:
1168- s.Style.Bold = false
1169- s.Style.Attrs &^= renderer.AttrDim
1170- case 23:
1171- s.Style.Italic = false
1172- case 24:
1173- s.Style.Attrs &^= renderer.AttrUnderline
1174- s.Style.UnderlineStyle = renderer.UnderlineNone
1175- case 25:
1176- s.Style.Attrs &^= renderer.AttrBlink
1177- case 27:
1178- s.Style.Inverse = false
1179- case 29:
1180- s.Style.Attrs &^= renderer.AttrStrikethrough
1181- case 30, 31, 32, 33, 34, 35, 36, 37:
1182- s.Style.Foreground = parts[i] - 30
1183- s.Style.HasForegroundRGB = false
1184- case 39:
1185- s.Style.Foreground = -1
1186- s.Style.HasForegroundRGB = false
1187- case 40, 41, 42, 43, 44, 45, 46, 47:
1188- s.Style.Background = parts[i] - 40
1189- s.Style.HasBackgroundRGB = false
1190- case 49:
1191- s.Style.Background = -1
1192- s.Style.HasBackgroundRGB = false
1193- case 90, 91, 92, 93, 94, 95, 96, 97:
1194- s.Style.Foreground = parts[i] - 90 + 8
1195- s.Style.HasForegroundRGB = false
1196- case 100, 101, 102, 103, 104, 105, 106, 107:
1197- s.Style.Background = parts[i] - 100 + 8
1198- s.Style.HasBackgroundRGB = false
1199- case 38:
1200- if i+2 < len(parts) && parts[i+1] == 5 {
1201- s.Style.Foreground = parts[i+2]
1202- s.Style.HasForegroundRGB = false
1203- i += 2
1204- } else if i+4 < len(parts) && parts[i+1] == 2 {
1205- s.Style.Foreground = -1
1206- s.Style.HasForegroundRGB = true
1207- s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1208- i += 4
1209- }
1210- case 48:
1211- if i+2 < len(parts) && parts[i+1] == 5 {
1212- s.Style.Background = parts[i+2]
1213- s.Style.HasBackgroundRGB = false
1214- i += 2
1215- } else if i+4 < len(parts) && parts[i+1] == 2 {
1216- s.Style.Background = -1
1217- s.Style.HasBackgroundRGB = true
1218- s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1219- i += 4
1220- }
1221- case 58:
1222- if i+2 < len(parts) && parts[i+1] == 5 {
1223- s.Style.UnderlineColor = parts[i+2]
1224- s.Style.HasUnderlineColor = true
1225- s.Style.HasUnderlineColorRGB = false
1226- i += 2
1227- } else if i+4 < len(parts) && parts[i+1] == 2 {
1228- s.Style.HasUnderlineColor = false
1229- s.Style.HasUnderlineColorRGB = true
1230- s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
1231- i += 4
1232- }
1233- case 59:
1234- s.Style.HasUnderlineColor = false
1235- s.Style.HasUnderlineColorRGB = false
1236- }
1237- }
1238-}
1239-
1240-func sgrRGB(r, g, b int) renderer.RGB {
1241- return renderer.RGB{
1242- R: uint8(clamp(r, 0, 255)),
1243- G: uint8(clamp(g, 0, 255)),
1244- B: uint8(clamp(b, 0, 255)),
1245- }
1246-}
1247-
1248-func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
1249- switch param {
1250- case 0:
1251- return renderer.UnderlineNone
1252- case 2:
1253- return renderer.UnderlineDouble
1254- case 3:
1255- return renderer.UnderlineCurly
1256- case 4:
1257- return renderer.UnderlineDotted
1258- case 5:
1259- return renderer.UnderlineDashed
1260- default:
1261- return renderer.UnderlineSingle
1262- }
1263-}
1264-
1265-func (s *Screen) saveCursor() {
1266- s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
1267-}
1268-
1269-func (s *Screen) restoreCursor() {
1270- if !s.savedCursor.saved {
1271- return
1272- }
1273- s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
1274- s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
1275- s.Style = s.savedCursor.style
1276- s.originMode = s.savedCursor.originMode
1277- s.insertMode = s.savedCursor.insertMode
1278-}
1279-
1280-func (s *Screen) reset() {
1281- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
1282- s.Row, s.Col = 0, 0
1283- s.Style = renderer.DefaultStyle()
1284- s.escapeBuf = s.escapeBuf[:0]
1285- s.savedCursor = cursorState{}
1286- s.alternate = nil
1287- s.originMode = false
1288- s.insertMode = false
1289- s.cursorVisible = true
1290- s.cursorStyle = 0
1291- s.cursorStyleSet = false
1292- s.mouseMode = 0
1293- s.mouseSGR = false
1294- s.bracketedPaste = false
1295- s.colorSchemeMode = false
1296- s.resetScrollRegion()
1297- s.fullRedraw()
1298-}
1299-
1300-func (s *Screen) setScrollRegion(parts []int) {
1301- if s.Frame.Height == 0 {
1302- return
1303- }
1304- top, bottom := 1, s.Frame.Height
1305- if len(parts) > 0 && parts[0] > 0 {
1306- top = parts[0]
1307- }
1308- if len(parts) > 1 && parts[1] > 0 {
1309- bottom = parts[1]
1310- }
1311- if top >= bottom {
1312- s.resetScrollRegion()
1313- } else {
1314- s.scrollTop = clamp(top-1, 0, s.Frame.Height-1)
1315- s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1)
1316- if s.scrollTop >= s.scrollBottom {
1317- s.resetScrollRegion()
1318- }
1319- }
1320- s.homeCursor()
1321-}
1322-
1323-func (s *Screen) resetScrollRegion() {
1324- s.scrollTop = 0
1325- if s.Frame.Height > 0 {
1326- s.scrollBottom = s.Frame.Height - 1
1327- } else {
1328- s.scrollBottom = 0
1329- }
1330-}
1331-
1332-func (s *Screen) setMode(private bool, parts []int, enabled bool) {
1333- if !private {
1334- for _, mode := range parts {
1335- switch mode {
1336- case 4:
1337- s.insertMode = enabled
1338- default:
1339- // Other ANSI modes (for example LNM) are intentionally ignored
1340- // until the screen model implements their observable behavior.
1341- continue
1342- }
1343- }
1344- return
1345- }
1346- for _, mode := range parts {
1347- switch mode {
1348- case 6:
1349- s.originMode = enabled
1350- s.homeCursor()
1351- case 47, 1047, 1049:
1352- if enabled {
1353- s.enterAlternateScreen()
1354- } else {
1355- s.exitAlternateScreen()
1356- }
1357- case 2026:
1358- s.syncUpdateActive = enabled
1359- case 2031:
1360- s.colorSchemeMode = enabled
1361- case 25:
1362- s.cursorVisible = enabled
1363- case 1000, 1002, 1003:
1364- if enabled {
1365- s.mouseMode = mode
1366- } else if s.mouseMode == mode {
1367- s.mouseMode = 0
1368- }
1369- case 1006:
1370- s.mouseSGR = enabled
1371- case 2004:
1372- s.bracketedPaste = enabled
1373- case 1, 1004, 1005:
1374- // Trackable terminal modes that do not directly affect the current
1375- // cell model yet. Consuming them prevents mode bytes from leaking.
1376- continue
1377- }
1378- }
1379-}
1380-
1381-func (s *Screen) enterAlternateScreen() {
1382- if s.alternate == nil {
1383- s.alternate = &screenState{
1384- frame: cloneFrame(s.Frame),
1385- row: s.Row,
1386- col: s.Col,
1387- style: s.Style,
1388- scrollTop: s.scrollTop,
1389- scrollBottom: s.scrollBottom,
1390- savedCursor: s.savedCursor,
1391- originMode: s.originMode,
1392- insertMode: s.insertMode,
1393- }
1394- }
1395- s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
1396- s.Row, s.Col = 0, 0
1397- s.Style = renderer.DefaultStyle()
1398- s.savedCursor = cursorState{}
1399- s.resetScrollRegion()
1400- s.fullRedraw()
1401-}
1402-
1403-func (s *Screen) exitAlternateScreen() {
1404- if s.alternate == nil {
1405- return
1406- }
1407- state := s.alternate
1408- s.Frame = cloneFrame(state.frame)
1409- s.Row = clamp(state.row, 0, s.Frame.Height-1)
1410- s.Col = clamp(state.col, 0, s.Frame.Width-1)
1411- s.Style = state.style
1412- s.scrollTop = state.scrollTop
1413- s.scrollBottom = state.scrollBottom
1414- s.savedCursor = state.savedCursor
1415- s.originMode = state.originMode
1416- s.insertMode = state.insertMode
1417- s.alternate = nil
1418- s.fullRedraw()
1419-}
1420-
1421-func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) {
1422- next := renderer.NewFrame(newW, newH)
1423- shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
1424- if evict != nil {
1425- for y := range shift {
1426- evict(old.Row(y))
1427- }
1428- }
1429- for dy := range newH {
1430- sy := dy + shift
1431- if sy >= old.Height {
1432- break
1433- }
1434- copy(next.Row(dy), old.Row(sy))
1435- repairFrameRow(next, dy)
1436- }
1437- return next, shift
1438-}
1439-
1440-func resizedCursor(cur cursorState, shift, width, height int) cursorState {
1441- if !cur.saved {
1442- return cur
1443- }
1444- cur.row = clamp(cur.row-shift, 0, height-1)
1445- cur.col = clamp(cur.col, 0, width-1)
1446- return cur
1447-}
1448-
1449-// cloneFrame produces an independent copy in canonical layout: it copies the
1450-// source's logical rows (via Row) into a fresh frame whose offsets are already
1451-// canonical, so a rotated source is normalized in the clone.
1452-func cloneFrame(frame renderer.Frame) renderer.Frame {
1453- out := renderer.NewFrame(frame.Width, frame.Height)
1454- for y := range frame.Height {
1455- copy(out.Row(y), frame.Row(y))
1456- }
1457- return out
1458-}
1459-
1460-func (s *Screen) insertChars(n int) {
1461- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
1462- return
1463- }
1464- w := s.Frame.Width
1465- if n > w-s.Col {
1466- n = w - s.Col
1467- }
1468- row := s.Frame.Row(s.Row)
1469- // A wide left half at Col-1 whose continuation sits at Col will be orphaned
1470- // by the shift; its repair falls outside the default damage rect.
1471- leftSplit := s.Col > 0 && row[s.Col].Continuation
1472- for x := w - 1; x >= s.Col+n; x-- {
1473- row[x] = row[x-n]
1474- }
1475- for x := s.Col; x < s.Col+n; x++ {
1476- row[x] = renderer.BlankCell()
1477- }
1478- s.repairRow(s.Row)
1479- dmgX := s.Col
1480- if leftSplit {
1481- dmgX = s.Col - 1
1482- }
1483- s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
1484-}
1485-
1486-func (s *Screen) deleteChars(n int) {
1487- if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
1488- return
1489- }
1490- w := s.Frame.Width
1491- if n > w-s.Col {
1492- n = w - s.Col
1493- }
1494- row := s.Frame.Row(s.Row)
1495- // A wide left half at Col-1 whose continuation sits at Col will be orphaned
1496- // by the shift; its repair falls outside the default damage rect.
1497- leftSplit := s.Col > 0 && row[s.Col].Continuation
1498- for x := s.Col; x < w-n; x++ {
1499- row[x] = row[x+n]
1500- }
1501- for x := w - n; x < w; x++ {
1502- row[x] = renderer.BlankCell()
1503- }
1504- s.repairRow(s.Row)
1505- dmgX := s.Col
1506- if leftSplit {
1507- dmgX = s.Col - 1
1508- }
1509- s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
1510-}
1511-
1512-func (s *Screen) insertLines(n int) {
1513- if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
1514- return
1515- }
1516- s.scrollDownRegion(s.Row, s.scrollBottom, n)
1517-}
1518-
1519-func (s *Screen) deleteLines(n int) {
1520- if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
1521- return
1522- }
1523- s.scrollUpRegion(s.Row, s.scrollBottom, n)
1524-}
1525-
1526-func (s *Screen) clearScreenMode(mode int) {
1527- s.clampCursor()
1528- switch mode {
1529- case 1:
1530- for y := range min(s.Row+1, s.Frame.Height) {
1531- end := s.Frame.Width
1532- if y == s.Row {
1533- end = min(s.Col+1, s.Frame.Width)
1534- }
1535- s.clearRow(y, 0, end)
1536- }
1537- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1})
1538- case 2, 3:
1539- blank := s.eraseCell()
1540- for i := range s.Frame.Cells {
1541- s.Frame.Cells[i] = blank
1542- }
1543- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
1544- default:
1545- for y := s.Row; y < s.Frame.Height; y++ {
1546- start := 0
1547- if y == s.Row {
1548- start = s.Col
1549- }
1550- s.clearRow(y, start, s.Frame.Width)
1551- }
1552- s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
1553- }
1554-}
1555-
1556-func (s *Screen) clearLineMode(mode int) {
1557- s.clampCursor()
1558- switch mode {
1559- case 1:
1560- start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
1561- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
1562- case 2:
1563- start, width := s.clearRow(s.Row, 0, s.Frame.Width)
1564- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
1565- default:
1566- start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
1567- s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
1568- }
1569-}
1570-
1571-func (s *Screen) clampCursor() {
1572- s.Row = clamp(s.Row, 0, s.Frame.Height-1)
1573- s.Col = clamp(s.Col, 0, s.Frame.Width-1)
1574-}
1575-
1576-func (s *Screen) parseCSIInts(params string) []int {
1577- s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
1578- return s.csiScratch
1579-}
1580-
1581-func parseCSIInts(params string) []int {
1582- return parseCSIIntsInto(nil, params)
1583-}
1584-
1585-func parseCSIIntsInto(out []int, params string) []int {
1586- if params == "" {
1587- return out
1588- }
1589- params = strings.TrimPrefix(params, "?")
1590- params = strings.TrimPrefix(params, ">")
1591- start := 0
1592- for i := 0; i <= len(params); i++ {
1593- if i == len(params) || params[i] == ';' {
1594- out = append(out, parseCSIInt(params[start:i]))
1595- start = i + 1
1596- }
1597- }
1598- return out
1599-}
1600-
1601-func (s *Screen) parseSGRInts(params string) []int {
1602- s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
1603- return s.sgrScratch
1604-}
1605-
1606-func parseSGRInts(params string) []int {
1607- return parseSGRIntsInto(nil, params)
1608-}
1609-
1610-func parseSGRIntsInto(out []int, params string) []int {
1611- if params == "" {
1612- return out
1613- }
1614- start := 0
1615- for i := 0; i <= len(params); i++ {
1616- if i == len(params) || params[i] == ';' {
1617- out = appendSGRGroup(out, params[start:i])
1618- start = i + 1
1619- }
1620- }
1621- return out
1622-}
1623-
1624-func appendSGRGroup(out []int, group string) []int {
1625- colon := false
1626- for i := 0; i < len(group); i++ {
1627- if group[i] == ':' {
1628- colon = true
1629- break
1630- }
1631- }
1632- if !colon {
1633- return append(out, parseCSIInt(group))
1634- }
1635-
1636- parts := countSGRColonFields(group)
1637- code := parseCSIInt(sgrColonField(group, 0))
1638- if code == 4 {
1639- if parts < 2 {
1640- return append(out, code)
1641- }
1642- return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
1643- }
1644- if code != 38 && code != 48 && code != 58 {
1645- return append(out, code)
1646- }
1647- mode := 0
1648- if parts > 1 {
1649- mode = parseCSIInt(sgrColonField(group, 1))
1650- }
1651- switch mode {
1652- case 5:
1653- if parts < 3 {
1654- return out
1655- }
1656- out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
1657- case 2:
1658- // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
1659- // the colorspace slot; code:mode:R:G:B omits that slot.
1660- start := 2
1661- if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
1662- start = 3
1663- }
1664- if parts < start+3 {
1665- return out
1666- }
1667- out = append(out, code, mode)
1668- for i := range 3 {
1669- out = append(out, parseCSIInt(sgrColonField(group, start+i)))
1670- }
1671- }
1672- return out
1673-}
1674-
1675-func countSGRColonFields(group string) int {
1676- count := 1
1677- for i := 0; i < len(group); i++ {
1678- if group[i] == ':' {
1679- count++
1680- }
1681- }
1682- return count
1683-}
1684-
1685-func sgrColonField(group string, index int) string {
1686- start := 0
1687- field := 0
1688- for i := 0; i <= len(group); i++ {
1689- if i == len(group) || group[i] == ':' {
1690- if field == index {
1691- return group[start:i]
1692- }
1693- field++
1694- start = i + 1
1695- }
1696- }
1697- return ""
1698-}
1699-
1700-func parseCSIInt(param string) int {
1701- if param == "" {
1702- return 0
1703- }
1704- v, err := strconv.Atoi(param)
1705- if err != nil {
1706- return 0
1707- }
1708- return v
1709-}
1710-
1711-func clamp(v, lo, hi int) int {
1712- if hi < lo {
1713- return lo
1714- }
1715- if v < lo {
1716- return lo
1717- }
1718- if v > hi {
1719- return hi
1720- }
1721- return v
1722-}
A pkg/vt/screen_csi.go +577 -0
@@ -0,0 +1,577 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func (s *Screen) consumeCSI(data []byte) (consumed int, partial bool) {
11+ end := -1
12+ for i := 2; i < len(data); i++ {
13+ if data[i] >= '@' && data[i] <= '~' {
14+ end = i
15+ break
16+ }
17+ }
18+ if end == -1 {
19+ return 0, true
20+ }
21+ params := string(data[2:end])
22+ cmd := data[end]
23+ s.applyCSI(params, cmd)
24+ return end + 1, false
25+}
26+
27+func (s *Screen) applyCSI(params string, cmd byte) {
28+ private := strings.HasPrefix(params, "?")
29+ parts := s.parseCSIInts(params)
30+ switch cmd {
31+ case 'c':
32+ switch params {
33+ case "", "0":
34+ s.respond([]byte("\x1b[?6c"))
35+ case ">", ">0":
36+ s.respond([]byte("\x1b[>0;0;0c"))
37+ }
38+ case 'n':
39+ switch firstPositive(parts, 0) {
40+ case 5:
41+ if !private {
42+ s.respond([]byte("\x1b[0n"))
43+ }
44+ case 996:
45+ if private {
46+ if report := s.colorSchemeReport(); report != nil {
47+ s.respond(report)
48+ }
49+ }
50+ case 6:
51+ resp := make([]byte, 0, 16)
52+ resp = append(resp, "\x1b["...)
53+ if private {
54+ resp = append(resp, '?')
55+ }
56+ resp = strconv.AppendInt(resp, int64(s.cursorReportRow()), 10)
57+ resp = append(resp, ';')
58+ resp = strconv.AppendInt(resp, int64(s.Col+1), 10)
59+ resp = append(resp, 'R')
60+ s.respond(resp)
61+ }
62+ case 'p':
63+ if modeText, ok := strings.CutSuffix(params, "$"); ok {
64+ if private {
65+ modeText = strings.TrimPrefix(modeText, "?")
66+ }
67+ mode, err := strconv.Atoi(modeText)
68+ if err != nil {
69+ return
70+ }
71+ state := s.modeReportState(private, mode)
72+ resp := make([]byte, 0, 16)
73+ resp = append(resp, "\x1b["...)
74+ if private {
75+ resp = append(resp, '?')
76+ }
77+ resp = strconv.AppendInt(resp, int64(mode), 10)
78+ resp = append(resp, ';')
79+ resp = strconv.AppendInt(resp, int64(state), 10)
80+ resp = append(resp, "$y"...)
81+ s.respond(resp)
82+ }
83+ case 'm':
84+ if strings.HasPrefix(params, ">") {
85+ return
86+ }
87+ s.applySGR(params)
88+ case 'q':
89+ s.applyCursorStyle(params)
90+ case 'J':
91+ mode := 0
92+ if len(parts) > 0 {
93+ mode = parts[0]
94+ }
95+ s.clearScreenMode(mode)
96+ case 'K':
97+ mode := 0
98+ if len(parts) > 0 {
99+ mode = parts[0]
100+ }
101+ s.clearLineMode(mode)
102+ case 'S':
103+ s.scrollUpBy(firstPositive(parts, 1))
104+ case 'T':
105+ s.scrollDownBy(firstPositive(parts, 1))
106+ case 'H', 'f':
107+ row, col := 1, 1
108+ if len(parts) > 0 && parts[0] > 0 {
109+ row = parts[0]
110+ }
111+ if len(parts) > 1 && parts[1] > 0 {
112+ col = parts[1]
113+ }
114+ s.addressCursor(row, col)
115+ case 'A':
116+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
117+ case 'B':
118+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
119+ case 'C':
120+ s.Col = clamp(s.Col+firstPositive(parts, 1), 0, s.Frame.Width-1)
121+ case 'D':
122+ s.Col = clamp(s.Col-firstPositive(parts, 1), 0, s.Frame.Width-1)
123+ case 'E':
124+ s.Row = clamp(s.Row+firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
125+ s.Col = 0
126+ case 'F':
127+ s.Row = clamp(s.Row-firstPositive(parts, 1), s.cursorMinRow(), s.cursorMaxRow())
128+ s.Col = 0
129+ case 'G':
130+ col := firstPositive(parts, 1)
131+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
132+ case 'd':
133+ row := firstPositive(parts, 1)
134+ s.Row = s.addressedRow(row)
135+ case '@':
136+ s.insertChars(firstPositive(parts, 1))
137+ case 'P':
138+ s.deleteChars(firstPositive(parts, 1))
139+ case 'L':
140+ s.insertLines(firstPositive(parts, 1))
141+ case 'M':
142+ s.deleteLines(firstPositive(parts, 1))
143+ case 'r':
144+ s.setScrollRegion(parts)
145+ case 's':
146+ s.saveCursor()
147+ case 'u':
148+ if params == "" {
149+ s.restoreCursor()
150+ }
151+ case 'h':
152+ s.setMode(private, parts, true)
153+ case 'l':
154+ s.setMode(private, parts, false)
155+ }
156+}
157+
158+func firstPositive(parts []int, fallback int) int {
159+ if len(parts) == 0 || parts[0] <= 0 {
160+ return fallback
161+ }
162+ return parts[0]
163+}
164+
165+func (s *Screen) modeReportState(private bool, mode int) int {
166+ if private {
167+ switch mode {
168+ case 6:
169+ return boolModeReportState(s.originMode)
170+ case 2026:
171+ return boolModeReportState(s.syncUpdateActive)
172+ case 2031:
173+ return boolModeReportState(s.colorSchemeMode)
174+ }
175+ return 0
176+ }
177+ if mode == 4 {
178+ return boolModeReportState(s.insertMode)
179+ }
180+ return 0
181+}
182+
183+func boolModeReportState(enabled bool) int {
184+ if enabled {
185+ return 1
186+ }
187+ return 2
188+}
189+
190+const sgrUnderlineStyleMarker = -1000
191+
192+func (s *Screen) applySGR(params string) {
193+ if params == "" {
194+ s.Style = renderer.DefaultStyle()
195+ return
196+ }
197+ parts := s.parseSGRInts(params)
198+ for i := 0; i < len(parts); i++ {
199+ switch parts[i] {
200+ case 0:
201+ s.Style = renderer.DefaultStyle()
202+ case 1:
203+ s.Style.Bold = true
204+ case 2:
205+ s.Style.Attrs |= renderer.AttrDim
206+ case 3:
207+ s.Style.Italic = true
208+ case 4:
209+ s.Style.Attrs |= renderer.AttrUnderline
210+ s.Style.UnderlineStyle = renderer.UnderlineSingle
211+ if i+2 < len(parts) && parts[i+1] == sgrUnderlineStyleMarker {
212+ underlineStyle := sgrUnderlineStyle(parts[i+2])
213+ if underlineStyle == renderer.UnderlineNone {
214+ s.Style.Attrs &^= renderer.AttrUnderline
215+ } else {
216+ s.Style.Attrs |= renderer.AttrUnderline
217+ }
218+ s.Style.UnderlineStyle = underlineStyle
219+ i += 2
220+ }
221+ case 5, 6:
222+ s.Style.Attrs |= renderer.AttrBlink
223+ case 7:
224+ s.Style.Inverse = true
225+ case 9:
226+ s.Style.Attrs |= renderer.AttrStrikethrough
227+ case 21:
228+ s.Style.Attrs |= renderer.AttrUnderline
229+ s.Style.UnderlineStyle = renderer.UnderlineDouble
230+ case 22:
231+ s.Style.Bold = false
232+ s.Style.Attrs &^= renderer.AttrDim
233+ case 23:
234+ s.Style.Italic = false
235+ case 24:
236+ s.Style.Attrs &^= renderer.AttrUnderline
237+ s.Style.UnderlineStyle = renderer.UnderlineNone
238+ case 25:
239+ s.Style.Attrs &^= renderer.AttrBlink
240+ case 27:
241+ s.Style.Inverse = false
242+ case 29:
243+ s.Style.Attrs &^= renderer.AttrStrikethrough
244+ case 30, 31, 32, 33, 34, 35, 36, 37:
245+ s.Style.Foreground = parts[i] - 30
246+ s.Style.HasForegroundRGB = false
247+ case 39:
248+ s.Style.Foreground = -1
249+ s.Style.HasForegroundRGB = false
250+ case 40, 41, 42, 43, 44, 45, 46, 47:
251+ s.Style.Background = parts[i] - 40
252+ s.Style.HasBackgroundRGB = false
253+ case 49:
254+ s.Style.Background = -1
255+ s.Style.HasBackgroundRGB = false
256+ case 90, 91, 92, 93, 94, 95, 96, 97:
257+ s.Style.Foreground = parts[i] - 90 + 8
258+ s.Style.HasForegroundRGB = false
259+ case 100, 101, 102, 103, 104, 105, 106, 107:
260+ s.Style.Background = parts[i] - 100 + 8
261+ s.Style.HasBackgroundRGB = false
262+ case 38:
263+ if i+2 < len(parts) && parts[i+1] == 5 {
264+ s.Style.Foreground = parts[i+2]
265+ s.Style.HasForegroundRGB = false
266+ i += 2
267+ } else if i+4 < len(parts) && parts[i+1] == 2 {
268+ s.Style.Foreground = -1
269+ s.Style.HasForegroundRGB = true
270+ s.Style.ForegroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
271+ i += 4
272+ }
273+ case 48:
274+ if i+2 < len(parts) && parts[i+1] == 5 {
275+ s.Style.Background = parts[i+2]
276+ s.Style.HasBackgroundRGB = false
277+ i += 2
278+ } else if i+4 < len(parts) && parts[i+1] == 2 {
279+ s.Style.Background = -1
280+ s.Style.HasBackgroundRGB = true
281+ s.Style.BackgroundRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
282+ i += 4
283+ }
284+ case 58:
285+ if i+2 < len(parts) && parts[i+1] == 5 {
286+ s.Style.UnderlineColor = parts[i+2]
287+ s.Style.HasUnderlineColor = true
288+ s.Style.HasUnderlineColorRGB = false
289+ i += 2
290+ } else if i+4 < len(parts) && parts[i+1] == 2 {
291+ s.Style.HasUnderlineColor = false
292+ s.Style.HasUnderlineColorRGB = true
293+ s.Style.UnderlineColorRGB = sgrRGB(parts[i+2], parts[i+3], parts[i+4])
294+ i += 4
295+ }
296+ case 59:
297+ s.Style.HasUnderlineColor = false
298+ s.Style.HasUnderlineColorRGB = false
299+ }
300+ }
301+}
302+
303+func sgrRGB(r, g, b int) renderer.RGB {
304+ return renderer.RGB{
305+ R: uint8(clamp(r, 0, 255)),
306+ G: uint8(clamp(g, 0, 255)),
307+ B: uint8(clamp(b, 0, 255)),
308+ }
309+}
310+
311+func sgrUnderlineStyle(param int) renderer.UnderlineStyle {
312+ switch param {
313+ case 0:
314+ return renderer.UnderlineNone
315+ case 2:
316+ return renderer.UnderlineDouble
317+ case 3:
318+ return renderer.UnderlineCurly
319+ case 4:
320+ return renderer.UnderlineDotted
321+ case 5:
322+ return renderer.UnderlineDashed
323+ default:
324+ return renderer.UnderlineSingle
325+ }
326+}
327+
328+func (s *Screen) insertChars(n int) {
329+ if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
330+ return
331+ }
332+ w := s.Frame.Width
333+ if n > w-s.Col {
334+ n = w - s.Col
335+ }
336+ row := s.Frame.Row(s.Row)
337+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
338+ // by the shift; its repair falls outside the default damage rect.
339+ leftSplit := s.Col > 0 && row[s.Col].Continuation
340+ for x := w - 1; x >= s.Col+n; x-- {
341+ row[x] = row[x-n]
342+ }
343+ for x := s.Col; x < s.Col+n; x++ {
344+ row[x] = renderer.BlankCell()
345+ }
346+ s.repairRow(s.Row)
347+ dmgX := s.Col
348+ if leftSplit {
349+ dmgX = s.Col - 1
350+ }
351+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
352+}
353+
354+func (s *Screen) deleteChars(n int) {
355+ if s.Row < 0 || s.Row >= s.Frame.Height || s.Col >= s.Frame.Width || n <= 0 {
356+ return
357+ }
358+ w := s.Frame.Width
359+ if n > w-s.Col {
360+ n = w - s.Col
361+ }
362+ row := s.Frame.Row(s.Row)
363+ // A wide left half at Col-1 whose continuation sits at Col will be orphaned
364+ // by the shift; its repair falls outside the default damage rect.
365+ leftSplit := s.Col > 0 && row[s.Col].Continuation
366+ for x := s.Col; x < w-n; x++ {
367+ row[x] = row[x+n]
368+ }
369+ for x := w - n; x < w; x++ {
370+ row[x] = renderer.BlankCell()
371+ }
372+ s.repairRow(s.Row)
373+ dmgX := s.Col
374+ if leftSplit {
375+ dmgX = s.Col - 1
376+ }
377+ s.record(renderer.Damage{Kind: renderer.DamageText, X: dmgX, Y: s.Row, Width: w - dmgX, Height: 1, Count: 1})
378+}
379+
380+func (s *Screen) insertLines(n int) {
381+ if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
382+ return
383+ }
384+ s.scrollDownRegion(s.Row, s.scrollBottom, n)
385+}
386+
387+func (s *Screen) deleteLines(n int) {
388+ if s.Row < s.scrollTop || s.Row > s.scrollBottom || n <= 0 {
389+ return
390+ }
391+ s.scrollUpRegion(s.Row, s.scrollBottom, n)
392+}
393+
394+func (s *Screen) clearScreenMode(mode int) {
395+ s.clampCursor()
396+ switch mode {
397+ case 1:
398+ for y := range min(s.Row+1, s.Frame.Height) {
399+ end := s.Frame.Width
400+ if y == s.Row {
401+ end = min(s.Col+1, s.Frame.Width)
402+ }
403+ s.clearRow(y, 0, end)
404+ }
405+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: min(s.Row+1, s.Frame.Height), Count: 1})
406+ case 2, 3:
407+ blank := s.eraseCell()
408+ for i := range s.Frame.Cells {
409+ s.Frame.Cells[i] = blank
410+ }
411+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: 0, Width: s.Frame.Width, Height: s.Frame.Height, Count: 1})
412+ default:
413+ for y := s.Row; y < s.Frame.Height; y++ {
414+ start := 0
415+ if y == s.Row {
416+ start = s.Col
417+ }
418+ s.clearRow(y, start, s.Frame.Width)
419+ }
420+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: 0, Y: s.Row, Width: s.Frame.Width, Height: s.Frame.Height - s.Row, Count: 1})
421+ }
422+}
423+
424+func (s *Screen) clearLineMode(mode int) {
425+ s.clampCursor()
426+ switch mode {
427+ case 1:
428+ start, width := s.clearRow(s.Row, 0, min(s.Col+1, s.Frame.Width))
429+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
430+ case 2:
431+ start, width := s.clearRow(s.Row, 0, s.Frame.Width)
432+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
433+ default:
434+ start, width := s.clearRow(s.Row, s.Col, s.Frame.Width)
435+ s.record(renderer.Damage{Kind: renderer.DamageClear, X: start, Y: s.Row, Width: width, Height: 1, Count: 1})
436+ }
437+}
438+
439+func (s *Screen) clampCursor() {
440+ s.Row = clamp(s.Row, 0, s.Frame.Height-1)
441+ s.Col = clamp(s.Col, 0, s.Frame.Width-1)
442+}
443+
444+func (s *Screen) parseCSIInts(params string) []int {
445+ s.csiScratch = parseCSIIntsInto(s.csiScratch[:0], params)
446+ return s.csiScratch
447+}
448+
449+func parseCSIInts(params string) []int {
450+ return parseCSIIntsInto(nil, params)
451+}
452+
453+func parseCSIIntsInto(out []int, params string) []int {
454+ if params == "" {
455+ return out
456+ }
457+ params = strings.TrimPrefix(params, "?")
458+ params = strings.TrimPrefix(params, ">")
459+ start := 0
460+ for i := 0; i <= len(params); i++ {
461+ if i == len(params) || params[i] == ';' {
462+ out = append(out, parseCSIInt(params[start:i]))
463+ start = i + 1
464+ }
465+ }
466+ return out
467+}
468+
469+func (s *Screen) parseSGRInts(params string) []int {
470+ s.sgrScratch = parseSGRIntsInto(s.sgrScratch[:0], params)
471+ return s.sgrScratch
472+}
473+
474+func parseSGRInts(params string) []int {
475+ return parseSGRIntsInto(nil, params)
476+}
477+
478+func parseSGRIntsInto(out []int, params string) []int {
479+ if params == "" {
480+ return out
481+ }
482+ start := 0
483+ for i := 0; i <= len(params); i++ {
484+ if i == len(params) || params[i] == ';' {
485+ out = appendSGRGroup(out, params[start:i])
486+ start = i + 1
487+ }
488+ }
489+ return out
490+}
491+
492+func appendSGRGroup(out []int, group string) []int {
493+ colon := false
494+ for i := 0; i < len(group); i++ {
495+ if group[i] == ':' {
496+ colon = true
497+ break
498+ }
499+ }
500+ if !colon {
501+ return append(out, parseCSIInt(group))
502+ }
503+
504+ parts := countSGRColonFields(group)
505+ code := parseCSIInt(sgrColonField(group, 0))
506+ if code == 4 {
507+ if parts < 2 {
508+ return append(out, code)
509+ }
510+ return append(out, code, sgrUnderlineStyleMarker, parseCSIInt(sgrColonField(group, 1)))
511+ }
512+ if code != 38 && code != 48 && code != 58 {
513+ return append(out, code)
514+ }
515+ mode := 0
516+ if parts > 1 {
517+ mode = parseCSIInt(sgrColonField(group, 1))
518+ }
519+ switch mode {
520+ case 5:
521+ if parts < 3 {
522+ return out
523+ }
524+ out = append(out, code, mode, parseCSIInt(sgrColonField(group, 2)))
525+ case 2:
526+ // code:mode::R:G:B and code:mode:cs:R:G:B both put RGB after
527+ // the colorspace slot; code:mode:R:G:B omits that slot.
528+ start := 2
529+ if parts > 2 && (sgrColonField(group, 2) == "" || parts >= 6) {
530+ start = 3
531+ }
532+ if parts < start+3 {
533+ return out
534+ }
535+ out = append(out, code, mode)
536+ for i := range 3 {
537+ out = append(out, parseCSIInt(sgrColonField(group, start+i)))
538+ }
539+ }
540+ return out
541+}
542+
543+func countSGRColonFields(group string) int {
544+ count := 1
545+ for i := 0; i < len(group); i++ {
546+ if group[i] == ':' {
547+ count++
548+ }
549+ }
550+ return count
551+}
552+
553+func sgrColonField(group string, index int) string {
554+ start := 0
555+ field := 0
556+ for i := 0; i <= len(group); i++ {
557+ if i == len(group) || group[i] == ':' {
558+ if field == index {
559+ return group[start:i]
560+ }
561+ field++
562+ start = i + 1
563+ }
564+ }
565+ return ""
566+}
567+
568+func parseCSIInt(param string) int {
569+ if param == "" {
570+ return 0
571+ }
572+ v, err := strconv.Atoi(param)
573+ if err != nil {
574+ return 0
575+ }
576+ return v
577+}
A pkg/vt/screen_csi_test.go +996 -0
@@ -0,0 +1,996 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestParseCSIInts(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ params string
16+ want []int
17+ }{
18+ {name: "empty", params: "", want: nil},
19+ {name: "single", params: "5", want: []int{5}},
20+ {name: "multiple", params: "1;2;3", want: []int{1, 2, 3}},
21+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
22+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
23+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
24+ {name: "question prefix", params: "?25", want: []int{25}},
25+ {name: "greater prefix", params: ">0;1", want: []int{0, 1}},
26+ {name: "prefix order", params: "?>7", want: []int{7}},
27+ {name: "junk token", params: "12x;4", want: []int{0, 4}},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ require.Equal(t, tt.want, parseCSIInts(tt.params))
33+ })
34+ }
35+}
36+
37+func TestParseSGRInts(t *testing.T) {
38+ t.Parallel()
39+
40+ tests := []struct {
41+ name string
42+ params string
43+ want []int
44+ }{
45+ {name: "empty", params: "", want: nil},
46+ {name: "single", params: "1", want: []int{1}},
47+ {name: "multiple", params: "1;31", want: []int{1, 31}},
48+ {name: "leading empty token", params: ";5", want: []int{0, 5}},
49+ {name: "double empty token", params: "5;;3", want: []int{5, 0, 3}},
50+ {name: "trailing empty token", params: "5;", want: []int{5, 0}},
51+ {name: "indexed foreground colon", params: "38:5:196", want: []int{38, 5, 196}},
52+ {name: "rgb foreground colon", params: "38:2:1:2:3", want: []int{38, 2, 1, 2, 3}},
53+ {name: "indexed background colon", params: "48:5:10", want: []int{48, 5, 10}},
54+ {name: "rgb foreground empty colorspace", params: "38:2::1:2:3", want: []int{38, 2, 1, 2, 3}},
55+ {name: "rgb foreground explicit colorspace", params: "38:2:9:1:2:3", want: []int{38, 2, 1, 2, 3}},
56+ {name: "non color colon group", params: "1:2:3", want: []int{1}},
57+ {name: "junk token", params: "abc;31", want: []int{0, 31}},
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ require.Equal(t, tt.want, parseSGRInts(tt.params))
63+ })
64+ }
65+}
66+
67+func TestSGR(t *testing.T) {
68+ tests := []struct {
69+ name string
70+ seq string
71+ check func(t *testing.T, s *Screen)
72+ }{
73+ {
74+ name: "SGR 0 resets to default style",
75+ seq: "\x1b[0m",
76+ check: func(t *testing.T, s *Screen) {
77+ if s.Style != (renderer.DefaultStyle()) {
78+ t.Errorf("style after SGR 0 = %+v, want default", s.Style)
79+ }
80+ },
81+ },
82+ {
83+ name: "SGR 1 sets bold",
84+ seq: "\x1b[1m",
85+ check: func(t *testing.T, s *Screen) {
86+ if !s.Style.Bold {
87+ t.Error("expected bold")
88+ }
89+ },
90+ },
91+ {
92+ name: "SGR 7 sets inverse",
93+ seq: "\x1b[7m",
94+ check: func(t *testing.T, s *Screen) {
95+ if !s.Style.Inverse {
96+ t.Error("expected inverse")
97+ }
98+ },
99+ },
100+ {
101+ name: "SGR 31 sets red foreground",
102+ seq: "\x1b[31m",
103+ check: func(t *testing.T, s *Screen) {
104+ if s.Style.Foreground != 1 {
105+ t.Errorf("foreground = %d, want 1 (red)", s.Style.Foreground)
106+ }
107+ },
108+ },
109+ {
110+ name: "SGR 44 sets blue background",
111+ seq: "\x1b[44m",
112+ check: func(t *testing.T, s *Screen) {
113+ if s.Style.Background != 4 {
114+ t.Errorf("background = %d, want 4 (blue)", s.Style.Background)
115+ }
116+ },
117+ },
118+ {
119+ name: "SGR 38;5 sets 256-color foreground",
120+ seq: "\x1b[38;5;82m",
121+ check: func(t *testing.T, s *Screen) {
122+ if s.Style.Foreground != 82 {
123+ t.Errorf("foreground = %d, want 82", s.Style.Foreground)
124+ }
125+ },
126+ },
127+ {
128+ name: "SGR 48;5 sets 256-color background",
129+ seq: "\x1b[48;5;200m",
130+ check: func(t *testing.T, s *Screen) {
131+ if s.Style.Background != 200 {
132+ t.Errorf("background = %d, want 200", s.Style.Background)
133+ }
134+ },
135+ },
136+ {
137+ name: "SGR 91 sets bright red foreground",
138+ seq: "\x1b[91m",
139+ check: func(t *testing.T, s *Screen) {
140+ if s.Style.Foreground != 9 {
141+ t.Errorf("foreground = %d, want 9 (bright red)", s.Style.Foreground)
142+ }
143+ },
144+ },
145+ {
146+ name: "SGR 107 sets bright white background",
147+ seq: "\x1b[107m",
148+ check: func(t *testing.T, s *Screen) {
149+ if s.Style.Background != 15 {
150+ t.Errorf("background = %d, want 15 (bright white)", s.Style.Background)
151+ }
152+ },
153+ },
154+ {
155+ name: "SGR 22 resets bold",
156+ seq: "\x1b[1mHello\x1b[22mWorld",
157+ check: func(t *testing.T, s *Screen) {
158+ if s.Style.Bold {
159+ t.Error("bold should be reset after 22")
160+ }
161+ },
162+ },
163+ {
164+ name: "SGR 27 resets inverse",
165+ seq: "\x1b[7m\x1b[27m",
166+ check: func(t *testing.T, s *Screen) {
167+ if s.Style.Inverse {
168+ t.Error("inverse should be reset after 27")
169+ }
170+ },
171+ },
172+ {
173+ name: "SGR 39 resets foreground to default",
174+ seq: "\x1b[31m\x1b[39m",
175+ check: func(t *testing.T, s *Screen) {
176+ if s.Style.Foreground != -1 {
177+ t.Errorf("foreground should be default after 39, got %d", s.Style.Foreground)
178+ }
179+ },
180+ },
181+ {
182+ name: "SGR 49 resets background to default",
183+ seq: "\x1b[44m\x1b[49m",
184+ check: func(t *testing.T, s *Screen) {
185+ if s.Style.Background != -1 {
186+ t.Errorf("background should be default after 49, got %d", s.Style.Background)
187+ }
188+ },
189+ },
190+ {
191+ name: "SGR style is applied to written cells",
192+ seq: "\x1b[1;31mX",
193+ check: func(t *testing.T, s *Screen) {
194+ c := cellAt(s, 0, 0)
195+ if !c.Style.Bold {
196+ t.Error("cell should be bold")
197+ }
198+ if c.Style.Foreground != 1 {
199+ t.Errorf("cell foreground = %d, want 1", c.Style.Foreground)
200+ }
201+ },
202+ },
203+ {
204+ name: "multiple sequential SGR sequences accumulate",
205+ seq: "\x1b[31m\x1b[1m\x1b[44mX",
206+ check: func(t *testing.T, s *Screen) {
207+ c := cellAt(s, 0, 0)
208+ if c.Style.Foreground != 1 || !c.Style.Bold || c.Style.Background != 4 {
209+ t.Errorf("cell style = %+v, want fg=1 bold bg=4", c.Style)
210+ }
211+ },
212+ },
213+ {
214+ name: "SGR 3 sets italic on style and cell, SGR 23 clears it",
215+ seq: "\x1b[3mX\x1b[23mY",
216+ check: func(t *testing.T, s *Screen) {
217+ if !cellAt(s, 0, 0).Style.Italic {
218+ t.Errorf("first cell italic = false, want true")
219+ }
220+ if cellAt(s, 1, 0).Style.Italic {
221+ t.Errorf("second cell italic = true, want false after SGR 23")
222+ }
223+ if s.Style.Italic {
224+ t.Errorf("current style italic = true, want false after SGR 23")
225+ }
226+ },
227+ },
228+ {
229+ name: "SGR preserves dim underline blink and strikethrough with resets",
230+ seq: "\x1b[2;4;5;9mA\x1b[22;24;25;29mB",
231+ check: func(t *testing.T, s *Screen) {
232+ first := cellAt(s, 0, 0).Style
233+ if first.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough {
234+ t.Errorf("first cell attrs = %b, want dim underline blink strikethrough", first.Attrs)
235+ }
236+ second := cellAt(s, 1, 0).Style
237+ if second.Attrs&(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 {
238+ t.Errorf("second cell attrs = %b, want resets cleared", second.Attrs)
239+ }
240+ },
241+ },
242+ {
243+ name: "SGR 22 resets both bold and dim",
244+ seq: "\x1b[1;2m\x1b[22m",
245+ check: func(t *testing.T, s *Screen) {
246+ if s.Style.Bold || s.Style.Attrs&renderer.AttrDim != 0 {
247+ t.Errorf("style after SGR 22 = bold:%v attrs:%b, want neither bold nor dim", s.Style.Bold, s.Style.Attrs)
248+ }
249+ },
250+ },
251+ {
252+ name: "SGR underline style and color are preserved and reset",
253+ seq: "\x1b[4:3;58:2::255:0:0mA\x1b[59mB",
254+ check: func(t *testing.T, s *Screen) {
255+ first := cellAt(s, 0, 0).Style
256+ if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
257+ t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
258+ }
259+ want := renderer.RGB{R: 255, G: 0, B: 0}
260+ if !first.HasUnderlineColorRGB || first.UnderlineColorRGB != want {
261+ t.Errorf("first underline color = rgb:%v %+v, want %+v", first.HasUnderlineColorRGB, first.UnderlineColorRGB, want)
262+ }
263+ second := cellAt(s, 1, 0).Style
264+ if second.HasUnderlineColorRGB || second.HasUnderlineColor {
265+ t.Errorf("second underline color flags = indexed:%v rgb:%v, want reset", second.HasUnderlineColor, second.HasUnderlineColorRGB)
266+ }
267+ },
268+ },
269+ {
270+ name: "SGR 4 colon zero clears underline",
271+ seq: "\x1b[4:3mA\x1b[4:0mB",
272+ check: func(t *testing.T, s *Screen) {
273+ first := cellAt(s, 0, 0).Style
274+ if first.Attrs&renderer.AttrUnderline == 0 || first.UnderlineStyle != renderer.UnderlineCurly {
275+ t.Errorf("first underline = attrs:%b style:%d, want curly underline", first.Attrs, first.UnderlineStyle)
276+ }
277+ second := cellAt(s, 1, 0).Style
278+ if second.Attrs&renderer.AttrUnderline != 0 || second.UnderlineStyle != renderer.UnderlineNone {
279+ t.Errorf("second underline = attrs:%b style:%d, want cleared", second.Attrs, second.UnderlineStyle)
280+ }
281+ },
282+ },
283+ {
284+ name: "empty SGR params reset style",
285+ seq: "\x1b[31m\x1b[1m\x1b[m", // empty params → reset
286+ check: func(t *testing.T, s *Screen) {
287+ if s.Style != (renderer.DefaultStyle()) {
288+ t.Errorf("style after empty SGR = %+v, want default", s.Style)
289+ }
290+ },
291+ },
292+ }
293+
294+ for _, tt := range tests {
295+ t.Run(tt.name, func(t *testing.T) {
296+ s := NewScreen(10, 3)
297+ s.Write([]byte(tt.seq))
298+ tt.check(t, s)
299+ })
300+ }
301+}
302+
303+func TestSGRTruecolor(t *testing.T) {
304+ tests := []struct {
305+ name string
306+ seq string
307+ check func(t *testing.T, s *Screen)
308+ }{
309+ {
310+ name: "SGR 38;2 sets truecolor foreground on style and cell",
311+ seq: "\x1b[38;2;12;34;56mX",
312+ check: func(t *testing.T, s *Screen) {
313+ want := renderer.RGB{R: 12, G: 34, B: 56}
314+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
315+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
316+ }
317+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
318+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
319+ }
320+ },
321+ },
322+ {
323+ name: "SGR 38:2 sets truecolor foreground",
324+ seq: "\x1b[38:2:12:34:56mX",
325+ check: func(t *testing.T, s *Screen) {
326+ want := renderer.RGB{R: 12, G: 34, B: 56}
327+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
328+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
329+ }
330+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
331+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
332+ }
333+ },
334+ },
335+ {
336+ name: "SGR 38:2 empty color space sets truecolor foreground",
337+ seq: "\x1b[38:2::12:34:56mX",
338+ check: func(t *testing.T, s *Screen) {
339+ want := renderer.RGB{R: 12, G: 34, B: 56}
340+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
341+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
342+ }
343+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
344+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
345+ }
346+ },
347+ },
348+ {
349+ name: "SGR 38:2 color space id sets truecolor foreground",
350+ seq: "\x1b[38:2:1:12:34:56mX",
351+ check: func(t *testing.T, s *Screen) {
352+ want := renderer.RGB{R: 12, G: 34, B: 56}
353+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
354+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
355+ }
356+ if got := cellAt(s, 0, 0).Style.ForegroundRGB; got != want {
357+ t.Errorf("cell foreground RGB = %+v, want %+v", got, want)
358+ }
359+ },
360+ },
361+ {
362+ name: "mixed semicolon and colon SGR groups",
363+ seq: "\x1b[1;38:2:10:20:30;7mX",
364+ check: func(t *testing.T, s *Screen) {
365+ want := renderer.RGB{R: 10, G: 20, B: 30}
366+ if !s.Style.Bold || !s.Style.Inverse {
367+ t.Errorf("bold/inverse = %v/%v, want true/true", s.Style.Bold, s.Style.Inverse)
368+ }
369+ if !s.Style.HasForegroundRGB || s.Style.ForegroundRGB != want {
370+ t.Errorf("foreground RGB = (%v, %+v), want true/%+v", s.Style.HasForegroundRGB, s.Style.ForegroundRGB, want)
371+ }
372+ },
373+ },
374+ {
375+ name: "truncated colon truecolor group does not consume next parameter",
376+ seq: "\x1b[38:2:1:2;31m",
377+ check: func(t *testing.T, s *Screen) {
378+ if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
379+ t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
380+ }
381+ },
382+ },
383+ {
384+ name: "SGR 48;2 sets truecolor background",
385+ seq: "\x1b[48;2;200;100;50m",
386+ check: func(t *testing.T, s *Screen) {
387+ want := renderer.RGB{R: 200, G: 100, B: 50}
388+ if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
389+ t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
390+ }
391+ },
392+ },
393+ {
394+ name: "SGR 48:2 sets truecolor background",
395+ seq: "\x1b[48:2:200:100:50m",
396+ check: func(t *testing.T, s *Screen) {
397+ want := renderer.RGB{R: 200, G: 100, B: 50}
398+ if !s.Style.HasBackgroundRGB || s.Style.BackgroundRGB != want {
399+ t.Errorf("background RGB = (%v, %+v), want true/%+v", s.Style.HasBackgroundRGB, s.Style.BackgroundRGB, want)
400+ }
401+ },
402+ },
403+ {
404+ name: "SGR 38:5 sets 256-color foreground",
405+ seq: "\x1b[38:5:82m",
406+ check: func(t *testing.T, s *Screen) {
407+ if s.Style.Foreground != 82 || s.Style.HasForegroundRGB {
408+ t.Errorf("foreground = %d rgb:%v, want 82 false", s.Style.Foreground, s.Style.HasForegroundRGB)
409+ }
410+ },
411+ },
412+ {
413+ name: "SGR 48:5 sets 256-color background",
414+ seq: "\x1b[48:5:200m",
415+ check: func(t *testing.T, s *Screen) {
416+ if s.Style.Background != 200 || s.Style.HasBackgroundRGB {
417+ t.Errorf("background = %d rgb:%v, want 200 false", s.Style.Background, s.Style.HasBackgroundRGB)
418+ }
419+ },
420+ },
421+ {
422+ name: "truncated colon foreground index group does not consume next parameter",
423+ seq: "\x1b[38:5;31m",
424+ check: func(t *testing.T, s *Screen) {
425+ if s.Style.Foreground != 1 || s.Style.HasForegroundRGB {
426+ t.Errorf("foreground = %d rgb:%v, want 1 false", s.Style.Foreground, s.Style.HasForegroundRGB)
427+ }
428+ },
429+ },
430+ {
431+ name: "truncated colon background index group does not consume next parameter",
432+ seq: "\x1b[48:5;44m",
433+ check: func(t *testing.T, s *Screen) {
434+ if s.Style.Background != 4 || s.Style.HasBackgroundRGB {
435+ t.Errorf("background = %d rgb:%v, want 4 false", s.Style.Background, s.Style.HasBackgroundRGB)
436+ }
437+ },
438+ },
439+ {
440+ name: "SGR 39 after truecolor clears truecolor foreground",
441+ seq: "\x1b[38;2;12;34;56m\x1b[39m",
442+ check: func(t *testing.T, s *Screen) {
443+ if s.Style.HasForegroundRGB || s.Style.Foreground != -1 {
444+ t.Errorf("foreground after reset = rgb:%v index:%d, want default", s.Style.HasForegroundRGB, s.Style.Foreground)
445+ }
446+ },
447+ },
448+ {
449+ name: "truecolor components are clamped to 0-255",
450+ seq: "\x1b[38;2;-1;300;42m",
451+ check: func(t *testing.T, s *Screen) {
452+ want := renderer.RGB{R: 0, G: 255, B: 42}
453+ if s.Style.ForegroundRGB != want {
454+ t.Errorf("foreground RGB = %+v, want %+v", s.Style.ForegroundRGB, want)
455+ }
456+ },
457+ },
458+ }
459+
460+ for _, tt := range tests {
461+ t.Run(tt.name, func(t *testing.T) {
462+ s := NewScreen(10, 3)
463+ s.Write([]byte(tt.seq))
464+ tt.check(t, s)
465+ })
466+ }
467+}
468+
469+func TestSGRColorModeTransitionsClearInactiveState(t *testing.T) {
470+ tests := []struct {
471+ name string
472+ seq string
473+ foreground int
474+ background int
475+ hasForeground bool
476+ hasBackground bool
477+ }{
478+ {name: "foreground rgb to bright", seq: "\x1b[38;2;1;2;3m\x1b[91m", foreground: 9, background: -1},
479+ {name: "foreground rgb to indexed", seq: "\x1b[38;2;1;2;3m\x1b[38;5;82m", foreground: 82, background: -1},
480+ {name: "foreground indexed to rgb", seq: "\x1b[38;5;82m\x1b[38;2;4;5;6m", foreground: -1, background: -1, hasForeground: true},
481+ {name: "background rgb to bright", seq: "\x1b[48;2;1;2;3m\x1b[107m", foreground: -1, background: 15},
482+ {name: "background rgb to indexed", seq: "\x1b[48;2;1;2;3m\x1b[48;5;200m", foreground: -1, background: 200},
483+ {name: "background rgb to default", seq: "\x1b[48;2;1;2;3m\x1b[49m", foreground: -1, background: -1},
484+ {name: "background indexed to rgb", seq: "\x1b[48;5;200m\x1b[48;2;4;5;6m", foreground: -1, background: -1, hasBackground: true},
485+ }
486+
487+ for _, tt := range tests {
488+ t.Run(tt.name, func(t *testing.T) {
489+ s := NewScreen(10, 3)
490+ s.Write([]byte(tt.seq))
491+
492+ if s.Style.Foreground != tt.foreground || s.Style.HasForegroundRGB != tt.hasForeground {
493+ t.Errorf("foreground = %d rgb:%v, want %d rgb:%v", s.Style.Foreground, s.Style.HasForegroundRGB, tt.foreground, tt.hasForeground)
494+ }
495+ if s.Style.Background != tt.background || s.Style.HasBackgroundRGB != tt.hasBackground {
496+ t.Errorf("background = %d rgb:%v, want %d rgb:%v", s.Style.Background, s.Style.HasBackgroundRGB, tt.background, tt.hasBackground)
497+ }
498+ })
499+ }
500+}
501+
502+func TestClearAndErase(t *testing.T) {
503+ tests := []struct {
504+ name string
505+ run func(t *testing.T)
506+ }{
507+ {
508+ name: "CSI 2J clears the whole screen",
509+ run: func(t *testing.T) {
510+ s := NewScreen(5, 3)
511+ s.Write([]byte("HelloWorldABCDE"))
512+ // Clear damage from writing.
513+ s.ClearDamage()
514+
515+ s.Write([]byte("\x1b[2J"))
516+
517+ // All cells should be blank.
518+ for y := range 3 {
519+ for x := range 5 {
520+ if c := cellAt(s, x, y); c.Rune != ' ' {
521+ t.Errorf("cell(%d,%d) = %q, want space after clear", x, y, c.Rune)
522+ }
523+ }
524+ }
525+
526+ d := s.Damage()
527+ if !hasDamageKind(d, renderer.DamageClear) {
528+ t.Error("expected DamageClear after CSI 2 J")
529+ }
530+ },
531+ },
532+ {
533+ name: "CSI K clears from cursor to end of line",
534+ run: func(t *testing.T) {
535+ s := NewScreen(10, 3)
536+ s.Write([]byte("HelloWorld"))
537+ s.ClearDamage()
538+
539+ // Position cursor at row 1 col 6 (0-indexed: 0, 5) then clear to end.
540+ s.Write([]byte("\x1b[1;6H"))
541+ s.Write([]byte("\x1b[K"))
542+
543+ for x := 5; x < 10; x++ {
544+ if c := cellAt(s, x, 0); c.Rune != ' ' {
545+ t.Errorf("cell(%d,0) = %q, want space after clear line", x, c.Rune)
546+ }
547+ }
548+ // First 5 chars should remain.
549+ assertCell(t, s, 0, 0, 'H')
550+
551+ d := s.Damage()
552+ if !hasDamageKind(d, renderer.DamageClear) {
553+ t.Error("expected DamageClear after CSI K")
554+ }
555+ },
556+ },
557+ {
558+ name: "erase display modes 0/1/2 (implicit, 1, 2/3)",
559+ run: func(t *testing.T) {
560+ s := NewScreen(5, 3)
561+ s.Write([]byte("abcdefghijklmno"))
562+ s.Write([]byte("\x1b[2;3H"))
563+ s.ClearDamage()
564+ s.Write([]byte("\x1b[J"))
565+ assertCell(t, s, 0, 0, 'a')
566+ assertCell(t, s, 1, 1, 'g')
567+ if c := cellAt(s, 2, 1); c.Rune != ' ' {
568+ t.Fatalf("cursor-to-end clear left %q at cursor", c.Rune)
569+ }
570+
571+ s = NewScreen(5, 3)
572+ s.Write([]byte("abcdefghijklmno"))
573+ s.Write([]byte("\x1b[2;3H"))
574+ s.Write([]byte("\x1b[1J"))
575+ if c := cellAt(s, 0, 0); c.Rune != ' ' {
576+ t.Fatalf("start-to-cursor clear left %q at start", c.Rune)
577+ }
578+ assertCell(t, s, 3, 1, 'i')
579+ },
580+ },
581+ {
582+ name: "erase line modes 0/1/2",
583+ run: func(t *testing.T) {
584+ s := NewScreen(5, 2)
585+ s.Write([]byte("abcde"))
586+ s.Write([]byte("\x1b[1;3H"))
587+ s.Write([]byte("\x1b[1K"))
588+ if c := cellAt(s, 0, 0); c.Rune != ' ' {
589+ t.Fatalf("line start clear left %q", c.Rune)
590+ }
591+ assertCell(t, s, 3, 0, 'd')
592+
593+ s = NewScreen(5, 2)
594+ s.Write([]byte("abcde"))
595+ s.Write([]byte("\x1b[1;3H"))
596+ s.Write([]byte("\x1b[2K"))
597+ for x := range 5 {
598+ if c := cellAt(s, x, 0); c.Rune != ' ' {
599+ t.Fatalf("line clear left %q at %d", c.Rune, x)
600+ }
601+ }
602+ },
603+ },
604+ }
605+
606+ for _, tt := range tests {
607+ t.Run(tt.name, tt.run)
608+ }
609+}
610+
611+func TestBackgroundColorErase(t *testing.T) {
612+ tests := []struct {
613+ name string
614+ width int
615+ height int
616+ seq string
617+ startX int
618+ wantStyle renderer.Style
619+ }{
620+ {
621+ name: "indexed background for EL",
622+ width: 8,
623+ height: 1,
624+ seq: "\x1b[1;31;48;5;236mX\x1b[K",
625+ startX: 1,
626+ wantStyle: renderer.Style{Foreground: -1, Background: 236},
627+ },
628+ {
629+ name: "truecolor background for full ED",
630+ width: 3,
631+ height: 2,
632+ seq: "abcdef\x1b[1;31;48;2;12;34;56m\x1b[2J",
633+ wantStyle: renderer.Style{
634+ Foreground: -1,
635+ Background: -1,
636+ HasBackgroundRGB: true,
637+ BackgroundRGB: renderer.RGB{R: 12, G: 34, B: 56},
638+ },
639+ },
640+ {
641+ name: "default background after SGR 49",
642+ width: 2,
643+ height: 1,
644+ seq: "\x1b[48;2;12;34;56m\x1b[49m\x1b[K",
645+ wantStyle: renderer.DefaultStyle(),
646+ },
647+ {
648+ name: "default background after SGR 0",
649+ width: 2,
650+ height: 1,
651+ seq: "\x1b[48;2;12;34;56m\x1b[0m\x1b[K",
652+ wantStyle: renderer.DefaultStyle(),
653+ },
654+ }
655+
656+ for _, tt := range tests {
657+ t.Run(tt.name, func(t *testing.T) {
658+ s := NewScreen(tt.width, tt.height)
659+ s.Write([]byte(tt.seq))
660+
661+ for y := range tt.height {
662+ for x := tt.startX; x < tt.width; x++ {
663+ cell := cellAt(s, x, y)
664+ if cell.Rune != ' ' || cell.Style != tt.wantStyle {
665+ t.Errorf("cell(%d,%d) = %+v, want space with style %+v", x, y, cell, tt.wantStyle)
666+ }
667+ }
668+ }
669+ })
670+ }
671+}
672+
673+func TestCursorMove(t *testing.T) {
674+ tests := []struct {
675+ name string
676+ seq string
677+ initRow int
678+ initCol int
679+ wantRow int
680+ wantCol int
681+ }{
682+ {name: "CSI H moves to explicit row/col", seq: "\x1b[3;4H", wantRow: 2, wantCol: 3},
683+ {name: "CSI H clamps to bottom-right when out of bounds", seq: "\x1b[100;200H", wantRow: 4, wantCol: 9},
684+ {name: "CSI f moves to explicit row/col", seq: "\x1b[2;8f", wantRow: 1, wantCol: 7},
685+ {name: "CSI C moves forward after CR", seq: "\r\x1b[3C", wantRow: 0, wantCol: 3},
686+ {name: "CSI H with no params moves to (1,1)", seq: "\x1b[H", wantRow: 0, wantCol: 0},
687+ {name: "CSI ;5H uses default row with explicit column", seq: "\x1b[;5H", initRow: 3, initCol: 4, wantRow: 0, wantCol: 4},
688+ }
689+
690+ for _, tt := range tests {
691+ t.Run(tt.name, func(t *testing.T) {
692+ s := NewScreen(10, 5)
693+ s.Row, s.Col = tt.initRow, tt.initCol
694+ s.Write([]byte(tt.seq))
695+
696+ if s.Row != tt.wantRow || s.Col != tt.wantCol {
697+ t.Errorf("cursor at row=%d col=%d, want row=%d col=%d", s.Row, s.Col, tt.wantRow, tt.wantCol)
698+ }
699+ })
700+ }
701+}
702+
703+func TestCSIEditingSequences(t *testing.T) {
704+ tests := []struct {
705+ name string
706+ run func(t *testing.T)
707+ }{
708+ {
709+ name: "directional cursor moves (A/B/C/D)",
710+ run: func(t *testing.T) {
711+ s := NewScreen(10, 4)
712+ s.Write([]byte("\x1b[3;5HX\x1b[2D<\x1b[1A^\x1b[1Bv\x1b[3C>"))
713+
714+ assertCell(t, s, 4, 2, 'X')
715+ assertCell(t, s, 3, 2, '<')
716+ assertCell(t, s, 4, 1, '^')
717+ assertCell(t, s, 5, 2, 'v')
718+ assertCell(t, s, 9, 2, '>')
719+ },
720+ },
721+ {
722+ name: "insert and delete characters (@ and P)",
723+ run: func(t *testing.T) {
724+ s := NewScreen(8, 2)
725+ s.Write([]byte("abcdef"))
726+ s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
727+ if got := lineText(s, 0); got != "abXYcdef" {
728+ t.Fatalf("line after ICH = %q, want %q", got, "abXYcdef")
729+ }
730+
731+ s.Write([]byte("\x1b[1;4H\x1b[3P"))
732+ if got := lineText(s, 0); got != "abXef " {
733+ t.Fatalf("line after DCH = %q, want %q", got, "abXef ")
734+ }
735+ },
736+ },
737+ {
738+ name: "insert and delete lines (L and M)",
739+ run: func(t *testing.T) {
740+ s := NewScreen(5, 4)
741+ s.Write([]byte("11111222223333344444"))
742+ s.Write([]byte("\x1b[2;1H\x1b[L"))
743+ if got := lineText(s, 1); got != " " {
744+ t.Fatalf("inserted line = %q, want blank", got)
745+ }
746+ if got := lineText(s, 2); got != "22222" {
747+ t.Fatalf("shifted line = %q, want 22222", got)
748+ }
749+
750+ s.Write([]byte("\x1b[2;1H\x1b[M"))
751+ if got := lineText(s, 1); got != "22222" {
752+ t.Fatalf("line after delete = %q, want 22222", got)
753+ }
754+ },
755+ },
756+ {
757+ name: "scroll region (CSI r) confines LF scrolling",
758+ run: func(t *testing.T) {
759+ s := NewScreen(5, 4)
760+ s.Write([]byte("11111222223333344444"))
761+ s.Write([]byte("\x1b[2;3r\x1b[3;1H\n"))
762+
763+ if got := lineText(s, 0); got != "11111" {
764+ t.Fatalf("row outside region changed: %q", got)
765+ }
766+ if got := lineText(s, 1); got != "33333" {
767+ t.Fatalf("region did not scroll up, row 1 = %q", got)
768+ }
769+ if got := lineText(s, 2); got != " " {
770+ t.Fatalf("region bottom not blanked, row 2 = %q", got)
771+ }
772+ if got := lineText(s, 3); got != "44444" {
773+ t.Fatalf("row below region changed: %q", got)
774+ }
775+ },
776+ },
777+ {
778+ name: "DECOM homes and addresses relative to scroll region",
779+ run: func(t *testing.T) {
780+ s := NewScreen(6, 5)
781+ s.Write([]byte("\x1b[2;4r\x1b[?6h"))
782+ if s.Row != 1 || s.Col != 0 {
783+ t.Fatalf("DECOM set cursor = (%d,%d), want (1,0)", s.Row, s.Col)
784+ }
785+
786+ s.Write([]byte("\x1b[2;3H"))
787+ if s.Row != 2 || s.Col != 2 {
788+ t.Fatalf("DECOM addressed cursor = (%d,%d), want (2,2)", s.Row, s.Col)
789+ }
790+ s.Write([]byte("\x1b[99;1H"))
791+ if s.Row != 3 || s.Col != 0 {
792+ t.Fatalf("DECOM clamped cursor = (%d,%d), want (3,0)", s.Row, s.Col)
793+ }
794+ },
795+ },
796+ {
797+ name: "DECOM already set homes DECSTBM to scroll region",
798+ run: func(t *testing.T) {
799+ s := NewScreen(6, 5)
800+ s.Write([]byte("\x1b[?6h\x1b[2;4r"))
801+ if s.Row != 1 || s.Col != 0 {
802+ t.Fatalf("DECSTBM cursor with DECOM set = (%d,%d), want (1,0)", s.Row, s.Col)
803+ }
804+
805+ s.Write([]byte("\x1b[2;3H"))
806+ if s.Row != 2 || s.Col != 2 {
807+ t.Fatalf("DECOM addressed cursor after DECSTBM = (%d,%d), want (2,2)", s.Row, s.Col)
808+ }
809+ s.Write([]byte("\x1b[99;1H"))
810+ if s.Row != 3 || s.Col != 0 {
811+ t.Fatalf("DECOM clamped cursor after DECSTBM = (%d,%d), want (3,0)", s.Row, s.Col)
812+ }
813+ },
814+ },
815+ {
816+ name: "DECOM reset homes and restores full-frame addressing",
817+ run: func(t *testing.T) {
818+ s := NewScreen(6, 5)
819+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[?6l"))
820+ if s.Row != 0 || s.Col != 0 {
821+ t.Fatalf("DECOM reset cursor = (%d,%d), want (0,0)", s.Row, s.Col)
822+ }
823+
824+ s.Write([]byte("\x1b[5;6H"))
825+ if s.Row != 4 || s.Col != 5 {
826+ t.Fatalf("full-frame cursor = (%d,%d), want (4,5)", s.Row, s.Col)
827+ }
828+ },
829+ },
830+ {
831+ name: "private alternate screen mode restores normal screen on exit",
832+ run: func(t *testing.T) {
833+ s := NewScreen(20, 5)
834+ s.Write([]byte("normal-line"))
835+ s.Write([]byte("\x1b[?1049h\x1b[2J\x1b[HLOCKED\x1b[3;4HAPP"))
836+ s.Write([]byte("\x1b[?1049lafter"))
837+
838+ if got := lineText(s, 0); got != "normal-lineafter " {
839+ t.Fatalf("row 0 = %q, want restored normal screen with post-exit text", got)
840+ }
841+ if got := lineText(s, 2); got != " " {
842+ t.Fatalf("alternate content leaked into row 2: %q", got)
843+ }
844+ },
845+ },
846+ }
847+
848+ for _, tt := range tests {
849+ t.Run(tt.name, tt.run)
850+ }
851+}
852+
853+func TestCSIScrollUp(t *testing.T) {
854+ tests := []struct {
855+ name string
856+ run func(t *testing.T)
857+ }{
858+ {
859+ name: "CSI S with no param scrolls up by 1 and preserves cursor",
860+ run: func(t *testing.T) {
861+ s := NewScreen(5, 4)
862+ s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
863+ s.ClearDamage()
864+
865+ // CSI S with no param → scroll up by 1.
866+ s.Write([]byte("\x1b[S"))
867+
868+ // Content should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = DDDDD, row 3 = blank.
869+ assertCell(t, s, 0, 0, 'B')
870+ assertCell(t, s, 0, 1, 'C')
871+ assertCell(t, s, 0, 2, 'D')
872+ assertCell(t, s, 0, 3, ' ')
873+
874+ // Cursor should NOT have moved.
875+ if s.Col != 5 || s.Row != 3 {
876+ t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
877+ }
878+
879+ d := s.Damage()
880+ if !hasDamageKind(d, renderer.DamageScrollUp) {
881+ t.Errorf("expected DamageScrollUp, got %v", damageKinds(d))
882+ }
883+ },
884+ },
885+ {
886+ name: "CSI 2S scrolls up by an explicit count",
887+ run: func(t *testing.T) {
888+ s := NewScreen(5, 4)
889+ s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
890+ s.ClearDamage()
891+
892+ // CSI 2 S → scroll up by 2.
893+ s.Write([]byte("\x1b[2S"))
894+
895+ // Content: row 0 = CCCCC, row 1 = DDDDD, row 2 = blank, row 3 = blank.
896+ assertCell(t, s, 0, 0, 'C')
897+ assertCell(t, s, 0, 1, 'D')
898+ assertCell(t, s, 0, 2, ' ')
899+ assertCell(t, s, 0, 3, ' ')
900+
901+ // Cursor preserved.
902+ if s.Col != 5 || s.Row != 3 {
903+ t.Errorf("cursor at col=%d row=%d, want col=5 row=3", s.Col, s.Row)
904+ }
905+ },
906+ },
907+ {
908+ name: "CSI 100S on a 3-row screen clamps to the full screen",
909+ run: func(t *testing.T) {
910+ s := NewScreen(5, 3)
911+ s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
912+ s.ClearDamage()
913+
914+ // CSI 100 S on a 3-row screen → clamp to 3 (entire screen blank).
915+ s.Write([]byte("\x1b[100S"))
916+
917+ // All rows should be blank.
918+ for y := range 3 {
919+ for x := range 5 {
920+ assertCell(t, s, x, y, ' ')
921+ }
922+ }
923+ },
924+ },
925+ {
926+ name: "CSI 0S defaults to a scroll count of 1",
927+ run: func(t *testing.T) {
928+ s := NewScreen(5, 3)
929+ s.Write([]byte("AAAAABBBBBCCCCC")) // fill 3 rows
930+ s.ClearDamage()
931+
932+ // CSI 0 S → default to 1 (parameter 0 means default in VT spec).
933+ s.Write([]byte("\x1b[0S"))
934+
935+ // Row 0 should have shifted up: row 0 = BBBBB, row 1 = CCCCC, row 2 = blank.
936+ assertCell(t, s, 0, 0, 'B')
937+ assertCell(t, s, 0, 1, 'C')
938+ assertCell(t, s, 0, 2, ' ')
939+ },
940+ },
941+ {
942+ name: "CSI 3S reports scroll count 3 and blanked-row text damage",
943+ run: func(t *testing.T) {
944+ s := NewScreen(5, 4)
945+ s.Write([]byte("AAAAABBBBBCCCCCDDDDD")) // fill all 4 rows
946+ s.ClearDamage()
947+
948+ s.Write([]byte("\x1b[3S")) // scroll up by 3
949+
950+ d := s.Damage()
951+ var scrollDamage *renderer.Damage
952+ for i, dd := range d {
953+ if dd.Kind == renderer.DamageScrollUp {
954+ scrollDamage = &d[i]
955+ break
956+ }
957+ }
958+ if scrollDamage == nil {
959+ t.Fatal("expected DamageScrollUp")
960+ }
961+ if scrollDamage.Count != 3 {
962+ t.Errorf("scroll count = %d, want 3", scrollDamage.Count)
963+ }
964+ if scrollDamage.Width != 5 || scrollDamage.Height != 4 {
965+ t.Errorf("scroll size = %dx%d, want 5x4", scrollDamage.Width, scrollDamage.Height)
966+ }
967+
968+ // Should also have text damage for the blanked rows (bottom 3 rows).
969+ if !hasDamageKind(d, renderer.DamageText) {
970+ t.Error("expected DamageText for blanked rows")
971+ }
972+ },
973+ },
974+ {
975+ name: "cursor position is preserved across a scroll-up",
976+ run: func(t *testing.T) {
977+ s := NewScreen(10, 5)
978+ s.Write([]byte("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE")) // fill all 5 rows
979+ s.ClearDamage()
980+
981+ // Move cursor to middle of row 2.
982+ s.Write([]byte("\x1b[3;3H")) // row=3, col=3
983+ s.Write([]byte("\x1b[2S")) // scroll up by 2
984+
985+ // Cursor should still be at 1-indexed (3,3) = 0-indexed (2,2).
986+ if s.Row != 2 || s.Col != 2 {
987+ t.Errorf("cursor at row=%d col=%d, want row=2 col=2", s.Row, s.Col)
988+ }
989+ },
990+ },
991+ }
992+
993+ for _, tt := range tests {
994+ t.Run(tt.name, tt.run)
995+ }
996+}
A pkg/vt/screen_cursor.go +89 -0
@@ -0,0 +1,89 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+type cursorState struct {
11+ row int
12+ col int
13+ style renderer.Style
14+ originMode bool
15+ insertMode bool
16+ saved bool
17+}
18+
19+func (s *Screen) cursorReportRow() int {
20+ if s.originMode {
21+ return clamp(s.Row, s.cursorMinRow(), s.cursorMaxRow()) - s.cursorMinRow() + 1
22+ }
23+ return s.Row + 1
24+}
25+
26+func (s *Screen) cursorMinRow() int {
27+ if s.originMode {
28+ return clamp(s.scrollTop, 0, s.Frame.Height-1)
29+ }
30+ return 0
31+}
32+
33+func (s *Screen) cursorMaxRow() int {
34+ if s.originMode {
35+ return clamp(s.scrollBottom, 0, s.Frame.Height-1)
36+ }
37+ return max(s.Frame.Height-1, 0)
38+}
39+
40+func (s *Screen) addressedRow(row int) int {
41+ if s.originMode {
42+ return clamp(s.scrollTop+row-1, s.cursorMinRow(), s.cursorMaxRow())
43+ }
44+ return clamp(row-1, 0, s.Frame.Height-1)
45+}
46+
47+func (s *Screen) addressCursor(row, col int) {
48+ s.Row = s.addressedRow(row)
49+ s.Col = clamp(col-1, 0, s.Frame.Width-1)
50+}
51+
52+func (s *Screen) homeCursor() {
53+ s.addressCursor(1, 1)
54+}
55+
56+func (s *Screen) applyCursorStyle(params string) {
57+ if strings.HasPrefix(params, ">") || !strings.HasSuffix(params, " ") {
58+ return
59+ }
60+ styleParam := strings.TrimSuffix(params, " ")
61+ style := 0
62+ if styleParam != "" {
63+ v, err := strconv.Atoi(styleParam)
64+ if err != nil {
65+ return
66+ }
67+ style = v
68+ }
69+ if style < 0 || style > 6 {
70+ return
71+ }
72+ s.cursorStyle = style
73+ s.cursorStyleSet = true
74+}
75+
76+func (s *Screen) saveCursor() {
77+ s.savedCursor = cursorState{row: s.Row, col: s.Col, style: s.Style, originMode: s.originMode, insertMode: s.insertMode, saved: true}
78+}
79+
80+func (s *Screen) restoreCursor() {
81+ if !s.savedCursor.saved {
82+ return
83+ }
84+ s.Row = clamp(s.savedCursor.row, 0, s.Frame.Height-1)
85+ s.Col = clamp(s.savedCursor.col, 0, s.Frame.Width-1)
86+ s.Style = s.savedCursor.style
87+ s.originMode = s.savedCursor.originMode
88+ s.insertMode = s.savedCursor.insertMode
89+}
A pkg/vt/screen_damage.go +74 -0
@@ -0,0 +1,74 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+// maxPendingDamage bounds metadata retained while no render transaction can
6+// acknowledge a screen. Saturation falls back to one exact full redraw.
7+const maxPendingDamage = 1024
8+
9+// DamageCapture is an immutable copy of pending damage at one screen generation.
10+type DamageCapture struct {
11+ Damage []renderer.Damage
12+ Generation uint64
13+}
14+
15+// Damage returns the current damage list. The caller must not modify the
16+// returned slice; ClearDamage must be called after the damage is consumed.
17+func (s *Screen) Damage() []renderer.Damage { return s.damage }
18+func (s *Screen) ClearDamage() {
19+ s.damage = s.damage[:0]
20+ s.damageSaturated = false
21+ s.damageFullRedrawSticky = false
22+}
23+
24+// CaptureDamage snapshots pending damage without consuming it.
25+func (s *Screen) CaptureDamage() DamageCapture {
26+ return DamageCapture{Damage: append([]renderer.Damage(nil), s.damage...), Generation: s.damageGeneration}
27+}
28+
29+// AcknowledgeDamage consumes a capture only if no screen mutation occurred
30+// since it was taken. A stale acknowledgement conservatively requests a full
31+// redraw, ensuring intervening writes remain visible to the next capture.
32+func (s *Screen) AcknowledgeDamage(generation uint64) bool {
33+ if generation != s.damageGeneration {
34+ s.fullRedraw()
35+ return false
36+ }
37+ s.ClearDamage()
38+ return true
39+}
40+
41+func (s *Screen) record(d renderer.Damage) {
42+ s.damageGeneration++
43+ if s.damageSaturated || s.damageFullRedrawSticky {
44+ return
45+ }
46+ if len(s.damage) >= maxPendingDamage {
47+ s.damage = []renderer.Damage{renderer.FullRedraw()}
48+ s.damageSaturated = true
49+ return
50+ }
51+ // Replace FullRedraw with the first concrete damage item.
52+ if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
53+ s.damage[0] = d
54+ return
55+ }
56+ // Coalesce adjacent single-cell text damage on the same line.
57+ if d.Kind == renderer.DamageText && d.Width == 1 && d.Height == 1 && len(s.damage) > 0 {
58+ last := &s.damage[len(s.damage)-1]
59+ if last.Kind == renderer.DamageText && last.Y == d.Y && last.X+last.Width == d.X && last.Height == 1 {
60+ last.Width++
61+ return
62+ }
63+ }
64+ s.damage = append(s.damage, d)
65+}
66+
67+func (s *Screen) fullRedraw() {
68+ s.damageGeneration++
69+ s.damage = []renderer.Damage{renderer.FullRedraw()}
70+ // A structural frame replacement (such as DEC 1049 exit) cannot be
71+ // represented by the following text damage alone. Retain the redraw until
72+ // the render owner acknowledges this screen generation.
73+ s.damageFullRedrawSticky = true
74+}
A pkg/vt/screen_damage_test.go +240 -0
@@ -0,0 +1,240 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestScrollDamage(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ run func(t *testing.T)
14+ }{
15+ {
16+ name: "scroll produces both DamageScrollUp and DamageText",
17+ run: func(t *testing.T) {
18+ s := NewScreen(5, 2)
19+ // Fill screen then cause a scroll.
20+ s.Write([]byte("AAAAABBBBB")) // both rows filled
21+ s.ClearDamage()
22+
23+ s.Write([]byte("CCCCC")) // forces a scroll up
24+
25+ d := s.Damage()
26+ if len(d) == 0 {
27+ t.Fatal("expected damage after scroll")
28+ }
29+
30+ if !hasDamageKind(d, renderer.DamageScrollUp) {
31+ t.Errorf("expected DamageScrollUp in %v", damageKinds(d))
32+ }
33+ if !hasDamageKind(d, renderer.DamageText) {
34+ t.Errorf("expected DamageText in %v", damageKinds(d))
35+ }
36+ },
37+ },
38+ {
39+ name: "scroll damage coordinates match the full-width scrolled region",
40+ run: func(t *testing.T) {
41+ s := NewScreen(5, 3)
42+ s.Write([]byte("AAAAABBBBBCCCCC")) // fill all 3 rows
43+ s.ClearDamage()
44+
45+ // Write another char to force scroll.
46+ s.Write([]byte("D"))
47+
48+ d := s.Damage()
49+ var scrollDamage *renderer.Damage
50+ for i, dd := range d {
51+ if dd.Kind == renderer.DamageScrollUp {
52+ scrollDamage = &d[i]
53+ break
54+ }
55+ }
56+ if scrollDamage == nil {
57+ t.Fatal("expected scroll damage")
58+ }
59+ if scrollDamage.X != 0 || scrollDamage.Y != 0 {
60+ t.Errorf("scroll position: (%d,%d), want (0,0)", scrollDamage.X, scrollDamage.Y)
61+ }
62+ if scrollDamage.Width != 5 || scrollDamage.Height != 3 {
63+ t.Errorf("scroll size: %dx%d, want 5x3", scrollDamage.Width, scrollDamage.Height)
64+ }
65+ if scrollDamage.Count != 1 {
66+ t.Errorf("scroll count = %d, want 1", scrollDamage.Count)
67+ }
68+ },
69+ },
70+ }
71+
72+ for _, tt := range tests {
73+ t.Run(tt.name, tt.run)
74+ }
75+}
76+
77+func TestDamageCoalescingBehavior(t *testing.T) {
78+ tests := []struct {
79+ name string
80+ run func(t *testing.T)
81+ }{
82+ {
83+ name: "adjacent single-cell writes coalesce into one damage entry",
84+ run: func(t *testing.T) {
85+ s := NewScreen(20, 3)
86+ s.Write([]byte("ABC")) // Three adjacent chars on one line
87+
88+ d := s.Damage()
89+ // Should be coalesced into a single DamageText.
90+ if len(d) != 1 {
91+ t.Fatalf("expected 1 coalesced damage, got %d: %+v", len(d), d)
92+ }
93+ if d[0].Kind != renderer.DamageText || d[0].X != 0 || d[0].Y != 0 || d[0].Width != 3 || d[0].Height != 1 {
94+ t.Errorf("unexpected coalesced damage: %+v", d[0])
95+ }
96+ },
97+ },
98+ {
99+ name: "newline breaks coalescing across lines",
100+ run: func(t *testing.T) {
101+ s := NewScreen(10, 3)
102+ s.Write([]byte("AB\nCD"))
103+
104+ d := s.Damage()
105+ // "AB" on line 0, "CD" on line 1 → two separate DamageText items.
106+ textCount := 0
107+ for _, dd := range d {
108+ if dd.Kind == renderer.DamageText {
109+ textCount++
110+ }
111+ }
112+ if textCount < 2 {
113+ t.Errorf("expected at least 2 DamageText items (one per line), got %d", textCount)
114+ }
115+ },
116+ },
117+ {
118+ name: "writing replaces the initial FullRedraw with text damage",
119+ run: func(t *testing.T) {
120+ s := NewScreen(5, 3)
121+ // NewScreen already has FullRedraw in damage.
122+ d := s.Damage()
123+ if len(d) != 1 || d[0].Kind != renderer.DamageFullRedraw {
124+ t.Fatalf("expected single FullRedraw, got %+v", d)
125+ }
126+
127+ // Writing should replace it with text damage.
128+ s.Write([]byte("X"))
129+ d = s.Damage()
130+ if len(d) == 0 {
131+ t.Fatal("expected damage after write")
132+ }
133+ if d[0].Kind == renderer.DamageFullRedraw {
134+ t.Error("FullRedraw should have been replaced by text damage")
135+ }
136+ },
137+ },
138+ }
139+
140+ for _, tt := range tests {
141+ t.Run(tt.name, tt.run)
142+ }
143+}
144+
145+func TestDamageSaturationBoundsPendingRecords(t *testing.T) {
146+ s := NewScreen(2, 2)
147+ s.ClearDamage()
148+
149+ for i := range 1_100 {
150+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: i % 2, Width: 1, Height: 1, Count: 1})
151+ }
152+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
153+
154+ // Saturation is sticky until the render owner acknowledges or clears it.
155+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
156+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage())
157+
158+ s.ClearDamage()
159+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1})
160+ require.Equal(t, []renderer.Damage{{Kind: renderer.DamageText, X: 0, Y: 0, Width: 1, Height: 1, Count: 1}}, s.Damage())
161+}
162+
163+func TestClearDamage(t *testing.T) {
164+ tests := []struct {
165+ name string
166+ }{
167+ {name: "ClearDamage empties the damage list"},
168+ }
169+
170+ for _, tt := range tests {
171+ t.Run(tt.name, func(t *testing.T) {
172+ s := NewScreen(5, 3)
173+ s.Write([]byte("Hello"))
174+ s.ClearDamage()
175+ d := s.Damage()
176+ if len(d) != 0 {
177+ t.Fatalf("expected empty damage after ClearDamage, got %+v", d)
178+ }
179+ })
180+ }
181+}
182+
183+func TestDamageReturnsReference(t *testing.T) {
184+ tests := []struct {
185+ name string
186+ }{
187+ {name: "Damage() returns a reference to the internal slice"},
188+ }
189+
190+ for _, tt := range tests {
191+ t.Run(tt.name, func(t *testing.T) {
192+ // Damage() should return a reference to internal slice (no copy).
193+ s := NewScreen(10, 3)
194+ s.Write([]byte("X"))
195+ d1 := s.Damage()
196+ // Internal slice should be the same (or at least reference the same backing).
197+ if len(d1) == 0 {
198+ t.Fatal("expected damage")
199+ }
200+ // Write more to trigger record.
201+ s.Write([]byte("Y"))
202+ d2 := s.Damage()
203+ _ = d2
204+ // Both d1 and d2 reference the internal slice; we just don't crash.
205+ })
206+ }
207+}
208+
209+func TestDamageCaptureAcknowledgementPreservesConcurrentWrite(t *testing.T) {
210+ s := NewScreen(8, 2)
211+ s.ClearDamage()
212+ s.Write([]byte("a"))
213+
214+ captured := s.CaptureDamage()
215+ require.NotEmpty(t, captured.Damage)
216+ s.Write([]byte("b"))
217+
218+ require.False(t, s.AcknowledgeDamage(captured.Generation))
219+ require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, s.Damage(), "stale acknowledgement must retain later writes conservatively")
220+}
221+
222+func TestDamageCaptureAcknowledgementClearsMatchingGeneration(t *testing.T) {
223+ s := NewScreen(8, 2)
224+ s.ClearDamage()
225+ s.Write([]byte("a"))
226+
227+ captured := s.CaptureDamage()
228+ require.True(t, s.AcknowledgeDamage(captured.Generation))
229+ require.Empty(t, s.Damage())
230+}
231+
232+func TestDamageCaptureOwnsDamageSlice(t *testing.T) {
233+ s := NewScreen(8, 2)
234+ s.ClearDamage()
235+ s.Write([]byte("a"))
236+
237+ captured := s.CaptureDamage()
238+ s.ClearDamage()
239+ require.NotEmpty(t, captured.Damage, "captured damage must not alias mutable screen storage")
240+}
A pkg/vt/screen_helpers_test.go +103 -0
@@ -0,0 +1,103 @@
1+package vt
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+func damageKinds(d []renderer.Damage) []renderer.DamageKind {
11+ ks := make([]renderer.DamageKind, len(d))
12+ for i, dd := range d {
13+ ks[i] = dd.Kind
14+ }
15+ return ks
16+}
17+
18+func hasDamageKind(d []renderer.Damage, kind renderer.DamageKind) bool {
19+ for _, dd := range d {
20+ if dd.Kind == kind {
21+ return true
22+ }
23+ }
24+ return false
25+}
26+
27+func cellAt(s *Screen, x, y int) renderer.Cell {
28+ return s.Frame.At(x, y)
29+}
30+
31+func assertFramesEqual(t *testing.T, got, want renderer.Frame) {
32+ t.Helper()
33+ if !reflect.DeepEqual(got, want) {
34+ t.Fatal("frame changed unexpectedly")
35+ }
36+}
37+
38+func assertCell(t *testing.T, s *Screen, x, y int, expected rune) {
39+ t.Helper()
40+ c := cellAt(s, x, y)
41+ if c.Rune != expected {
42+ t.Errorf("cell(%d,%d) rune = %q, want %q", x, y, c.Rune, expected)
43+ }
44+ if c.Continuation {
45+ t.Errorf("cell(%d,%d) unexpectedly marked as continuation", x, y)
46+ }
47+}
48+
49+// assertContinuation asserts the cell at (x,y) is the right half of a
50+// wide-character pair (Continuation set, Rune 0).
51+func assertContinuation(t *testing.T, s *Screen, x, y int) {
52+ t.Helper()
53+ c := cellAt(s, x, y)
54+ if !c.Continuation {
55+ t.Errorf("cell(%d,%d) expected continuation, got rune=%q continuation=%v", x, y, c.Rune, c.Continuation)
56+ }
57+ if c.Rune != 0 {
58+ t.Errorf("cell(%d,%d) continuation rune = %q, want 0", x, y, c.Rune)
59+ }
60+}
61+
62+// assertBlank asserts the cell at (x,y) is a blank default cell.
63+func assertBlank(t *testing.T, s *Screen, x, y int) {
64+ t.Helper()
65+ c := cellAt(s, x, y)
66+ if c.Rune != ' ' || c.Continuation {
67+ t.Errorf("cell(%d,%d) = {rune:%q cont:%v}, want blank space", x, y, c.Rune, c.Continuation)
68+ }
69+}
70+
71+func assertNoOrphanWideCells(t *testing.T, s *Screen) {
72+ t.Helper()
73+ for y := range s.Frame.Height {
74+ for x := range s.Frame.Width {
75+ c := cellAt(s, x, y)
76+ if c.Continuation {
77+ if x == 0 || cellAt(s, x-1, y).Continuation || renderer.RuneWidth(cellAt(s, x-1, y).Rune) != 2 {
78+ t.Fatalf("orphan continuation at (%d,%d)", x, y)
79+ }
80+ continue
81+ }
82+ if renderer.RuneWidth(c.Rune) == 2 && (x+1 >= s.Frame.Width || !cellAt(s, x+1, y).Continuation) {
83+ t.Fatalf("orphan wide head at (%d,%d) rune %q", x, y, c.Rune)
84+ }
85+ }
86+ }
87+}
88+
89+func lineText(s *Screen, y int) string {
90+ out := make([]rune, s.Frame.Width)
91+ for x := range s.Frame.Width {
92+ out[x] = s.Frame.At(x, y).Rune
93+ }
94+ return string(out)
95+}
96+
97+func rowString(row []renderer.Cell) string {
98+ out := make([]rune, len(row))
99+ for i, cell := range row {
100+ out[i] = cell.Rune
101+ }
102+ return string(out)
103+}
A pkg/vt/screen_integration_test.go +79 -0
@@ -0,0 +1,79 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestIntegrationScenarios(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ run func(t *testing.T)
13+ }{
14+ {
15+ name: "renderer stays in sync after VT edit damage",
16+ run: func(t *testing.T) {
17+ s := NewScreen(8, 2)
18+ r := renderer.New(renderer.Capabilities{})
19+ s.Write([]byte("abcdef"))
20+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
21+ t.Fatal(err)
22+ }
23+ s.ClearDamage()
24+
25+ s.Write([]byte("\x1b[1;3H\x1b[2@XY"))
26+ if _, err := r.Draw(s.Frame, s.Damage()); err != nil {
27+ t.Fatal(err)
28+ }
29+ s.ClearDamage()
30+
31+ out, err := r.Draw(s.Frame, nil)
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if len(out) != 0 {
36+ t.Fatalf("renderer emitted stale follow-up output after VT edit damage: %q", string(out))
37+ }
38+ },
39+ },
40+ {
41+ name: "DCS sequence terminated by ST is ignored",
42+ run: func(t *testing.T) {
43+ s := NewScreen(20, 2)
44+ s.Write([]byte("\x1bP+q696e646e\x1b\\prompt"))
45+
46+ assertCell(t, s, 0, 0, 'p')
47+ assertCell(t, s, 1, 0, 'r')
48+ assertCell(t, s, 2, 0, 'o')
49+ assertCell(t, s, 3, 0, 'm')
50+ assertCell(t, s, 4, 0, 'p')
51+ assertCell(t, s, 5, 0, 't')
52+ if s.Col != 6 || s.Row != 0 {
53+ t.Errorf("cursor at row=%d col=%d, want row=0 col=6", s.Row, s.Col)
54+ }
55+ },
56+ },
57+ {
58+ name: "fish-like prompt redraw preserves typed characters",
59+ run: func(t *testing.T) {
60+ s := NewScreen(10, 2)
61+ s.Write([]byte("> "))
62+ s.Write([]byte("a\r\x1b[3C"))
63+ s.Write([]byte("\x1b[91mb\x1b[39m\x1b[K\r\x1b[4C"))
64+
65+ assertCell(t, s, 0, 0, '>')
66+ assertCell(t, s, 1, 0, ' ')
67+ assertCell(t, s, 2, 0, 'a')
68+ assertCell(t, s, 3, 0, 'b')
69+ if s.Row != 0 || s.Col != 4 {
70+ t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
71+ }
72+ },
73+ },
74+ }
75+
76+ for _, tt := range tests {
77+ t.Run(tt.name, tt.run)
78+ }
79+}
A pkg/vt/screen_modes.go +227 -0
@@ -0,0 +1,227 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+type screenState struct {
6+ frame renderer.Frame
7+ row int
8+ col int
9+ style renderer.Style
10+ scrollTop int
11+ scrollBottom int
12+ savedCursor cursorState
13+ originMode bool
14+ insertMode bool
15+}
16+
17+// SyncUpdateActive reports whether DEC private mode 2026 (synchronized update)
18+// is currently enabled by the child process.
19+func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
20+
21+// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
22+// by the child process.
23+func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
24+
25+// ColorSchemeMode reports whether DEC private mode 2031 is currently enabled.
26+func (s *Screen) ColorSchemeMode() bool { return s.colorSchemeMode }
27+
28+// SetColorScheme updates the host color scheme and notifies subscribed child apps.
29+func (s *Screen) SetColorScheme(light bool) {
30+ if s.colorSchemeSet && s.colorSchemeLight == light {
31+ return
32+ }
33+ s.colorSchemeSet = true
34+ s.colorSchemeLight = light
35+ if s.colorSchemeMode {
36+ s.respond(s.colorSchemeReport())
37+ }
38+}
39+
40+// ClearColorScheme marks the host color scheme as unknown. Future child color
41+// scheme queries are silent until SetColorScheme supplies a known value again.
42+func (s *Screen) ClearColorScheme() {
43+ s.colorSchemeSet = false
44+}
45+
46+// ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
47+// Hosts use this as a safety valve if a child enters synchronized update mode
48+// and never sends the matching end sequence.
49+func (s *Screen) ForceSyncEnd() { s.syncUpdateActive = false }
50+
51+// SetDefaultColors sets the terminal default foreground/background colors used
52+// to answer child OSC 10/11 color queries. Passing ok=false makes color queries
53+// silent until known colors are supplied again.
54+func (s *Screen) SetDefaultColors(fg, bg renderer.RGB, ok bool) {
55+ s.defaultFG = fg
56+ s.defaultBG = bg
57+ s.defaultColorsKnown = ok
58+}
59+
60+func (s *Screen) CursorRow() int { return s.Row }
61+func (s *Screen) CursorCol() int { return s.Col }
62+func (s *Screen) CursorVisible() bool {
63+ return s.cursorVisible
64+}
65+func (s *Screen) CursorStyle() (int, bool) { return s.cursorStyle, s.cursorStyleSet }
66+func (s *Screen) MouseMode() (int, bool) { return s.mouseMode, s.mouseSGR }
67+func (s *Screen) AltScreenActive() bool { return s.alternate != nil }
68+
69+// TerminalTitle returns the latest title set by OSC 0 or OSC 2.
70+func (s *Screen) TerminalTitle() string { return s.terminalTitle }
71+
72+func (s *Screen) respond(b []byte) {
73+ if s.OnResponse != nil {
74+ s.OnResponse(b)
75+ }
76+}
77+
78+func (s *Screen) colorSchemeReport() []byte {
79+ if !s.colorSchemeSet {
80+ return nil
81+ }
82+ if s.colorSchemeLight {
83+ return []byte(ColorSchemeReportLight)
84+ }
85+ return []byte(ColorSchemeReportDark)
86+}
87+
88+func (s *Screen) reset() {
89+ s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
90+ s.Row, s.Col = 0, 0
91+ s.Style = renderer.DefaultStyle()
92+ s.escapeBuf = s.escapeBuf[:0]
93+ s.savedCursor = cursorState{}
94+ s.alternate = nil
95+ s.originMode = false
96+ s.insertMode = false
97+ s.cursorVisible = true
98+ s.cursorStyle = 0
99+ s.cursorStyleSet = false
100+ s.mouseMode = 0
101+ s.mouseSGR = false
102+ s.bracketedPaste = false
103+ s.colorSchemeMode = false
104+ s.resetScrollRegion()
105+ s.fullRedraw()
106+}
107+
108+func (s *Screen) setScrollRegion(parts []int) {
109+ if s.Frame.Height == 0 {
110+ return
111+ }
112+ top, bottom := 1, s.Frame.Height
113+ if len(parts) > 0 && parts[0] > 0 {
114+ top = parts[0]
115+ }
116+ if len(parts) > 1 && parts[1] > 0 {
117+ bottom = parts[1]
118+ }
119+ if top >= bottom {
120+ s.resetScrollRegion()
121+ } else {
122+ s.scrollTop = clamp(top-1, 0, s.Frame.Height-1)
123+ s.scrollBottom = clamp(bottom-1, 0, s.Frame.Height-1)
124+ if s.scrollTop >= s.scrollBottom {
125+ s.resetScrollRegion()
126+ }
127+ }
128+ s.homeCursor()
129+}
130+
131+func (s *Screen) resetScrollRegion() {
132+ s.scrollTop = 0
133+ if s.Frame.Height > 0 {
134+ s.scrollBottom = s.Frame.Height - 1
135+ } else {
136+ s.scrollBottom = 0
137+ }
138+}
139+
140+func (s *Screen) setMode(private bool, parts []int, enabled bool) {
141+ if !private {
142+ for _, mode := range parts {
143+ switch mode {
144+ case 4:
145+ s.insertMode = enabled
146+ default:
147+ // Other ANSI modes (for example LNM) are intentionally ignored
148+ // until the screen model implements their observable behavior.
149+ continue
150+ }
151+ }
152+ return
153+ }
154+ for _, mode := range parts {
155+ switch mode {
156+ case 6:
157+ s.originMode = enabled
158+ s.homeCursor()
159+ case 47, 1047, 1049:
160+ if enabled {
161+ s.enterAlternateScreen()
162+ } else {
163+ s.exitAlternateScreen()
164+ }
165+ case 2026:
166+ s.syncUpdateActive = enabled
167+ case 2031:
168+ s.colorSchemeMode = enabled
169+ case 25:
170+ s.cursorVisible = enabled
171+ case 1000, 1002, 1003:
172+ if enabled {
173+ s.mouseMode = mode
174+ } else if s.mouseMode == mode {
175+ s.mouseMode = 0
176+ }
177+ case 1006:
178+ s.mouseSGR = enabled
179+ case 2004:
180+ s.bracketedPaste = enabled
181+ case 1, 1004, 1005:
182+ // Trackable terminal modes that do not directly affect the current
183+ // cell model yet. Consuming them prevents mode bytes from leaking.
184+ continue
185+ }
186+ }
187+}
188+
189+func (s *Screen) enterAlternateScreen() {
190+ if s.alternate == nil {
191+ s.alternate = &screenState{
192+ frame: cloneFrame(s.Frame),
193+ row: s.Row,
194+ col: s.Col,
195+ style: s.Style,
196+ scrollTop: s.scrollTop,
197+ scrollBottom: s.scrollBottom,
198+ savedCursor: s.savedCursor,
199+ originMode: s.originMode,
200+ insertMode: s.insertMode,
201+ }
202+ }
203+ s.Frame = renderer.NewFrame(s.Frame.Width, s.Frame.Height)
204+ s.Row, s.Col = 0, 0
205+ s.Style = renderer.DefaultStyle()
206+ s.savedCursor = cursorState{}
207+ s.resetScrollRegion()
208+ s.fullRedraw()
209+}
210+
211+func (s *Screen) exitAlternateScreen() {
212+ if s.alternate == nil {
213+ return
214+ }
215+ state := s.alternate
216+ s.Frame = cloneFrame(state.frame)
217+ s.Row = clamp(state.row, 0, s.Frame.Height-1)
218+ s.Col = clamp(state.col, 0, s.Frame.Width-1)
219+ s.Style = state.style
220+ s.scrollTop = state.scrollTop
221+ s.scrollBottom = state.scrollBottom
222+ s.savedCursor = state.savedCursor
223+ s.originMode = state.originMode
224+ s.insertMode = state.insertMode
225+ s.alternate = nil
226+ s.fullRedraw()
227+}
A pkg/vt/screen_modes_test.go +228 -0
@@ -0,0 +1,228 @@
1+package vt
2+
3+import (
4+ "testing"
5+)
6+
7+func TestESCSequences(t *testing.T) {
8+ tests := []struct {
9+ name string
10+ run func(t *testing.T)
11+ }{
12+ {
13+ name: "ESC = (DECKPAM) is ignored, following text is printable",
14+ run: func(t *testing.T) {
15+ s := NewScreen(10, 2)
16+ s.Write([]byte("\x1b=abc"))
17+
18+ assertCell(t, s, 0, 0, 'a')
19+ assertCell(t, s, 1, 0, 'b')
20+ assertCell(t, s, 2, 0, 'c')
21+ if s.Col != 3 || s.Row != 0 {
22+ t.Errorf("cursor at row=%d col=%d, want row=0 col=3", s.Row, s.Col)
23+ }
24+ },
25+ },
26+ {
27+ name: "ESC 7 / ESC 8 save and restore the cursor",
28+ run: func(t *testing.T) {
29+ s := NewScreen(10, 3)
30+ s.Write([]byte("abc\x1b7\x1b[2;5HZZ\x1b8X"))
31+
32+ assertCell(t, s, 0, 0, 'a')
33+ assertCell(t, s, 1, 0, 'b')
34+ assertCell(t, s, 2, 0, 'c')
35+ assertCell(t, s, 3, 0, 'X')
36+ assertCell(t, s, 4, 1, 'Z')
37+ assertCell(t, s, 5, 1, 'Z')
38+ if s.Row != 0 || s.Col != 4 {
39+ t.Errorf("cursor at row=%d col=%d, want row=0 col=4", s.Row, s.Col)
40+ }
41+ },
42+ },
43+ {
44+ name: "ESC 7 / ESC 8 restore origin and insert modes",
45+ run: func(t *testing.T) {
46+ s := NewScreen(6, 5)
47+ s.Write([]byte("\x1b[2;4r\x1b[?6h\x1b[4h\x1b7"))
48+ s.Write([]byte("\x1b[?6l\x1b[4l\x1b8"))
49+
50+ if !s.originMode {
51+ t.Fatal("origin mode was not restored")
52+ }
53+ if !s.insertMode {
54+ t.Fatal("insert mode was not restored")
55+ }
56+
57+ s.Write([]byte("\x1b[1;1Habcd\x1b[1;3HXY"))
58+ if s.Row != 1 || s.Col != 4 {
59+ t.Fatalf("cursor after restored origin and insert modes = (%d,%d), want (1,4)", s.Row, s.Col)
60+ }
61+ if got := lineText(s, 1); got != "abXYcd" {
62+ t.Fatalf("line after restored insert mode = %q, want %q", got, "abXYcd")
63+ }
64+ },
65+ },
66+ {
67+ name: "ESC D (index), ESC E (next line), ESC M (reverse index)",
68+ run: func(t *testing.T) {
69+ s := NewScreen(5, 3)
70+ s.Write([]byte("A\x1bDB\x1bEC"))
71+
72+ assertCell(t, s, 0, 0, 'A')
73+ assertCell(t, s, 1, 1, 'B')
74+ assertCell(t, s, 0, 2, 'C')
75+
76+ s = NewScreen(5, 3)
77+ s.Write([]byte("\x1b[2;1Hmid\r\x1bMtop"))
78+ assertCell(t, s, 0, 0, 't')
79+ assertCell(t, s, 0, 1, 'm')
80+ },
81+ },
82+ }
83+
84+ for _, tt := range tests {
85+ t.Run(tt.name, tt.run)
86+ }
87+}
88+
89+func TestScreenReportsSynchronizedUpdateMode(t *testing.T) {
90+ s := NewScreen(10, 2)
91+ s.Write([]byte("\x1b[?2026h"))
92+ if !s.SyncUpdateActive() {
93+ t.Fatal("sync update mode should be active")
94+ }
95+ s.Write([]byte("\x1b[?2026l"))
96+ if s.SyncUpdateActive() {
97+ t.Fatal("sync update mode should be inactive")
98+ }
99+}
100+
101+func TestForceSyncEnd(t *testing.T) {
102+ s := NewScreen(10, 2)
103+ s.Write([]byte("\x1b[?2026h"))
104+ if !s.SyncUpdateActive() {
105+ t.Fatal("sync update mode should be active before forcing end")
106+ }
107+ s.ForceSyncEnd()
108+ if s.SyncUpdateActive() {
109+ t.Fatal("sync update mode should be inactive after forcing end")
110+ }
111+}
112+
113+func TestScreenCursorAndMouseStateAccessors(t *testing.T) {
114+ tests := []struct {
115+ name string
116+ seq string
117+ check func(t *testing.T, s *Screen)
118+ }{
119+ {
120+ name: "cursor position and visibility are exposed",
121+ seq: "\x1b[2;3H\x1b[?25l",
122+ check: func(t *testing.T, s *Screen) {
123+ if s.CursorRow() != 1 || s.CursorCol() != 2 {
124+ t.Fatalf("cursor = row %d col %d, want row 1 col 2", s.CursorRow(), s.CursorCol())
125+ }
126+ if s.CursorVisible() {
127+ t.Fatal("cursor should be hidden")
128+ }
129+ s.Write([]byte("\x1b[?25h"))
130+ if !s.CursorVisible() {
131+ t.Fatal("cursor should be visible")
132+ }
133+ },
134+ },
135+ {
136+ name: "mouse mode and SGR are exposed",
137+ seq: "\x1b[?1002h\x1b[?1006h",
138+ check: func(t *testing.T, s *Screen) {
139+ mode, sgr := s.MouseMode()
140+ if mode != 1002 || !sgr {
141+ t.Fatalf("MouseMode() = (%d, %v), want (1002, true)", mode, sgr)
142+ }
143+ },
144+ },
145+ {
146+ name: "reset restores cursor and mouse defaults",
147+ seq: "\x1b[?25l\x1b[?1003h\x1b[?1006h\x1bc",
148+ check: func(t *testing.T, s *Screen) {
149+ mode, sgr := s.MouseMode()
150+ if !s.CursorVisible() || mode != 0 || sgr {
151+ t.Fatalf("after reset CursorVisible=%v MouseMode=(%d,%v), want true,(0,false)", s.CursorVisible(), mode, sgr)
152+ }
153+ },
154+ },
155+ }
156+ for _, tt := range tests {
157+ t.Run(tt.name, func(t *testing.T) {
158+ s := NewScreen(10, 4)
159+ s.Write([]byte(tt.seq))
160+ tt.check(t, s)
161+ })
162+ }
163+}
164+
165+func TestScreenMouseModeDisableInactiveIsNoOp(t *testing.T) {
166+ s := NewScreen(10, 2)
167+ s.Write([]byte("\x1b[?1002h"))
168+ s.Write([]byte("\x1b[?1000l"))
169+ mode, _ := s.MouseMode()
170+ if mode != 1002 {
171+ t.Fatalf("mouse mode after disabling inactive 1000 = %d, want 1002", mode)
172+ }
173+ s.Write([]byte("\x1b[?1002l"))
174+ mode, _ = s.MouseMode()
175+ if mode != 0 {
176+ t.Fatalf("mouse mode after disabling active 1002 = %d, want 0", mode)
177+ }
178+}
179+
180+func TestScreenCursorStyleDECSCUSR(t *testing.T) {
181+ tests := []struct {
182+ name string
183+ seq string
184+ wantStyle int
185+ wantSet bool
186+ }{
187+ {name: "explicit style", seq: "\x1b[5 q", wantStyle: 5, wantSet: true},
188+ {name: "blank style parameter", seq: "\x1b[ q", wantStyle: 0, wantSet: true},
189+ {name: "invalid low style is ignored", seq: "\x1b[-1 q", wantStyle: 0, wantSet: false},
190+ {name: "invalid high style is ignored", seq: "\x1b[7 q", wantStyle: 0, wantSet: false},
191+ {name: "XTVERSION is ignored", seq: "\x1b[>0q", wantStyle: 0, wantSet: false},
192+ }
193+ for _, tt := range tests {
194+ t.Run(tt.name, func(t *testing.T) {
195+ s := NewScreen(10, 2)
196+ s.Write([]byte(tt.seq))
197+ style, set := s.CursorStyle()
198+ if style != tt.wantStyle || set != tt.wantSet {
199+ t.Fatalf("CursorStyle() = (%d, %v), want (%d, %v)", style, set, tt.wantStyle, tt.wantSet)
200+ }
201+ })
202+ }
203+}
204+
205+func TestScreenInvalidCursorStyleDoesNotOverwriteCurrentStyle(t *testing.T) {
206+ s := NewScreen(10, 2)
207+ s.Write([]byte("\x1b[5 q"))
208+ s.Write([]byte("\x1b[99 q"))
209+ style, set := s.CursorStyle()
210+ if style != 5 || !set {
211+ t.Fatalf("CursorStyle() = (%d, %v), want (5, true)", style, set)
212+ }
213+}
214+
215+func TestScreenAltScreenActiveAccessor(t *testing.T) {
216+ s := NewScreen(10, 2)
217+ if s.AltScreenActive() {
218+ t.Fatal("alt screen should start inactive")
219+ }
220+ s.Write([]byte("\x1b[?1049h"))
221+ if !s.AltScreenActive() {
222+ t.Fatal("alt screen should be active")
223+ }
224+ s.Write([]byte("\x1b[?1049l"))
225+ if s.AltScreenActive() {
226+ t.Fatal("alt screen should be inactive after exit")
227+ }
228+}
A pkg/vt/screen_osc.go +159 -0
@@ -0,0 +1,159 @@
1+package vt
2+
3+import (
4+ "strconv"
5+ "strings"
6+
7+ "github.com/bnema/vev/pkg/renderer"
8+)
9+
10+const (
11+ progressStateClear = iota
12+ progressStateNormal
13+ progressStateError
14+ progressStateIndeterminate
15+ progressStatePaused
16+)
17+
18+func (s *Screen) consumeOSC(data []byte) (consumed int, partial bool) {
19+ for i := 2; i < len(data); i++ {
20+ switch data[i] {
21+ case 0x07:
22+ s.applyOSC(data[2:i], []byte{0x07})
23+ s.handleOSC(data[2:i])
24+ return i + 1, false
25+ case 0x1b:
26+ if i+1 < len(data) && data[i+1] == '\\' {
27+ s.applyOSC(data[2:i], []byte{0x1b, '\\'})
28+ s.handleOSC(data[2:i])
29+ return i + 2, false
30+ }
31+ }
32+ }
33+ return 0, true
34+}
35+
36+func (s *Screen) applyOSC(payload, terminator []byte) {
37+ if !s.defaultColorsKnown {
38+ return
39+ }
40+
41+ var color renderer.RGB
42+ switch string(payload) {
43+ case "10;?":
44+ color = s.defaultFG
45+ case "11;?":
46+ color = s.defaultBG
47+ default:
48+ return
49+ }
50+
51+ resp := make([]byte, 0, len(payload)+len("\x1b];rgb:0000/0000/0000")+len(terminator))
52+ resp = append(resp, "\x1b]"...)
53+ resp = append(resp, payload[:2]...)
54+ resp = append(resp, ";rgb:"...)
55+ resp = appendOSCColorComponent(resp, color.R)
56+ resp = append(resp, '/')
57+ resp = appendOSCColorComponent(resp, color.G)
58+ resp = append(resp, '/')
59+ resp = appendOSCColorComponent(resp, color.B)
60+ resp = append(resp, terminator...)
61+ s.respond(resp)
62+}
63+
64+func appendOSCColorComponent(dst []byte, c uint8) []byte {
65+ const hex = "0123456789abcdef"
66+ v := uint16(c)<<8 | uint16(c)
67+ return append(dst, hex[v>>12&0xf], hex[v>>8&0xf], hex[v>>4&0xf], hex[v&0xf])
68+}
69+
70+// handleOSC inspects a complete OSC payload (between "ESC ]" and its
71+// terminator). Terminal titles, notification sequences (OSC 9, OSC 777
72+// "notify"), and clipboard set requests (OSC 52) are acted on; every other OSC
73+// is discarded.
74+
75+func (s *Screen) handleOSC(payload []byte) {
76+ if len(payload) >= len("52;") && payload[0] == '5' && payload[1] == '2' && payload[2] == ';' {
77+ s.handleOSC52(string(payload[len("52;"):]))
78+ return
79+ }
80+ p := string(payload)
81+ if selector, title, ok := strings.Cut(p, ";"); ok && (selector == "0" || selector == "2") {
82+ s.terminalTitle = title
83+ return
84+ }
85+ if p == "9;4" || strings.HasPrefix(p, "9;4;") {
86+ s.handleProgress(p[len("9;4"):])
87+ return
88+ }
89+ if s.OnNotify == nil {
90+ return
91+ }
92+ switch {
93+ case strings.HasPrefix(p, "9;"):
94+ s.OnNotify("", p[len("9;"):])
95+ case strings.HasPrefix(p, "777;"):
96+ parts := strings.SplitN(p[len("777;"):], ";", 3)
97+ if parts[0] != "notify" {
98+ return
99+ }
100+ var title, body string
101+ if len(parts) > 1 {
102+ title = parts[1]
103+ }
104+ if len(parts) > 2 {
105+ body = parts[2]
106+ }
107+ s.OnNotify(title, body)
108+ }
109+}
110+
111+func (s *Screen) handleProgress(rest string) {
112+ rest = strings.TrimPrefix(rest, ";")
113+ if rest == "" {
114+ return
115+ }
116+ token, _, _ := strings.Cut(rest, ";")
117+ state, err := strconv.Atoi(token)
118+ if err != nil {
119+ return
120+ }
121+ switch state {
122+ case progressStateNormal, progressStateIndeterminate, progressStatePaused:
123+ s.progressState = state
124+ case progressStateClear:
125+ previous := s.progressState
126+ s.progressState = state
127+ if previous == progressStateNormal || previous == progressStateIndeterminate || previous == progressStatePaused {
128+ if s.OnProgress != nil {
129+ s.OnProgress(false)
130+ }
131+ }
132+ case progressStateError:
133+ previous := s.progressState
134+ s.progressState = state
135+ if previous != progressStateError {
136+ if s.OnProgress != nil {
137+ s.OnProgress(true)
138+ }
139+ }
140+ }
141+}
142+
143+// handleOSC52 parses the "<selection>;<data>" remainder of an OSC 52
144+// clipboard payload (selection may be empty; split on the first ";"). A
145+// clipboard query (data == "?") is always ignored — vev never answers
146+// clipboard queries. A payload with no second ";" is malformed and ignored.
147+
148+func (s *Screen) handleOSC52(rest string) {
149+ _, data, ok := strings.Cut(rest, ";")
150+ if !ok {
151+ return
152+ }
153+ if data == "?" {
154+ return
155+ }
156+ if s.OnClipboard != nil {
157+ s.OnClipboard(data)
158+ }
159+}
A pkg/vt/screen_osc_test.go +429 -0
@@ -0,0 +1,429 @@
1+package vt
2+
3+import (
4+ "strings"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestOnBellFiresForLoneBEL(t *testing.T) {
11+ s := NewScreen(10, 2)
12+ rang := 0
13+ s.OnBell = func() { rang++ }
14+ s.Write([]byte("hi\ahi\a"))
15+ require.Equal(t, 2, rang)
16+ require.Equal(t, "hihi", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
17+}
18+
19+func TestOnBellIgnoresOSCTerminator(t *testing.T) {
20+ s := NewScreen(10, 2)
21+ rang := 0
22+ s.OnBell = func() { rang++ }
23+ s.Write([]byte("\x1b]0;title\a")) // BEL terminates the OSC, it is not a bell
24+ require.Equal(t, 0, rang)
25+}
26+
27+func TestBELWithoutCallbackIsDiscarded(t *testing.T) {
28+ s := NewScreen(10, 2)
29+ s.Write([]byte("a\ab")) // no OnBell set: must not panic
30+ require.Equal(t, "ab", strings.TrimRight(rowString(s.Frame.Row(0)), " "))
31+}
32+
33+func TestTerminalTitleRetainsOSC0AndOSC2(t *testing.T) {
34+ tests := []struct {
35+ name string
36+ seq string
37+ want string
38+ }{
39+ {name: "OSC 0 BEL terminated", seq: "\x1b]0;zero title\x07", want: "zero title"},
40+ {name: "OSC 0 ST terminated", seq: "\x1b]0;zero title\x1b\\", want: "zero title"},
41+ {name: "OSC 2 BEL terminated", seq: "\x1b]2;two title\x07", want: "two title"},
42+ {name: "OSC 2 ST terminated", seq: "\x1b]2;two title\x1b\\", want: "two title"},
43+ }
44+
45+ for _, tt := range tests {
46+ t.Run(tt.name, func(t *testing.T) {
47+ s := NewScreen(10, 2)
48+ bells := 0
49+ s.OnBell = func() { bells++ }
50+ s.ClearDamage()
51+
52+ s.Write([]byte(tt.seq))
53+
54+ require.Equal(t, tt.want, s.TerminalTitle())
55+ require.Equal(t, 0, bells)
56+ require.Empty(t, s.Damage())
57+ require.Equal(t, strings.Repeat(" ", s.Frame.Width), rowString(s.Frame.Row(0)))
58+ })
59+ }
60+}
61+
62+func TestTerminalTitleHandlesSplitWritesReplacementAndClear(t *testing.T) {
63+ s := NewScreen(10, 2)
64+
65+ s.Write([]byte("\x1b]0;split"))
66+ s.Write([]byte(" title\x1b"))
67+ require.Empty(t, s.TerminalTitle())
68+ s.Write([]byte("\\"))
69+ require.Equal(t, "split title", s.TerminalTitle())
70+
71+ s.Write([]byte("\x1b]2;replacement\x07"))
72+ require.Equal(t, "replacement", s.TerminalTitle())
73+
74+ s.Write([]byte("\x1b]0;\x07"))
75+ require.Empty(t, s.TerminalTitle())
76+}
77+
78+func TestTerminalTitleIgnoresUnrelatedAndOversizedOSC(t *testing.T) {
79+ s := NewScreen(10, 2)
80+ s.Write([]byte("\x1b]0;retained\x07"))
81+
82+ for _, seq := range []string{
83+ "\x1b]1;icon title\x07",
84+ "\x1b]20;not selector two\x1b\\",
85+ "\x1b]777;other;x;y\x07",
86+ } {
87+ s.Write([]byte(seq))
88+ require.Equal(t, "retained", s.TerminalTitle())
89+ }
90+
91+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
92+ s.Write([]byte("\x1b]2;" + payload))
93+ require.Equal(t, "retained", s.TerminalTitle())
94+ require.Empty(t, s.escapeBuf)
95+}
96+
97+func TestOnNotifyOSC9(t *testing.T) {
98+ s := NewScreen(10, 2)
99+ var gotTitle, gotBody string
100+ calls := 0
101+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
102+ s.Write([]byte("\x1b]9;agent done\x07"))
103+ require.Equal(t, 1, calls)
104+ require.Equal(t, "", gotTitle)
105+ require.Equal(t, "agent done", gotBody)
106+}
107+
108+func TestOSC9ProgressTransitions(t *testing.T) {
109+ tests := []struct {
110+ name string
111+ seqs []string
112+ want []bool
113+ }{
114+ {
115+ name: "finish fires once",
116+ seqs: []string{"9;4;1;50", "9;4;0;100", "9;4;0;100"},
117+ want: []bool{false},
118+ },
119+ {
120+ name: "error fires once repeat no fire",
121+ seqs: []string{"9;4;2;0", "9;4;2;50"},
122+ want: []bool{true},
123+ },
124+ {
125+ name: "indeterminate clear fires",
126+ seqs: []string{"9;4;3", "9;4;0"},
127+ want: []bool{false},
128+ },
129+ {
130+ name: "paused clear fires",
131+ seqs: []string{"9;4;4;80", "9;4;0;80"},
132+ want: []bool{false},
133+ },
134+ {
135+ name: "ongoing updates silent",
136+ seqs: []string{"9;4;1;10", "9;4;1;50", "9;4;3;90", "9;4;4;90"},
137+ },
138+ {
139+ name: "bare clear silent",
140+ seqs: []string{"9;4;0"},
141+ },
142+ {
143+ name: "bare progress ignored",
144+ seqs: []string{"9;4"},
145+ },
146+ {
147+ name: "unparseable ignored",
148+ seqs: []string{"9;4;bogus", "9;4;99", "9;4;1", "9;4;0"},
149+ want: []bool{false},
150+ },
151+ {
152+ name: "error active clear fires error then finish",
153+ seqs: []string{"9;4;2;0", "9;4;1;50", "9;4;0;100"},
154+ want: []bool{true, false},
155+ },
156+ {
157+ name: "error clear only fires error",
158+ seqs: []string{"9;4;2;0", "9;4;0;100"},
159+ want: []bool{true},
160+ },
161+ }
162+
163+ for _, tt := range tests {
164+ t.Run(tt.name, func(t *testing.T) {
165+ s := NewScreen(10, 2)
166+ var got []bool
167+ s.OnProgress = func(errored bool) { got = append(got, errored) }
168+ for _, seq := range tt.seqs {
169+ s.Write([]byte("\x1b]" + seq + "\x07"))
170+ }
171+ require.Equal(t, tt.want, got)
172+ })
173+ }
174+}
175+
176+func TestOSC9ProgressSplitAcrossWritesFiresOnce(t *testing.T) {
177+ s := NewScreen(10, 2)
178+ calls := 0
179+ s.OnProgress = func(bool) { calls++ }
180+ s.Write([]byte("\x1b]9;4;1"))
181+ s.Write([]byte(";50\x07\x1b]9;4;0;100\x07"))
182+ require.Equal(t, 1, calls)
183+}
184+
185+func TestOSC9ProgressNilCallbackDoesNotPanic(t *testing.T) {
186+ s := NewScreen(10, 2)
187+ require.NotPanics(t, func() {
188+ s.Write([]byte("\x1b]9;4;1;50\x07"))
189+ s.Write([]byte("\x1b]9;4;0;100\x07"))
190+ s.Write([]byte("\x1b]9;4;2;0\x07"))
191+ })
192+}
193+
194+func TestOSC9ProgressSTTerminated(t *testing.T) {
195+ s := NewScreen(10, 2)
196+ var got []bool
197+ s.OnProgress = func(errored bool) { got = append(got, errored) }
198+ s.Write([]byte("\x1b]9;4;1;50\x1b\\\x1b]9;4;0;100\x1b\\"))
199+ require.Equal(t, []bool{false}, got)
200+}
201+
202+func TestOSC9ProgressStateTrackedBeforeCallbackSet(t *testing.T) {
203+ s := NewScreen(10, 2)
204+ s.Write([]byte("\x1b]9;4;1;50\x07"))
205+ var got []bool
206+ s.OnProgress = func(errored bool) { got = append(got, errored) }
207+ s.Write([]byte("\x1b]9;4;0;100\x07"))
208+ require.Equal(t, []bool{false}, got)
209+}
210+
211+func TestOnNotifyIgnoresOSC9Progress(t *testing.T) {
212+ s := NewScreen(10, 2)
213+ calls := 0
214+ s.OnNotify = func(string, string) { calls++ }
215+ s.Write([]byte("\x1b]9;4;1;50\x07"))
216+ s.Write([]byte("\x1b]9;4;0;100\x07"))
217+ s.Write([]byte("\x1b]9;4;2;0\x07"))
218+ require.Equal(t, 0, calls)
219+}
220+
221+func TestOnNotifyOSC940RemainsGeneric(t *testing.T) {
222+ s := NewScreen(10, 2)
223+ var gotTitle, gotBody string
224+ calls := 0
225+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
226+ s.Write([]byte("\x1b]9;40;not progress\x07"))
227+ require.Equal(t, 1, calls)
228+ require.Equal(t, "", gotTitle)
229+ require.Equal(t, "40;not progress", gotBody)
230+}
231+
232+func TestOnNotifyOSC777STTerminated(t *testing.T) {
233+ s := NewScreen(10, 2)
234+ var gotTitle, gotBody string
235+ calls := 0
236+ s.OnNotify = func(title, body string) { gotTitle, gotBody = title, body; calls++ }
237+ s.Write([]byte("\x1b]777;notify;Claude;needs input\x1b\\"))
238+ require.Equal(t, 1, calls)
239+ require.Equal(t, "Claude", gotTitle)
240+ require.Equal(t, "needs input", gotBody)
241+}
242+
243+func TestOnNotifyIgnoresOtherOSC(t *testing.T) {
244+ s := NewScreen(10, 2)
245+ calls := 0
246+ s.OnNotify = func(string, string) { calls++ }
247+ s.Write([]byte("\x1b]0;window title\x07")) // title: discarded
248+ s.Write([]byte("\x1b]777;other;x;y\x1b\\")) // not "notify": discarded
249+ require.Equal(t, 0, calls)
250+}
251+
252+func TestOnNotifySplitAcrossWrites(t *testing.T) {
253+ s := NewScreen(10, 2)
254+ calls := 0
255+ s.OnNotify = func(string, string) { calls++ }
256+ s.Write([]byte("\x1b]9;par"))
257+ s.Write([]byte("tial\x07"))
258+ require.Equal(t, 1, calls)
259+}
260+
261+func TestOnClipboardBELTerminated(t *testing.T) {
262+ s := NewScreen(10, 2)
263+ var got string
264+ calls := 0
265+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
266+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
267+ require.Equal(t, 1, calls)
268+ require.Equal(t, "aGVsbG8=", got)
269+}
270+
271+func TestOnClipboardSTTerminated(t *testing.T) {
272+ s := NewScreen(10, 2)
273+ var got string
274+ calls := 0
275+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
276+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x1b\\"))
277+ require.Equal(t, 1, calls)
278+ require.Equal(t, "aGVsbG8=", got)
279+}
280+
281+func TestOnClipboardQueryIgnored(t *testing.T) {
282+ s := NewScreen(10, 2)
283+ calls := 0
284+ s.OnClipboard = func(string) { calls++ }
285+ s.Write([]byte("\x1b]52;c;?\x07"))
286+ require.Equal(t, 0, calls)
287+}
288+
289+func TestOnClipboardEmptySelection(t *testing.T) {
290+ s := NewScreen(10, 2)
291+ var got string
292+ calls := 0
293+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
294+ s.Write([]byte("\x1b]52;;aGVsbG8=\x07"))
295+ require.Equal(t, 1, calls)
296+ require.Equal(t, "aGVsbG8=", got)
297+}
298+
299+func TestOnClipboardNilCallbackDoesNotPanic(t *testing.T) {
300+ s := NewScreen(10, 2)
301+ require.NotPanics(t, func() {
302+ s.Write([]byte("\x1b]52;c;aGVsbG8=\x07"))
303+ })
304+}
305+
306+func TestOnClipboardSplitAcrossWrites(t *testing.T) {
307+ s := NewScreen(10, 2)
308+ var got string
309+ calls := 0
310+ s.OnClipboard = func(b64 string) { got = b64; calls++ }
311+ s.Write([]byte("\x1b]52;c;aGVs"))
312+ s.Write([]byte("bG8=\x07"))
313+ require.Equal(t, 1, calls)
314+ require.Equal(t, "aGVsbG8=", got)
315+}
316+
317+func TestOnClipboardNoSecondSemicolonIgnored(t *testing.T) {
318+ s := NewScreen(10, 2)
319+ calls := 0
320+ s.OnClipboard = func(string) { calls++ }
321+ s.Write([]byte("\x1b]52;c\x07")) // no second ";" -> no data field
322+ require.Equal(t, 0, calls)
323+}
324+
325+func TestOSCSequences(t *testing.T) {
326+ tests := []struct {
327+ name string
328+ run func(t *testing.T)
329+ }{
330+ {
331+ name: "BEL-terminated OSC is ignored",
332+ run: func(t *testing.T) {
333+ s := NewScreen(20, 2)
334+ s.Write([]byte("\x1b]0;~/Projects/ymux - fish\x07fish$ "))
335+
336+ assertCell(t, s, 0, 0, 'f')
337+ assertCell(t, s, 1, 0, 'i')
338+ assertCell(t, s, 2, 0, 's')
339+ assertCell(t, s, 3, 0, 'h')
340+ assertCell(t, s, 4, 0, '$')
341+ assertCell(t, s, 5, 0, ' ')
342+ if s.Col != 6 || s.Row != 0 {
343+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
344+ }
345+ },
346+ },
347+ {
348+ name: "ST-terminated OSC is ignored",
349+ run: func(t *testing.T) {
350+ s := NewScreen(20, 2)
351+ s.Write([]byte("\x1b]133;A;click_events=1\x1b\\> "))
352+
353+ assertCell(t, s, 0, 0, '>')
354+ assertCell(t, s, 1, 0, ' ')
355+ if s.Col != 2 || s.Row != 0 {
356+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
357+ }
358+ },
359+ },
360+ {
361+ name: "multiple OSC sequences in a row are all ignored",
362+ run: func(t *testing.T) {
363+ s := NewScreen(20, 2)
364+ s.Write([]byte("\x1b]7;file://host/tmp\x07\x1b]11;?\\=\x07\x1b]133;B\x1b\\prompt"))
365+
366+ assertCell(t, s, 0, 0, 'p')
367+ assertCell(t, s, 1, 0, 'r')
368+ assertCell(t, s, 2, 0, 'o')
369+ assertCell(t, s, 3, 0, 'm')
370+ assertCell(t, s, 4, 0, 'p')
371+ assertCell(t, s, 5, 0, 't')
372+ if s.Col != 6 || s.Row != 0 {
373+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
374+ }
375+ },
376+ },
377+ {
378+ name: "BEL-terminated OSC split across writes is ignored",
379+ run: func(t *testing.T) {
380+ s := NewScreen(20, 2)
381+ s.Write([]byte("\x1b]0;~/Projects/ymux"))
382+ s.Write([]byte(" - fish\x07fish$ "))
383+
384+ assertCell(t, s, 0, 0, 'f')
385+ assertCell(t, s, 1, 0, 'i')
386+ assertCell(t, s, 2, 0, 's')
387+ assertCell(t, s, 3, 0, 'h')
388+ assertCell(t, s, 4, 0, '$')
389+ assertCell(t, s, 5, 0, ' ')
390+ if s.Col != 6 || s.Row != 0 {
391+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
392+ }
393+ },
394+ },
395+ {
396+ name: "ST-terminated OSC split across writes is ignored",
397+ run: func(t *testing.T) {
398+ s := NewScreen(20, 2)
399+ s.Write([]byte("\x1b]133;A;click_events=1\x1b"))
400+ s.Write([]byte("\\> "))
401+
402+ assertCell(t, s, 0, 0, '>')
403+ assertCell(t, s, 1, 0, ' ')
404+ if s.Col != 2 || s.Row != 0 {
405+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
406+ }
407+ },
408+ },
409+ {
410+ name: "unterminated OSC sequence is bounded and dropped",
411+ run: func(t *testing.T) {
412+ s := NewScreen(20, 2)
413+ payload := strings.Repeat("x", maxEscapeBufferLen+1)
414+ s.Write([]byte("\x1b]0;" + payload))
415+ s.Write([]byte("OK"))
416+
417+ assertCell(t, s, 0, 0, 'O')
418+ assertCell(t, s, 1, 0, 'K')
419+ if len(s.escapeBuf) != 0 {
420+ t.Fatalf("escapeBuf length = %d, want 0 after overlong unterminated OSC", len(s.escapeBuf))
421+ }
422+ },
423+ },
424+ }
425+
426+ for _, tt := range tests {
427+ t.Run(tt.name, tt.run)
428+ }
429+}
A pkg/vt/screen_resize.go +92 -0
@@ -0,0 +1,92 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+func (s *Screen) Resize(width, height int) {
6+ if width == s.Frame.Width && height == s.Frame.Height {
7+ return
8+ }
9+ evict := s.recordEvicted
10+
11+ if s.alternate != nil {
12+ var shift int
13+ s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, nil)
14+ s.Row = clamp(s.Row-shift, 0, height-1)
15+ s.Col = clamp(s.Col, 0, width-1)
16+ s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
17+ s.alternate.frame, shift = resizeFrame(s.alternate.frame, width, height, s.alternate.row, evict)
18+ s.alternate.row = clamp(s.alternate.row-shift, 0, height-1)
19+ s.alternate.col = clamp(s.alternate.col, 0, width-1)
20+ s.alternate.savedCursor = resizedCursor(s.alternate.savedCursor, shift, width, height)
21+ s.alternate.scrollTop = clamp(s.alternate.scrollTop-shift, 0, height-1)
22+ s.alternate.scrollBottom = clamp(s.alternate.scrollBottom-shift, 0, height-1)
23+ if s.alternate.scrollTop >= s.alternate.scrollBottom {
24+ s.alternate.scrollTop = 0
25+ s.alternate.scrollBottom = max(height-1, 0)
26+ }
27+ } else {
28+ var shift int
29+ s.Frame, shift = resizeFrame(s.Frame, width, height, s.Row, evict)
30+ s.Row = clamp(s.Row-shift, 0, height-1)
31+ s.Col = clamp(s.Col, 0, width-1)
32+ s.savedCursor = resizedCursor(s.savedCursor, shift, width, height)
33+ }
34+
35+ // A resize can split an in-flight escape sequence from the terminal state it
36+ // was meant to mutate; keep the durable child state but discard partial bytes.
37+ s.escapeBuf = s.escapeBuf[:0]
38+ s.resetScrollRegion()
39+ s.fullRedraw()
40+}
41+
42+func resizeFrame(old renderer.Frame, newW, newH, cursorRow int, evict func([]renderer.Cell)) (renderer.Frame, int) {
43+ next := renderer.NewFrame(newW, newH)
44+ shift := clamp(cursorRow-(newH-1), 0, max(old.Height-newH, 0))
45+ if evict != nil {
46+ for y := range shift {
47+ evict(old.Row(y))
48+ }
49+ }
50+ for dy := range newH {
51+ sy := dy + shift
52+ if sy >= old.Height {
53+ break
54+ }
55+ copy(next.Row(dy), old.Row(sy))
56+ repairFrameRow(next, dy)
57+ }
58+ return next, shift
59+}
60+
61+func resizedCursor(cur cursorState, shift, width, height int) cursorState {
62+ if !cur.saved {
63+ return cur
64+ }
65+ cur.row = clamp(cur.row-shift, 0, height-1)
66+ cur.col = clamp(cur.col, 0, width-1)
67+ return cur
68+}
69+
70+// cloneFrame produces an independent copy in canonical layout: it copies the
71+// source's logical rows (via Row) into a fresh frame whose offsets are already
72+// canonical, so a rotated source is normalized in the clone.
73+func cloneFrame(frame renderer.Frame) renderer.Frame {
74+ out := renderer.NewFrame(frame.Width, frame.Height)
75+ for y := range frame.Height {
76+ copy(out.Row(y), frame.Row(y))
77+ }
78+ return out
79+}
80+
81+func clamp(v, lo, hi int) int {
82+ if hi < lo {
83+ return lo
84+ }
85+ if v < lo {
86+ return lo
87+ }
88+ if v > hi {
89+ return hi
90+ }
91+ return v
92+}
A pkg/vt/screen_resize_test.go +260 -0
@@ -0,0 +1,260 @@
1+package vt
2+
3+import (
4+ "math/rand"
5+ "strings"
6+ "testing"
7+
8+ "github.com/bnema/vev/pkg/renderer"
9+)
10+
11+func TestResize(t *testing.T) {
12+ setRow := func(s *Screen, y int, text string) {
13+ for x, r := range []rune(text) {
14+ if x >= s.Frame.Width {
15+ break
16+ }
17+ s.Frame.Set(x, y, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
18+ }
19+ }
20+ rowString := func(s *Screen, y int) string {
21+ runes := make([]rune, s.Frame.Width)
22+ for x := range s.Frame.Width {
23+ runes[x] = s.Frame.At(x, y).Rune
24+ }
25+ return string(runes)
26+ }
27+
28+ tests := []struct {
29+ name string
30+ run func(t *testing.T)
31+ }{
32+ {
33+ name: "same size preserves content and skips FullRedraw",
34+ run: func(t *testing.T) {
35+ s := NewScreen(5, 3)
36+ s.ClearDamage()
37+ setRow(s, 0, "Hello")
38+
39+ s.Resize(5, 3)
40+
41+ if got := rowString(s, 0); got != "Hello" {
42+ t.Fatalf("row 0 = %q, want Hello", got)
43+ }
44+ if d := s.Damage(); len(d) != 0 {
45+ t.Fatalf("same-size resize damage = %v, want none", damageKinds(d))
46+ }
47+ },
48+ },
49+ {
50+ name: "grow preserves visible content and cursor",
51+ run: func(t *testing.T) {
52+ s := NewScreen(5, 2)
53+ setRow(s, 0, "Hello")
54+ setRow(s, 1, "World")
55+ s.Row, s.Col = 1, 4
56+
57+ s.Resize(8, 4)
58+
59+ if got := rowString(s, 0); got != "Hello " {
60+ t.Fatalf("row 0 = %q", got)
61+ }
62+ if got := rowString(s, 1); got != "World " {
63+ t.Fatalf("row 1 = %q", got)
64+ }
65+ if s.Row != 1 || s.Col != 4 {
66+ t.Fatalf("cursor = (%d,%d), want (1,4)", s.Row, s.Col)
67+ }
68+ if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
69+ t.Fatalf("resize damage = %v, want FullRedraw", damageKinds(s.Damage()))
70+ }
71+ },
72+ },
73+ {
74+ name: "height shrink evicts top lines and follows cursor",
75+ run: func(t *testing.T) {
76+ s := NewScreen(4, 5)
77+ for y, text := range []string{"0000", "1111", "2222", "3333", "4444"} {
78+ setRow(s, y, text)
79+ }
80+ s.Row, s.Col = 4, 2
81+ var evicted []string
82+ s.OnLineEvicted = func(row []renderer.Cell) {
83+ runes := make([]rune, len(row))
84+ for i, c := range row {
85+ runes[i] = c.Rune
86+ }
87+ evicted = append(evicted, string(runes))
88+ }
89+
90+ s.Resize(4, 3)
91+
92+ if got, want := evicted, []string{"0000", "1111"}; strings.Join(got, ",") != strings.Join(want, ",") {
93+ t.Fatalf("evicted = %v, want %v", got, want)
94+ }
95+ if got := rowString(s, 0); got != "2222" {
96+ t.Fatalf("row 0 = %q", got)
97+ }
98+ if s.Row != 2 || s.Col != 2 {
99+ t.Fatalf("cursor = (%d,%d), want (2,2)", s.Row, s.Col)
100+ }
101+ if !hasDamageKind(s.Damage(), renderer.DamageFullRedraw) {
102+ t.Fatalf("shrink damage = %v, want FullRedraw", damageKinds(s.Damage()))
103+ }
104+ },
105+ },
106+ {
107+ name: "width truncates and pads without reflow",
108+ run: func(t *testing.T) {
109+ s := NewScreen(6, 2)
110+ setRow(s, 0, "abcdef")
111+ setRow(s, 1, "ghijkl")
112+
113+ s.Resize(3, 2)
114+ if got := rowString(s, 0); got != "abc" {
115+ t.Fatalf("truncated row = %q", got)
116+ }
117+ s.Resize(5, 2)
118+ if got := rowString(s, 1); got != "ghi " {
119+ t.Fatalf("padded row = %q", got)
120+ }
121+ },
122+ },
123+ {
124+ name: "width shrink repairs truncated wide head",
125+ run: func(t *testing.T) {
126+ s := NewScreen(4, 1)
127+ s.Write([]byte("AB界"))
128+
129+ s.Resize(3, 1)
130+
131+ assertCell(t, s, 0, 0, 'A')
132+ assertCell(t, s, 1, 0, 'B')
133+ assertBlank(t, s, 2, 0)
134+ assertNoOrphanWideCells(t, s)
135+ },
136+ },
137+ {
138+ name: "random wide resize repairs copied rows",
139+ run: func(t *testing.T) {
140+ rng := rand.New(rand.NewSource(1))
141+ for range 200 {
142+ s := NewScreen(8, 4)
143+ s.Write([]byte("a界b好c語d"))
144+ s.Row = rng.Intn(s.Frame.Height)
145+
146+ s.Resize(rng.Intn(8)+1, rng.Intn(4)+1)
147+
148+ assertNoOrphanWideCells(t, s)
149+ }
150+ },
151+ },
152+ {
153+ name: "cursor and saved cursor clamp",
154+ run: func(t *testing.T) {
155+ s := NewScreen(6, 4)
156+ s.Row, s.Col = 3, 5
157+ s.saveCursor()
158+
159+ s.Resize(3, 2)
160+
161+ if s.Row != 1 || s.Col != 2 {
162+ t.Fatalf("cursor = (%d,%d), want (1,2)", s.Row, s.Col)
163+ }
164+ s.Row, s.Col = 0, 0
165+ s.restoreCursor()
166+ if s.Row != 1 || s.Col != 2 {
167+ t.Fatalf("restored cursor = (%d,%d), want (1,2)", s.Row, s.Col)
168+ }
169+ },
170+ },
171+ {
172+ name: "alternate resize keeps saved normal content",
173+ run: func(t *testing.T) {
174+ s := NewScreen(5, 3)
175+ setRow(s, 0, "shell")
176+ s.Row = 0
177+ s.Write([]byte("\x1b[?1049h"))
178+ setRow(s, 0, "alt!!")
179+
180+ s.Resize(7, 4)
181+ s.Write([]byte("\x1b[?1049l"))
182+
183+ if got := rowString(s, 0); got != "shell " {
184+ t.Fatalf("normal row after alt resize = %q", got)
185+ }
186+ },
187+ },
188+ {
189+ name: "alternate resize shifts active and saved cursors",
190+ run: func(t *testing.T) {
191+ s := NewScreen(5, 5)
192+ s.Row, s.Col = 3, 4
193+ s.saveCursor()
194+ s.scrollTop, s.scrollBottom = 1, 4
195+ s.Row = 4
196+ s.Write([]byte("\x1b[?1049h"))
197+ s.Row, s.Col = 3, 4
198+ s.saveCursor()
199+ s.Row = 4
200+
201+ // The expected rows account for the resize shift that keeps the
202+ // cursor visible after NewScreen -> saveCursor -> Resize -> restoreCursor,
203+ // not just width/height clamping.
204+ s.Resize(3, 3)
205+
206+ if s.Row != 2 || s.Col != 2 {
207+ t.Fatalf("active alt cursor = (%d,%d), want (2,2)", s.Row, s.Col)
208+ }
209+ s.Row, s.Col = 0, 0
210+ s.restoreCursor()
211+ if s.Row != 1 || s.Col != 2 {
212+ t.Fatalf("active alt saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
213+ }
214+
215+ s.Write([]byte("\x1b[?1049l"))
216+ if s.scrollTop != 0 || s.scrollBottom != 2 {
217+ t.Fatalf("normal scroll region = (%d,%d), want (0,2)", s.scrollTop, s.scrollBottom)
218+ }
219+ s.Row, s.Col = 0, 0
220+ s.restoreCursor()
221+ if s.Row != 1 || s.Col != 2 {
222+ t.Fatalf("normal saved cursor = (%d,%d), want (1,2)", s.Row, s.Col)
223+ }
224+ },
225+ },
226+ {
227+ name: "style mouse cursor and bracketed paste modes survive",
228+ run: func(t *testing.T) {
229+ s := NewScreen(5, 2)
230+ s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
231+
232+ s.Resize(6, 3)
233+
234+ if !s.Style.Bold {
235+ t.Fatal("bold style was reset")
236+ }
237+ if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
238+ t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
239+ }
240+ if !s.BracketedPasteMode() {
241+ t.Fatal("bracketed paste mode was reset")
242+ }
243+ if s.CursorVisible() {
244+ t.Fatal("cursor visibility was reset")
245+ }
246+ if style, ok := s.CursorStyle(); style != 3 || !ok {
247+ t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
248+ }
249+ s.Write([]byte("\x1b[?2004l"))
250+ if s.BracketedPasteMode() {
251+ t.Fatal("bracketed paste mode remained enabled after reset")
252+ }
253+ },
254+ },
255+ }
256+
257+ for _, tt := range tests {
258+ t.Run(tt.name, tt.run)
259+ }
260+}
A pkg/vt/screen_scroll.go +121 -0
@@ -0,0 +1,121 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+func (s *Screen) index() {
6+ if s.Frame.Height == 0 {
7+ return
8+ }
9+ if s.Row == s.scrollBottom && s.Row >= s.scrollTop {
10+ s.scrollUpRegion(s.scrollTop, s.scrollBottom, 1)
11+ return
12+ }
13+ if s.Row+1 < s.Frame.Height {
14+ s.Row++
15+ return
16+ }
17+ s.Row = s.Frame.Height - 1
18+}
19+
20+func (s *Screen) nextLine() {
21+ s.Col = 0
22+ s.index()
23+}
24+
25+func (s *Screen) reverseIndex() {
26+ if s.Frame.Height == 0 {
27+ return
28+ }
29+ if s.Row == s.scrollTop && s.Row <= s.scrollBottom {
30+ s.scrollDownRegion(s.scrollTop, s.scrollBottom, 1)
31+ return
32+ }
33+ if s.Row > 0 {
34+ s.Row--
35+ }
36+}
37+
38+// scrollUpBy scrolls the current scroll region up by n lines (CSI S / SU).
39+// The cursor position is preserved.
40+func (s *Screen) scrollUpBy(n int) {
41+ if n <= 0 {
42+ return
43+ }
44+ s.scrollUpRegion(s.scrollTop, s.scrollBottom, n)
45+}
46+
47+func (s *Screen) scrollDownBy(n int) {
48+ if n <= 0 {
49+ return
50+ }
51+ s.scrollDownRegion(s.scrollTop, s.scrollBottom, n)
52+}
53+
54+func (s *Screen) scrollUpRegion(top, bottom, n int) {
55+ if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
56+ return
57+ }
58+ top, bottom, ok := s.normalizedRegion(top, bottom)
59+ if !ok {
60+ return
61+ }
62+ w := s.Frame.Width
63+ height := bottom - top + 1
64+ if n > height {
65+ n = height
66+ }
67+ // VT scroll regions always span the full frame width, so we rotate the
68+ // frame's line offsets (recycling and blanking the evicted rows in place)
69+ // instead of copying cells. See renderer.Frame.ScrollUp.
70+ s.emitLineEvicted(top, n)
71+ s.Frame.ScrollUp(top, bottom, n)
72+ s.record(renderer.Damage{Kind: renderer.DamageScrollUp, X: 0, Y: top, Width: w, Height: height, Count: n})
73+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: bottom - n + 1, Width: w, Height: n, Count: 1})
74+}
75+
76+func (s *Screen) emitLineEvicted(top, n int) {
77+ // Only rows leaving the top edge of the primary screen belong to global
78+ // scrollback. Interior DECSTBM scroll regions are local mutations.
79+ if s.alternate != nil || top != 0 {
80+ return
81+ }
82+ for y := top; y < top+n; y++ {
83+ s.recordEvicted(s.Frame.Row(y))
84+ }
85+}
86+
87+func (s *Screen) recordEvicted(row []renderer.Cell) {
88+ if s.history != nil {
89+ s.history.Append(row)
90+ }
91+ if s.OnLineEvicted != nil {
92+ s.OnLineEvicted(append([]renderer.Cell(nil), row...))
93+ }
94+}
95+
96+func (s *Screen) scrollDownRegion(top, bottom, n int) {
97+ if s.Frame.Width == 0 || s.Frame.Height == 0 || n <= 0 {
98+ return
99+ }
100+ top, bottom, ok := s.normalizedRegion(top, bottom)
101+ if !ok {
102+ return
103+ }
104+ w := s.Frame.Width
105+ height := bottom - top + 1
106+ if n > height {
107+ n = height
108+ }
109+ // Full-width region: rotate line offsets instead of copying cells.
110+ s.Frame.ScrollDown(top, bottom, n)
111+ s.record(renderer.Damage{Kind: renderer.DamageText, X: 0, Y: top, Width: w, Height: height, Count: 1})
112+}
113+
114+func (s *Screen) normalizedRegion(top, bottom int) (int, int, bool) {
115+ if s.Frame.Height == 0 || top > bottom {
116+ return 0, 0, false
117+ }
118+ top = clamp(top, 0, s.Frame.Height-1)
119+ bottom = clamp(bottom, 0, s.Frame.Height-1)
120+ return top, bottom, top <= bottom
121+}
A pkg/vt/screen_scroll_test.go +79 -0
@@ -0,0 +1,79 @@
1+package vt
2+
3+import (
4+ "testing"
5+)
6+
7+// TestWideCharSurvivesRotatedScroll proves a wide-character pair travels intact
8+// with its line when a full-width scroll rotates line offsets rather than
9+// copying cells.
10+func TestWideCharSurvivesRotatedScroll(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ run func(t *testing.T)
14+ }{
15+ {
16+ name: "wide pair moves up one row on newline scroll",
17+ run: func(t *testing.T) {
18+ s := NewScreen(4, 3)
19+ // Place a wide rune (世, width 2) on the bottom row, then scroll up.
20+ s.Write([]byte("\x1b[3;1H世"))
21+ assertCell(t, s, 0, 2, '世')
22+ assertContinuation(t, s, 1, 2)
23+ // Newline at the bottom row scrolls the region up by one.
24+ s.Write([]byte("\n"))
25+ // The wide pair is now on row 1, intact; bottom row blank.
26+ assertCell(t, s, 0, 1, '世')
27+ assertContinuation(t, s, 1, 1)
28+ assertBlank(t, s, 0, 2)
29+ assertBlank(t, s, 1, 2)
30+ if err := s.Frame.CheckInvariants(); err != nil {
31+ t.Fatalf("invariants: %v", err)
32+ }
33+ },
34+ },
35+ {
36+ name: "wide pair moves down on reverse index",
37+ run: func(t *testing.T) {
38+ s := NewScreen(4, 3)
39+ s.Write([]byte("\x1b[1;1H世")) // top row
40+ assertCell(t, s, 0, 0, '世')
41+ assertContinuation(t, s, 1, 0)
42+ s.Write([]byte("\x1b[1;1H\x1bM")) // cursor top, reverse index -> scroll down
43+ assertCell(t, s, 0, 1, '世')
44+ assertContinuation(t, s, 1, 1)
45+ assertBlank(t, s, 0, 0)
46+ if err := s.Frame.CheckInvariants(); err != nil {
47+ t.Fatalf("invariants: %v", err)
48+ }
49+ },
50+ },
51+ }
52+ for _, tt := range tests {
53+ t.Run(tt.name, tt.run)
54+ }
55+}
56+
57+// TestLineOffsetInvariantAfterVTSequences drives interleaved writes and scrolls
58+// through the VT layer and asserts the frame's line-offset stays a valid
59+// permutation.
60+func TestLineOffsetInvariantAfterVTSequences(t *testing.T) {
61+ s := NewScreen(10, 6)
62+ seqs := [][]byte{
63+ []byte("line0\r\nline1\r\nline2\r\nline3\r\nline4\r\nline5"),
64+ []byte("\x1b[2;5r"), // scroll region rows 2..5
65+ []byte("\x1b[5;1Hmore\n"), // scroll within region
66+ []byte("\x1b[1;1H\x1bM"), // reverse index at top
67+ []byte("\x1b[3S"), // scroll up 3
68+ []byte("\x1b[2T"), // scroll down 2
69+ []byte("\x1b[r"), // reset region
70+ []byte("\x1b[6;1Hbottom\n"),
71+ }
72+ for i, seq := range seqs {
73+ s.Write(seq)
74+ s.ClearDamage()
75+ if err := s.Frame.CheckInvariants(); err != nil {
76+ t.Fatalf("after seq %d: invariants broken: %v", i, err)
77+ }
78+ }
79+}
D pkg/vt/screen_test.go +0 -3337 large diff (3337 lines changed): collapsed
A pkg/vt/screen_write.go +227 -0
@@ -0,0 +1,227 @@
1+package vt
2+
3+import "github.com/bnema/vev/pkg/renderer"
4+
5+func (s *Screen) putRune(r rune) {
6+ switch r {
7+ case '\a':
8+ if s.OnBell != nil {
9+ s.OnBell()
10+ }
11+ return
12+ case 0x00, 0x0e, 0x0f, 0x7f:
13+ return
14+ case '\r':
15+ s.Col = 0
16+ case '\n', '\v', '\f':
17+ if s.Frame.Width > 0 && s.Col >= s.Frame.Width {
18+ s.Col = s.Frame.Width - 1
19+ }
20+ s.index()
21+ case '\b':
22+ if s.Col > 0 {
23+ s.Col--
24+ }
25+ case '\t':
26+ // HT moves to the next tab stop without modifying the frame. Clamp at
27+ // the right edge rather than triggering deferred wrap or a scroll.
28+ if s.Frame.Width > 0 {
29+ s.Col = min(s.Col+8-s.Col%8, s.Frame.Width-1)
30+ }
31+ default:
32+ // Drop C1 control characters (U+0080-U+009F).
33+ if r >= 0x80 && r <= 0x9F {
34+ return
35+ }
36+ if r >= 0x20 {
37+ s.putPrintable(r)
38+ }
39+ }
40+}
41+
42+func (s *Screen) putPrintable(r rune) {
43+ w := renderer.RuneWidth(r)
44+ // Skip combining marks and zero-width characters.
45+ if w == 0 {
46+ return
47+ }
48+ if s.Frame.Width == 0 || s.Frame.Height == 0 {
49+ return
50+ }
51+ // Deferred wrap: cursor sits past the last column.
52+ if s.Col >= s.Frame.Width {
53+ s.Col = 0
54+ s.index()
55+ }
56+ if s.Row >= s.Frame.Height {
57+ s.Row = s.Frame.Height - 1
58+ }
59+
60+ // A wide rune must never straddle the right edge. If it does not fit on the
61+ // current line, clear the abandoned last cell and wrap to the next line.
62+ if w == 2 && s.Col+1 >= s.Frame.Width {
63+ if s.Frame.Width < 2 {
64+ // The screen is too narrow to ever hold a wide rune; store a narrow
65+ // replacement so the cell's renderer width matches its layout.
66+ r = '\uFFFD'
67+ w = renderer.RuneWidth(r)
68+ } else {
69+ // The abandoned cell may itself be the continuation of a pair whose
70+ // left half sits one column back; report damage over the full span.
71+ cx := s.Col
72+ if s.Col > 0 && s.Frame.At(s.Col, s.Row).Continuation {
73+ cx = s.Col - 1
74+ }
75+ s.clearWidePairAt(s.Col, s.Row)
76+ s.Frame.Set(s.Col, s.Row, renderer.BlankCell())
77+ s.record(renderer.Damage{Kind: renderer.DamageText, X: cx, Y: s.Row, Width: s.Col - cx + 1, Height: 1, Count: 1})
78+ s.Col = 0
79+ s.index()
80+ if s.Row >= s.Frame.Height {
81+ s.Row = s.Frame.Height - 1
82+ }
83+ }
84+ }
85+
86+ insertDamageX := s.Col
87+ insertDamageWidth := 0
88+ if s.insertMode {
89+ row := s.Frame.Row(s.Row)
90+ leftSplit := s.Col > 0 && row[s.Col].Continuation
91+ for x := s.Frame.Width - 1; x >= s.Col+w; x-- {
92+ row[x] = row[x-w]
93+ }
94+ for x := s.Col; x < s.Col+w; x++ {
95+ row[x] = renderer.BlankCell()
96+ }
97+ s.repairRow(s.Row)
98+ if leftSplit {
99+ insertDamageX = s.Col - 1
100+ }
101+ insertDamageWidth = s.Frame.Width - insertDamageX
102+ }
103+
104+ // Determine the range of cells actually modified, extending over any wide
105+ // pair the write lands on so no orphaned half is left behind.
106+ lo, hi := s.Col, s.Col+w-1
107+ if s.Frame.At(s.Col, s.Row).Continuation {
108+ lo = s.Col - 1
109+ }
110+ if right := s.Col + w; right < s.Frame.Width && s.Frame.At(right, s.Row).Continuation {
111+ hi = right
112+ }
113+ for x := lo; x <= hi; x++ {
114+ s.Frame.Set(x, s.Row, renderer.BlankCell())
115+ }
116+ s.Frame.Set(s.Col, s.Row, renderer.Cell{Rune: r, Style: s.Style})
117+ if w == 2 {
118+ s.Frame.Set(s.Col+1, s.Row, renderer.Cell{Continuation: true, Style: s.Style})
119+ }
120+ if insertDamageWidth > 0 {
121+ lo = min(lo, insertDamageX)
122+ hi = max(hi, insertDamageX+insertDamageWidth-1)
123+ }
124+ s.record(renderer.Damage{Kind: renderer.DamageText, X: lo, Y: s.Row, Width: hi - lo + 1, Height: 1, Count: 1})
125+ s.Col += w
126+}
127+
128+// clearWidePairAt blanks both halves of a wide-character pair when the cell at
129+// (x,y) is either the left (wide) half or the right (continuation) half. It is
130+// O(1) and assumes the pair invariant (a continuation is preceded by its wide
131+// left half) holds — which the writer maintains.
132+
133+func (s *Screen) clearWidePairAt(x, y int) {
134+ if x < 0 || x >= s.Frame.Width || y < 0 || y >= s.Frame.Height {
135+ return
136+ }
137+ if s.Frame.At(x, y).Continuation {
138+ if x-1 >= 0 {
139+ s.Frame.Set(x-1, y, renderer.BlankCell())
140+ }
141+ s.Frame.Set(x, y, renderer.BlankCell())
142+ return
143+ }
144+ if x+1 < s.Frame.Width && s.Frame.At(x+1, y).Continuation {
145+ s.Frame.Set(x, y, renderer.BlankCell())
146+ s.Frame.Set(x+1, y, renderer.BlankCell())
147+ }
148+}
149+
150+func (s *Screen) eraseCell() renderer.Cell {
151+ style := renderer.DefaultStyle()
152+ if s.Style.HasBackgroundRGB {
153+ style.HasBackgroundRGB = true
154+ style.BackgroundRGB = s.Style.BackgroundRGB
155+ } else {
156+ style.Background = s.Style.Background
157+ }
158+ return renderer.Cell{Rune: ' ', Style: style}
159+}
160+
161+// clearRow blanks cells [x0,x1) on row y, extending the range to swallow either
162+// half of a wide pair that straddles a boundary so no orphan half is left. It
163+// returns the actual modified span [start, start+width).
164+
165+func (s *Screen) clearRow(y, x0, x1 int) (start, width int) {
166+ if y < 0 || y >= s.Frame.Height {
167+ return x0, 0
168+ }
169+ if x0 < 0 {
170+ x0 = 0
171+ }
172+ if x1 > s.Frame.Width {
173+ x1 = s.Frame.Width
174+ }
175+ if x0 >= x1 {
176+ return x0, 0
177+ }
178+ // Left boundary: a continuation at x0 means its wide left half sits at x0-1.
179+ if x0 > 0 && s.Frame.At(x0, y).Continuation {
180+ x0--
181+ }
182+ // Right boundary: a continuation at x1 means its wide left half (at x1-1) is
183+ // inside the range and will be blanked, so swallow the continuation too.
184+ if x1 < s.Frame.Width && s.Frame.At(x1, y).Continuation {
185+ x1++
186+ }
187+ blank := s.eraseCell()
188+ for x := x0; x < x1; x++ {
189+ s.Frame.Set(x, y, blank)
190+ }
191+ return x0, x1 - x0
192+}
193+
194+// repairRow blanks any orphaned wide-character halves on row y: a continuation
195+// cell not preceded by its wide left half, or a wide rune not followed by its
196+// continuation. Used after erase/insert/delete operations that may split a pair
197+// at a boundary or by shifting cells.
198+
199+func (s *Screen) repairRow(y int) {
200+ repairFrameRow(s.Frame, y)
201+}
202+
203+func repairFrameRow(frame renderer.Frame, y int) {
204+ if y < 0 || y >= frame.Height {
205+ return
206+ }
207+ w := frame.Width
208+ for x := range w {
209+ c := frame.At(x, y)
210+ if c.Continuation {
211+ if x == 0 {
212+ frame.Set(x, y, renderer.BlankCell())
213+ continue
214+ }
215+ left := frame.At(x-1, y)
216+ if left.Continuation || renderer.RuneWidth(left.Rune) != 2 {
217+ frame.Set(x, y, renderer.BlankCell())
218+ }
219+ continue
220+ }
221+ if renderer.RuneWidth(c.Rune) == 2 {
222+ if x+1 >= w || !frame.At(x+1, y).Continuation {
223+ frame.Set(x, y, renderer.BlankCell())
224+ }
225+ }
226+ }
227+}
A pkg/vt/screen_write_test.go +924 -0
@@ -0,0 +1,924 @@
1+package vt
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/pkg/renderer"
7+)
8+
9+func TestWritePrintableAndUTF8(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ run func(t *testing.T)
13+ }{
14+ {
15+ name: "printable ASCII advances cursor and records damage",
16+ run: func(t *testing.T) {
17+ s := NewScreen(5, 3)
18+ s.Write([]byte("Hi"))
19+
20+ assertCell(t, s, 0, 0, 'H')
21+ assertCell(t, s, 1, 0, 'i')
22+ if s.Col != 2 || s.Row != 0 {
23+ t.Errorf("cursor at col=%d row=%d, want col=2 row=0", s.Col, s.Row)
24+ }
25+
26+ // Should have damage.
27+ d := s.Damage()
28+ if len(d) == 0 {
29+ t.Fatal("expected damage after writing")
30+ }
31+ },
32+ },
33+ {
34+ name: "UTF-8 multi-byte runes occupy one cell each",
35+ run: func(t *testing.T) {
36+ s := NewScreen(10, 3)
37+ s.Write([]byte("aäéø©"))
38+
39+ assertCell(t, s, 0, 0, 'a')
40+ assertCell(t, s, 1, 0, 'ä')
41+ assertCell(t, s, 2, 0, 'é')
42+ assertCell(t, s, 3, 0, 'ø')
43+ assertCell(t, s, 4, 0, '©')
44+ },
45+ },
46+ {
47+ name: "printable beyond width wraps to next line",
48+ run: func(t *testing.T) {
49+ s := NewScreen(4, 2)
50+ s.Write([]byte("ABCDE"))
51+
52+ assertCell(t, s, 0, 0, 'A')
53+ assertCell(t, s, 1, 0, 'B')
54+ assertCell(t, s, 2, 0, 'C')
55+ assertCell(t, s, 3, 0, 'D')
56+ // E wrapped to next line.
57+ assertCell(t, s, 0, 1, 'E')
58+ },
59+ },
60+ {
61+ name: "printable beyond screen height scrolls",
62+ run: func(t *testing.T) {
63+ s := NewScreen(3, 2)
64+ // Fill both lines.
65+ s.Write([]byte("ABC"))
66+ s.Write([]byte("DEF"))
67+ // Row is now 1 (bottom). One more char should scroll.
68+ s.Write([]byte("G"))
69+
70+ // After scroll: old row 0 = "DEF", new row 1 = "G ".
71+ assertCell(t, s, 0, 0, 'D')
72+ assertCell(t, s, 1, 0, 'E')
73+ assertCell(t, s, 2, 0, 'F')
74+ assertCell(t, s, 0, 1, 'G')
75+ assertCell(t, s, 1, 1, ' ')
76+
77+ // Check scroll damage.
78+ d := s.Damage()
79+ if !hasDamageKind(d, renderer.DamageScrollUp) {
80+ t.Error("expected scroll damage")
81+ }
82+ },
83+ },
84+ }
85+
86+ for _, tt := range tests {
87+ t.Run(tt.name, tt.run)
88+ }
89+}
90+
91+func TestCursorControlChars(t *testing.T) {
92+ tests := []struct {
93+ name string
94+ run func(t *testing.T)
95+ }{
96+ {
97+ name: "CR moves column back to 0",
98+ run: func(t *testing.T) {
99+ s := NewScreen(10, 3)
100+ s.Write([]byte("Hello"))
101+ // CR moves column back to 0.
102+ s.Write([]byte("\rWorld"))
103+
104+ assertCell(t, s, 0, 0, 'W')
105+ assertCell(t, s, 1, 0, 'o')
106+ assertCell(t, s, 2, 0, 'r')
107+ assertCell(t, s, 3, 0, 'l')
108+ assertCell(t, s, 4, 0, 'd')
109+ if s.Col != 5 {
110+ t.Errorf("col after CR + World = %d, want 5", s.Col)
111+ }
112+ },
113+ },
114+ {
115+ name: "LF with CR moves to next line column 0",
116+ run: func(t *testing.T) {
117+ s := NewScreen(5, 3)
118+ s.Write([]byte("A\r\nB\r\nC"))
119+
120+ assertCell(t, s, 0, 0, 'A')
121+ assertCell(t, s, 0, 1, 'B')
122+ assertCell(t, s, 0, 2, 'C')
123+ if s.Row != 2 || s.Col != 1 {
124+ t.Errorf("cursor at row=%d col=%d, want row=2 col=1", s.Row, s.Col)
125+ }
126+ },
127+ },
128+ {
129+ name: "LF alone does not reset column",
130+ run: func(t *testing.T) {
131+ s := NewScreen(5, 3)
132+ s.Write([]byte("AB\nC"))
133+
134+ assertCell(t, s, 0, 0, 'A')
135+ assertCell(t, s, 1, 0, 'B')
136+ assertCell(t, s, 0, 1, ' ')
137+ assertCell(t, s, 2, 1, 'C')
138+ if s.Row != 1 || s.Col != 3 {
139+ t.Errorf("cursor at row=%d col=%d, want row=1 col=3", s.Row, s.Col)
140+ }
141+ },
142+ },
143+ {
144+ name: "LF at pending wrap boundary advances exactly one line",
145+ run: func(t *testing.T) {
146+ s := NewScreen(5, 4)
147+ s.Write([]byte("ABCDE\nZ"))
148+
149+ assertCell(t, s, 4, 0, 'E')
150+ assertCell(t, s, 4, 1, 'Z')
151+ if s.Row != 1 || s.Col != 5 {
152+ t.Errorf("cursor at row=%d col=%d, want row=1 col=5", s.Row, s.Col)
153+ }
154+ },
155+ },
156+ {
157+ name: "LF scrolls when both lines are full",
158+ run: func(t *testing.T) {
159+ s := NewScreen(5, 2)
160+ // Fill both lines then CRLF to scroll and return to column 0.
161+ s.Write([]byte("AAAAA"))
162+ s.Write([]byte("BBBBB"))
163+ s.Write([]byte("\r\nCCCCC"))
164+
165+ assertCell(t, s, 0, 0, 'B')
166+ assertCell(t, s, 4, 1, 'C')
167+ },
168+ },
169+ {
170+ name: "BS moves cursor back without touching the cell",
171+ run: func(t *testing.T) {
172+ s := NewScreen(10, 3)
173+ s.Write([]byte("Hello\b"))
174+
175+ if s.Col != 4 {
176+ t.Errorf("col after BS = %d, want 4", s.Col)
177+ }
178+ assertCell(t, s, 4, 0, 'o') // cell unchanged, just cursor moved
179+ },
180+ },
181+ {
182+ name: "BS at column zero is a no-op",
183+ run: func(t *testing.T) {
184+ s := NewScreen(10, 3)
185+ s.Write([]byte("\b")) // BS at col 0 should be no-op
186+
187+ if s.Col != 0 {
188+ t.Errorf("col = %d, want 0", s.Col)
189+ }
190+ },
191+ },
192+ {
193+ name: "tab moves cursor without changing cells or damage",
194+ run: func(t *testing.T) {
195+ s := NewScreen(10, 2)
196+ for y := range s.Frame.Height {
197+ for x := range s.Frame.Width {
198+ s.Frame.Set(x, y, renderer.Cell{Rune: rune('a' + y*s.Frame.Width + x)})
199+ }
200+ }
201+ before := s.Frame.Clone()
202+ s.Row, s.Col = 1, 6
203+ s.ClearDamage()
204+
205+ s.Write([]byte("\t"))
206+ if s.Row != 1 || s.Col != 8 {
207+ t.Errorf("cursor after tab = row=%d col=%d, want row=1 col=8", s.Row, s.Col)
208+ }
209+ assertFramesEqual(t, s.Frame, before)
210+ if got := s.Damage(); len(got) != 0 {
211+ t.Errorf("tab damage = %+v, want none", got)
212+ }
213+
214+ // A tab whose next stop lies past the edge clamps at the last
215+ // column; it must not wrap or scroll the bottom row.
216+ s.Write([]byte("\t"))
217+ if s.Row != 1 || s.Col != 9 {
218+ t.Errorf("cursor after clamped tab = row=%d col=%d, want row=1 col=9", s.Row, s.Col)
219+ }
220+ assertFramesEqual(t, s.Frame, before)
221+ if got := s.Damage(); len(got) != 0 {
222+ t.Errorf("clamped tab damage = %+v, want none", got)
223+ }
224+ },
225+ },
226+ }
227+
228+ for _, tt := range tests {
229+ t.Run(tt.name, tt.run)
230+ }
231+}
232+
233+func TestInvalidInputIgnored(t *testing.T) {
234+ tests := []struct {
235+ name string
236+ input []byte
237+ }{
238+ {
239+ name: "invalid UTF-8 continuation byte without start byte",
240+ input: []byte{0x80, 0x81},
241+ },
242+ {
243+ name: "unhandled control characters below 0x20",
244+ input: []byte{0x01, 0x02, 0x03, 0x05, 0x06, 0x0b, 0x0c, 0x0e, 0x0f},
245+ },
246+ }
247+
248+ for _, tt := range tests {
249+ t.Run(tt.name, func(t *testing.T) {
250+ s := NewScreen(10, 3)
251+ s.Write(tt.input)
252+ // Should not panic, and no cells should be modified.
253+ for y := range 3 {
254+ for x := range 10 {
255+ if c := cellAt(s, x, y); c.Rune != ' ' {
256+ t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
257+ }
258+ }
259+ }
260+ })
261+ }
262+}
263+
264+func TestWriteAtEdgeNoPanic(t *testing.T) {
265+ tests := []struct {
266+ name string
267+ }{
268+ {name: "writing past a 1x1 screen scrolls instead of panicking"},
269+ }
270+
271+ for _, tt := range tests {
272+ t.Run(tt.name, func(t *testing.T) {
273+ s := NewScreen(1, 1)
274+ // Fill the only cell then try to write more (should scroll).
275+ s.Write([]byte("ABC"))
276+ // Should not panic.
277+ _ = cellAt(s, 0, 0)
278+ })
279+ }
280+}
281+
282+func TestStylePersists(t *testing.T) {
283+ tests := []struct {
284+ name string
285+ }{
286+ {name: "bold persists across separate Write calls"},
287+ }
288+
289+ for _, tt := range tests {
290+ t.Run(tt.name, func(t *testing.T) {
291+ s := NewScreen(10, 3)
292+ s.Write([]byte("\x1b[1mAB"))
293+ s.Write([]byte("CD"))
294+
295+ c1 := cellAt(s, 0, 0)
296+ c2 := cellAt(s, 2, 0)
297+ if !c1.Style.Bold || !c2.Style.Bold {
298+ t.Error("bold should persist across Write calls")
299+ }
300+ })
301+ }
302+}
303+
304+func TestWideAndZeroWidthRunes(t *testing.T) {
305+ tests := []struct {
306+ name string
307+ run func(t *testing.T)
308+ }{
309+ {
310+ name: "CJK writes wide left cell plus continuation",
311+ run: func(t *testing.T) {
312+ s := NewScreen(10, 3)
313+ // Two CJK characters, each width 2: left cell holds the rune,
314+ // right cell is a continuation marker.
315+ s.Write([]byte("你好"))
316+
317+ assertCell(t, s, 0, 0, '你')
318+ assertContinuation(t, s, 1, 0)
319+ assertCell(t, s, 2, 0, '好')
320+ assertContinuation(t, s, 3, 0)
321+ if s.Col != 4 || s.Row != 0 {
322+ t.Errorf("cursor at col=%d row=%d, want col=4 row=0", s.Col, s.Row)
323+ }
324+ },
325+ },
326+ {
327+ name: "CJK mixed with ASCII",
328+ run: func(t *testing.T) {
329+ s := NewScreen(10, 3)
330+ s.Write([]byte("A你B"))
331+
332+ assertCell(t, s, 0, 0, 'A')
333+ assertCell(t, s, 1, 0, '你')
334+ assertContinuation(t, s, 2, 0)
335+ assertCell(t, s, 3, 0, 'B')
336+ if s.Col != 4 {
337+ t.Errorf("cursor at col=%d, want 4", s.Col)
338+ }
339+ },
340+ },
341+ {
342+ name: "emoji writes wide pair",
343+ run: func(t *testing.T) {
344+ s := NewScreen(10, 3)
345+ s.Write([]byte("\U0001f600")) // 😀 grinning face
346+
347+ assertCell(t, s, 0, 0, '\U0001f600')
348+ assertContinuation(t, s, 1, 0)
349+ if s.Col != 2 {
350+ t.Errorf("cursor at col=%d, want 2", s.Col)
351+ }
352+ },
353+ },
354+ {
355+ name: "combining mark is skipped without advancing cursor",
356+ run: func(t *testing.T) {
357+ s := NewScreen(10, 3)
358+ // 'A' followed by combining acute accent U+0301
359+ s.Write([]byte("A\xcc\x81"))
360+
361+ assertCell(t, s, 0, 0, 'A')
362+ // Combining mark should be skipped — no cell written, no cursor advance.
363+ if s.Col != 1 {
364+ t.Errorf("cursor at col=%d, want 1 (combining mark should not advance)", s.Col)
365+ }
366+ },
367+ },
368+ {
369+ name: "zero-width characters are skipped without advancing cursor",
370+ run: func(t *testing.T) {
371+ s := NewScreen(10, 3)
372+ // Zero-width space U+200B
373+ s.Write([]byte("A\xe2\x80\x8bB"))
374+
375+ assertCell(t, s, 0, 0, 'A')
376+ assertCell(t, s, 1, 0, 'B')
377+ if s.Col != 2 {
378+ t.Errorf("cursor at col=%d, want 2 (zero-width should not advance)", s.Col)
379+ }
380+ },
381+ },
382+ {
383+ name: "CJK surrounded by ASCII on both sides",
384+ run: func(t *testing.T) {
385+ s := NewScreen(10, 3)
386+ s.Write([]byte("a你b好c"))
387+
388+ assertCell(t, s, 0, 0, 'a')
389+ assertCell(t, s, 1, 0, '你')
390+ assertContinuation(t, s, 2, 0)
391+ assertCell(t, s, 3, 0, 'b')
392+ assertCell(t, s, 4, 0, '好')
393+ assertContinuation(t, s, 5, 0)
394+ assertCell(t, s, 6, 0, 'c')
395+ if s.Col != 7 {
396+ t.Errorf("cursor at col=%d, want 7", s.Col)
397+ }
398+ },
399+ },
400+ {
401+ name: "wide char at last column wraps to next line, abandoned cell cleared",
402+ run: func(t *testing.T) {
403+ s := NewScreen(3, 2)
404+ // Fill the first two columns; cursor lands on the last column.
405+ s.Write([]byte("AB")) // cells [A B _], Col=2
406+ // A wide char at the last column cannot straddle the edge: it
407+ // wraps to the next line, clearing the abandoned last cell.
408+ s.Write([]byte("你"))
409+
410+ assertCell(t, s, 0, 0, 'A')
411+ assertCell(t, s, 1, 0, 'B')
412+ assertBlank(t, s, 2, 0) // abandoned last cell cleared
413+ assertCell(t, s, 0, 1, '你')
414+ assertContinuation(t, s, 1, 1)
415+ if s.Col != 2 || s.Row != 1 {
416+ t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
417+ }
418+ },
419+ },
420+ {
421+ name: "CJK damage width is 2",
422+ run: func(t *testing.T) {
423+ s := NewScreen(10, 3)
424+ s.ClearDamage()
425+ s.Write([]byte("你"))
426+
427+ d := s.Damage()
428+ if len(d) == 0 {
429+ t.Fatal("expected damage")
430+ }
431+ if d[0].Width != 2 {
432+ t.Errorf("damage width = %d, want 2", d[0].Width)
433+ }
434+ },
435+ },
436+ {
437+ name: "mixed ASCII, CJK, emoji, and combining marks keep cursor aligned",
438+ run: func(t *testing.T) {
439+ s := NewScreen(10, 3)
440+ // ASCII 'a', CJK '你', emoji '😀', ASCII 'b', combining acute.
441+ s.Write([]byte("a你\U0001f600b\xcc\x81"))
442+
443+ assertCell(t, s, 0, 0, 'a')
444+ assertCell(t, s, 1, 0, '你')
445+ assertContinuation(t, s, 2, 0)
446+ assertCell(t, s, 3, 0, '\U0001f600')
447+ assertContinuation(t, s, 4, 0)
448+ assertCell(t, s, 5, 0, 'b')
449+ // Combining mark skipped, no cell at col 6.
450+ if s.Col != 6 || s.Row != 0 {
451+ t.Errorf("cursor at col=%d row=%d, want col=6 row=0", s.Col, s.Row)
452+ }
453+ },
454+ },
455+ {
456+ name: "overwrite left half of wide pair with narrow clears both",
457+ run: func(t *testing.T) {
458+ s := NewScreen(10, 3)
459+ s.Write([]byte("你")) // (0)=你 (1)=cont
460+ s.Write([]byte("\x1b[1;1H")) // cursor home
461+ s.Write([]byte("X")) // overwrite the wide left half
462+
463+ assertCell(t, s, 0, 0, 'X')
464+ assertBlank(t, s, 1, 0) // orphaned continuation cleared
465+ },
466+ },
467+ {
468+ name: "overwrite right half (continuation) with narrow clears both",
469+ run: func(t *testing.T) {
470+ s := NewScreen(10, 3)
471+ s.Write([]byte("你")) // (0)=你 (1)=cont
472+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (continuation)
473+ s.Write([]byte("X")) // overwrite the continuation
474+
475+ assertBlank(t, s, 0, 0) // orphaned wide left cleared
476+ assertCell(t, s, 1, 0, 'X')
477+ },
478+ },
479+ {
480+ name: "overwrite half of wide pair with a new wide char",
481+ run: func(t *testing.T) {
482+ s := NewScreen(10, 3)
483+ s.Write([]byte("你好")) // (0)你 (1)cont (2)好 (3)cont
484+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (cont of 你)
485+ s.Write([]byte("学")) // wide write at cols 1,2
486+
487+ assertBlank(t, s, 0, 0) // 你 left half orphaned → cleared
488+ assertCell(t, s, 1, 0, '学')
489+ assertContinuation(t, s, 2, 0)
490+ assertBlank(t, s, 3, 0) // 好 continuation orphaned → cleared
491+ },
492+ },
493+ {
494+ name: "erase to end of line covering a continuation clears its wide left half",
495+ run: func(t *testing.T) {
496+ s := NewScreen(10, 3)
497+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
498+ s.Write([]byte("\x1b[1;3H")) // cursor to col 2 (continuation)
499+ s.Write([]byte("\x1b[K")) // erase from col 2 to end of line
500+
501+ assertCell(t, s, 0, 0, 'A')
502+ assertBlank(t, s, 1, 0) // 你 left half orphaned by erase → cleared
503+ assertBlank(t, s, 2, 0)
504+ assertBlank(t, s, 3, 0)
505+ },
506+ },
507+ {
508+ name: "erase to start of line covering a wide left clears its continuation",
509+ run: func(t *testing.T) {
510+ s := NewScreen(10, 3)
511+ s.Write([]byte("A你B")) // A(0) 你(1) cont(2) B(3)
512+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1 (wide left)
513+ s.Write([]byte("\x1b[1K")) // erase from start to col 1 inclusive
514+
515+ assertBlank(t, s, 0, 0)
516+ assertBlank(t, s, 1, 0)
517+ assertBlank(t, s, 2, 0) // continuation orphaned by erase → cleared
518+ assertCell(t, s, 3, 0, 'B')
519+ },
520+ },
521+ {
522+ name: "wrap abandoning a continuation cell clears its pair and extends damage",
523+ run: func(t *testing.T) {
524+ s := NewScreen(4, 2)
525+ s.Write([]byte("AB你")) // A(0) B(1) 你(2) cont(3)
526+ s.Write([]byte("\x1b[1;4H")) // cursor to col 3: the continuation cell
527+ s.ClearDamage()
528+ // A wide rune at the last column wraps; the abandoned last cell
529+ // is a continuation, so its wide left half (col 2) must be
530+ // cleared too and the damage must span both columns.
531+ s.Write([]byte("好"))
532+
533+ assertCell(t, s, 0, 0, 'A')
534+ assertCell(t, s, 1, 0, 'B')
535+ assertBlank(t, s, 2, 0) // orphaned wide left cleared
536+ assertBlank(t, s, 3, 0) // abandoned continuation cleared
537+ assertCell(t, s, 0, 1, '好')
538+ assertContinuation(t, s, 1, 1)
539+ if s.Col != 2 || s.Row != 1 {
540+ t.Errorf("cursor at col=%d row=%d, want col=2 row=1", s.Col, s.Row)
541+ }
542+
543+ d := s.Damage()
544+ if len(d) == 0 {
545+ t.Fatal("expected damage")
546+ }
547+ // First damage item covers the cleared pair on row 0.
548+ if d[0].X != 2 || d[0].Y != 0 || d[0].Width != 2 {
549+ t.Errorf("wrap damage = {X:%d Y:%d W:%d}, want {X:2 Y:0 W:2}", d[0].X, d[0].Y, d[0].Width)
550+ }
551+ },
552+ },
553+ {
554+ name: "insert chars at a continuation splits the pair and repairs orphans",
555+ run: func(t *testing.T) {
556+ s := NewScreen(6, 2)
557+ s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
558+ s.Write([]byte("\x1b[1;4H")) // cursor to col 3: continuation of 好
559+ s.ClearDamage()
560+ s.Write([]byte("\x1b[1@")) // ICH 1: shift right from col 3
561+
562+ // The shift splits 好/cont: 好 stays at col 2, its continuation
563+ // moves to col 4. Both orphans must be repaired to blanks.
564+ assertCell(t, s, 0, 0, '你')
565+ assertContinuation(t, s, 1, 0)
566+ assertBlank(t, s, 2, 0) // 好 orphaned by the split → cleared
567+ assertBlank(t, s, 3, 0) // inserted blank
568+ assertBlank(t, s, 4, 0) // shifted continuation orphaned → cleared
569+ assertBlank(t, s, 5, 0)
570+
571+ d := s.Damage()
572+ if len(d) == 0 {
573+ t.Fatal("expected damage")
574+ }
575+ // Damage must extend one column left to cover the repaired 好.
576+ if d[0].X != 2 || d[0].Width != 4 {
577+ t.Errorf("ICH damage = {X:%d W:%d}, want {X:2 W:4}", d[0].X, d[0].Width)
578+ }
579+ },
580+ },
581+ {
582+ name: "insert chars shifting a wide pair off the right edge repairs the left half",
583+ run: func(t *testing.T) {
584+ s := NewScreen(4, 2)
585+ s.Write([]byte("你好")) // 你(0) cont(1) 好(2) cont(3)
586+ s.Write([]byte("\x1b[1;1H")) // cursor home
587+ s.Write([]byte("\x1b[1@")) // ICH 1: shift the row right by 1
588+
589+ // 好 lands on the last column with its continuation pushed off
590+ // the edge; the orphaned wide left must be blanked.
591+ assertBlank(t, s, 0, 0)
592+ assertCell(t, s, 1, 0, '你')
593+ assertContinuation(t, s, 2, 0)
594+ assertBlank(t, s, 3, 0) // 好 lost its continuation → cleared
595+ },
596+ },
597+ {
598+ name: "delete char at the left half of a wide pair repairs the orphan",
599+ run: func(t *testing.T) {
600+ s := NewScreen(6, 2)
601+ s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
602+ s.Write([]byte("\x1b[1;1H")) // cursor to col 0: wide left of 你
603+ s.ClearDamage()
604+ s.Write([]byte("\x1b[1P")) // DCH 1
605+
606+ // 你's continuation shifts to col 0 with no left half → repaired.
607+ assertBlank(t, s, 0, 0)
608+ assertCell(t, s, 1, 0, '好')
609+ assertContinuation(t, s, 2, 0)
610+ assertCell(t, s, 3, 0, 'A')
611+ assertCell(t, s, 4, 0, 'B')
612+ assertBlank(t, s, 5, 0)
613+
614+ d := s.Damage()
615+ if len(d) == 0 {
616+ t.Fatal("expected damage")
617+ }
618+ if d[0].X != 0 || d[0].Width != 6 {
619+ t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
620+ }
621+ },
622+ },
623+ {
624+ name: "delete char at a continuation repairs the wide left and extends damage",
625+ run: func(t *testing.T) {
626+ s := NewScreen(6, 2)
627+ s.Write([]byte("你好AB")) // 你(0) cont(1) 好(2) cont(3) A(4) B(5)
628+ s.Write([]byte("\x1b[1;2H")) // cursor to col 1: continuation of 你
629+ s.ClearDamage()
630+ s.Write([]byte("\x1b[1P")) // DCH 1: delete the continuation
631+
632+ // 你 at col 0 loses its continuation → repaired to blank.
633+ assertBlank(t, s, 0, 0)
634+ assertCell(t, s, 1, 0, '好')
635+ assertContinuation(t, s, 2, 0)
636+ assertCell(t, s, 3, 0, 'A')
637+ assertCell(t, s, 4, 0, 'B')
638+ assertBlank(t, s, 5, 0)
639+
640+ d := s.Damage()
641+ if len(d) == 0 {
642+ t.Fatal("expected damage")
643+ }
644+ // Damage must extend one column left (to col 0) to cover the
645+ // repaired wide left half.
646+ if d[0].X != 0 || d[0].Width != 6 {
647+ t.Errorf("DCH damage = {X:%d W:%d}, want {X:0 W:6}", d[0].X, d[0].Width)
648+ }
649+ },
650+ },
651+ {
652+ name: "IRM inserts narrow printable cells and reset restores overwrite",
653+ run: func(t *testing.T) {
654+ s := NewScreen(8, 1)
655+ s.Write([]byte("abcd\x1b[1;3H\x1b[4hX"))
656+
657+ if got := lineText(s, 0); got != "abXcd " {
658+ t.Fatalf("IRM inserted line = %q, want %q", got, "abXcd ")
659+ }
660+ s.Write([]byte("\x1b[4l\x1b[1;3HY"))
661+ if got := lineText(s, 0); got != "abYcd " {
662+ t.Fatalf("IRM reset overwrite line = %q, want %q", got, "abYcd ")
663+ }
664+ },
665+ },
666+ {
667+ name: "IRM inserts wide printable cells and repairs evicted wide head",
668+ run: func(t *testing.T) {
669+ s := NewScreen(6, 1)
670+ s.Write([]byte("ab界cd"))
671+ s.Write([]byte("\x1b[1;3H\x1b[4h好"))
672+
673+ assertCell(t, s, 0, 0, 'a')
674+ assertCell(t, s, 1, 0, 'b')
675+ assertCell(t, s, 2, 0, '好')
676+ assertContinuation(t, s, 3, 0)
677+ assertCell(t, s, 4, 0, '界')
678+ assertContinuation(t, s, 5, 0)
679+ assertNoOrphanWideCells(t, s)
680+ },
681+ },
682+ {
683+ name: "IRM wide insert at right edge repairs orphan",
684+ run: func(t *testing.T) {
685+ s := NewScreen(5, 1)
686+ s.Write([]byte("abc界"))
687+ s.Write([]byte("\x1b[1;2H\x1b[4h好"))
688+
689+ assertCell(t, s, 0, 0, 'a')
690+ assertCell(t, s, 1, 0, '好')
691+ assertContinuation(t, s, 2, 0)
692+ assertCell(t, s, 3, 0, 'b')
693+ assertCell(t, s, 4, 0, 'c')
694+ assertNoOrphanWideCells(t, s)
695+ },
696+ },
697+ {
698+ name: "wide char on a width-1 screen becomes a narrow replacement rune",
699+ run: func(t *testing.T) {
700+ s := NewScreen(1, 2)
701+ // A wide rune cannot fit on a 1-column screen. Store a narrow
702+ // replacement instead so renderer width matches the cell layout.
703+ s.Write([]byte("你"))
704+
705+ assertCell(t, s, 0, 0, '\uFFFD')
706+ if got := renderer.RuneWidth(cellAt(s, 0, 0).Rune); got != 1 {
707+ t.Errorf("stored rune width = %d, want 1", got)
708+ }
709+ if cellAt(s, 0, 0).Continuation {
710+ t.Error("width-1 replacement must not be a continuation")
711+ }
712+ assertNoOrphanWideCells(t, s)
713+ if s.Col != 1 {
714+ t.Errorf("cursor at col=%d, want 1", s.Col)
715+ }
716+ },
717+ },
718+ {
719+ name: "wide pair survives a scroll",
720+ run: func(t *testing.T) {
721+ s := NewScreen(4, 2)
722+ s.Write([]byte("你\r\n好")) // row0: 你, row1: 好
723+ s.Write([]byte("\r\n")) // bottom row → scroll up
724+
725+ // After scrolling up, row1's 好 moves to row0 intact.
726+ assertCell(t, s, 0, 0, '好')
727+ assertContinuation(t, s, 1, 0)
728+ },
729+ },
730+ {
731+ name: "combining mark alone writes no cell and produces no damage",
732+ run: func(t *testing.T) {
733+ s := NewScreen(10, 3)
734+ s.ClearDamage()
735+ s.Write([]byte("\xcc\x81")) // combining acute accent alone (no base char)
736+
737+ // No cells should be modified.
738+ for y := range 3 {
739+ for x := range 10 {
740+ if c := cellAt(s, x, y); c.Rune != ' ' {
741+ t.Errorf("cell(%d,%d) = %q, want space", x, y, c.Rune)
742+ }
743+ }
744+ }
745+ if s.Col != 0 || s.Row != 0 {
746+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
747+ }
748+ d := s.Damage()
749+ if len(d) != 0 {
750+ t.Errorf("expected no damage for combining mark alone, got %+v", d)
751+ }
752+ },
753+ },
754+ }
755+
756+ for _, tt := range tests {
757+ t.Run(tt.name, tt.run)
758+ }
759+}
760+
761+func TestRuneWidth(t *testing.T) {
762+ tests := []struct {
763+ name string
764+ r rune
765+ width int
766+ }{
767+ {"space", ' ', 1},
768+ {"lowercase ascii letter", 'a', 1},
769+ {"uppercase ascii letter", 'A', 1},
770+ {"ascii digit", '1', 1},
771+ {"soft hyphen", 0x00AD, 0},
772+ {"combining grapheme joiner", 0x034F, 0},
773+ {"combining acute accent", 0x0301, 0},
774+ {"combining grave accent", 0x0300, 0},
775+ {"zero-width space", 0x200B, 0},
776+ {"zero-width non-joiner", 0x200C, 0},
777+ {"zero-width joiner", 0x200D, 0},
778+ {"BOM / zero-width no-break space", 0xFEFF, 0},
779+ {"CJK Unified Ideograph (一)", 0x4E00, 2},
780+ {"CJK (二)", 0x4E8C, 2},
781+ {"CJK end of BMP", 0x9FFF, 2},
782+ {"CJK Extension A", 0x3400, 2},
783+ {"Hangul Syllable (가)", 0xAC00, 2},
784+ {"Hangul Syllable end", 0xD7AF, 2},
785+ {"CJK Ideographic Space", 0x3000, 2},
786+ {"Fullwidth Exclamation Mark", 0xFF01, 2},
787+ {"Fullwidth Cent Sign", 0xFFE0, 2},
788+ {"grinning face emoji", 0x1F600, 2},
789+ {"rocket emoji", 0x1F680, 2},
790+ {"hiragana a", 0x3042, 2},
791+ {"katakana a", 0x30A2, 2},
792+ {"CJK Extension B", 0x20000, 2},
793+ {"supplemental symbols (robot)", 0x1F916, 2},
794+ {"symbols extended-A (sari)", 0x1FA71, 2},
795+ {"ideographic full stop", 0x3002, 2},
796+ {"NUL", '\x00', 0},
797+ {"SOH", '\x01', 0},
798+ {"ESC", '\x1b', 0},
799+ {"DEL", 0x7F, 0},
800+ }
801+
802+ for _, tt := range tests {
803+ t.Run(tt.name, func(t *testing.T) {
804+ got := renderer.RuneWidth(tt.r)
805+ if got != tt.width {
806+ t.Errorf("renderer.RuneWidth(%U %q) = %d, want %d", tt.r, tt.r, got, tt.width)
807+ }
808+ })
809+ }
810+}
811+
812+func TestC1Controls(t *testing.T) {
813+ tests := []struct {
814+ name string
815+ run func(t *testing.T)
816+ }{
817+ {
818+ name: "C1 controls are dropped without moving the cursor",
819+ run: func(t *testing.T) {
820+ s := NewScreen(10, 3)
821+ s.Write([]byte{0xC2, 0x80}) // U+0080 PAD
822+ s.Write([]byte{0xC2, 0x9F}) // U+009F APC
823+
824+ // No cells should be modified.
825+ for y := range 3 {
826+ for x := range 10 {
827+ if c := cellAt(s, x, y); c.Rune != ' ' {
828+ t.Errorf("cell(%d,%d) = %q, want space after C1 controls", x, y, c.Rune)
829+ }
830+ }
831+ }
832+ // Cursor should not have moved.
833+ if s.Col != 0 || s.Row != 0 {
834+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
835+ }
836+ },
837+ },
838+ {
839+ name: "the full C1 control range U+0080-U+009F is dropped",
840+ run: func(t *testing.T) {
841+ s := NewScreen(10, 3)
842+ // Send all C1 control runes U+0080 through U+009F.
843+ for r := 0x80; r <= 0x9F; r++ {
844+ s.Write([]byte{byte(0xC0 | r>>6), byte(0x80 | r&0x3F)})
845+ }
846+
847+ // No cells should be modified.
848+ for y := range 3 {
849+ for x := range 10 {
850+ if c := cellAt(s, x, y); c.Rune != ' ' {
851+ t.Errorf("cell(%d,%d) = %q, want space after C1 range", x, y, c.Rune)
852+ }
853+ }
854+ }
855+ if s.Col != 0 || s.Row != 0 {
856+ t.Errorf("cursor at col=%d row=%d, want col=0 row=0", s.Col, s.Row)
857+ }
858+ },
859+ },
860+ {
861+ name: "printable characters still work after C1 controls",
862+ run: func(t *testing.T) {
863+ s := NewScreen(10, 3)
864+ // Printable characters should still work after C1 controls.
865+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
866+ s.Write([]byte("AB"))
867+ s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
868+ s.Write([]byte("CD"))
869+
870+ assertCell(t, s, 0, 0, 'A')
871+ assertCell(t, s, 1, 0, 'B')
872+ assertCell(t, s, 2, 0, 'C')
873+ assertCell(t, s, 3, 0, 'D')
874+ if s.Col != 4 {
875+ t.Errorf("cursor at col=%d, want 4", s.Col)
876+ }
877+ },
878+ },
879+ {
880+ name: "C1 controls mixed with CR preserve CR behavior",
881+ run: func(t *testing.T) {
882+ s := NewScreen(10, 3)
883+ // C1 controls mixed with CR/LF should preserve CR/LF behavior.
884+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
885+ s.Write([]byte("A\rB")) // CR moves cursor back, overwrite A
886+ s.Write([]byte{0xC2, 0x9F}) // U+009F (dropped)
887+
888+ assertCell(t, s, 0, 0, 'B')
889+ },
890+ },
891+ {
892+ name: "C1 controls mixed with LF preserve LF behavior",
893+ run: func(t *testing.T) {
894+ s := NewScreen(10, 3)
895+ s.Write([]byte{0xC2, 0x80}) // U+0080 (dropped)
896+ s.Write([]byte("A\nB"))
897+
898+ assertCell(t, s, 0, 0, 'A')
899+ assertCell(t, s, 1, 1, 'B')
900+ if s.Row != 1 || s.Col != 2 {
901+ t.Errorf("cursor at row=%d col=%d, want row=1 col=2 (LF advances row without CR)", s.Row, s.Col)
902+ }
903+ },
904+ },
905+ {
906+ name: "C1 controls produce no damage",
907+ run: func(t *testing.T) {
908+ s := NewScreen(10, 3)
909+ s.ClearDamage()
910+ // C1 controls should produce no damage.
911+ s.Write([]byte{0xC2, 0x80, 0xC2, 0x81, 0xC2, 0x9F})
912+
913+ d := s.Damage()
914+ if len(d) != 0 {
915+ t.Errorf("expected no damage from C1 controls, got %+v", d)
916+ }
917+ },
918+ },
919+ }
920+
921+ for _, tt := range tests {
922+ t.Run(tt.name, tt.run)
923+ }
924+}

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close