detached f8d0bc7

Merge pull request #92 from bnema/refactor/targeted-hotspots

refactor: split architectural hotspots by responsibility
commit
f8d0bc7b9c18eedddee4c0854bcd4e9684838553
author
bnema · · 2026-07-15 05:36 UTC
committer
GitHub · · 2026-07-15 05:36 UTC
merge of
694827a + 8b2d766 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
87 files · +13573 -13045
M .mockery.yml +1 -1
@@ -26,7 +26,7 @@ packages:
26 config:26 config:
27 all: true27 all: true
28 dir: internal/ports/mocks28 dir: internal/ports/mocks
29- filename: mocks.go29+ filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go'
30 pkgname: portsmocks30 pkgname: portsmocks
31 github.com/bnema/vev/internal/usecase/command:31 github.com/bnema/vev/internal/usecase/command:
32 interfaces:32 interfaces:
M AGENTS.md +2 -1
@@ -16,7 +16,8 @@ go test ./pkg/renderer ./pkg/vt ./internal/adapters/ipc ./internal/usecase/daemo
16 ```16 ```
17 17
18 - Format with **goimports**.18 - 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.
20 - `make test` runs `go test ./... -race`.21 - `make test` runs `go test ./... -race`.
21 - `make lint` checks `goimports -l`, then `go vet`.22 - `make lint` checks `goimports -l`, then `go vet`.
22 - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.23 - Enable daemon pprof with `VEV_PPROF_ADDR=127.0.0.1:6060 vev --daemon`.
M Makefile +5 -0
@@ -15,4 +15,9 @@ lint:
15 go vet ./...15 go vet ./...
16 16
17 mocks:17 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
18 mockery23 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() {
176 }176 }
177 177
178 func (t *Transport) sendControl(kind byte, id uint64) error {178 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+
179 var payload [9]byte185 var payload [9]byte
180 payload[0] = kind186 payload[0] = kind
181 binary.BigEndian.PutUint64(payload[1:], id)187 binary.BigEndian.PutUint64(payload[1:], id)
@@ -214,3 +220,7 @@ func (t *Transport) sendControl(kind byte, id uint64) error {
214 }220 }
215 return nil221 return nil
216 }222 }
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) {
208 awaitSignal(t, ackStarted, "ACK write while advisory probe remains blocked")208 awaitSignal(t, ackStarted, "ACK write while advisory probe remains blocked")
209 }209 }
210 210
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+
211 func TestProbeDoesNotBlockOnDataWriter(t *testing.T) {249 func TestProbeDoesNotBlockOnDataWriter(t *testing.T) {
212 aPC, bPC := newPair()250 aPC, bPC := newPair()
213 a, err := NewTransportWithOptions(aPC, bPC.addr, key(), 1, 2, Options{251 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
3 3
4 import (4 import (
5 "context"5 "context"
6- "encoding/binary"
7 "errors"6 "errors"
8 "net"7 "net"
9- "slices"
10 "sync"8 "sync"
11 "time"9 "time"
12 10
@@ -226,7 +224,12 @@ type Transport struct {
226 in chan ports.Frame224 in chan ports.Frame
227 done chan struct{}225 done chan struct{}
228 226
229- writeMu sync.Mutex227+ // 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
230 // beforeDataPace is a test synchronization hook. It is read under mu.233 // beforeDataPace is a test synchronization hook. It is read under mu.
231 beforeDataPace func()234 beforeDataPace func()
232 // afterDataPaceWait observes a data pacer's next manual-clock deadline.235 // afterDataPaceWait observes a data pacer's next manual-clock deadline.
@@ -324,319 +327,6 @@ func (s *writeDeadlineState) expire(now time.Time) {
324 s.mu.Unlock()327 s.mu.Unlock()
325 }328 }
326 329
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-
640 // Probe sends an authenticated datagram and waits for the peer to authenticate a330 // Probe sends an authenticated datagram and waits for the peer to authenticate a
641 // response or for ctx to expire. It is intended for UDP bootstrap reachability checks.331 // response or for ctx to expire. It is intended for UDP bootstrap reachability checks.
642 func (t *Transport) Probe(ctx context.Context) error {332 func (t *Transport) Probe(ctx context.Context) error {
@@ -694,44 +384,6 @@ func (t *Transport) unregisterProbe(id uint64) {
694 t.mu.Unlock()384 t.mu.Unlock()
695 }385 }
696 386
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-
735 func (t *Transport) pendingExists(seq uint64) bool {387 func (t *Transport) pendingExists(seq uint64) bool {
736 t.mu.Lock()388 t.mu.Lock()
737 defer t.mu.Unlock()389 defer t.mu.Unlock()
@@ -739,939 +391,5 @@ func (t *Transport) pendingExists(seq uint64) bool {
739 return ok391 return ok
740 }392 }
741 393
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-
1625 // DatagramTransport marks Transport as a UDP-style datagram transport.394 // 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-
1677 func (*Transport) DatagramTransport() {}395 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 (
250 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {250 newRemoteDialerFactoryWithRuntimeObserver = func(observer ports.SerializedRuntimeObserver) ports.RemoteDialerFactory {
251 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)251 return remoteadapter.NewDialerFactoryWithRuntimeObserver(observer)
252 }252 }
253- runClientWithRuntimeObserver = client.RunWithRuntimeObserver253+ 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+ }
254 )260 )
255 261
256 func performanceTrace(clk ports.Clock) (ports.SerializedRuntimeObserver, io.Closer, error) {262 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
484 },490 },
485 remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),491 remoteDialerFactory: newRemoteDialerFactoryWithRuntimeObserver(observer),
486 selectedRemoteTransport: os.Getenv(envRemoteTransport),492 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 {493+ runClient: runClientWithDeps,
488- return runClientWithRuntimeObserver(ctx, dialer, terminal, behaviorClock, intent, name, remote, clipboard, log, observer)494+ createDetached: createDetachedLocalSession,
489- },495+ clipboard: clipboard.New(),
490- createDetached: createDetachedLocalSession,496+ runtimeObserver: observer,
491- clipboard: clipboard.New(),
492 })497 })
493 }498 }
494 499
@@ -500,13 +505,15 @@ func defaultRemoteDialerFactory() ports.RemoteDialerFactory {
500 return remoteadapter.NewDialerFactory()505 return remoteadapter.NewDialerFactory()
501 }506 }
502 507
508+type runClientFunc func(context.Context, client.Dependencies, client.AttachRequest) error
509+
503 type runAttachDeps struct {510 type runAttachDeps struct {
504- attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
505 localDialer func() ports.Dialer511 localDialer func() ports.Dialer
506 remoteDialerFactory ports.RemoteDialerFactory512 remoteDialerFactory ports.RemoteDialerFactory
507 selectedRemoteTransport string513 selectedRemoteTransport string
508- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error514+ runClient runClientFunc
509 createDetached func(context.Context, string) error515 createDetached func(context.Context, string) error
516+ runtimeObserver ports.SerializedRuntimeObserver
510 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see517 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
511 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).518 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
512 // Only used for the remote-dialer branch below; local attaches never519 // 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
535 542
536 runClient := deps.runClient543 runClient := deps.runClient
537 if runClient == nil {544 if runClient == nil {
538- runClient = client.Run545+ runClient = runClientWithDeps
539 }546 }
540 if remoteTarget != "" {547 if remoteTarget != "" {
541 mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)548 mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
@@ -553,26 +560,33 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
553 if err != nil {560 if err != nil {
554 return err561 return err
555 }562 }
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})
557 }571 }
558 572
559- attachLocal := deps.attachLocal573+ localDialer := deps.localDialer
560- if attachLocal == nil {574+ if localDialer == nil {
561- localDialer := deps.localDialer575+ localDialer = defaultLocalDialer
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- }
571 }576 }
572 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{577 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
573 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),578 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
574 attach: func(ctx context.Context, intent uint8, name string) error {579 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})
576 },590 },
577 killDaemon: requestDaemonStop,591 killDaemon: requestDaemonStop,
578 settleAfterKill: waitForDaemonStop,592 settleAfterKill: waitForDaemonStop,
M internal/app/run_test.go +16 -25
@@ -5,7 +5,6 @@ import (
5 "context"5 "context"
6 "errors"6 "errors"
7 "io"7 "io"
8- "log/slog"
9 "net"8 "net"
10 "os"9 "os"
11 "os/exec"10 "os/exec"
@@ -401,10 +400,6 @@ func TestRunLocalAttachDeclineKeepsOriginalError(t *testing.T) {
401 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {400 func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
402 called := false401 called := false
403 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{402 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "outer", nil, runAttachDeps{
404- attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
405- called = true
406- return nil
407- },
408 createDetached: func(context.Context, string) error {403 createDetached: func(context.Context, string) error {
409 called = true404 called = true
410 return nil405 return nil
@@ -424,10 +419,6 @@ func TestRunAttachRejectsNestedVEVBeforeDial(t *testing.T) {
424 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {419 func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
425 var gotName string420 var gotName string
426 err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{421 err := runAttachWithDeps(context.Background(), ports.IntentNew, "scratch", "", "outer", nil, runAttachDeps{
427- attachLocal: func(context.Context, uint8, string, *slog.Logger) error {
428- t.Fatal("nested new should not attach to the session")
429- return nil
430- },
431 createDetached: func(_ context.Context, name string) error {422 createDetached: func(_ context.Context, name string) error {
432 gotName = name423 gotName = name
433 return nil424 return nil
@@ -488,16 +479,16 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
488 remoteDialerFactory: factory,479 remoteDialerFactory: factory,
489 selectedRemoteTransport: tt.selectedTransport,480 selectedRemoteTransport: tt.selectedTransport,
490 clipboard: clip,481 clipboard: clip,
491- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {482+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
492- nd, ok := d.(namedDialer)483+ nd, ok := deps.Dialer.(namedDialer)
493 if !ok {484 if !ok {
494- t.Fatalf("dialer type = %T, want namedDialer", d)485+ t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
495 }486 }
496 gotDialer = nd.name487 gotDialer = nd.name
497- gotRemote = remote488+ gotRemote = request.Remote
498- gotClipboard = clipboard489+ gotClipboard = deps.Clipboard
499- if intent != ports.IntentAttach || name != "work" {490+ if request.Intent != ports.IntentAttach || request.SessionName != "work" {
500- t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)491+ t.Fatalf("intent/name = %d/%q, want attach/work", request.Intent, request.SessionName)
501 }492 }
502 return nil493 return nil
503 },494 },
@@ -525,7 +516,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
525 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{516 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
526 remoteDialerFactory: factory,517 remoteDialerFactory: factory,
527 selectedRemoteTransport: "serial",518 selectedRemoteTransport: "serial",
528- runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {519+ runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
529 runClientCalled = true520 runClientCalled = true
530 return nil521 return nil
531 },522 },
@@ -546,7 +537,7 @@ func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
546 537
547 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{538 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
548 remoteDialerFactory: factory,539 remoteDialerFactory: factory,
549- runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {540+ runClient: func(context.Context, client.Dependencies, client.AttachRequest) error {
550 runClientCalled = true541 runClientCalled = true
551 return nil542 return nil
552 },543 },
@@ -568,16 +559,16 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
568 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },559 localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
569 remoteDialerFactory: factory,560 remoteDialerFactory: factory,
570 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach561 clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
571- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {562+ runClient: func(_ context.Context, deps client.Dependencies, request client.AttachRequest) error {
572- nd, ok := d.(namedDialer)563+ nd, ok := deps.Dialer.(namedDialer)
573 if !ok {564 if !ok {
574- t.Fatalf("dialer type = %T, want namedDialer", d)565+ t.Fatalf("dialer type = %T, want namedDialer", deps.Dialer)
575 }566 }
576 gotDialer = nd.name567 gotDialer = nd.name
577- gotRemote = remote568+ gotRemote = request.Remote
578- gotClipboard = clipboard569+ gotClipboard = deps.Clipboard
579- if intent != ports.IntentEphemeral || name != "" {570+ if request.Intent != ports.IntentEphemeral || request.SessionName != "" {
580- t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)571+ t.Fatalf("intent/name = %d/%q, want ephemeral/empty", request.Intent, request.SessionName)
581 }572 }
582 return nil573 return nil
583 },574 },
M internal/app/transport_observability_test.go +8 -8
@@ -6,7 +6,6 @@ import (
6 "encoding/json"6 "encoding/json"
7 "errors"7 "errors"
8 "io"8 "io"
9- "log/slog"
10 "os"9 "os"
11 "path/filepath"10 "path/filepath"
12 "strconv"11 "strconv"
@@ -16,6 +15,7 @@ import (
16 15
17 "github.com/bnema/vev/internal/ports"16 "github.com/bnema/vev/internal/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"17 portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/usecase/client"
19 "github.com/stretchr/testify/mock"19 "github.com/stretchr/testify/mock"
20 )20 )
21 21
@@ -229,17 +229,17 @@ func TestRunAttachPropagatesOneObserverToRemoteTransportFactory(t *testing.T) {
229 }229 }
230 t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })230 t.Cleanup(func() { newRemoteDialerFactoryWithRuntimeObserver = originalFactory })
231 231
232- originalRunClient := runClientWithRuntimeObserver232+ originalRunClient := runClientWithDeps
233- runClientWithRuntimeObserver = func(_ context.Context, gotDialer ports.Dialer, _ ports.Terminal, _ ports.Clock, _ uint8, _ string, _ bool, _ ports.ClipboardReader, _ *slog.Logger, got ports.SerializedRuntimeObserver) error {233+ runClientWithDeps = func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
234- if got != observer {234+ if deps.RuntimeObserver != observer {
235- t.Fatalf("client transport observer = %v, want process observer %v", got, observer)235+ t.Fatalf("client transport observer = %v, want process observer %v", deps.RuntimeObserver, observer)
236 }236 }
237- if gotDialer != (namedDialer{name: "remote"}) {237+ if deps.Dialer != (namedDialer{name: "remote"}) {
238- t.Fatalf("remote transport dialer = %v, want configured factory dialer", gotDialer)238+ t.Fatalf("remote transport dialer = %v, want configured factory dialer", deps.Dialer)
239 }239 }
240 return nil240 return nil
241 }241 }
242- t.Cleanup(func() { runClientWithRuntimeObserver = originalRunClient })242+ t.Cleanup(func() { runClientWithDeps = originalRunClient })
243 243
244 if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil {244 if err := runAttach(context.Background(), ports.IntentAttach, "work", "remote.example"); err != nil {
245 t.Fatal(err)245 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 (
143 statusClear = "\r\x1b[2K"143 statusClear = "\r\x1b[2K"
144 )144 )
145 145
146-// Run connects via dialer and runs the attach client. It owns the terminal146+// Dependencies supplies the collaborators required by a Runner.
147-// lifecycle above attach attempts so raw mode remains active while a live147+type Dependencies struct {
148-// client process redials a lost link.148+ Dialer ports.Dialer
149-//149+ Terminal ports.Terminal
150-// remote and clipboard together gate the clipboard-image-transfer feature150+ Clock ports.Clock
151-// (docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md):151+ Clipboard ports.ClipboardReader
152-// Ctrl+V is only intercepted on a remote attach (remote true) with a152+ Logger *slog.Logger
153-// ClipboardReader configured (clipboard non-nil) — local attaches forward153+ RuntimeObserver ports.SerializedRuntimeObserver
154-// 0x16 untouched so the locally running agent's own clipboard handling154+}
155-// applies.155+
156-// Option configures opt-in client runtime observation.156+// AttachRequest identifies the session and transport mode for one client run.
157-type Option func(*runtimeOptions)157+type AttachRequest struct {
158-158+ Intent uint8
159-type runtimeOptions struct {159+ SessionName string
160- observer ports.SerializedRuntimeObserver160+ Remote bool
161-}161+}
162-162+
163-// WithRuntimeObserver accepts only a composition-root serialized observer, so163+// Runner owns the client lifecycle across one or more attachment attempts.
164-// terminal and transport progress can never wait for a raw trace sink.164+type Runner struct {
165-func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {165+ dialer ports.Dialer
166- return func(opts *runtimeOptions) { opts.observer = observer }166+ term ports.Terminal
167-}167+ clock ports.Clock
168-168+ clipboard ports.ClipboardReader
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 {169+ logger *slog.Logger
170- return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, nil)170+ runtimeObserver ports.SerializedRuntimeObserver
171-}171+}
172-172+
173-// RunWithRuntimeObserver is the application wiring entry point.173+// NewRunner constructs a client runner. A nil logger uses the process default.
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 {174+func NewRunner(deps Dependencies) *Runner {
175- return run(ctx, dialer, term, clk, intent, name, remote, clipboard, log, observer)175+ log := deps.Logger
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) {
179 if log == nil {176 if log == nil {
180 log = slog.Default()177 log = slog.Default()
181 }178 }
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) {
182 ms := milestones{}193 ms := milestones{}
183 194
184 defer func() {195 defer func() {
185 if retErr != nil {196 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())
187 }198 }
188 }()199 }()
189 200
@@ -201,7 +212,7 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
201 return nil212 return nil
202 }213 }
203 var err error214 var err error
204- restore, err = term.EnterRaw()215+ restore, err = r.term.EnterRaw()
205 if err != nil {216 if err != nil {
206 return fmt.Errorf("vev: entering raw mode: %w", err)217 return fmt.Errorf("vev: entering raw mode: %w", err)
207 }218 }
@@ -211,87 +222,31 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
211 }222 }
212 223
213 resumeToken := uint64(0)224 resumeToken := uint64(0)
214- attemptIntent := intent225+ attemptRequest := request
215- attemptName := name
216 backoff := defaultReconnectBackoff.initial226 backoff := defaultReconnectBackoff.initial
217- showingStatus := false
218- var reconnectToastRect domain.Rect
219 themeState := &terminalThemeState{}227 themeState := &terminalThemeState{}
220- statusStage := reconnectStageOfflineRetrying228+ reconnect := &reconnectUI{
221- redrawRemoteStatus := func(size domain.Size) {229+ term: r.term,
222- if !rawEntered || !remote {230+ remote: request.Remote,
223- return231+ rawEntered: &rawEntered,
224- }232+ stage: reconnectStageOfflineRetrying,
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)
278 }233 }
279 234
280 for {235 for {
281- transport, err := dialer.Dial(ctx)236+ transport, err := r.dialer.Dial(ctx)
282 if err != nil {237 if err != nil {
283 if resumeToken == 0 || ctx.Err() != nil {238 if resumeToken == 0 || ctx.Err() != nil {
284- clearStatus()239+ reconnect.clear()
285 return err240 return err
286 }241 }
287- log.Warn("reconnect dial failed", "err", err, "backoff", backoff)242+ r.logger.Warn("reconnect dial failed", "err", err, "backoff", backoff)
288- drawStatus()243+ reconnect.draw()
289- if !sleepWhileReconnecting(backoff) {244+ if !reconnect.sleep(ctx, r.clock, backoff) {
290- clearStatus()245+ reconnect.clear()
291 return ctx.Err()246 return ctx.Err()
292 }247 }
293 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)248 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
294- attemptIntent = ports.IntentResume249+ attemptRequest.Intent = ports.IntentResume
295 continue250 continue
296 }251 }
297 ms.dialed = true252 ms.dialed = true
@@ -300,7 +255,18 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
300 if reporter, ok := transport.(ports.LinkStateReporter); ok {255 if reporter, ok := transport.(ports.LinkStateReporter); ok {
301 linkEvents = reporter.LinkEvents()256 linkEvents = reporter.LinkEvents()
302 }257 }
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)
304 if result.welcomed {270 if result.welcomed {
305 backoff = defaultReconnectBackoff.initial271 backoff = defaultReconnectBackoff.initial
306 }272 }
@@ -311,27 +277,101 @@ func run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port
311 resumeToken = result.resumeToken277 resumeToken = result.resumeToken
312 }278 }
313 if result.sessionName != "" {279 if result.sessionName != "" {
314- attemptName = result.sessionName280+ attemptRequest.SessionName = result.sessionName
315 }281 }
316 if result.err == nil {282 if result.err == nil {
317- clearStatus()283+ reconnect.clear()
318 return nil284 return nil
319 }285 }
320 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {286 if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
321- clearStatus()287+ reconnect.clear()
322 return result.err288 return result.err
323 }289 }
324- log.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)290+ r.logger.Warn("reconnecting after attach error", "err", result.err, "backoff", backoff)
325- drawStatusStage(reconnectStageSSH)291+ reconnect.drawStage(reconnectStageSSH)
326- if !sleepWhileReconnecting(backoff) {292+ if !reconnect.sleep(ctx, r.clock, backoff) {
327- clearStatus()293+ reconnect.clear()
328 return ctx.Err()294 return ctx.Err()
329 }295 }
330 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)296 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
331- attemptIntent = ports.IntentResume297+ attemptRequest.Intent = ports.IntentResume
332 }298 }
333 }299 }
334 300
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+
335 func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {375 func sleepReconnect(ctx context.Context, clk ports.Clock, d time.Duration) bool {
336 t := clk.NewTimer(d)376 t := clk.NewTimer(d)
337 defer t.Stop()377 defer t.Stop()
@@ -364,25 +404,17 @@ func sleepReconnectWithResizeEvents(ctx context.Context, clk ports.Clock, d time
364 }404 }
365 }405 }
366 406
367-// Attach connects an already-dialed transport to the controlling terminal407+type attachAttempt struct {
368-// and runs the attach loop until the session detaches, the daemon408+ runner *Runner
369-// disappears, or the context is cancelled.409+ transport ports.Transport
370-//410+ request AttachRequest
371-// It is kept as a compatibility wrapper for callers that still own dialing.411+ resumeToken uint64
372-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, clk ports.Clock, intent uint8, name string, options ...Option) error {412+ clientID [16]byte
373- var opts runtimeOptions413+ milestones *milestones
374- for _, option := range options {414+ themeState *terminalThemeState
375- if option != nil {415+ enterRaw func() error
376- option(&opts)416+ reconnect *reconnectUI
377- }417+ linkEvents <-chan ports.LinkEvent
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
386 }418 }
387 419
388 type attachResult struct {420 type attachResult struct {
@@ -452,11 +484,23 @@ func requestedOutputWindow(transport ports.Transport) uint8 {
452 return 8484 return 8
453 }485 }
454 486
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 {487+func (a *attachAttempt) run(ctx context.Context) attachResult {
456- var observer ports.RuntimeObserver488+ transport := a.transport
457- if len(observers) != 0 {489+ term := a.runner.term
458- observer = observers[0]490+ clk := a.runner.clock
459- }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
460 // 1. Handshake: send Hello with our size and TERM.504 // 1. Handshake: send Hello with our size and TERM.
461 size, err := term.Size()505 size, err := term.Size()
462 if err != nil {506 if err != nil {
@@ -527,7 +571,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
527 if err := enterRaw(); err != nil {571 if err := enterRaw(); err != nil {
528 return welcomedResult(err)572 return welcomedResult(err)
529 }573 }
530- clearStatus()574+ reconnect.clear()
531 reportedTheme, restoreTheme := themeState.reportedTheme()575 reportedTheme, restoreTheme := themeState.reportedTheme()
532 576
533 // 4. Derive a cancellable context so the pumps always unwind when the577 // 4. Derive a cancellable context so the pumps always unwind when the
@@ -561,7 +605,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
561 clip = clipboard605 clip = clipboard
562 }606 }
563 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)607 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
564- go runStdin(loopCtx, cancel, term.In(), sendCh, clk, themeState, requestColors, clip, log)608+ go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors}).run()
565 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)609 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
566 610
567 // 5. Output/main loop: the only goroutine that touches the terminal.611 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -585,15 +629,15 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
585 continue629 continue
586 }630 }
587 if ev.State == ports.LinkStateConnected {631 if ev.State == ports.LinkStateConnected {
588- clearStatus()632+ reconnect.clear()
589 continue633 continue
590 }634 }
591 if ev.State == ports.LinkStateDegraded {635 if ev.State == ports.LinkStateDegraded {
592 log.Warn("UDP link degraded")636 log.Warn("UDP link degraded")
593- clearStatus()637+ reconnect.clear()
594 continue638 continue
595 }639 }
596- drawStatusStage(stageForLinkState(ev.State))640+ reconnect.drawStage(stageForLinkState(ev.State))
597 if ev.State == ports.LinkStateOffline {641 if ev.State == ports.LinkStateOffline {
598 // Stop waiting for the transport to reach Dead at 60s: exit with642 // Stop waiting for the transport to reach Dead at 60s: exit with
599 // a retryable error so Run re-dials over ssh with the resume643 // a retryable error so Run re-dials over ssh with the resume
@@ -751,17 +795,38 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
751 }795 }
752 }796 }
753 797
754-// runStdin pumps terminal input to the daemon as Input frames. A read error798+// stdinPump pumps terminal input to the daemon as Input frames. A read error
755 // or EOF is treated as a detach: it best-effort sends a Detach frame and799 // or EOF is treated as a detach: it best-effort sends a Detach frame and
756 // cancels the loop.800 // cancels the loop.
757 //801 //
758 // Known MVP limitation: a bare io.Reader cannot be unblocked from outside,802 // Known MVP limitation: a bare io.Reader cannot be unblocked from outside,
759 // so on shutdown initiated elsewhere (daemon detach, transport loss) this803 // so on shutdown initiated elsewhere (daemon detach, transport loss) this
760 // goroutine stays parked in Read until the next byte arrives or the process804 // goroutine stays parked in Read until the next byte arrives or the process
761-// exits. That is harmless here — Attach has already returned and restored805+// exits. That is harmless here — Run has already returned and restored the
762-// the terminal — and matches the standard pattern for stdin pumps; a806+// terminal — and matches the standard pattern for stdin pumps; a
763 // closable stdin duplicate could lift it later if ever needed.807 // closable stdin duplicate could lift it later if ever needed.
764-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) {808+type stdinPump struct {
809+ ctx context.Context
810+ cancel context.CancelFunc
811+ in io.Reader
812+ out chan<- ports.Frame
813+ clock ports.Clock
814+ themeState *terminalThemeState
815+ clipboard ports.ClipboardReader
816+ logger *slog.Logger
817+ requestColors func()
818+}
819+
820+func (p *stdinPump) run() {
821+ ctx := p.ctx
822+ cancel := p.cancel
823+ in := p.in
824+ out := p.out
825+ clk := p.clock
826+ themeState := p.themeState
827+ requestColors := p.requestColors
828+ clipboard := p.clipboard
829+ log := p.logger
765 defer log.Debug("stdin pump exited")830 defer log.Debug("stdin pump exited")
766 buf := make([]byte, stdinBufSize)831 buf := make([]byte, stdinBufSize)
767 var scanner theme.Scanner832 var scanner theme.Scanner
M internal/usecase/client/client_test.go +45 -19
@@ -155,6 +155,32 @@ func isType(typ ports.MsgType) any {
155 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })155 return mock.MatchedBy(func(f ports.Frame) bool { return f.Type == typ })
156 }156 }
157 157
158+// transportDialer adapts one already-open test transport to the Runner API.
159+type transportDialer struct{ transport ports.Transport }
160+
161+func (d transportDialer) Dial(context.Context) (ports.Transport, error) {
162+ return d.transport, nil
163+}
164+
165+func runTestClient(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
166+ return client.NewRunner(deps).Run(ctx, request)
167+}
168+
169+func testDependencies(dialer ports.Dialer, terminal ports.Terminal, clock ports.Clock, clipboard ports.ClipboardReader, observer ports.SerializedRuntimeObserver) client.Dependencies {
170+ return client.Dependencies{
171+ Dialer: dialer,
172+ Terminal: terminal,
173+ Clock: clock,
174+ Clipboard: clipboard,
175+ Logger: slog.New(slog.DiscardHandler),
176+ RuntimeObserver: observer,
177+ }
178+}
179+
180+func attachTestDependencies(transport ports.Transport, terminal ports.Terminal, clock ports.Clock) client.Dependencies {
181+ return testDependencies(transportDialer{transport: transport}, terminal, clock, nil, nil)
182+}
183+
158 const testPreWelcomeTimeout = 15 * time.Second184 const testPreWelcomeTimeout = 15 * time.Second
159 185
160 func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {186 func newHandshakeClock(t *testing.T, timerCount int) (*portsmocks.MockClock, <-chan chan time.Time) {
@@ -226,7 +252,7 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
226 252
227 errCh := make(chan error, 1)253 errCh := make(chan error, 1)
228 go func() {254 go func() {
229- errCh <- client.Run(ctx, dialer, term, clk, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))255+ errCh <- runTestClient(ctx, testDependencies(dialer, term, clk, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
230 }()256 }()
231 257
232 var timerC chan time.Time258 var timerC chan time.Time
@@ -294,7 +320,7 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
294 defer unblock()320 defer unblock()
295 tr.EXPECT().Close().Return(nil).Once()321 tr.EXPECT().Close().Return(nil).Once()
296 322
297- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")323+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
298 require.NoError(t, err)324 require.NoError(t, err)
299 325
300 select {326 select {
@@ -328,7 +354,7 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
328 defer unblock()354 defer unblock()
329 tr.EXPECT().Close().Return(nil).Once()355 tr.EXPECT().Close().Return(nil).Once()
330 356
331- require.NoError(t, client.Attach(context.Background(), markedDatagramTransport{tr}, tm, realClock{}, ports.IntentEphemeral, ""))357+ require.NoError(t, runTestClient(context.Background(), attachTestDependencies(markedDatagramTransport{tr}, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
332 }358 }
333 359
334 func TestAttachHappyPath(t *testing.T) {360 func TestAttachHappyPath(t *testing.T) {
@@ -348,7 +374,7 @@ func TestAttachHappyPath(t *testing.T) {
348 defer unblock()374 defer unblock()
349 tr.EXPECT().Close().Return(nil).Once()375 tr.EXPECT().Close().Return(nil).Once()
350 376
351- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")377+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
352 require.NoError(t, err)378 require.NoError(t, err)
353 require.Equal(t, "hello world", out.String())379 require.Equal(t, "hello world", out.String())
354 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")380 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
@@ -367,7 +393,7 @@ func TestAttachVersionMismatch(t *testing.T) {
367 ).Once()393 ).Once()
368 tr.EXPECT().Close().Return(nil).Once()394 tr.EXPECT().Close().Return(nil).Once()
369 395
370- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentAttach, "main")396+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main"})
371 require.Error(t, err)397 require.Error(t, err)
372 var pe *client.ProtocolError398 var pe *client.ProtocolError
373 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)399 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -391,7 +417,7 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
391 defer unblock()417 defer unblock()
392 tr.EXPECT().Close().Return(nil).Once()418 tr.EXPECT().Close().Return(nil).Once()
393 419
394- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")420+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
395 require.Error(t, err)421 require.Error(t, err)
396 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")422 require.Equal(t, int32(1), restoreCount.Load(), "restore must run after mid-stream Recv error")
397 }423 }
@@ -412,7 +438,7 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
412 defer unblock()438 defer unblock()
413 tr.EXPECT().Close().Return(nil).Once()439 tr.EXPECT().Close().Return(nil).Once()
414 440
415- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")441+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
416 require.Error(t, err)442 require.Error(t, err)
417 require.ErrorIs(t, err, io.EOF)443 require.ErrorIs(t, err, io.EOF)
418 require.Equal(t, int32(1), restoreCount.Load())444 require.Equal(t, int32(1), restoreCount.Load())
@@ -484,7 +510,7 @@ func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
484 }).Maybe()510 }).Maybe()
485 tr.EXPECT().Close().Return(nil).Once()511 tr.EXPECT().Close().Return(nil).Once()
486 512
487- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")513+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
488 require.NoError(t, err)514 require.NoError(t, err)
489 require.Equal(t, int32(1), restoreCount.Load())515 require.Equal(t, int32(1), restoreCount.Load())
490 516
@@ -563,7 +589,7 @@ func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
563 }).Maybe()589 }).Maybe()
564 tr.EXPECT().Close().Return(nil).Once()590 tr.EXPECT().Close().Return(nil).Once()
565 591
566- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")592+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
567 require.NoError(t, err)593 require.NoError(t, err)
568 select {594 select {
569 case th := <-gotTheme:595 case th := <-gotTheme:
@@ -623,7 +649,7 @@ func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
623 }).Maybe()649 }).Maybe()
624 tr.EXPECT().Close().Return(nil).Once()650 tr.EXPECT().Close().Return(nil).Once()
625 651
626- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")652+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
627 require.NoError(t, err)653 require.NoError(t, err)
628 select {654 select {
629 case got := <-gotInput:655 case got := <-gotInput:
@@ -682,7 +708,7 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
682 }).Maybe()708 }).Maybe()
683 tr.EXPECT().Close().Return(nil).Once()709 tr.EXPECT().Close().Return(nil).Once()
684 710
685- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")711+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
686 require.NoError(t, err)712 require.NoError(t, err)
687 select {713 select {
688 case got := <-gotInput:714 case got := <-gotInput:
@@ -742,7 +768,7 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
742 }).Maybe()768 }).Maybe()
743 tr.EXPECT().Close().Return(nil).Once()769 tr.EXPECT().Close().Return(nil).Once()
744 770
745- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")771+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
746 require.NoError(t, err)772 require.NoError(t, err)
747 select {773 select {
748 case got := <-gotInput:774 case got := <-gotInput:
@@ -805,7 +831,7 @@ func TestAttachForwardsResize(t *testing.T) {
805 resizeCh <- domain.Size{Cols: 120, Rows: 40}831 resizeCh <- domain.Size{Cols: 120, Rows: 40}
806 }()832 }()
807 833
808- err := client.Attach(context.Background(), tr, tm, realClock{}, ports.IntentEphemeral, "")834+ err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
809 require.NoError(t, err)835 require.NoError(t, err)
810 select {836 select {
811 case r := <-gotResize:837 case r := <-gotResize:
@@ -834,9 +860,9 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
834 d := portsmocks.NewMockDialer(t)860 d := portsmocks.NewMockDialer(t)
835 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()861 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
836 862
837- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))863+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: false})
838 require.Error(t, err)864 require.Error(t, err)
839- require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")865+ require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attach attempt errors")
840 }866 }
841 867
842 func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {868 func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
@@ -854,7 +880,7 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
854 d := portsmocks.NewMockDialer(t)880 d := portsmocks.NewMockDialer(t)
855 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()881 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
856 882
857- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))883+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
858 require.Error(t, err)884 require.Error(t, err)
859 var pe *client.ProtocolError885 var pe *client.ProtocolError
860 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)886 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
@@ -866,7 +892,7 @@ func TestRunPhaseASingleAttempt(t *testing.T) {
866 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()892 d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
867 tm := portsmocks.NewMockTerminal(t)893 tm := portsmocks.NewMockTerminal(t)
868 894
869- err := client.Run(context.Background(), d, tm, realClock{}, ports.IntentEphemeral, "", false, nil, slog.New(slog.DiscardHandler))895+ err := runTestClient(context.Background(), testDependencies(d, tm, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: "", Remote: false})
870 require.ErrorIs(t, err, dialErr)896 require.ErrorIs(t, err, dialErr)
871 }897 }
872 898
@@ -956,7 +982,7 @@ func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
956 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}982 tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
957 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}983 d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
958 984
959- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))985+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
960 require.NoError(t, err)986 require.NoError(t, err)
961 require.Equal(t, int32(3), d.calls.Load())987 require.Equal(t, int32(3), d.calls.Load())
962 require.Equal(t, int32(1), term.rawCount.Load())988 require.Equal(t, int32(1), term.rawCount.Load())
@@ -983,7 +1009,7 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
983 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}1009 tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
984 d := &sequenceDialer{trs: []ports.Transport{tr}}1010 d := &sequenceDialer{trs: []ports.Transport{tr}}
985 1011
986- err := client.Run(context.Background(), d, term, realClock{}, ports.IntentAttach, "main", false, nil, slog.New(slog.DiscardHandler))1012+ err := runTestClient(context.Background(), testDependencies(d, term, realClock{}, nil, nil), client.AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: false})
987 require.Error(t, err)1013 require.Error(t, err)
988 var de *client.DetachedError1014 var de *client.DetachedError
989 require.True(t, errors.As(err, &de))1015 require.True(t, errors.As(err, &de))
M internal/usecase/client/clipboard_intercept_test.go +1 -2
@@ -4,7 +4,6 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "io"6 "io"
7- "log/slog"
8 "testing"7 "testing"
9 "time"8 "time"
10 9
@@ -86,7 +85,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
86 d := portsmocks.NewMockDialer(t)85 d := portsmocks.NewMockDialer(t)
87 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()86 d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
88 87
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})
90 require.NoError(t, err)89 require.NoError(t, err)
91 return gotInput, gotImage90 return gotInput, gotImage
92 }91 }
M internal/usecase/client/reconnect.go +1 -1
@@ -14,7 +14,7 @@ type reconnectBackoff struct {
14 14
15 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}15 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
16 16
17-// errLinkOffline is returned by attachOnce when the transport link reaches the17+// errLinkOffline is returned by an attach attempt when the transport link reaches the
18 // Offline state. It is retryable so Run re-dials over ssh with the resume token18 // Offline state. It is retryable so Run re-dials over ssh with the resume token
19 // at ~30s of silence instead of waiting the full Dead timeout at 60s.19 // at ~30s of silence instead of waiting the full Dead timeout at 60s.
20 var errLinkOffline = errors.New("vev: link offline; reconnecting")20 var errLinkOffline = errors.New("vev: link offline; reconnecting")
M internal/usecase/client/reconnect_toast_test.go +86 -46
@@ -8,6 +8,7 @@ import (
8 "io"8 "io"
9 "log/slog"9 "log/slog"
10 "strings"10 "strings"
11+ "sync"
11 "testing"12 "testing"
12 "time"13 "time"
13 14
@@ -71,6 +72,34 @@ func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *re
71 72
72 func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }73 func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
73 74
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+
74 func reconnectToastWelcome(token uint64) ports.Frame {103 func reconnectToastWelcome(token uint64) ports.Frame {
75 return reconnectToastWelcomeNamed("", token)104 return reconnectToastWelcomeNamed("", token)
76 }105 }
@@ -191,66 +220,81 @@ func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock {
191 return clk220 return clk
192 }221 }
193 222
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+
194 func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {256 func TestReconnectToastDegradedClearsModalAndProbingIsVisible(t *testing.T) {
195- term := newReconnectToastTerminalHarness(t)257+ out := newReconnectToastOutputRecorder()
258+ term := newReconnectToastTerminalHarnessWithOutput(t, out)
196 defer term.closeInput()259 defer term.closeInput()
197 tr := newReconnectToastLinkTransport()260 tr := newReconnectToastLinkTransport()
198 tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}261 tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}
199 262
200- stages := make(chan reconnectStage, 4)
201- clears := make(chan struct{}, 4)
202 resultCh := make(chan attachResult, 1)263 resultCh := make(chan attachResult, 1)
203 ms := milestones{}264 ms := milestones{}
204- go func() {265+ attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms)
205- resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error {266+ go func() { resultCh <- attempt.run(context.Background()) }()
206- _, err := term.term.EnterRaw()267+
207- return err268+ tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}
208- }, func() { clears <- struct{}{} }, func(stage reconnectStage) {269+ probed := <-out.completed
209- stages <- stage270+ require.Contains(t, probed, reconnectStageMessage(reconnectStageProbingUDP))
210- }, tr.LinkEvents(), true, nil, slog.New(slog.DiscardHandler))
211- }()
212 271
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.
215 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}272 tr.events <- ports.LinkEvent{State: ports.LinkStateDegraded}
216- select {273+ cleared := <-out.completed
217- case <-clears:274+ assertReconnectToastClearCoversBounds(t, cleared, reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageProbingUDP)))
218- case <-time.After(time.Second):275+
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- }
226 tr.events <- ports.LinkEvent{State: ports.LinkStateProbing}276 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+
228 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}280 tr.recvCh <- reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)}
229 result := <-resultCh281 result := <-resultCh
230 require.NoError(t, result.err)282 require.NoError(t, result.err)
231 require.True(t, result.welcomed)283 require.True(t, result.welcomed)
232 }284 }
233 285
234-func TestAttachOnceOfflineLinkEventReturnsReconnectableError(t *testing.T) {286+func TestAttachAttemptOfflineLinkEventReturnsReconnectableError(t *testing.T) {
235 term := newReconnectToastTerminalHarness(t)287 term := newReconnectToastTerminalHarness(t)
236 defer term.closeInput()288 defer term.closeInput()
237 tr := newReconnectToastLinkTransport()289 tr := newReconnectToastLinkTransport()
238 tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}290 tr.recvCh <- reconnectToastRecv{frame: reconnectToastWelcome(44)}
239 291
240- stages := make(chan reconnectStage, 4)
241 resultCh := make(chan attachResult, 1)292 resultCh := make(chan attachResult, 1)
242 ms := milestones{}293 ms := milestones{}
243- go func() {294+ attempt := newReconnectAttachAttempt(term.term, tr, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true}, 0, &terminalThemeState{}, tr.LinkEvents(), &ms)
244- resultCh <- attachOnce(context.Background(), tr, term.term, newReconnectHandshakeClock(t), ports.IntentAttach, "main", 0, [16]byte{1}, &ms, &terminalThemeState{}, func() error {295+ go func() { resultCh <- attempt.run(context.Background()) }()
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- }()
251 296
252 tr.events <- ports.LinkEvent{State: ports.LinkStateOffline}297 tr.events <- ports.LinkEvent{State: ports.LinkStateOffline}
253- require.Equal(t, reconnectStageOfflineRetrying, <-stages)
254 result := <-resultCh298 result := <-resultCh
255 require.ErrorIs(t, result.err, errLinkOffline)299 require.ErrorIs(t, result.err, errLinkOffline)
256 require.True(t, result.welcomed)300 require.True(t, result.welcomed)
@@ -412,7 +456,7 @@ func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) {
412 }}456 }}
413 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}457 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}}
414 458
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})
416 require.ErrorIs(t, err, context.Canceled)460 require.ErrorIs(t, err, context.Canceled)
417 require.True(t, out.failed)461 require.True(t, out.failed)
418 assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))462 assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH)))
@@ -447,7 +491,7 @@ func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
447 }}491 }}
448 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}492 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
449 493
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})
451 require.NoError(t, err)495 require.NoError(t, err)
452 require.True(t, tr1.closed)496 require.True(t, tr1.closed)
453 require.True(t, tr2.closed)497 require.True(t, tr2.closed)
@@ -565,12 +609,8 @@ func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) {
565 ctx, cancel := context.WithCancel(context.Background())609 ctx, cancel := context.WithCancel(context.Background())
566 resultCh := make(chan attachResult, 1)610 resultCh := make(chan attachResult, 1)
567 ms := milestones{}611 ms := milestones{}
568- go func() {612+ attempt := newReconnectAttachAttempt(term.term, transport, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentResume, SessionName: "main", Remote: true}, 44, themeState, nil, &ms)
569- resultCh <- attachOnce(ctx, transport, term.term, newReconnectHandshakeClock(t), ports.IntentResume, "main", 44, [16]byte{1}, &ms, themeState, func() error {613+ go func() { resultCh <- attempt.run(ctx) }()
570- _, err := term.term.EnterRaw()
571- return err
572- }, func() {}, func(reconnectStage) {}, nil, true, nil, slog.New(slog.DiscardHandler))
573- }()
574 614
575 select {615 select {
576 case <-transport.started:616 case <-transport.started:
@@ -615,7 +655,7 @@ func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) {
615 tr2 := newReconnectThemeTransport(55, false)655 tr2 := newReconnectThemeTransport(55, false)
616 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}656 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
617 657
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})
619 require.NoError(t, err)659 require.NoError(t, err)
620 require.True(t, tr2.observedTheme.HasForeground)660 require.True(t, tr2.observedTheme.HasForeground)
621 require.True(t, tr2.observedTheme.HasBackground)661 require.True(t, tr2.observedTheme.HasBackground)
@@ -649,7 +689,7 @@ func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
649 }}689 }}
650 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}690 dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
651 691
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})
653 require.NoError(t, err)693 require.NoError(t, err)
654 require.Equal(t, 2, dialer.calls)694 require.Equal(t, 2, dialer.calls)
655 695
@@ -734,7 +774,7 @@ func TestRemoteReconnectToastLifecycle(t *testing.T) {
734 dialer := portsmocks.NewMockDialer(t)774 dialer := portsmocks.NewMockDialer(t)
735 tt.configure(t, dialer)775 tt.configure(t, dialer)
736 776
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})
738 tt.wantErr(t, err)778 tt.wantErr(t, err)
739 out := term.out.String()779 out := term.out.String()
740 require.Contains(t, out, reconnectToastMessage)780 require.Contains(t, out, reconnectToastMessage)
M internal/usecase/client/transport_observability_test.go +7 -7
@@ -17,11 +17,11 @@ import (
17 "github.com/bnema/vev/internal/usecase/client"17 "github.com/bnema/vev/internal/usecase/client"
18 )18 )
19 19
20-func TestRuntimeObserverOptionRequiresSerializedContract(t *testing.T) {20+func TestRuntimeObserverDependencyUsesSerializedContract(t *testing.T) {
21- got := reflect.TypeOf(client.WithRuntimeObserver).In(0)21+ deps := reflect.TypeFor[client.Dependencies]()
22- want := reflect.TypeFor[ports.SerializedRuntimeObserver]()22+ field, ok := deps.FieldByName("RuntimeObserver")
23- if got != want {23+ if !ok || field.Type != reflect.TypeFor[ports.SerializedRuntimeObserver]() {
24- t.Fatalf("WithRuntimeObserver accepts %v, want %v; raw blocking sinks must not enter the hot path", got, want)24+ t.Fatalf("Dependencies.RuntimeObserver is %v, want %v; raw blocking sinks must not enter the hot path", field.Type, reflect.TypeFor[ports.SerializedRuntimeObserver]())
25 }25 }
26 }26 }
27 27
@@ -57,7 +57,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
57 ctx, cancel := context.WithCancel(context.Background())57 ctx, cancel := context.WithCancel(context.Background())
58 result := make(chan error, 1)58 result := make(chan error, 1)
59 go func() {59 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})
61 }()61 }()
62 awaitClientRuntime(t, observer.entered, "blocking observer")62 awaitClientRuntime(t, observer.entered, "blocking observer")
63 awaitClientRuntime(t, flushed, "terminal flush")63 awaitClientRuntime(t, flushed, "terminal flush")
@@ -95,7 +95,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
95 transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()95 transport.EXPECT().Send(isType(ports.MsgAck)).Return(nil).Maybe()
96 transport.EXPECT().Close().Return(nil).Once()96 transport.EXPECT().Close().Return(nil).Once()
97 97
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})
99 require.NoError(t, err)99 require.NoError(t, err)
100 reporter.Flush()100 reporter.Flush()
101 require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")101 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 (
14 "github.com/bnema/vev/internal/ports"14 "github.com/bnema/vev/internal/ports"
15 portsmocks "github.com/bnema/vev/internal/ports/mocks"15 portsmocks "github.com/bnema/vev/internal/ports/mocks"
16 "github.com/bnema/vev/internal/usecase/keys"16 "github.com/bnema/vev/internal/usecase/keys"
17+ "github.com/bnema/vev/internal/usecase/layout"
17 "github.com/bnema/vev/internal/usecase/ui"18 "github.com/bnema/vev/internal/usecase/ui"
18 "github.com/bnema/vev/pkg/renderer"19 "github.com/bnema/vev/pkg/renderer"
19 )20 )
@@ -366,11 +367,10 @@ func TestAnimationRepaintConfinedToBarRows(t *testing.T) {
366 require.NotContains(t, string(data), "MIDSCREENMARKER")367 require.NotContains(t, string(data), "MIDSCREENMARKER")
367 }368 }
368 369
369-// TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars pins the370+// TestComposeFrameAttentionFrameChangeDamagesOnlyBars pins the same invariant
370-// same invariant at the composeClientFrameWithState level: with a warm371+// through the production composition pipeline: with a warm cache and no pane
371-// barCache and no screen damage, changing only bars.attentionFrame must372+// damage, changing only bars.attentionFrame damages only the chrome rows.
372-// produce damage confined to row 0 (top bar) and the last row (bottom bar).373+func TestComposeFrameAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
373-func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testing.T) {
374 d, sess, _, _, releases := newManualTabSession(t, 2)374 d, sess, _, _, releases := newManualTabSession(t, 2)
375 defer releases[0]()375 defer releases[0]()
376 defer releases[1]()376 defer releases[1]()
@@ -381,16 +381,39 @@ func TestComposeClientFrameWithStateAttentionFrameChangeDamagesOnlyBars(t *testi
381 sess.mu.Unlock()381 sess.mu.Unlock()
382 382
383 tb := sess.tabs[0]383 tb := sess.tabs[0]
384- var cache barCache384+ pane := tb.focusedPane()
385+ pane.screen.Write([]byte("ATTENTION-PANE-CONTENT"))
385 bars := d.barStateFor(sess, "")386 bars := d.barStateFor(sess, "")
386- _, damage := composeClientFrameWithState(bars, tb, true, &cache)387+ area := domain.Rect{Width: pane.screen.Frame.Width, Height: pane.screen.Frame.Height}
387- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)388+ placement := layout.Placement{ID: pane.id, Content: area}
388- tb.focusedPane().screen.ClearDamage()389+ state := capturedRenderState{
389-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.
390 bars.attentionFrame++406 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")
392 require.NotEmpty(t, damage)415 require.NotEmpty(t, damage)
393- lastRow := tb.focusedPane().screen.Frame.Height + 1416+ lastRow := pane.screen.Frame.Height + 1
394 for _, dmg := range damage {417 for _, dmg := range damage {
395 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)418 require.Contains(t, []int{0, lastRow}, dmg.Y, "expected damage confined to bar rows, got y=%d", dmg.Y)
396 }419 }
M internal/usecase/daemon/copymode_test.go +9 -1
@@ -151,7 +151,15 @@ func TestCopyModeFrameIncludesTopAndBottomChrome(t *testing.T) {
151 mode := scopy.NewMode(snap)151 mode := scopy.NewMode(snap)
152 152
153 bars := barState{status: sess.statusSegments(true)}153 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
155 frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)163 frame, damage := composeCopyClientFrame(mode, &snap, domain.Rect{X: 0, Y: 1, Width: 12, Height: 3}, base, bars)
156 164
157 require.Equal(t, 80, frame.Width)165 require.Equal(t, 80, frame.Width)
M internal/usecase/daemon/floating_render.go +24 -4
@@ -9,7 +9,7 @@ import (
9 9
10 // floatingGeometry describes a popup's outer frame and terminal content area.10 // floatingGeometry describes a popup's outer frame and terminal content area.
11 // Bounds and Inner always share the same coordinate space. Committed pane11 // Bounds and Inner always share the same coordinate space. Committed pane
12-// geometry is origin-zero and tab-content-relative; composeFloatingFrame12+// geometry is origin-zero and tab-content-relative; composeCapturedFloatingFrame
13 // translates both fields and returns/caches frame-absolute geometry. Callers13 // translates both fields and returns/caches frame-absolute geometry. Callers
14 // must not translate a composed or cached value again.14 // must not translate a composed or cached value again.
15 type floatingGeometry struct {15 type floatingGeometry struct {
@@ -85,10 +85,30 @@ func calculateContentFloatingGeometry(content domain.Size, cfg domain.FloatingCo
85 }85 }
86 }86 }
87 87
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+
89 frame := base.Clone()109 frame := base.Clone()
90- legacyLayout := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}110+ layoutSnapshot := tabLayoutSnapshot{placements: layoutSnap.placements, area: layoutSnap.area, focus: layoutSnap.focus, ok: layoutSnap.valid}
91- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacyLayout, theme)111+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, theme)
92 geometry := floating.geometry.translate(content.X, content.Y)112 geometry := floating.geometry.translate(content.X, content.Y)
93 blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))113 blitPaneFrame(frame, geometry.Inner, floating.pane.frame, false, themeui.NewDimmer(theme))
94 damage := append([]renderer.Damage(nil), baseDamage...)114 damage := append([]renderer.Damage(nil), baseDamage...)
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, themeui.NewDimmer(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) {
128 }128 }
129 }129 }
130 130
131-func TestComposeFloatingFrameDoesNotMutateSourceAndDamagesTitle(t *testing.T) {131+func TestComposeCapturedFloatingFrameDoesNotMutateSourceAndDamagesTitle(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
135 base := renderer.NewFrame(40, 12)132 base := renderer.NewFrame(40, 12)
136 base.Set(2, 2, renderer.Cell{Rune: 'B'})133 base.Set(2, 2, renderer.Cell{Rune: 'B'})
137 content := domain.Rect{Y: 1, Width: 40, Height: 10}134 content := domain.Rect{Y: 1, Width: 40, Height: 10}
138- cache := &composedFrameCache{}135+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
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)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+ })
140 require.Equal(t, 'B', base.At(2, 2).Rune, "backdrop must only touch the composed destination")152 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)153+ frameGeometry := geometry.translate(content.X, content.Y)
142- require.Equal(t, '┌', frame.At(geometry.Bounds.X, geometry.Bounds.Y).Rune)154+ require.Equal(t, '┌', frame.At(frameGeometry.Bounds.X, frameGeometry.Bounds.Y).Rune)
143- require.Equal(t, 'F', frame.At(geometry.Inner.X, geometry.Inner.Y).Rune)155+ require.Equal(t, 'F', frame.At(frameGeometry.Inner.X, frameGeometry.Inner.Y).Rune)
144 var gotTitle strings.Builder156 var gotTitle strings.Builder
145- for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {157+ for x := frameGeometry.Bounds.X + 2; x < frameGeometry.Bounds.X+frameGeometry.Bounds.Width-2; x++ {
146- gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)158+ gotTitle.WriteRune(frame.At(x, frameGeometry.Bounds.Y).Rune)
147 }159 }
148 require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─"))160 require.Equal(t, "nvim: project/main.go", strings.TrimRight(gotTitle.String(), "─"))
149 161
150- p.mu.Lock()162+ floating.titleGeneration++
151- p.title.generation++163+ _, damage := composeCapturedFloatingFrame(floatingComposeInput{
152- p.mu.Unlock()164+ baseFrame: base,
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)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+ })
154 require.Len(t, damage, 1)175 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)
156 require.Equal(t, 1, damage[0].Height)177 require.Equal(t, 1, damage[0].Height)
157 }178 }
158 179
159-func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {180+func TestComposeCapturedFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
160 initial := floatingGeometry{181 initial := floatingGeometry{
161 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},182 Bounds: domain.Rect{X: 4, Y: 2, Width: 8, Height: 5},
162 Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3},183 Inner: domain.Rect{X: 5, Y: 3, Width: 6, Height: 3},
@@ -176,20 +197,27 @@ func TestComposeFloatingFrameGeometryChangesInvalidateCache(t *testing.T) {
176 }197 }
177 for _, tt := range tests {198 for _, tt := range tests {
178 t.Run(tt.name, func(t *testing.T) {199 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{}
182 base := renderer.NewFrame(30, 12)200 base := renderer.NewFrame(30, 12)
183 content := domain.Rect{Y: 1, Width: 30, Height: 10}201 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)
185 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)210 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
186 211
187- p.mu.Lock()212+ initialInput.floating.geometry = tt.next
188- p.rect, p.popupGeometry = tt.next.Inner, tt.next213+ initialInput.cache = composeCacheInput{
189- p.mu.Unlock()214+ valid: true,
190- _, damage, _ = composeFloatingFrame(base, nil, p, 1, content, floatingGeometry{}, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)215+ floatingGeneration: 1,
216+ floatingGeometry: initial.translate(content.X, content.Y),
217+ floatingTitleGeneration: 1,
218+ }
219+ _, damage = composeCapturedFloatingFrame(initialInput)
191 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)220 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
192- require.Equal(t, tt.next.translate(content.X, content.Y), cache.floatingGeometry)
193 })221 })
194 }222 }
195 }223 }
@@ -239,8 +267,17 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
239 base := renderer.NewFrame(40, 12)267 base := renderer.NewFrame(40, 12)
240 content := domain.Rect{Y: 1, Width: 40, Height: 10}268 content := domain.Rect{Y: 1, Width: 40, Height: 10}
241 cfg := domain.FloatingConfig{Width: 80, Height: 80}269 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)270+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)
243- geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg).translate(content.X, content.Y)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)
244 var gotTitle strings.Builder281 var gotTitle strings.Builder
245 for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {282 for x := geometry.Bounds.X + 2; x < geometry.Bounds.X+geometry.Bounds.Width-2; x++ {
246 gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)283 gotTitle.WriteRune(frame.At(x, geometry.Bounds.Y).Rune)
@@ -248,10 +285,14 @@ func TestComposeFloatingFrameRendersPaneOwnedCommandFallback(t *testing.T) {
248 require.Equal(t, "btop", strings.TrimRight(gotTitle.String(), "─"))285 require.Equal(t, "btop", strings.TrimRight(gotTitle.String(), "─"))
249 }286 }
250 287
251-func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {288+func TestCaptureAndComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
252 p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})289 p := newPane("floating", nil, domain.Size{Cols: 80, Rows: 24})
253- base := renderer.NewFrame(80, 24)290+ tb := newTab(nil, domain.Size{Cols: 80, Rows: 24})
254- content := domain.Rect{Width: 80, Height: 24}291+ installTestFloating(tb, p, true)
292+ ac := &attachedClient{}
293+ ac.initOverlays()
294+ sess := &session{tabs: []*tab{tb}, client: ac}
295+ base := barState{}
255 cfg := domain.FloatingConfig{Width: 100, Height: 100}296 cfg := domain.FloatingConfig{Width: 100, Height: 100}
256 297
257 start := make(chan struct{})298 start := make(chan struct{})
@@ -261,7 +302,6 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
261 defer wg.Done()302 defer wg.Done()
262 <-start303 <-start
263 for range 500 {304 for range 500 {
264- // This is the same pane lock used by ptyReader around Screen.Write.
265 p.mu.Lock()305 p.mu.Lock()
266 p.screen.Write([]byte("\x1b[Hreader"))306 p.screen.Write([]byte("\x1b[Hreader"))
267 p.mu.Unlock()307 p.mu.Unlock()
@@ -270,9 +310,14 @@ func TestComposeFloatingFrameSynchronizesWithPTYReader(t *testing.T) {
270 go func() {310 go func() {
271 defer wg.Done()311 defer wg.Done()
272 <-start312 <-start
273- cache := &composedFrameCache{}313+ cache := composeCacheInput{}
274 for range 500 {314 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
276 }321 }
277 }()322 }()
278 close(start)323 close(start)
@@ -287,39 +332,59 @@ func TestDrawFloatingBorderOmitsTinyAxes(t *testing.T) {
287 }332 }
288 }333 }
289 334
290-func BenchmarkComposeFloatingFrameCached(b *testing.B) {335+func TestComposeCapturedFloatingFrameCachedAllocationsAreOnlyFrameClone(t *testing.T) {
291- p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
292- p.title.displayFallback = "float"
293 base := renderer.NewFrame(80, 24)336 base := renderer.NewFrame(80, 24)
294 content := domain.Rect{Y: 1, Width: 80, Height: 22}337 content := domain.Rect{Y: 1, Width: 80, Height: 22}
295- cfg := domain.FloatingConfig{Width: 80, Height: 80}338+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
296- desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)339+ input := floatingComposeInput{
297- cache := &composedFrameCache{}340+ baseFrame: base,
298- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)341+ floating: capturedFloatingRenderState{
299- p.screen.ClearDamage()342+ visible: true,
300-343+ pane: capturedPaneRenderState{frame: renderer.NewFrame(62, 18), title: "float", titleGeneration: 1},
301- b.ReportAllocs()344+ geometry: geometry, title: "float", generation: 1, titleGeneration: 1,
302- b.ResetTimer()345+ },
303- for b.Loop() {346+ content: content,
304- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)347+ cache: composeCacheInput{
348+ valid: true, floatingGeneration: 1, floatingGeometry: geometry.translate(content.X, content.Y), floatingTitleGeneration: 1,
349+ },
305 }350 }
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))
306 }357 }
307 358
308-func TestComposeFloatingFrameCachedDoesNotAllocate(t *testing.T) {359+func BenchmarkComposeCapturedFloatingFrameCached(b *testing.B) {
309- p := newPane("floating", nil, domain.Size{Cols: 62, Rows: 18})
310- p.title.displayFallback = "float"
311 base := renderer.NewFrame(80, 24)360 base := renderer.NewFrame(80, 24)
312 content := domain.Rect{Y: 1, Width: 80, Height: 22}361 content := domain.Rect{Y: 1, Width: 80, Height: 22}
313- cfg := domain.FloatingConfig{Width: 80, Height: 80}362+ geometry := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, domain.FloatingConfig{Width: 80, Height: 80})
314- desired := calculateContentFloatingGeometry(domain.Size{Cols: content.Width, Rows: content.Height}, cfg)363+ input := floatingComposeInput{
315- cache := &composedFrameCache{}364+ baseFrame: base,
316- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)365+ floating: capturedFloatingRenderState{
317- p.screen.ClearDamage()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+ }
318 382
319- allocs := testing.AllocsPerRun(100, func() {383+ b.ReportAllocs()
320- composeFloatingFrame(base, nil, p, 1, content, desired, tabLayoutSnapshot{}, themeui.Theme{}, cache, false)384+ b.ResetTimer()
321- })385+ for b.Loop() {
322- require.Zero(t, allocs, "cached floating composition must not allocate without damage or title changes")386+ composeCapturedFloatingFrame(input)
387+ }
323 }388 }
324 389
325 func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {390 func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
@@ -368,7 +433,7 @@ func TestToggleFloatingResizesHiddenPaneOnShowAndRetriesFailure(t *testing.T) {
368 433
369 func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {434 func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T) {
370 cfg := domain.FloatingConfig{Width: 50, Height: 50}435 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}
372 newContent := domain.Rect{Y: 1, Width: 100, Height: 40}437 newContent := domain.Rect{Y: 1, Width: 100, Height: 40}
373 oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg)438 oldGeometry := calculateContentFloatingGeometry(domain.Size{Cols: oldContent.Width, Rows: oldContent.Height}, cfg)
374 newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg)439 newGeometry := calculateContentFloatingGeometry(domain.Size{Cols: newContent.Width, Rows: newContent.Height}, cfg)
@@ -384,14 +449,28 @@ func TestFailedFloatingResizeKeepsCommittedRenderAndInputGeometry(t *testing.T)
384 require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))449 require.False(t, applyFloatingResizePlanForTest(d, p, newGeometry))
385 tb.mu.Lock()450 tb.mu.Lock()
386 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)451 _, inputGeometry, visible := tb.visibleFloatingSnapshotLocked(cfg)
387- copyRect := copyTargetRectLocked(tabLayoutSnapshot{}, newContent, p, p, true, oldGeometry.translate(newContent.X, newContent.Y))
388 tb.mu.Unlock()452 tb.mu.Unlock()
389 require.True(t, visible)453 require.True(t, visible)
390 require.Equal(t, oldGeometry, inputGeometry)454 require.Equal(t, oldGeometry, inputGeometry)
391- require.Equal(t, oldGeometry.translate(newContent.X, newContent.Y).Inner, copyRect)
392 455
456+ p.mu.Lock()
457+ captured := capturePaneRenderStateLocked(p, oldGeometry.Inner)
458+ p.mu.Unlock()
393 base := renderer.NewFrame(newContent.Width, newContent.Height+2)459 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+ })
395 oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y)474 oldFrameGeometry := oldGeometry.translate(newContent.X, newContent.Y)
396 newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y)475 newFrameGeometry := newGeometry.translate(newContent.X, newContent.Y)
397 require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune)476 require.Equal(t, '┌', frame.At(oldFrameGeometry.Bounds.X, oldFrameGeometry.Bounds.Y).Rune)
M internal/usecase/daemon/pane_actions_test.go +10 -1
@@ -16,6 +16,7 @@ import (
16 portsmocks "github.com/bnema/vev/internal/ports/mocks"16 portsmocks "github.com/bnema/vev/internal/ports/mocks"
17 scopy "github.com/bnema/vev/internal/usecase/copy"17 scopy "github.com/bnema/vev/internal/usecase/copy"
18 "github.com/bnema/vev/internal/usecase/layout"18 "github.com/bnema/vev/internal/usecase/layout"
19+ "github.com/bnema/vev/pkg/renderer"
19 )20 )
20 21
21 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {22 func TestSplitPaneCreatesFocusedShellInRequestedPosition(t *testing.T) {
@@ -309,7 +310,15 @@ func TestCloseOriginalPaneLeavesSurvivorFunctional(t *testing.T) {
309 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))310 daemonKeyHandler{d: d, ac: ac}.Forward([]byte("Z"))
310 require.Equal(t, []byte("Z"), <-writes)311 require.Equal(t, []byte("Z"), <-writes)
311 tb.focusedPane().screen.Write([]byte("survivor"))312 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
313 require.Contains(t, frameRowString(frame, 1), "survivor")322 require.Contains(t, frameRowString(frame, 1), "survivor")
314 }323 }
315 324
M internal/usecase/daemon/render.go +8 -1
@@ -325,7 +325,14 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool, lease *att
325 copyFeedback: overlays.copyFeedback,325 copyFeedback: overlays.copyFeedback,
326 }326 }
327 endCapture := marks.span(ports.RuntimeCaptureStart, ports.RuntimeCaptureEnd, 0)327 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+ })
329 endCapture(0, ok)336 endCapture(0, ok)
330 if !ok {337 if !ok {
331 ac.sendMu.Unlock()338 ac.sendMu.Unlock()
M internal/usecase/daemon/render_capture.go +20 -1
@@ -166,11 +166,30 @@ func uncertainDamage(damage []renderer.Damage, width, height int) bool {
166 return false166 return false
167 }167 }
168 168
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+
169 // capturePrimaryRenderState is the ownership boundary for a primary render178 // capturePrimaryRenderState is the ownership boundary for a primary render
170 // transaction. Callers hold attachment sendMu; this function then follows179 // transaction. Callers hold attachment sendMu; this function then follows
171 // session -> tab -> pane lock order. ACK-blocked capture returns before touching180 // session -> tab -> pane lock order. ACK-blocked capture returns before touching
172 // VT damage, and every captured pane records a receipt for successful emission.181 // 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
174 if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {193 if sess == nil || ac == nil || (ac.output != nil && ac.output.atCapacity()) {
175 return nil, false194 return nil, false
176 }195 }
M internal/usecase/daemon/render_damage_receipt_test.go +47 -2
@@ -5,6 +5,7 @@ import (
5 5
6 "github.com/bnema/vev/internal/domain"6 "github.com/bnema/vev/internal/domain"
7 "github.com/bnema/vev/internal/ports"7 "github.com/bnema/vev/internal/ports"
8+ "github.com/bnema/vev/internal/usecase/layout"
8 "github.com/bnema/vev/internal/usecase/picker"9 "github.com/bnema/vev/internal/usecase/picker"
9 "github.com/bnema/vev/pkg/renderer"10 "github.com/bnema/vev/pkg/renderer"
10 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
@@ -26,7 +27,14 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
26 p.mu.Unlock()27 p.mu.Unlock()
27 28
28 ac.sendMu.Lock()29 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+ })
30 ac.sendMu.Unlock()38 ac.sendMu.Unlock()
31 require.True(t, ok)39 require.True(t, ok)
32 require.Len(t, state.receipts, 1)40 require.Len(t, state.receipts, 1)
@@ -38,6 +46,36 @@ func TestPrimaryCaptureAloneRecordsDamageReceipts(t *testing.T) {
38 46
39 // These are deliberately end-to-end pipeline tests: the pending damage comes47 // These are deliberately end-to-end pipeline tests: the pending damage comes
40 // from a real pane VT Write, not a hand-built FullRedraw.48 // 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+
41 func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {79 func TestRenderDamageReceiptRetainsRealVTDamageAcrossFailedEmission(t *testing.T) {
42 for _, tt := range []struct {80 for _, tt := range []struct {
43 name string81 name string
@@ -120,7 +158,14 @@ func TestRenderDamageReceiptStaleGenerationForcesFullRedraw(t *testing.T) {
120 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {158 func captureComposeForReceiptTest(t *testing.T, sess *session, ac *attachedClient) (*capturedRenderState, composedRenderFrame) {
121 t.Helper()159 t.Helper()
122 ac.sendMu.Lock() // emitFrame releases the transaction lock.160 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+ })
124 require.True(t, ok)169 require.True(t, ok)
125 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)170 return state, composeFrame(*state, ac.pipelineCache, ac.pipelineScratch)
126 }171 }
D internal/usecase/daemon/render_legacy_test.go +0 -281
@@ -1,281 +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- styles := newThemeStyles(theme)
162- defaultDimmer := themeui.NewDimmer(theme)
163- inactivePaneDimmer := themeui.NewDimmer(theme, themeui.WithForegroundDimming(inactivePaneForegroundDimming))
164- placements, ok := layoutSnap.placements, layoutSnap.ok && layoutSnap.root == root && layoutSnap.area == contentArea
165- if !ok {
166- placements, ok = layout.Solve(root, contentArea)
167- }
168- var fallback *pane
169- if !ok {
170- fallback = tb.focusedPane()
171- if fallback == nil {
172- return nil
173- }
174- placements = []layout.Placement{{ID: fallback.id, Content: contentArea}}
175- }
176- // A valid cache has the same layout, so its title IDs remain valid. Reset
177- // the existing cache only when rebuilding after layout or frame churn.
178- if titleGenerations != nil && !cacheValid {
179- clear(titleGenerations)
180- }
181- if ok && !cacheValid {
182- drawDividers(frame, root, area, defaultDimmer.Dim(styles.border))
183- }
184- var damage []renderer.Damage
185- for _, pl := range placements {
186- p := tb.panes[pl.ID]
187- if p == nil && fallback != nil && pl.ID == fallback.id {
188- p = fallback
189- }
190- if p == nil {
191- continue
192- }
193- focused := tb.tree.Focus == pl.ID
194- pl = offsetPlacement(pl, area.X, area.Y)
195- if pl.TitleBar.Height > 0 {
196- generation := drawPaneTitleBar(frame, pl, p, focused, styles, defaultDimmer)
197- if cacheValid && (titleGenerations == nil || titleGenerations[pl.ID] != generation) {
198- titleDamage := pl.TitleBar
199- titleDamage.X -= area.X
200- titleDamage.Y -= area.Y
201- damage = append(damage, renderer.Damage{Kind: renderer.DamageText, X: titleDamage.X, Y: titleDamage.Y, Width: titleDamage.Width, Height: titleDamage.Height})
202- }
203- if titleGenerations != nil {
204- titleGenerations[pl.ID] = generation
205- }
206- }
207- if pl.Collapsed || pl.Content.Width <= 0 || pl.Content.Height <= 0 {
208- if consumeDamage {
209- p.mu.Lock()
210- captured := capturePaneRenderStateLocked(p, domain.Rect{})
211- p.screen.AcknowledgeDamage(captured.damageGeneration)
212- p.mu.Unlock()
213- }
214- continue
215- }
216- var paneFrame renderer.Frame
217- var paneDamage []renderer.Damage
218- if consumeDamage {
219- p.mu.Lock()
220- captured := capturePaneRenderStateLocked(p, pl.Content)
221- p.screen.AcknowledgeDamage(captured.damageGeneration)
222- p.mu.Unlock()
223- paneFrame, paneDamage = captured.frame, captured.damage
224- } else {
225- p.mu.Lock()
226- paneDamage = p.screen.Damage()
227- if !cacheValid || len(paneDamage) > 0 {
228- blitPaneFrame(frame, pl.Content, p.screen.Frame, !focused, inactivePaneDimmer)
229- }
230- for _, d := range paneDamage {
231- localContent := pl.Content
232- localContent.X -= area.X
233- localContent.Y -= area.Y
234- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
235- }
236- p.mu.Unlock()
237- continue
238- }
239- if !cacheValid || len(paneDamage) > 0 {
240- blitPaneFrame(frame, pl.Content, paneFrame, !focused, inactivePaneDimmer)
241- }
242- for _, d := range paneDamage {
243- localContent := pl.Content
244- localContent.X -= area.X
245- localContent.Y -= area.Y
246- damage = append(damage, translatePaneDamage(d, localContent, contentArea)...)
247- }
248- }
249- return damage
250-}
251-
252-func sameTabLayoutSnapshot(a, b tabLayoutSnapshot) bool {
253- return a.ok == b.ok && a.root == b.root && a.fingerprint == b.fingerprint && a.area == b.area && a.focus == b.focus
254-}
255-
256-func clearFrameRect(frame renderer.Frame, r domain.Rect) {
257- blank := renderer.BlankCell()
258- for y := r.Y; y < r.Y+r.Height && y < frame.Height; y++ {
259- for x := r.X; x < r.X+r.Width && x < frame.Width; x++ {
260- frame.Set(x, y, blank)
261- }
262- }
263-}
264-
265-func drawPaneTitleBar(frame renderer.Frame, pl layout.Placement, p *pane, focused bool, styles themeStyles, dimmer themeui.Dimmer) uint64 {
266- style := styles.border
267- if focused {
268- style = styles.statusBar
269- } else {
270- style = dimmer.Dim(style)
271- }
272- for x := pl.TitleBar.X; x < pl.TitleBar.X+pl.TitleBar.Width && x < frame.Width; x++ {
273- frame.Set(x, pl.TitleBar.Y, renderer.Cell{Rune: ' ', Style: style})
274- }
275- p.mu.Lock()
276- title := p.displayTitleLocked()
277- generation := p.title.generation
278- p.mu.Unlock()
279- ui.DrawText(frame, pl.TitleBar.X, pl.TitleBar.Y, pl.TitleBar.X+pl.TitleBar.Width, title, style)
280- return generation
281-}
M internal/usecase/daemon/render_pipeline.go +13 -3
@@ -90,6 +90,7 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
90 if titles == nil {90 if titles == nil {
91 titles = make(map[layout.PaneID]uint64, len(state.panes))91 titles = make(map[layout.PaneID]uint64, len(state.panes))
92 }92 }
93+ clear(titles)
93 damage := scratch.damage[:0]94 damage := scratch.damage[:0]
94 for _, pane := range state.panes {95 for _, pane := range state.panes {
95 pl := offsetPlacement(pane.placement, 0, 1)96 pl := offsetPlacement(pane.placement, 0, 1)
@@ -115,7 +116,16 @@ func composeFrame(state capturedRenderState, in composeCacheInput, scratchIn ...
115 baseFrame := frame116 baseFrame := frame
116 if state.floating.visible {117 if state.floating.visible {
117 var floatingDamage []renderer.Damage118 var floatingDamage []renderer.Damage
118- 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+ })
119 damage = floatingDamage129 damage = floatingDamage
120 }130 }
121 if !full {131 if !full {
@@ -228,9 +238,9 @@ func composeCapturedOverlays(state capturedRenderState, frame renderer.Frame, da
228 }238 }
229 frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)239 frame, damage = composeCopyClientFrame(o.copyMode, o.copySnapshot, target, frame, state.bars)
230 }240 }
231- 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}
232 if o.paletteActive && !state.floating.visible {242 if o.paletteActive && !state.floating.visible {
233- (overlayBackdrop{DimPaneContents: true}).apply(frame, content, legacy, state.theme)243+ (overlayBackdrop{DimPaneContents: true}).apply(frame, content, layoutSnapshot, state.theme)
234 }244 }
235 for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {245 for _, modal := range []capturedModal{o.copySearch, o.picker, o.palette, o.prompt} {
236 if modal.inner.Width == 0 && modal.inner.Height == 0 {246 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
3 import (3 import (
4 "errors"4 "errors"
5 "io"5 "io"
6+ "maps"
6 "testing"7 "testing"
7 8
8 "github.com/bnema/vev/internal/domain"9 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
10 "github.com/bnema/vev/internal/usecase/layout"11 "github.com/bnema/vev/internal/usecase/layout"
12+ themeui "github.com/bnema/vev/internal/usecase/theme"
11 "github.com/bnema/vev/pkg/renderer"13 "github.com/bnema/vev/pkg/renderer"
12 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
13 )15 )
@@ -86,6 +88,168 @@ func TestComposeFrameClearsFloatingFrameWhenItCloses(t *testing.T) {
86 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)88 require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, result.damage)
87 }89 }
88 90
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+
89 func cacheState(title string, generation uint64) capturedRenderState {253 func cacheState(title string, generation uint64) capturedRenderState {
90 pane := renderer.NewFrame(6, 1)254 pane := renderer.NewFrame(6, 1)
91 for x, r := range title[:min(len(title), 6)] {255 for x, r := range title[:min(len(title), 6)] {
@@ -106,9 +270,7 @@ func cloneComposeCache(in composeCacheInput) composeCacheInput {
106 out := in270 out := in
107 out.frame = in.frame.Clone()271 out.frame = in.frame.Clone()
108 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))272 out.titleGenerations = make(map[layout.PaneID]uint64, len(in.titleGenerations))
109- for id, generation := range in.titleGenerations {273+ maps.Copy(out.titleGenerations, in.titleGenerations)
110- out.titleGenerations[id] = generation
111- }
112 out.damage = append([]renderer.Damage(nil), in.damage...)274 out.damage = append([]renderer.Damage(nil), in.damage...)
113 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)275 out.bars.top = append([]renderer.Cell(nil), in.bars.top...)
114 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)276 out.bars.bottom = append([]renderer.Cell(nil), in.bars.bottom...)
M internal/usecase/daemon/render_pipeline_test.go +18 -7
@@ -1,6 +1,9 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
4 "io"7 "io"
5 "os"8 "os"
6 "path/filepath"9 "path/filepath"
@@ -163,14 +166,22 @@ func TestComposeFrameUsesCapturedValuesAfterOwnersMutate(t *testing.T) {
163 }166 }
164 167
165 func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {168 func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
166- for _, name := range []string{"render.go", "floating_render.go", "picker.go"} {169+ files, err := filepath.Glob("*.go")
167- data, err := os.ReadFile(filepath.Join(".", name))170+ require.NoError(t, err)
171+ for _, name := range files {
172+ parsed, err := parser.ParseFile(token.NewFileSet(), name, nil, 0)
168 require.NoError(t, err)173 require.NoError(t, err)
169- source := string(data)174+ for _, declaration := range parsed.Decls {
170- for _, forbidden := range []string{175+ switch declaration := declaration.(type) {
171- "func composeClientFrame", "func composeTabFrame", "func composeFloatingFrame",176+ case *ast.FuncDecl:
172- } {177+ forbidden := declaration.Name.Name == "composeFloatingFrame" || declaration.Name.Name == "copyTargetRectLocked" || strings.HasPrefix(declaration.Name.Name, "composeClientFrame") || strings.HasPrefix(declaration.Name.Name, "composeTabFrame")
173- require.NotContains(t, source, forbidden, "%s retains duplicate production composition path", name)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+ }
174 }185 }
175 }186 }
176 data, err := os.ReadFile("render_pipeline.go")187 data, err := os.ReadFile("render_pipeline.go")
M internal/usecase/daemon/render_test.go +0 -308
@@ -802,229 +802,6 @@ func TestTranslatePaneDamagePreservesFullWidthScrollFastPathOnly(t *testing.T) {
802 }802 }
803 }803 }
804 804
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.Equal(t, renderer.RGB{R: 96, G: 96, B: 96}, frame.At(0, 0).Style.ForegroundRGB, "inactive panes should use stronger foreground dimming")
829- require.False(t, left.screen.Frame.At(0, 0).Style.HasForegroundRGB, "dimming must not mutate vt.Screen")
830- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 0, Width: 1, Height: 1, Count: 1})
831-}
832-
833-func TestComposeClientFrameCacheSkipsUndamagedPaneBlits(t *testing.T) {
834- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
835- left := win.focusedPane()
836- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
837- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
838- win.tree.Focus = right.id
839- win.panes[right.id] = right
840- left.screen.Write([]byte("L"))
841- right.screen.Write([]byte("R"))
842- var bars barCache
843- var composed composedFrameCache
844- sess := &session{id: "s", name: "work", tabs: []*tab{win}}
845- composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, true, solveTabLayoutLocked(win), &bars, &composed)
846- left.screen.ClearDamage()
847- right.screen.ClearDamage()
848- left.screen.Frame.Set(0, 0, renderer.Cell{Rune: 'Z'})
849- right.screen.Write([]byte("x"))
850-
851- frame, damage := composeClientFrameWithLayoutCached(barState{status: sess.statusSegments(true)}, win, false, solveTabLayoutLocked(win), &bars, &composed)
852-
853- require.Equal(t, 'L', frame.At(0, 1).Rune, "undamaged left pane should remain cached, not re-blitted")
854- require.Equal(t, 'x', frame.At(22, 1).Rune)
855- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 22, Y: 1, Width: 1, Height: 1, Count: 1})
856-}
857-
858-func TestPaintComposeConsumesOnlyDamageIncludedInFrame(t *testing.T) {
859- win := newTab(nil, domain.Size{Cols: 20, Rows: 4})
860- p := win.focusedPane()
861- p.screen.Write([]byte("old"))
862- var bars barCache
863- var composed composedFrameCache
864- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
865-
866- _, damage := composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
867- require.NotEmpty(t, damage)
868- require.Empty(t, p.screen.Damage(), "paint compose should consume damage it included in the frame")
869-
870- p.screen.Write([]byte("new"))
871- require.NotEmpty(t, p.screen.Damage(), "damage produced after paint compose must remain for a later render")
872-
873- _, _ = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
874- require.NotEmpty(t, p.screen.Damage(), "standalone compose helpers should remain non-consuming")
875-}
876-
877-func TestPaintComposeClearsCollapsedPaneDamage(t *testing.T) {
878- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
879- p1 := win.focusedPane()
880- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
881- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
882- win.tree.Focus = p2.id
883- win.panes[p2.id] = p2
884- p1.screen.Write([]byte("hidden"))
885- p2.screen.Write([]byte("shown"))
886- var bars barCache
887- var composed composedFrameCache
888- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
889-
890- _, _ = composeClientFrameWithLayoutCachedConsumeDamage(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
891-
892- require.Empty(t, p1.screen.Damage(), "collapsed pane damage should not accumulate while paint renders stack title bars")
893- require.Empty(t, p2.screen.Damage())
894-}
895-
896-func TestComposeClientFrameCacheFocusChangeReblitsDimmedPanes(t *testing.T) {
897- win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
898- left := win.focusedPane()
899- right := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 4})
900- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
901- win.panes[right.id] = right
902- left.screen.Write([]byte("L"))
903- right.screen.Write([]byte("R"))
904- 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}}
905- var bars barCache
906- var composed composedFrameCache
907- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true), theme: theme}
908- win.tree.Focus = left.id
909- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
910- left.screen.ClearDamage()
911- right.screen.ClearDamage()
912- leftWasFocused := composed.frame.At(0, 1).Style
913- rightWasDimmed := composed.frame.At(21, 1).Style
914-
915- win.tree.Focus = right.id
916- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
917-
918- require.Equal(t, rightWasDimmed, frame.At(0, 1).Style, "previously focused pane should be re-blitted dimmed")
919- require.Equal(t, leftWasFocused, frame.At(21, 1).Style, "newly focused pane should be re-blitted undimmed")
920- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
921-}
922-
923-func TestComposeClientFrameCacheLayoutChangeClearsStaleDividers(t *testing.T) {
924- win := newTab(nil, domain.Size{Cols: 41, Rows: 5})
925- left := win.focusedPane()
926- right := newPane("pane-2", nil, domain.Size{Cols: 41, Rows: 5})
927- win.panes[right.id] = right
928- left.screen.Write([]byte("L"))
929- right.screen.Write([]byte("R"))
930- var bars barCache
931- var composed composedFrameCache
932- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
933- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
934- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
935- require.Equal(t, '│', composed.frame.At(20, 1).Rune)
936- left.screen.ClearDamage()
937- right.screen.ClearDamage()
938-
939- win.tree.Root = &layout.Node{Kind: layout.Split, Dir: layout.Vertical, Children: []*layout.Node{layout.NewLeaf(left.id), layout.NewLeaf(right.id)}}
940- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
941-
942- require.NotEqual(t, '│', frame.At(20, 1).Rune, "old vertical divider must be cleared when layout changes")
943- require.Equal(t, '─', frame.At(20, 3).Rune)
944- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
945-}
946-
947-func TestComposeTabFrameCachedTitleBarsDoNotAllocate(t *testing.T) {
948- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
949- p := win.focusedPane()
950- p.title.processName = "shell"
951- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
952- layoutSnap := solveTabLayoutLocked(win)
953- frame := renderer.NewFrame(20, 5)
954- titleGenerations := map[layout.PaneID]uint64{p.id: p.title.generation}
955- p.screen.ClearDamage()
956-
957- composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
958- allocs := testing.AllocsPerRun(100, func() {
959- composeTabFrameIntoWithLayoutOptions(win, frame, domain.Rect{Width: 20, Height: 5}, themeui.Theme{}, layoutSnap, true, false, titleGenerations)
960- })
961- require.Zero(t, allocs, "valid cached title-bar renders must not allocate")
962-}
963-
964-func TestComposeTabFrameStackUsesShellFallback(t *testing.T) {
965- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
966- p := win.focusedPane()
967- p.title.displayFallback = "fish"
968- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p.id)}, Expanded: p.id}
969-
970- frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, themeui.Theme{})
971-
972- require.Equal(t, "fish", rowText(frame.Row(0))[:4])
973-}
974-
975-func TestComposeTabFrameStackDrawsTitleBarsAndDimsCollapsed(t *testing.T) {
976- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
977- p1 := win.focusedPane()
978- p1.title.processName = "one"
979- p1.screen.ClearDamage()
980- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
981- p2.title.processName = "two"
982- p2.screen.Write([]byte("T"))
983- p2.screen.ClearDamage()
984-
985- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
986- win.tree.Focus = p2.id
987- win.panes[p2.id] = p2
988-
989- 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}}
990- frame, _ := composeTabFrame(win, domain.Rect{Width: 20, Height: 5}, theme)
991-
992- require.Equal(t, "one", rowText(frame.Row(0))[:3])
993- require.Equal(t, "two", rowText(frame.Row(1))[:3])
994- require.Equal(t, 'T', frame.At(0, 2).Rune)
995- require.True(t, frame.At(0, 0).Style.HasForegroundRGB, "collapsed title bar should use dimmed chrome")
996- require.True(t, frame.At(0, 1).Style.Inverse || frame.At(0, 1).Style.HasBackgroundRGB, "focused title bar should use accent chrome")
997-}
998-
999-func TestComposeClientFrameCacheRefreshesStackTitleBars(t *testing.T) {
1000- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})
1001- p1 := win.focusedPane()
1002- p1.title.processName = "one"
1003- p2 := newPane("pane-2", nil, domain.Size{Cols: 20, Rows: 3})
1004- p2.title.processName = "two"
1005- p2.screen.Write([]byte("T"))
1006- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{layout.NewLeaf(p1.id), layout.NewLeaf(p2.id)}, Expanded: p2.id}
1007- win.tree.Focus = p2.id
1008- win.panes[p2.id] = p2
1009- var bars barCache
1010- var composed composedFrameCache
1011- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
1012- composeClientFrameWithLayoutCached(state, win, true, solveTabLayoutLocked(win), &bars, &composed)
1013- p1.screen.ClearDamage()
1014- p2.screen.ClearDamage()
1015-
1016- p1.title.processName = "renamed"
1017- p1.title.generation++
1018- frame, damage := composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
1019-
1020- require.Equal(t, "renamed", rowText(frame.Row(1))[:7])
1021- require.NotEqual(t, []renderer.Damage{renderer.FullRedraw()}, damage, "title-only changes should not force a full layout reset")
1022- require.Contains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1})
1023-
1024- _, damage = composeClientFrameWithLayoutCached(state, win, false, solveTabLayoutLocked(win), &bars, &composed)
1025- require.NotContains(t, damage, renderer.Damage{Kind: renderer.DamageText, X: 0, Y: 1, Width: 20, Height: 1}, "unchanged title must not redraw its row")
1026-}
1027-
1028 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {805 func TestOverlayPaintInvalidationShowsAndRestoresBaseFrame(t *testing.T) {
1029 tests := []struct {806 tests := []struct {
1030 name string807 name string
@@ -1171,56 +948,6 @@ func TestPaintEarlyReturnReleasesOverlayRenderLocks(t *testing.T) {
1171 }948 }
1172 }949 }
1173 950
1174-func TestComposeClientFrameBarCacheSkipsUnchangedBars(t *testing.T) {
1175- sess, win := newBarCacheTestSession()
1176- var cache barCache
1177-
1178- _, damage := composeClientFrame(sess, win, true, "", &cache)
1179- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1180- win.focusedPane().screen.ClearDamage()
1181- win.focusedPane().screen.Write([]byte("x"))
1182-
1183- _, damage = composeClientFrame(sess, win, false, "", &cache)
1184-
1185- require.NotEmpty(t, damage)
1186- for _, d := range damage {
1187- require.NotEqual(t, 0, d.Y, "unchanged top bar should not be damaged")
1188- require.NotEqual(t, win.focusedPane().screen.Frame.Height+1, d.Y, "unchanged bottom bar should not be damaged")
1189- }
1190-}
1191-
1192-func TestComposeClientFrameBarCacheDamagesChangedBottomOnly(t *testing.T) {
1193- sess, win := newBarCacheTestSession()
1194- var cache barCache
1195- composeClientFrame(sess, win, true, "", &cache)
1196- win.focusedPane().screen.ClearDamage()
1197-
1198- sess.name = "renamed"
1199- _, damage := composeClientFrame(sess, win, false, "", &cache)
1200-
1201- 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)
1202-}
1203-
1204-func TestComposeClientFrameFullRedrawPrimesBarCache(t *testing.T) {
1205- sess, win := newBarCacheTestSession()
1206- var cache barCache
1207- cache.top = []renderer.Cell{renderer.BlankCell()}
1208- cache.bottom = []renderer.Cell{renderer.BlankCell()}
1209-
1210- _, damage := composeClientFrame(sess, win, true, "", &cache)
1211- require.Equal(t, []renderer.Damage{renderer.FullRedraw()}, damage)
1212- win.focusedPane().screen.ClearDamage()
1213-
1214- _, damage = composeClientFrame(sess, win, false, "", &cache)
1215- require.Empty(t, damage)
1216-}
1217-
1218-func newBarCacheTestSession() (*session, *tab) {
1219- win := newTab(newScriptPTY(nil), domain.Size{Cols: 20, Rows: 3})
1220- sess := &session{id: "s", name: "work", tabs: []*tab{win}}
1221- return sess, win
1222-}
1223-
1224 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {951 func TestResizeOrdersPTYBeforeScreen(t *testing.T) {
1225 newSize := domain.Size{Cols: 100, Rows: 30}952 newSize := domain.Size{Cols: 100, Rows: 30}
1226 953
@@ -1465,41 +1192,6 @@ func TestComposeCopyClientFrameOverlaysBaseAtTarget(t *testing.T) {
1465 require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")1192 require.Equal(t, renderer.BlankCell(), frame.At(19, 7), "status filler must be blank cells, not zero cells")
1466 }1193 }
1467 1194
1468-func TestCopyTargetRectLocked(t *testing.T) {
1469- tb := newTab(nil, domain.Size{Cols: 80, Rows: 23})
1470- cfg := domain.Defaults().Floating
1471- contentArea := domain.Rect{Y: 1, Width: 80, Height: 23}
1472- fp := newPane("floating", nil, domain.Size{Cols: 10, Rows: 4})
1473- stray := newPane("stray", nil, domain.Size{Cols: 10, Rows: 4})
1474- tb.mu.Lock()
1475- defer tb.mu.Unlock()
1476- layoutSnap := solveTabLayoutLocked(tb)
1477- main := tb.focusedPane()
1478- fp.popupGeometry = calculateContentFloatingGeometry(domain.Size{Cols: contentArea.Width, Rows: contentArea.Height}, cfg)
1479- floatingFrameGeometry := fp.popupGeometry.translate(contentArea.X, contentArea.Y)
1480-
1481- cases := []struct {
1482- name string
1483- pane *pane
1484- floating *pane
1485- hasFloating bool
1486- want domain.Rect
1487- }{
1488- {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},
1489- {name: "normal source targets solved placement", pane: main, want: domain.Rect{X: 0, Y: 1, Width: 80, Height: 23}},
1490- {name: "unplaced source falls back to pane frame", pane: stray, want: domain.Rect{X: 0, Y: 1, Width: 10, Height: 4}},
1491- }
1492- for _, tc := range cases {
1493- t.Run(tc.name, func(t *testing.T) {
1494- got := copyTargetRectLocked(layoutSnap, contentArea, tc.pane, tc.floating, tc.hasFloating, floatingFrameGeometry)
1495- require.Equal(t, tc.want, got)
1496- if tc.hasFloating {
1497- require.NotEqual(t, fp.popupGeometry.Inner, got, "content-relative geometry must not target the client frame directly")
1498- }
1499- })
1500- }
1501-}
1502-
1503 func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {1195 func TestPaintComposesCopyBodyAboveFloating(t *testing.T) {
1504 normal, releaseNormal := newBlockingPTY(t)1196 normal, releaseNormal := newBlockingPTY(t)
1505 floatingPTY, releaseFloating := newBlockingPTY(t)1197 floatingPTY, releaseFloating := newBlockingPTY(t)
M internal/usecase/daemon/status_test.go +37 -9
@@ -87,7 +87,16 @@ func TestStatusCompositionGolden(t *testing.T) {
87 win.size = domain.Size{Cols: 12, Rows: 2}87 win.size = domain.Size{Cols: 12, Rows: 2}
88 win.focusedPane().screen.Write([]byte("hello"))88 win.focusedPane().screen.Write([]byte("hello"))
89 89
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
91 100
92 require.Equal(t, 12, frame.Width)101 require.Equal(t, 12, frame.Width)
93 require.Equal(t, 4, frame.Height)102 require.Equal(t, 4, frame.Height)
@@ -409,8 +418,10 @@ func TestStatusCompositionUsesTruecolorTheme(t *testing.T) {
409 })418 })
410 419
411 bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}420 bars := barState{status: sess.statusSegments(true), theme: ac.getTheme()}
412- frame, damage := composeClientFrameWithState(bars, win, true)421+ composed := composeFrame(capturedRenderState{
413- out, err := renderer.New(renderer.Capabilities{}).Draw(frame, damage)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)
414 425
415 require.NoError(t, err)426 require.NoError(t, err)
416 require.Contains(t, string(out), ";48;2;")427 require.Contains(t, string(out), ";48;2;")
@@ -788,7 +799,9 @@ func TestStatusMarksEphemeralSession(t *testing.T) {
788 win.focusedPane().screen.Resize(12, 2)799 win.focusedPane().screen.Resize(12, 2)
789 win.size = domain.Size{Cols: 12, Rows: 2}800 win.size = domain.Size{Cols: 12, Rows: 2}
790 801
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
792 805
793 require.Equal(t, " 1 (sh) ", rowText(frame.Row(0)))806 require.Equal(t, " 1 (sh) ", rowText(frame.Row(0)))
794 require.Equal(t, " 0* ", rowText(frame.Row(3)))807 require.Equal(t, " 0* ", rowText(frame.Row(3)))
@@ -853,13 +866,19 @@ func TestStatusCopyFeedbackRendersOnlyWhenFullyFits(t *testing.T) {
853 win.focusedPane().screen.Resize(30, 2)866 win.focusedPane().screen.Resize(30, 2)
854 win.size = domain.Size{Cols: 30, Rows: 2}867 win.size = domain.Size{Cols: 30, Rows: 2}
855 868
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
857 require.Equal(t, " work ok", rowText(frame.Row(3)))872 require.Equal(t, " work ok", rowText(frame.Row(3)))
858 873
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
860 require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))877 require.Equal(t, " work 1234567890123456789", rowText(frame.Row(3)))
861 878
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
863 require.Equal(t, " work ", rowText(frame.Row(3)))882 require.Equal(t, " work ", rowText(frame.Row(3)))
864 }883 }
865 884
@@ -923,7 +942,9 @@ func TestTopBarRendersAttentionBell(t *testing.T) {
923 sess.tabs[1].attentionAt = time.Unix(10, 0)942 sess.tabs[1].attentionAt = time.Unix(10, 0)
924 sess.mu.Unlock()943 sess.mu.Unlock()
925 944
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
927 948
928 // Tab 2's label is enriched with its focused pane's title ("sh", the949 // Tab 2's label is enriched with its focused pane's title ("sh", the
929 // default shell fallback) and truncated to fit alongside the bell. The950 // default shell fallback) and truncated to fit alongside the bell. The
@@ -1040,7 +1061,14 @@ func TestCapturePrimaryRenderStatePreservesContextualMRUModeThroughScratchReuse(
1040 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)1061 _, sess, ac, _ := newManualSessionWithPTYs(t, nil)
1041 capture := func(bars barState) capturedRenderState {1062 capture := func(bars barState) capturedRenderState {
1042 t.Helper()1063 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+ })
1044 require.True(t, ok)1072 require.True(t, ok)
1045 return *state1073 return *state
1046 }1074 }
M internal/usecase/daemon/title_cache_regression_test.go +16 -20
@@ -10,28 +10,24 @@ import (
10 "github.com/bnema/vev/internal/usecase/layout"10 "github.com/bnema/vev/internal/usecase/layout"
11 )11 )
12 12
13-func TestComposeClientFrameCachePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {13+func TestComposeFramePrunesTitleGenerationsAfterLayoutChurn(t *testing.T) {
14- win := newTab(nil, domain.Size{Cols: 20, Rows: 5})14+ committed := composeCacheInput{}
15- first := win.focusedPane()15+ scratch := composeCacheInput{}
16- var bars barCache16+ first := layout.PaneID("pane-1")
17- var composed composedFrameCache
18- state := barState{status: (&session{id: "s", name: "work", tabs: []*tab{win}}).statusSegments(true)}
19 17
20- for i := 0; i < 8; i++ {18+ for i := range 8 {
21 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))19 id := layout.PaneID(fmt.Sprintf("pane-%d", i+2))
22- replacement := newPane(id, nil, domain.Size{Cols: 20, Rows: 3})20+ state := capturedRenderState{
23- win.panes = map[layout.PaneID]*pane{first.id: first, id: replacement}21+ reset: i == 0,
24- win.tree.Root = &layout.Node{Kind: layout.Stack, Children: []*layout.Node{22+ layout: capturedTabLayout{area: domain.Rect{Width: 20, Height: 5}, fingerprint: fmt.Sprintf("layout-%d", i), valid: true},
25- layout.NewLeaf(first.id),23+ panes: []capturedPaneRenderState{
26- layout.NewLeaf(id),24+ {id: first, title: "first", titleGeneration: 1, placement: layout.Placement{ID: first, TitleBar: domain.Rect{Width: 20, Height: 1}, Collapsed: true}},
27- }, Expanded: id}25+ {id: id, title: "replacement", titleGeneration: 1, placement: layout.Placement{ID: id, TitleBar: domain.Rect{Y: 1, Width: 20, Height: 1}, Collapsed: true}},
28- win.tree.Focus = id26+ },
27+ }
28+ out := composeFrame(state, committed, scratch)
29+ scratch, committed = committed, out.cache
29 30
30- composeClientFrameWithLayoutCached(state, win, i == 0, solveTabLayoutLocked(win), &bars, &composed)31+ require.Equal(t, map[layout.PaneID]uint64{first: 1, id: 1}, committed.titleGenerations)
31-
32- require.Equal(t, map[layout.PaneID]uint64{
33- first.id: composed.titleGenerations[first.id],
34- id: composed.titleGenerations[id],
35- }, composed.titleGenerations)
36 }32 }
37 }33 }
M pkg/vt/history_test.go +45 -0
@@ -147,6 +147,51 @@ func TestHistoryOwnedByScreenIgnoresAlternateScreenEvictions(t *testing.T) {
147 }147 }
148 }148 }
149 149
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+
150 func historyRow(text string) []renderer.Cell {195 func historyRow(text string) []renderer.Cell {
151 row := make([]renderer.Cell, len(text))196 row := make([]renderer.Cell, len(text))
152 for i, r := range text {197 for i, r := range text {
M pkg/vt/screen.go +0 -1521
@@ -1,44 +1,17 @@
1 package vt1 package vt
2 2
3 import (3 import (
4- "strconv"
5- "strings"
6 "unicode/utf8"4 "unicode/utf8"
7 5
8 "github.com/bnema/vev/pkg/renderer"6 "github.com/bnema/vev/pkg/renderer"
9 )7 )
10 8
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-
32 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads9 // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads
33 // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base6410 // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64
34 // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,11 // expansion plus the OSC wrapper. pkg/vt cannot import internal/usecase/copy,
35 // so keep this value in sync if that payload cap changes.12 // so keep this value in sync if that payload cap changes.
36 const maxEscapeBufferLen = 128 * 102413 const maxEscapeBufferLen = 128 * 1024
37 14
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-
42 const (15 const (
43 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.16 // ColorSchemeReportDark is the DEC 2031 dark-scheme report.
44 ColorSchemeReportDark = "\x1b[?997;1n"17 ColorSchemeReportDark = "\x1b[?997;1n"
@@ -46,14 +19,6 @@ const (
46 ColorSchemeReportLight = "\x1b[?997;2n"19 ColorSchemeReportLight = "\x1b[?997;2n"
47 )20 )
48 21
49-const (
50- progressStateClear = iota
51- progressStateNormal
52- progressStateError
53- progressStateIndeterminate
54- progressStatePaused
55-)
56-
57 type Screen struct {22 type Screen struct {
58 Frame renderer.Frame23 Frame renderer.Frame
59 Row int24 Row int
@@ -143,130 +108,6 @@ func NewScreenWithHistory(width, height int, config HistoryConfig) *Screen {
143 // configured with NewScreenWithHistory.108 // configured with NewScreenWithHistory.
144 func (s *Screen) History() *History { return s.history }109 func (s *Screen) History() *History { return s.history }
145 110
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-
270 func (s *Screen) Write(data []byte) {111 func (s *Screen) Write(data []byte) {
271 if len(s.escapeBuf) > 0 {112 if len(s.escapeBuf) > 0 {
272 combined := make([]byte, 0, len(s.escapeBuf)+len(data))113 combined := make([]byte, 0, len(s.escapeBuf)+len(data))
@@ -300,393 +141,6 @@ func (s *Screen) Write(data []byte) {
300 }141 }
301 }142 }
302 143
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-
690 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {144 func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
691 if len(data) < 2 {145 if len(data) < 2 {
692 return 0, true146 return 0, true
@@ -737,164 +191,6 @@ func (s *Screen) consumeEscape(data []byte) (consumed int, partial bool) {
737 }191 }
738 }192 }
739 193
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-
898 func consumeSTString(data []byte) (consumed int, partial bool) {194 func consumeSTString(data []byte) (consumed int, partial bool) {
899 for i := 2; i < len(data); i++ {195 for i := 2; i < len(data); i++ {
900 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {196 if data[i] == 0x1b && i+1 < len(data) && data[i+1] == '\\' {
@@ -903,820 +199,3 @@ func consumeSTString(data []byte) (consumed int, partial bool) {
903 }199 }
904 return 0, true200 return 0, true
905 }201 }
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