Merge pull request #92 from bnema/refactor/targeted-hotspots
refactor: split architectural hotspots by responsibility
87 files · +13573 -13045
M .mockery.yml +1 -1
| @@ -26,7 +26,7 @@ packages: | |||||
| 26 | config: | 26 | config: | ||
| 27 | all: true | 27 | all: true | ||
| 28 | dir: internal/ports/mocks | 28 | dir: internal/ports/mocks | ||
| 29 | - | filename: mocks.go | 29 | + | filename: '{{.InterfaceFile | base | trimSuffix ".go"}}_mock.go' |
| 30 | pkgname: portsmocks | 30 | 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 | mockery | 23 | 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]byte | 185 | var payload [9]byte | ||
| 180 | payload[0] = kind | 186 | 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 nil | 221 | 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.Frame | 224 | in chan ports.Frame | ||
| 227 | done chan struct{} | 225 | done chan struct{} | ||
| 228 | 226 | ||||
| 229 | - | writeMu sync.Mutex | 227 | + | // Connection replacement takes writeMu, controlConnMu, then mu. Data writes |
| 228 | + | // take only writeMu; control writes take controlConnMu's read lock, then mu. | |||
| 229 | + | // This keeps control independent of data writes while preventing a rebind | |||
| 230 | + | // from retiring its PacketConn during a control write. | |||
| 231 | + | writeMu sync.Mutex | |||
| 232 | + | controlConnMu sync.RWMutex | |||
| 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 a | 330 | // 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 ok | 391 | 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.RunWithRuntimeObserver | 253 | + | runClientWithDeps runClientFunc = func( |
| 254 | + | ctx context.Context, | |||
| 255 | + | deps client.Dependencies, | |||
| 256 | + | request client.AttachRequest, | |||
| 257 | + | ) error { | |||
| 258 | + | return client.NewRunner(deps).Run(ctx, request) | |||
| 259 | + | } | |||
| 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.Dialer | 511 | localDialer func() ports.Dialer | ||
| 506 | remoteDialerFactory ports.RemoteDialerFactory | 512 | remoteDialerFactory ports.RemoteDialerFactory | ||
| 507 | selectedRemoteTransport string | 513 | selectedRemoteTransport string | ||
| 508 | - | runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error | 514 | + | runClient runClientFunc |
| 509 | createDetached func(context.Context, string) error | 515 | createDetached func(context.Context, string) error | ||
| 516 | + | runtimeObserver ports.SerializedRuntimeObserver | |||
| 510 | // clipboard reads a clipboard image on a remote attach's Ctrl+V (see | 517 | // 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 never | 519 | // 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.runClient | 543 | runClient := deps.runClient | ||
| 537 | if runClient == nil { | 544 | if runClient == nil { | ||
| 538 | - | runClient = client.Run | 545 | + | 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 err | 561 | 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.attachLocal | 573 | + | localDialer := deps.localDialer |
| 560 | - | if attachLocal == nil { | 574 | + | if localDialer == nil { |
| 561 | - | localDialer := deps.localDialer | 575 | + | 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 := false | 401 | 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 = true | 404 | called = true | ||
| 410 | return nil | 405 | 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 string | 420 | 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 = name | 423 | gotName = name | ||
| 433 | return nil | 424 | 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.name | 487 | gotDialer = nd.name | ||
| 497 | - | gotRemote = remote | 488 | + | gotRemote = request.Remote |
| 498 | - | gotClipboard = clipboard | 489 | + | 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 nil | 493 | 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 = true | 520 | runClientCalled = true | ||
| 530 | return nil | 521 | 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 = true | 541 | runClientCalled = true | ||
| 551 | return nil | 542 | 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 attach | 561 | 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.name | 567 | gotDialer = nd.name | ||
| 577 | - | gotRemote = remote | 568 | + | gotRemote = request.Remote |
| 578 | - | gotClipboard = clipboard | 569 | + | 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 nil | 573 | 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 := runClientWithRuntimeObserver | 232 | + | 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 nil | 240 | 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 terminal | 146 | + | // Dependencies supplies the collaborators required by a Runner. |
| 147 | - | // lifecycle above attach attempts so raw mode remains active while a live | 147 | + | 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 feature | 150 | + | 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 a | 152 | + | Logger *slog.Logger |
| 153 | - | // ClipboardReader configured (clipboard non-nil) — local attaches forward | 153 | + | RuntimeObserver ports.SerializedRuntimeObserver |
| 154 | - | // 0x16 untouched so the locally running agent's own clipboard handling | 154 | + | } |
| 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.SerializedRuntimeObserver | 160 | + | Remote bool |
| 161 | - | } | 161 | + | } |
| 162 | - | 162 | + | ||
| 163 | - | // WithRuntimeObserver accepts only a composition-root serialized observer, so | 163 | + | // 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 nil | 212 | return nil | ||
| 202 | } | 213 | } | ||
| 203 | var err error | 214 | 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 := intent | 225 | + | attemptRequest := request |
| 215 | - | attemptName := name | |||
| 216 | backoff := defaultReconnectBackoff.initial | 226 | backoff := defaultReconnectBackoff.initial | ||
| 217 | - | showingStatus := false | |||
| 218 | - | var reconnectToastRect domain.Rect | |||
| 219 | themeState := &terminalThemeState{} | 227 | themeState := &terminalThemeState{} | ||
| 220 | - | statusStage := reconnectStageOfflineRetrying | 228 | + | reconnect := &reconnectUI{ |
| 221 | - | redrawRemoteStatus := func(size domain.Size) { | 229 | + | term: r.term, |
| 222 | - | if !rawEntered || !remote { | 230 | + | remote: request.Remote, |
| 223 | - | return | 231 | + | 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 err | 240 | 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.IntentResume | 249 | + | attemptRequest.Intent = ports.IntentResume |
| 295 | continue | 250 | continue | ||
| 296 | } | 251 | } | ||
| 297 | ms.dialed = true | 252 | 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.initial | 271 | 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.resumeToken | 277 | resumeToken = result.resumeToken | ||
| 312 | } | 278 | } | ||
| 313 | if result.sessionName != "" { | 279 | if result.sessionName != "" { | ||
| 314 | - | attemptName = result.sessionName | 280 | + | attemptRequest.SessionName = result.sessionName |
| 315 | } | 281 | } | ||
| 316 | if result.err == nil { | 282 | if result.err == nil { | ||
| 317 | - | clearStatus() | 283 | + | reconnect.clear() |
| 318 | return nil | 284 | 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.err | 288 | 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.IntentResume | 297 | + | 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 terminal | 407 | + | type attachAttempt struct { |
| 368 | - | // and runs the attach loop until the session detaches, the daemon | 408 | + | 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 runtimeOptions | 413 | + | 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 8 | 484 | 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.RuntimeObserver | 488 | + | 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 the | 577 | // 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 = clipboard | 605 | 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 | continue | 629 | continue | ||
| 586 | } | 630 | } | ||
| 587 | if ev.State == ports.LinkStateConnected { | 631 | if ev.State == ports.LinkStateConnected { | ||
| 588 | - | clearStatus() | 632 | + | reconnect.clear() |
| 589 | continue | 633 | 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 | continue | 638 | 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 with | 642 | // Stop waiting for the transport to reach Dead at 60s: exit with | ||
| 599 | // a retryable error so Run re-dials over ssh with the resume | 643 | // 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 error | 798 | + | // 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 and | 799 | // 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) this | 803 | // so on shutdown initiated elsewhere (daemon detach, transport loss) this | ||
| 760 | // goroutine stays parked in Read until the next byte arrives or the process | 804 | // goroutine stays parked in Read until the next byte arrives or the process | ||
| 761 | - | // exits. That is harmless here — Attach has already returned and restored | 805 | + | // exits. That is harmless here — Run has already returned and restored the |
| 762 | - | // the terminal — and matches the standard pattern for stdin pumps; a | 806 | + | // 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.Scanner | 832 | 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.Second | 184 | 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.Time | 258 | 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.ProtocolError | 398 | 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.ProtocolError | 885 | 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.DetachedError | 1014 | 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, gotImage | 90 | 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 the | 17 | + | // 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 token | 18 | // 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 clk | 220 | 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 err | 268 | + | tr.events <- ports.LinkEvent{State: ports.LinkStateProbing} |
| 208 | - | }, func() { clears <- struct{}{} }, func(stage reconnectStage) { | 269 | + | probed := <-out.completed |
| 209 | - | stages <- stage | 270 | + | 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 := <-resultCh | 281 | 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 := <-resultCh | 298 | 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 the | 370 | + | // TestComposeFrameAttentionFrameChangeDamagesOnlyBars pins the same invariant |
| 370 | - | // same invariant at the composeClientFrameWithState level: with a warm | 371 | + | // through the production composition pipeline: with a warm cache and no pane |
| 371 | - | // barCache and no screen damage, changing only bars.attentionFrame must | 372 | + | // 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 barCache | 384 | + | 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 + 1 | 416 | + | 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 pane | 11 | // Bounds and Inner always share the same coordinate space. Committed pane | ||
| 12 | - | // geometry is origin-zero and tab-content-relative; composeFloatingFrame | 12 | + | // geometry is origin-zero and tab-content-relative; composeCapturedFloatingFrame |
| 13 | // translates both fields and returns/caches frame-absolute geometry. Callers | 13 | // 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.Builder | 156 | 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.next | 213 | + | 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.Builder | 281 | 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 | <-start | 303 | <-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 | <-start | 312 | <-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 false | 166 | 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 render | 178 | // capturePrimaryRenderState is the ownership boundary for a primary render | ||
| 170 | // transaction. Callers hold attachment sendMu; this function then follows | 179 | // transaction. Callers hold attachment sendMu; this function then follows | ||
| 171 | // session -> tab -> pane lock order. ACK-blocked capture returns before touching | 180 | // 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, false | 194 | 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 comes | 47 | // 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 string | 81 | 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 := frame | 116 | baseFrame := frame | ||
| 116 | if state.floating.visible { | 117 | if state.floating.visible { | ||
| 117 | var floatingDamage []renderer.Damage | 118 | 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 = floatingDamage | 129 | 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 := in | 270 | 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 daemon | 1 | 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 string | 807 | 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", the | 949 | // 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. The | 950 | // 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 *state | 1073 | 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 barCache | 16 | + | 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 = id | 26 | + | }, |
| 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 vt | 1 | 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 payloads | 9 | // maxEscapeBufferLen must stay large enough for OSC 52 clipboard payloads | ||
| 33 | // forwarded by internal/usecase/copy.OSC52MaxPayloadBytes after base64 | 10 | // 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 * 1024 | 13 | 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.Frame | 23 | Frame renderer.Frame | ||
| 59 | Row int | 24 | 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, true | 146 | 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, true | 200 | 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 | + | } | |||