detached 75dd680

feat(remote): add resilient attach transport

commit
75dd680f4a2745b20baa2eb3ed90e47d24029dd9
author
brice · · 2026-07-04 10:52 UTC
committer
brice · · 2026-07-04 11:29 UTC
parent
e8e16e8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
26 files · +2534 -138
A internal/adapters/dgram/transport.go +274 -0
@@ -0,0 +1,274 @@
1+// Package dgram adapts authenticated UDP-style packet connections to ports.Transport.
2+package dgram
3+
4+import (
5+ "context"
6+ "encoding/binary"
7+ "errors"
8+ "net"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ pdgram "github.com/bnema/vev/pkg/dgram"
14+)
15+
16+const (
17+ recData byte = 1
18+ recAck byte = 2
19+ defaultResend = 40 * time.Millisecond
20+)
21+
22+type Transport struct {
23+ pc net.PacketConn
24+ codec *pdgram.Codec
25+ sendDir, recvDir uint32
26+ mtu int
27+
28+ mu sync.Mutex
29+ peer net.Addr
30+ ctr uint64
31+ seq uint64
32+ pending map[uint64]*pending
33+ closed bool
34+
35+ replay *pdgram.ReplayWindow
36+ reasm *pdgram.Reassembler
37+ delivered map[uint64]struct{}
38+ deliveredMax uint64
39+ in chan ports.Frame
40+ done chan struct{}
41+}
42+
43+type pending struct {
44+ frame ports.Frame
45+ last time.Time
46+}
47+
48+func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {
49+ c, err := pdgram.NewCodec(key)
50+ if err != nil {
51+ return nil, err
52+ }
53+ t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: pdgram.DefaultMTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), delivered: make(map[uint64]struct{}), in: make(chan ports.Frame, 32), done: make(chan struct{})}
54+ go t.readLoop()
55+ go t.resendLoop()
56+ return t, nil
57+}
58+
59+func (t *Transport) Send(f ports.Frame) error {
60+ // Until full ACK-driven terminal state sync exists, every frame is reliable;
61+ // dropping MsgOutput can permanently desynchronize the client screen.
62+ reliable := true
63+ t.mu.Lock()
64+ t.seq++
65+ seq := t.seq
66+ if reliable {
67+ t.pending[seq] = &pending{frame: f}
68+ }
69+ t.mu.Unlock()
70+ return t.sendData(seq, reliable, f)
71+}
72+
73+func (t *Transport) Recv() (ports.Frame, error) {
74+ select {
75+ case f := <-t.in:
76+ return f, nil
77+ case <-t.done:
78+ return ports.Frame{}, errors.New("dgram: closed")
79+ }
80+}
81+func (t *Transport) Close() error {
82+ t.mu.Lock()
83+ if !t.closed {
84+ t.closed = true
85+ close(t.done)
86+ }
87+ t.mu.Unlock()
88+ return t.pc.Close()
89+}
90+
91+func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
92+
93+func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {
94+ p := encodeData(seq, reliable, f)
95+ return t.sendPayload(p)
96+}
97+func (t *Transport) sendAck(seq uint64) {
98+ var b [9]byte
99+ b[0] = recAck
100+ binary.BigEndian.PutUint64(b[1:], seq)
101+ _ = t.sendPayload(b[:])
102+}
103+func (t *Transport) sendPayload(p []byte) error {
104+ frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
105+ if err != nil {
106+ return err
107+ }
108+ for _, f := range frags {
109+ raw, err := pdgram.MarshalFragment(f)
110+ if err != nil {
111+ return err
112+ }
113+ pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
114+ t.mu.Lock()
115+ peer := t.peer
116+ closed := t.closed
117+ t.mu.Unlock()
118+ if closed {
119+ return errors.New("dgram: closed")
120+ }
121+ if peer == nil {
122+ return errors.New("dgram: no peer")
123+ }
124+ if _, err := t.pc.WriteTo(pkt, peer); err != nil {
125+ return err
126+ }
127+ }
128+ return nil
129+}
130+func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr }
131+
132+func (t *Transport) readLoop() {
133+ buf := make([]byte, 64*1024)
134+ for {
135+ n, addr, err := t.pc.ReadFrom(buf)
136+ if err != nil {
137+ return
138+ }
139+ _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
140+ if err != nil {
141+ continue
142+ }
143+ t.mu.Lock()
144+ t.peer = addr
145+ t.mu.Unlock()
146+ frag, err := pdgram.UnmarshalFragment(pt)
147+ if err != nil {
148+ continue
149+ }
150+ payload, ok, err := t.reasm.Add(frag)
151+ if err != nil || !ok {
152+ continue
153+ }
154+ t.handleRecord(payload)
155+ }
156+}
157+
158+func (t *Transport) handleRecord(p []byte) {
159+ if len(p) < 1 {
160+ return
161+ }
162+ switch p[0] {
163+ case recAck:
164+ if len(p) != 9 {
165+ return
166+ }
167+ seq := binary.BigEndian.Uint64(p[1:])
168+ t.mu.Lock()
169+ delete(t.pending, seq)
170+ t.mu.Unlock()
171+ case recData:
172+ seq, reliable, f, ok := decodeData(p)
173+ if !ok {
174+ return
175+ }
176+ if reliable {
177+ t.sendAck(seq)
178+ if !t.markReliableDelivered(seq) {
179+ return
180+ }
181+ }
182+ go t.deliver(f)
183+ }
184+}
185+
186+func (t *Transport) deliver(f ports.Frame) {
187+ select {
188+ case t.in <- f:
189+ case <-t.done:
190+ }
191+}
192+
193+func (t *Transport) markReliableDelivered(seq uint64) bool {
194+ t.mu.Lock()
195+ defer t.mu.Unlock()
196+ if _, ok := t.delivered[seq]; ok {
197+ return false
198+ }
199+ t.delivered[seq] = struct{}{}
200+ if seq > t.deliveredMax {
201+ t.deliveredMax = seq
202+ }
203+ const window = 1024
204+ if t.deliveredMax > window {
205+ cutoff := t.deliveredMax - window
206+ for s := range t.delivered {
207+ if s < cutoff {
208+ delete(t.delivered, s)
209+ }
210+ }
211+ }
212+ return true
213+}
214+
215+func (t *Transport) resendLoop() {
216+ tick := time.NewTicker(defaultResend)
217+ defer tick.Stop()
218+ for {
219+ select {
220+ case <-tick.C:
221+ now := time.Now()
222+ var resend []struct {
223+ seq uint64
224+ f ports.Frame
225+ }
226+ t.mu.Lock()
227+ for seq, p := range t.pending {
228+ if p.last.IsZero() || now.Sub(p.last) >= defaultResend {
229+ p.last = now
230+ resend = append(resend, struct {
231+ seq uint64
232+ f ports.Frame
233+ }{seq, p.frame})
234+ }
235+ }
236+ t.mu.Unlock()
237+ for _, r := range resend {
238+ _ = t.sendData(r.seq, true, r.f)
239+ }
240+ case <-t.done:
241+ return
242+ }
243+ }
244+}
245+
246+func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
247+ b := make([]byte, 12+len(f.Payload))
248+ b[0] = recData
249+ binary.BigEndian.PutUint64(b[1:9], seq)
250+ if reliable {
251+ b[9] = 1
252+ }
253+ b[10] = byte(f.Type)
254+ b[11] = 0
255+ copy(b[12:], f.Payload)
256+ return b
257+}
258+func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
259+ if len(b) < 12 || b[0] != recData {
260+ return 0, false, ports.Frame{}, false
261+ }
262+ return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true
263+}
264+
265+type Dialer struct {
266+ PC net.PacketConn
267+ Peer net.Addr
268+ Key []byte
269+ SendDir, RecvDir uint32
270+}
271+
272+func (d Dialer) Dial(ctx context.Context) (ports.Transport, error) {
273+ return NewTransport(d.PC, d.Peer, d.Key, d.SendDir, d.RecvDir)
274+}
@@ -0,0 +1,274 @@
1+// Package dgram adapts authenticated UDP-style packet connections to ports.Transport.
2+package dgram
3+
4+import (
5+ "context"
6+ "encoding/binary"
7+ "errors"
8+ "net"
9+ "sync"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/ports"
13+ pdgram "github.com/bnema/vev/pkg/dgram"
14+)
15+
16+const (
17+ recData byte = 1
18+ recAck byte = 2
19+ defaultResend = 40 * time.Millisecond
20+)
21+
22+type Transport struct {
23+ pc net.PacketConn
24+ codec *pdgram.Codec
25+ sendDir, recvDir uint32
26+ mtu int
27+
28+ mu sync.Mutex
29+ peer net.Addr
30+ ctr uint64
31+ seq uint64
32+ pending map[uint64]*pending
33+ closed bool
34+
35+ replay *pdgram.ReplayWindow
36+ reasm *pdgram.Reassembler
37+ delivered map[uint64]struct{}
38+ deliveredMax uint64
39+ in chan ports.Frame
40+ done chan struct{}
41+}
42+
43+type pending struct {
44+ frame ports.Frame
45+ last time.Time
46+}
47+
48+func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {
49+ c, err := pdgram.NewCodec(key)
50+ if err != nil {
51+ return nil, err
52+ }
53+ t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: pdgram.DefaultMTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), delivered: make(map[uint64]struct{}), in: make(chan ports.Frame, 32), done: make(chan struct{})}
54+ go t.readLoop()
55+ go t.resendLoop()
56+ return t, nil
57+}
58+
59+func (t *Transport) Send(f ports.Frame) error {
60+ // Until full ACK-driven terminal state sync exists, every frame is reliable;
61+ // dropping MsgOutput can permanently desynchronize the client screen.
62+ reliable := true
63+ t.mu.Lock()
64+ t.seq++
65+ seq := t.seq
66+ if reliable {
67+ t.pending[seq] = &pending{frame: f}
68+ }
69+ t.mu.Unlock()
70+ return t.sendData(seq, reliable, f)
71+}
72+
73+func (t *Transport) Recv() (ports.Frame, error) {
74+ select {
75+ case f := <-t.in:
76+ return f, nil
77+ case <-t.done:
78+ return ports.Frame{}, errors.New("dgram: closed")
79+ }
80+}
81+func (t *Transport) Close() error {
82+ t.mu.Lock()
83+ if !t.closed {
84+ t.closed = true
85+ close(t.done)
86+ }
87+ t.mu.Unlock()
88+ return t.pc.Close()
89+}
90+
91+func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
92+
93+func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {
94+ p := encodeData(seq, reliable, f)
95+ return t.sendPayload(p)
96+}
97+func (t *Transport) sendAck(seq uint64) {
98+ var b [9]byte
99+ b[0] = recAck
100+ binary.BigEndian.PutUint64(b[1:], seq)
101+ _ = t.sendPayload(b[:])
102+}
103+func (t *Transport) sendPayload(p []byte) error {
104+ frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
105+ if err != nil {
106+ return err
107+ }
108+ for _, f := range frags {
109+ raw, err := pdgram.MarshalFragment(f)
110+ if err != nil {
111+ return err
112+ }
113+ pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
114+ t.mu.Lock()
115+ peer := t.peer
116+ closed := t.closed
117+ t.mu.Unlock()
118+ if closed {
119+ return errors.New("dgram: closed")
120+ }
121+ if peer == nil {
122+ return errors.New("dgram: no peer")
123+ }
124+ if _, err := t.pc.WriteTo(pkt, peer); err != nil {
125+ return err
126+ }
127+ }
128+ return nil
129+}
130+func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr }
131+
132+func (t *Transport) readLoop() {
133+ buf := make([]byte, 64*1024)
134+ for {
135+ n, addr, err := t.pc.ReadFrom(buf)
136+ if err != nil {
137+ return
138+ }
139+ _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
140+ if err != nil {
141+ continue
142+ }
143+ t.mu.Lock()
144+ t.peer = addr
145+ t.mu.Unlock()
146+ frag, err := pdgram.UnmarshalFragment(pt)
147+ if err != nil {
148+ continue
149+ }
150+ payload, ok, err := t.reasm.Add(frag)
151+ if err != nil || !ok {
152+ continue
153+ }
154+ t.handleRecord(payload)
155+ }
156+}
157+
158+func (t *Transport) handleRecord(p []byte) {
159+ if len(p) < 1 {
160+ return
161+ }
162+ switch p[0] {
163+ case recAck:
164+ if len(p) != 9 {
165+ return
166+ }
167+ seq := binary.BigEndian.Uint64(p[1:])
168+ t.mu.Lock()
169+ delete(t.pending, seq)
170+ t.mu.Unlock()
171+ case recData:
172+ seq, reliable, f, ok := decodeData(p)
173+ if !ok {
174+ return
175+ }
176+ if reliable {
177+ t.sendAck(seq)
178+ if !t.markReliableDelivered(seq) {
179+ return
180+ }
181+ }
182+ go t.deliver(f)
183+ }
184+}
185+
186+func (t *Transport) deliver(f ports.Frame) {
187+ select {
188+ case t.in <- f:
189+ case <-t.done:
190+ }
191+}
192+
193+func (t *Transport) markReliableDelivered(seq uint64) bool {
194+ t.mu.Lock()
195+ defer t.mu.Unlock()
196+ if _, ok := t.delivered[seq]; ok {
197+ return false
198+ }
199+ t.delivered[seq] = struct{}{}
200+ if seq > t.deliveredMax {
201+ t.deliveredMax = seq
202+ }
203+ const window = 1024
204+ if t.deliveredMax > window {
205+ cutoff := t.deliveredMax - window
206+ for s := range t.delivered {
207+ if s < cutoff {
208+ delete(t.delivered, s)
209+ }
210+ }
211+ }
212+ return true
213+}
214+
215+func (t *Transport) resendLoop() {
216+ tick := time.NewTicker(defaultResend)
217+ defer tick.Stop()
218+ for {
219+ select {
220+ case <-tick.C:
221+ now := time.Now()
222+ var resend []struct {
223+ seq uint64
224+ f ports.Frame
225+ }
226+ t.mu.Lock()
227+ for seq, p := range t.pending {
228+ if p.last.IsZero() || now.Sub(p.last) >= defaultResend {
229+ p.last = now
230+ resend = append(resend, struct {
231+ seq uint64
232+ f ports.Frame
233+ }{seq, p.frame})
234+ }
235+ }
236+ t.mu.Unlock()
237+ for _, r := range resend {
238+ _ = t.sendData(r.seq, true, r.f)
239+ }
240+ case <-t.done:
241+ return
242+ }
243+ }
244+}
245+
246+func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
247+ b := make([]byte, 12+len(f.Payload))
248+ b[0] = recData
249+ binary.BigEndian.PutUint64(b[1:9], seq)
250+ if reliable {
251+ b[9] = 1
252+ }
253+ b[10] = byte(f.Type)
254+ b[11] = 0
255+ copy(b[12:], f.Payload)
256+ return b
257+}
258+func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
259+ if len(b) < 12 || b[0] != recData {
260+ return 0, false, ports.Frame{}, false
261+ }
262+ return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true
263+}
264+
265+type Dialer struct {
266+ PC net.PacketConn
267+ Peer net.Addr
268+ Key []byte
269+ SendDir, RecvDir uint32
270+}
271+
272+func (d Dialer) Dial(ctx context.Context) (ports.Transport, error) {
273+ return NewTransport(d.PC, d.Peer, d.Key, d.SendDir, d.RecvDir)
274+}
A internal/adapters/dgram/transport_test.go +232 -0
@@ -0,0 +1,232 @@
1+package dgram
2+
3+import (
4+ "bytes"
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 testAddr string
17+
18+func (a testAddr) Network() string { return "mem" }
19+func (a testAddr) String() string { return string(a) }
20+
21+type packet struct {
22+ b []byte
23+ addr net.Addr
24+}
25+type fakePC struct {
26+ addr net.Addr
27+ mu sync.Mutex
28+ closed bool
29+ in chan packet
30+ peers map[string]*fakePC
31+ drop func([]byte, net.Addr) bool
32+}
33+
34+func newPair() (*fakePC, *fakePC) {
35+ a := &fakePC{addr: testAddr("a"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
36+ b := &fakePC{addr: testAddr("b"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
37+ a.peers["b"] = b
38+ b.peers["a"] = a
39+ return a, b
40+}
41+func (p *fakePC) ReadFrom(b []byte) (int, net.Addr, error) {
42+ q, ok := <-p.in
43+ if !ok {
44+ return 0, nil, errors.New("closed")
45+ }
46+ copy(b, q.b)
47+ return len(q.b), q.addr, nil
48+}
49+func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
50+ p.mu.Lock()
51+ closed := p.closed
52+ p.mu.Unlock()
53+ if closed {
54+ return 0, errors.New("closed")
55+ }
56+ if p.drop != nil && p.drop(b, addr) {
57+ return len(b), nil
58+ }
59+ peer := p.peers[addr.String()]
60+ peer.mu.Lock()
61+ peerClosed := peer.closed
62+ peer.mu.Unlock()
63+ if peerClosed {
64+ return 0, errors.New("peer closed")
65+ }
66+ peer.in <- packet{append([]byte(nil), b...), p.addr}
67+ return len(b), nil
68+}
69+func (p *fakePC) Close() error {
70+ p.mu.Lock()
71+ defer p.mu.Unlock()
72+ if !p.closed {
73+ p.closed = true
74+ close(p.in)
75+ }
76+ return nil
77+}
78+func (p *fakePC) LocalAddr() net.Addr { return p.addr }
79+func (p *fakePC) SetDeadline(time.Time) error { return nil }
80+func (p *fakePC) SetReadDeadline(time.Time) error { return nil }
81+func (p *fakePC) SetWriteDeadline(time.Time) error { return nil }
82+
83+func key() []byte { return bytes.Repeat([]byte{1}, pdgram.KeySize) }
84+
85+func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
86+ aPC, bPC := newPair()
87+ var droppedAck atomic.Bool
88+ bPC.drop = func(_ []byte, addr net.Addr) bool {
89+ return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
90+ }
91+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ defer func() {
100+ if err := a.Close(); err != nil {
101+ t.Errorf("close a: %v", err)
102+ }
103+ }()
104+ defer func() {
105+ if err := b.Close(); err != nil {
106+ t.Errorf("close b: %v", err)
107+ }
108+ }()
109+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
110+ t.Fatal(err)
111+ }
112+ got := recvWithin(t, b, time.Second)
113+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !droppedAck.Load() {
114+ t.Fatalf("got=%+v droppedAck=%v", got, droppedAck.Load())
115+ }
116+ if got, ok := recvMaybe(b, 3*defaultResend); ok {
117+ t.Fatalf("duplicate reliable frame delivered: %+v", got)
118+ }
119+}
120+
121+func TestReliableInputRetransmitsUntilAck(t *testing.T) {
122+ aPC, bPC := newPair()
123+ var dropped atomic.Bool
124+ aPC.drop = func(_ []byte, addr net.Addr) bool {
125+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
126+ }
127+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ defer func() {
136+ if err := a.Close(); err != nil {
137+ t.Errorf("close a: %v", err)
138+ }
139+ }()
140+ defer func() {
141+ if err := b.Close(); err != nil {
142+ t.Errorf("close b: %v", err)
143+ }
144+ }()
145+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
146+ t.Fatal(err)
147+ }
148+ got := recvWithin(t, b, time.Second)
149+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
150+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
151+ }
152+}
153+
154+func TestAuthenticatedOnlyRehome(t *testing.T) {
155+ aPC, bPC := newPair()
156+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ defer func() {
161+ if err := a.Close(); err != nil {
162+ t.Errorf("close a: %v", err)
163+ }
164+ }()
165+ _ = bPC
166+ old := a.Peer().String()
167+ aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
168+ time.Sleep(30 * time.Millisecond)
169+ if a.Peer().String() != old {
170+ t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
171+ }
172+ c, _ := pdgram.NewCodec(key())
173+ rec := encodeData(7, false, ports.Frame{Type: ports.MsgPing})
174+ frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
175+ raw, _ := pdgram.MarshalFragment(frags[0])
176+ aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
177+ time.Sleep(30 * time.Millisecond)
178+ if a.Peer().String() != "evil" {
179+ t.Fatalf("authenticated packet did not rehome: %v", a.Peer())
180+ }
181+}
182+
183+func TestOutputRetransmitsUntilAck(t *testing.T) {
184+ aPC, bPC := newPair()
185+ var dropped atomic.Bool
186+ aPC.drop = func(_ []byte, addr net.Addr) bool {
187+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
188+ }
189+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
190+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
191+ defer func() {
192+ if err := a.Close(); err != nil {
193+ t.Errorf("close a: %v", err)
194+ }
195+ }()
196+ defer func() {
197+ if err := b.Close(); err != nil {
198+ t.Errorf("close b: %v", err)
199+ }
200+ }()
201+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil {
202+ t.Fatal(err)
203+ }
204+ got := recvWithin(t, b, time.Second)
205+ if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
206+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
207+ }
208+}
209+
210+func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
211+ ch := make(chan ports.Frame, 1)
212+ go func() { f, _ := tr.Recv(); ch <- f }()
213+ select {
214+ case f := <-ch:
215+ return f, true
216+ case <-time.After(d):
217+ return ports.Frame{}, false
218+ }
219+}
220+
221+func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
222+ t.Helper()
223+ ch := make(chan ports.Frame, 1)
224+ go func() { f, _ := tr.Recv(); ch <- f }()
225+ select {
226+ case f := <-ch:
227+ return f
228+ case <-time.After(d):
229+ t.Fatal("timeout")
230+ return ports.Frame{}
231+ }
232+}
@@ -0,0 +1,232 @@
1+package dgram
2+
3+import (
4+ "bytes"
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 testAddr string
17+
18+func (a testAddr) Network() string { return "mem" }
19+func (a testAddr) String() string { return string(a) }
20+
21+type packet struct {
22+ b []byte
23+ addr net.Addr
24+}
25+type fakePC struct {
26+ addr net.Addr
27+ mu sync.Mutex
28+ closed bool
29+ in chan packet
30+ peers map[string]*fakePC
31+ drop func([]byte, net.Addr) bool
32+}
33+
34+func newPair() (*fakePC, *fakePC) {
35+ a := &fakePC{addr: testAddr("a"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
36+ b := &fakePC{addr: testAddr("b"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
37+ a.peers["b"] = b
38+ b.peers["a"] = a
39+ return a, b
40+}
41+func (p *fakePC) ReadFrom(b []byte) (int, net.Addr, error) {
42+ q, ok := <-p.in
43+ if !ok {
44+ return 0, nil, errors.New("closed")
45+ }
46+ copy(b, q.b)
47+ return len(q.b), q.addr, nil
48+}
49+func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
50+ p.mu.Lock()
51+ closed := p.closed
52+ p.mu.Unlock()
53+ if closed {
54+ return 0, errors.New("closed")
55+ }
56+ if p.drop != nil && p.drop(b, addr) {
57+ return len(b), nil
58+ }
59+ peer := p.peers[addr.String()]
60+ peer.mu.Lock()
61+ peerClosed := peer.closed
62+ peer.mu.Unlock()
63+ if peerClosed {
64+ return 0, errors.New("peer closed")
65+ }
66+ peer.in <- packet{append([]byte(nil), b...), p.addr}
67+ return len(b), nil
68+}
69+func (p *fakePC) Close() error {
70+ p.mu.Lock()
71+ defer p.mu.Unlock()
72+ if !p.closed {
73+ p.closed = true
74+ close(p.in)
75+ }
76+ return nil
77+}
78+func (p *fakePC) LocalAddr() net.Addr { return p.addr }
79+func (p *fakePC) SetDeadline(time.Time) error { return nil }
80+func (p *fakePC) SetReadDeadline(time.Time) error { return nil }
81+func (p *fakePC) SetWriteDeadline(time.Time) error { return nil }
82+
83+func key() []byte { return bytes.Repeat([]byte{1}, pdgram.KeySize) }
84+
85+func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
86+ aPC, bPC := newPair()
87+ var droppedAck atomic.Bool
88+ bPC.drop = func(_ []byte, addr net.Addr) bool {
89+ return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
90+ }
91+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
92+ if err != nil {
93+ t.Fatal(err)
94+ }
95+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
96+ if err != nil {
97+ t.Fatal(err)
98+ }
99+ defer func() {
100+ if err := a.Close(); err != nil {
101+ t.Errorf("close a: %v", err)
102+ }
103+ }()
104+ defer func() {
105+ if err := b.Close(); err != nil {
106+ t.Errorf("close b: %v", err)
107+ }
108+ }()
109+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
110+ t.Fatal(err)
111+ }
112+ got := recvWithin(t, b, time.Second)
113+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !droppedAck.Load() {
114+ t.Fatalf("got=%+v droppedAck=%v", got, droppedAck.Load())
115+ }
116+ if got, ok := recvMaybe(b, 3*defaultResend); ok {
117+ t.Fatalf("duplicate reliable frame delivered: %+v", got)
118+ }
119+}
120+
121+func TestReliableInputRetransmitsUntilAck(t *testing.T) {
122+ aPC, bPC := newPair()
123+ var dropped atomic.Bool
124+ aPC.drop = func(_ []byte, addr net.Addr) bool {
125+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
126+ }
127+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
128+ if err != nil {
129+ t.Fatal(err)
130+ }
131+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
132+ if err != nil {
133+ t.Fatal(err)
134+ }
135+ defer func() {
136+ if err := a.Close(); err != nil {
137+ t.Errorf("close a: %v", err)
138+ }
139+ }()
140+ defer func() {
141+ if err := b.Close(); err != nil {
142+ t.Errorf("close b: %v", err)
143+ }
144+ }()
145+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
146+ t.Fatal(err)
147+ }
148+ got := recvWithin(t, b, time.Second)
149+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
150+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
151+ }
152+}
153+
154+func TestAuthenticatedOnlyRehome(t *testing.T) {
155+ aPC, bPC := newPair()
156+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ defer func() {
161+ if err := a.Close(); err != nil {
162+ t.Errorf("close a: %v", err)
163+ }
164+ }()
165+ _ = bPC
166+ old := a.Peer().String()
167+ aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
168+ time.Sleep(30 * time.Millisecond)
169+ if a.Peer().String() != old {
170+ t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
171+ }
172+ c, _ := pdgram.NewCodec(key())
173+ rec := encodeData(7, false, ports.Frame{Type: ports.MsgPing})
174+ frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
175+ raw, _ := pdgram.MarshalFragment(frags[0])
176+ aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
177+ time.Sleep(30 * time.Millisecond)
178+ if a.Peer().String() != "evil" {
179+ t.Fatalf("authenticated packet did not rehome: %v", a.Peer())
180+ }
181+}
182+
183+func TestOutputRetransmitsUntilAck(t *testing.T) {
184+ aPC, bPC := newPair()
185+ var dropped atomic.Bool
186+ aPC.drop = func(_ []byte, addr net.Addr) bool {
187+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
188+ }
189+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
190+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
191+ defer func() {
192+ if err := a.Close(); err != nil {
193+ t.Errorf("close a: %v", err)
194+ }
195+ }()
196+ defer func() {
197+ if err := b.Close(); err != nil {
198+ t.Errorf("close b: %v", err)
199+ }
200+ }()
201+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil {
202+ t.Fatal(err)
203+ }
204+ got := recvWithin(t, b, time.Second)
205+ if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
206+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
207+ }
208+}
209+
210+func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
211+ ch := make(chan ports.Frame, 1)
212+ go func() { f, _ := tr.Recv(); ch <- f }()
213+ select {
214+ case f := <-ch:
215+ return f, true
216+ case <-time.After(d):
217+ return ports.Frame{}, false
218+ }
219+}
220+
221+func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
222+ t.Helper()
223+ ch := make(chan ports.Frame, 1)
224+ go func() { f, _ := tr.Recv(); ch <- f }()
225+ select {
226+ case f := <-ch:
227+ return f
228+ case <-time.After(d):
229+ t.Fatal("timeout")
230+ return ports.Frame{}
231+ }
232+}
M internal/app/remote_test.go +32 -1
@@ -1,6 +1,37 @@
11 package app
22
3-import "testing"
3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
10+ var b limitedBuffer
11+ chunk := strings.Repeat("x", maxBootstrapStderr+1024)
12+ n, err := b.Write([]byte(chunk))
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ if n != len(chunk) {
17+ t.Fatalf("n=%d, want %d", n, len(chunk))
18+ }
19+ if got := len(b.String()); got != maxBootstrapStderr {
20+ t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
21+ }
22+}
23+
24+func TestRemoteDatagramDialerReportsUDPAndStdioFailures(t *testing.T) {
25+ t.Setenv("PATH", t.TempDir())
26+ _, err := (remoteDatagramDialer{target: "example.invalid"}).Dial(context.Background())
27+ if err == nil {
28+ t.Fatal("expected dial error")
29+ }
30+ msg := err.Error()
31+ if !strings.Contains(msg, "datagram dial failed") || !strings.Contains(msg, "stdio fallback failed") {
32+ t.Fatalf("error %q does not include both failures", msg)
33+ }
34+}
435
536 func TestParseRemoteAttachTarget(t *testing.T) {
637 tests := []struct {
@@ -1,6 +1,37 @@
1 package app1 package app
2 2
3-import "testing"3+import (
4+ "context"
5+ "strings"
6+ "testing"
7+)
8+
9+func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
10+ var b limitedBuffer
11+ chunk := strings.Repeat("x", maxBootstrapStderr+1024)
12+ n, err := b.Write([]byte(chunk))
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ if n != len(chunk) {
17+ t.Fatalf("n=%d, want %d", n, len(chunk))
18+ }
19+ if got := len(b.String()); got != maxBootstrapStderr {
20+ t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
21+ }
22+}
23+
24+func TestRemoteDatagramDialerReportsUDPAndStdioFailures(t *testing.T) {
25+ t.Setenv("PATH", t.TempDir())
26+ _, err := (remoteDatagramDialer{target: "example.invalid"}).Dial(context.Background())
27+ if err == nil {
28+ t.Fatal("expected dial error")
29+ }
30+ msg := err.Error()
31+ if !strings.Contains(msg, "datagram dial failed") || !strings.Contains(msg, "stdio fallback failed") {
32+ t.Fatalf("error %q does not include both failures", msg)
33+ }
34+}
4 35
5 func TestParseRemoteAttachTarget(t *testing.T) {36 func TestParseRemoteAttachTarget(t *testing.T) {
6 tests := []struct {37 tests := []struct {
M internal/app/run.go +232 -13
@@ -5,20 +5,27 @@
55 package app
66
77 import (
8+ "bufio"
89 "context"
10+ "crypto/rand"
11+ "encoding/base64"
912 "errors"
1013 "fmt"
1114 "io"
1215 "log/slog"
16+ "net"
1317 "net/http"
1418 _ "net/http/pprof"
1519 "os"
20+ "os/exec"
1621 "os/signal"
22+ "strings"
1723 "syscall"
1824 "text/tabwriter"
1925 "time"
2026
2127 "github.com/bnema/vev/internal/adapters/clock"
28+ "github.com/bnema/vev/internal/adapters/dgram"
2229 "github.com/bnema/vev/internal/adapters/ipc"
2330 "github.com/bnema/vev/internal/adapters/pty"
2431 "github.com/bnema/vev/internal/adapters/sshstdio"
@@ -42,6 +49,7 @@ const (
4249 kindKill
4350 kindDaemon
4451 kindStdio
52+ kindUDPBootstrap
4553 kindHelp
4654 kindVersion
4755 )
@@ -116,6 +124,15 @@ func parseArgs(args []string) (command, error) {
116124 cmd.name = args[1]
117125 }
118126 return cmd, nil
127+ case "_udp-bootstrap":
128+ if len(args) > 2 {
129+ return command{}, usagef("`_udp-bootstrap` accepts at most one session name")
130+ }
131+ cmd := command{kind: kindUDPBootstrap}
132+ if len(args) == 2 {
133+ cmd.name = args[1]
134+ }
135+ return cmd, nil
119136 case "new":
120137 if len(args) < 2 || args[1] == "" {
121138 return command{}, usagef("`new` requires a session name")
@@ -185,6 +202,8 @@ func dispatch(ctx context.Context, cmd command) error {
185202 return runDaemon()
186203 case kindStdio:
187204 return runStdio(ctx)
205+ case kindUDPBootstrap:
206+ return runUDPBootstrap(ctx, cmd.name)
188207 case kindList:
189208 return runList(ctx)
190209 case kindKill:
@@ -252,13 +271,24 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
252271 defer func() { _ = logFile.Close() }()
253272
254273 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{
255- attachLocal: attachLocalDaemon,
274+ localDialer: defaultLocalDialer,
275+ remoteDialer: defaultRemoteDialer,
276+ runClient: client.Run,
256277 createDetached: createDetachedLocalSession,
257278 })
258279 }
259280
281+func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
282+
283+func defaultRemoteDialer(target, session string) ports.Dialer {
284+ return remoteDatagramDialer{target: target, session: session}
285+}
286+
260287 type runAttachDeps struct {
261- attachLocal func(context.Context, uint8, string) error
288+ attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests
289+ localDialer func() ports.Dialer
290+ remoteDialer func(target, session string) ports.Dialer
291+ runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string) error
262292 createDetached func(context.Context, string) error
263293 }
264294
@@ -270,18 +300,31 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
270300 return errors.New("vev: sessions should be nested with care; unset VEV to force")
271301 }
272302
303+ runClient := deps.runClient
304+ if runClient == nil {
305+ runClient = client.Run
306+ }
273307 if remoteTarget != "" {
274- transport, err := sshstdio.Dial(remoteTarget, name)
275- if err != nil {
276- return err
308+ remoteDialer := deps.remoteDialer
309+ if remoteDialer == nil {
310+ remoteDialer = defaultRemoteDialer
277311 }
278- // client.Attach owns and closes transport.
279- return client.Attach(ctx, transport, term.New(), intent, name)
312+ return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), intent, name)
280313 }
281314
315+ attachLocal := deps.attachLocal
316+ if attachLocal == nil {
317+ localDialer := deps.localDialer
318+ if localDialer == nil {
319+ localDialer = defaultLocalDialer
320+ }
321+ attachLocal = func(ctx context.Context, intent uint8, name string) error {
322+ return runClient(ctx, localDialer(), term.New(), intent, name)
323+ }
324+ }
282325 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
283326 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
284- attach: deps.attachLocal,
327+ attach: attachLocal,
285328 killDaemon: requestDaemonStop,
286329 settleAfterKill: waitForDaemonStop,
287330 })
@@ -332,13 +375,122 @@ func isDaemonVersionDrift(err error) bool {
332375 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
333376 }
334377
335-func attachLocalDaemon(ctx context.Context, intent uint8, name string) error {
336- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
378+type localDaemonDialer struct{ dir string }
379+
380+func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
381+ return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
382+}
383+
384+const maxBootstrapStderr = 64 * 1024
385+
386+type limitedBuffer struct {
387+ buf []byte
388+}
389+
390+func (b *limitedBuffer) Write(p []byte) (int, error) {
391+ if len(b.buf) < maxBootstrapStderr {
392+ keep := min(len(p), maxBootstrapStderr-len(b.buf))
393+ b.buf = append(b.buf, p[:keep]...)
394+ }
395+ return len(p), nil
396+}
397+
398+func (b *limitedBuffer) String() string { return string(b.buf) }
399+
400+type remoteDatagramDialer struct {
401+ target string
402+ session string
403+}
404+
405+func (d remoteDatagramDialer) Dial(ctx context.Context) (ports.Transport, error) {
406+ tr, udpErr := d.dialDatagram(ctx)
407+ if udpErr == nil {
408+ return tr, nil
409+ }
410+ tr, stdioErr := sshstdio.Dial(d.target, d.session)
411+ if stdioErr != nil {
412+ return nil, fmt.Errorf("datagram dial failed: %w; stdio fallback failed: %w", udpErr, stdioErr)
413+ }
414+ return tr, nil
415+}
416+
417+func (d remoteDatagramDialer) dialDatagram(ctx context.Context) (ports.Transport, error) {
418+ remote := []string{shellQuote("vev"), shellQuote("_udp-bootstrap")}
419+ if d.session != "" {
420+ remote = append(remote, shellQuote(d.session))
421+ }
422+ cmd := exec.CommandContext(ctx, "ssh", "--", d.target, strings.Join(remote, " "))
423+ stdout, err := cmd.StdoutPipe()
337424 if err != nil {
338- return err
425+ return nil, err
426+ }
427+ var stderr limitedBuffer
428+ cmd.Stderr = &stderr
429+ if err := cmd.Start(); err != nil {
430+ return nil, err
431+ }
432+ started := true
433+ defer func() {
434+ if started {
435+ _ = cmd.Process.Kill()
436+ _ = cmd.Wait()
437+ }
438+ }()
439+
440+ line, err := bufio.NewReader(stdout).ReadString('\n')
441+ if err != nil {
442+ return nil, fmt.Errorf("udp bootstrap: %w: %s", err, strings.TrimSpace(stderr.String()))
443+ }
444+ fields := strings.Fields(strings.TrimSpace(line))
445+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
446+ return nil, fmt.Errorf("udp bootstrap: unexpected reply %q", strings.TrimSpace(line))
447+ }
448+ key, err := base64.StdEncoding.DecodeString(fields[2])
449+ if err != nil {
450+ return nil, err
451+ }
452+ host := sshTargetHost(d.target)
453+ peer, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, fields[1]))
454+ if err != nil {
455+ return nil, err
456+ }
457+ var lc net.ListenConfig
458+ pc, err := lc.ListenPacket(ctx, "udp", ":0")
459+ if err != nil {
460+ return nil, err
339461 }
340- // client.Attach owns and closes transport.
341- return client.Attach(ctx, transport, term.New(), intent, name)
462+ tr, err := dgram.NewTransport(pc, peer, key, 1, 2)
463+ if err != nil {
464+ _ = pc.Close()
465+ return nil, err
466+ }
467+ started = false
468+ return closeBothTransport{Transport: tr, close: func() error {
469+ _ = tr.Close()
470+ _ = cmd.Process.Kill()
471+ return cmd.Wait()
472+ }}, nil
473+}
474+
475+func sshTargetHost(target string) string {
476+ if at := strings.LastIndexByte(target, '@'); at >= 0 {
477+ target = target[at+1:]
478+ }
479+ if h, _, err := net.SplitHostPort(target); err == nil {
480+ return h
481+ }
482+ return target
483+}
484+
485+type closeBothTransport struct {
486+ ports.Transport
487+ close func() error
488+}
489+
490+func (t closeBothTransport) Close() error { return t.close() }
491+
492+func shellQuote(s string) string {
493+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
342494 }
343495
344496 func createDetachedLocalSession(ctx context.Context, name string) error {
@@ -462,6 +614,73 @@ func runStdio(ctx context.Context) error {
462614 return proxyTransports(ctx, stdio, transport)
463615 }
464616
617+// runUDPBootstrap is the hidden remote-side bootstrap used before switching to
618+// authenticated datagrams. It deliberately prints only an ephemeral UDP port and
619+// random session key to stdout so callers can fall back to _stdio if setup fails.
620+func runUDPBootstrap(ctx context.Context, session string) error {
621+ select {
622+ case <-ctx.Done():
623+ return ctx.Err()
624+ default:
625+ }
626+ var lc net.ListenConfig
627+ conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0")
628+ if err != nil {
629+ return err
630+ }
631+ defer func() { _ = conn.Close() }()
632+ key := make([]byte, 32)
633+ if _, err := rand.Read(key); err != nil {
634+ return err
635+ }
636+ daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
637+ if err != nil {
638+ return err
639+ }
640+ defer func() { _ = daemonTr.Close() }()
641+ dg, err := dgram.NewTransport(conn, nil, key, 2, 1)
642+ if err != nil {
643+ return err
644+ }
645+ defer func() { _ = dg.Close() }()
646+
647+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
648+ if !ok {
649+ return errors.New("vev: udp bootstrap did not get a UDP address")
650+ }
651+ if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {
652+ return err
653+ }
654+ _ = os.Stdout.Sync()
655+
656+ var dgramTr ports.Transport = dg
657+ if session != "" {
658+ dgramTr = preHelloNameTransport{Transport: dg, session: session}
659+ }
660+ return proxyTransports(ctx, dgramTr, daemonTr)
661+}
662+
663+type preHelloNameTransport struct {
664+ ports.Transport
665+ session string
666+}
667+
668+func (t preHelloNameTransport) Recv() (ports.Frame, error) {
669+ f, err := t.Transport.Recv()
670+ if err != nil || f.Type != ports.MsgHello || t.session == "" {
671+ return f, err
672+ }
673+ h, err := ports.UnmarshalHello(f.Payload)
674+ if err != nil {
675+ return f, nil
676+ }
677+ if h.Name == "" {
678+ h.Name = t.session
679+ f.Payload = ports.MarshalHello(h)
680+ }
681+ return f, nil
682+}
683+
465684 func proxyTransports(ctx context.Context, a, b ports.Transport) error {
466685 errCh := make(chan error, 2)
467686 copyFrames := func(dst, src ports.Transport) {
@@ -5,20 +5,27 @@
5 package app5 package app
6 6
7 import (7 import (
8+ "bufio"
8 "context"9 "context"
10+ "crypto/rand"
11+ "encoding/base64"
9 "errors"12 "errors"
10 "fmt"13 "fmt"
11 "io"14 "io"
12 "log/slog"15 "log/slog"
16+ "net"
13 "net/http"17 "net/http"
14 _ "net/http/pprof"18 _ "net/http/pprof"
15 "os"19 "os"
20+ "os/exec"
16 "os/signal"21 "os/signal"
22+ "strings"
17 "syscall"23 "syscall"
18 "text/tabwriter"24 "text/tabwriter"
19 "time"25 "time"
20 26
21 "github.com/bnema/vev/internal/adapters/clock"27 "github.com/bnema/vev/internal/adapters/clock"
28+ "github.com/bnema/vev/internal/adapters/dgram"
22 "github.com/bnema/vev/internal/adapters/ipc"29 "github.com/bnema/vev/internal/adapters/ipc"
23 "github.com/bnema/vev/internal/adapters/pty"30 "github.com/bnema/vev/internal/adapters/pty"
24 "github.com/bnema/vev/internal/adapters/sshstdio"31 "github.com/bnema/vev/internal/adapters/sshstdio"
@@ -42,6 +49,7 @@ const (
42 kindKill49 kindKill
43 kindDaemon50 kindDaemon
44 kindStdio51 kindStdio
52+ kindUDPBootstrap
45 kindHelp53 kindHelp
46 kindVersion54 kindVersion
47 )55 )
@@ -116,6 +124,15 @@ func parseArgs(args []string) (command, error) {
116 cmd.name = args[1]124 cmd.name = args[1]
117 }125 }
118 return cmd, nil126 return cmd, nil
127+ case "_udp-bootstrap":
128+ if len(args) > 2 {
129+ return command{}, usagef("`_udp-bootstrap` accepts at most one session name")
130+ }
131+ cmd := command{kind: kindUDPBootstrap}
132+ if len(args) == 2 {
133+ cmd.name = args[1]
134+ }
135+ return cmd, nil
119 case "new":136 case "new":
120 if len(args) < 2 || args[1] == "" {137 if len(args) < 2 || args[1] == "" {
121 return command{}, usagef("`new` requires a session name")138 return command{}, usagef("`new` requires a session name")
@@ -185,6 +202,8 @@ func dispatch(ctx context.Context, cmd command) error {
185 return runDaemon()202 return runDaemon()
186 case kindStdio:203 case kindStdio:
187 return runStdio(ctx)204 return runStdio(ctx)
205+ case kindUDPBootstrap:
206+ return runUDPBootstrap(ctx, cmd.name)
188 case kindList:207 case kindList:
189 return runList(ctx)208 return runList(ctx)
190 case kindKill:209 case kindKill:
@@ -252,13 +271,24 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
252 defer func() { _ = logFile.Close() }()271 defer func() { _ = logFile.Close() }()
253 272
254 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{273 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{
255- attachLocal: attachLocalDaemon,274+ localDialer: defaultLocalDialer,
275+ remoteDialer: defaultRemoteDialer,
276+ runClient: client.Run,
256 createDetached: createDetachedLocalSession,277 createDetached: createDetachedLocalSession,
257 })278 })
258 }279 }
259 280
281+func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
282+
283+func defaultRemoteDialer(target, session string) ports.Dialer {
284+ return remoteDatagramDialer{target: target, session: session}
285+}
286+
260 type runAttachDeps struct {287 type runAttachDeps struct {
261- attachLocal func(context.Context, uint8, string) error288+ attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests
289+ localDialer func() ports.Dialer
290+ remoteDialer func(target, session string) ports.Dialer
291+ runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string) error
262 createDetached func(context.Context, string) error292 createDetached func(context.Context, string) error
263 }293 }
264 294
@@ -270,18 +300,31 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
270 return errors.New("vev: sessions should be nested with care; unset VEV to force")300 return errors.New("vev: sessions should be nested with care; unset VEV to force")
271 }301 }
272 302
303+ runClient := deps.runClient
304+ if runClient == nil {
305+ runClient = client.Run
306+ }
273 if remoteTarget != "" {307 if remoteTarget != "" {
274- transport, err := sshstdio.Dial(remoteTarget, name)308+ remoteDialer := deps.remoteDialer
275- if err != nil {309+ if remoteDialer == nil {
276- return err310+ remoteDialer = defaultRemoteDialer
277 }311 }
278- // client.Attach owns and closes transport.312+ return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), intent, name)
279- return client.Attach(ctx, transport, term.New(), intent, name)
280 }313 }
281 314
315+ attachLocal := deps.attachLocal
316+ if attachLocal == nil {
317+ localDialer := deps.localDialer
318+ if localDialer == nil {
319+ localDialer = defaultLocalDialer
320+ }
321+ attachLocal = func(ctx context.Context, intent uint8, name string) error {
322+ return runClient(ctx, localDialer(), term.New(), intent, name)
323+ }
324+ }
282 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{325 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
283 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),326 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
284- attach: deps.attachLocal,327+ attach: attachLocal,
285 killDaemon: requestDaemonStop,328 killDaemon: requestDaemonStop,
286 settleAfterKill: waitForDaemonStop,329 settleAfterKill: waitForDaemonStop,
287 })330 })
@@ -332,13 +375,122 @@ func isDaemonVersionDrift(err error) bool {
332 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal375 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
333 }376 }
334 377
335-func attachLocalDaemon(ctx context.Context, intent uint8, name string) error {378+type localDaemonDialer struct{ dir string }
336- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)379+
380+func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
381+ return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
382+}
383+
384+const maxBootstrapStderr = 64 * 1024
385+
386+type limitedBuffer struct {
387+ buf []byte
388+}
389+
390+func (b *limitedBuffer) Write(p []byte) (int, error) {
391+ if len(b.buf) < maxBootstrapStderr {
392+ keep := min(len(p), maxBootstrapStderr-len(b.buf))
393+ b.buf = append(b.buf, p[:keep]...)
394+ }
395+ return len(p), nil
396+}
397+
398+func (b *limitedBuffer) String() string { return string(b.buf) }
399+
400+type remoteDatagramDialer struct {
401+ target string
402+ session string
403+}
404+
405+func (d remoteDatagramDialer) Dial(ctx context.Context) (ports.Transport, error) {
406+ tr, udpErr := d.dialDatagram(ctx)
407+ if udpErr == nil {
408+ return tr, nil
409+ }
410+ tr, stdioErr := sshstdio.Dial(d.target, d.session)
411+ if stdioErr != nil {
412+ return nil, fmt.Errorf("datagram dial failed: %w; stdio fallback failed: %w", udpErr, stdioErr)
413+ }
414+ return tr, nil
415+}
416+
417+func (d remoteDatagramDialer) dialDatagram(ctx context.Context) (ports.Transport, error) {
418+ remote := []string{shellQuote("vev"), shellQuote("_udp-bootstrap")}
419+ if d.session != "" {
420+ remote = append(remote, shellQuote(d.session))
421+ }
422+ cmd := exec.CommandContext(ctx, "ssh", "--", d.target, strings.Join(remote, " "))
423+ stdout, err := cmd.StdoutPipe()
337 if err != nil {424 if err != nil {
338- return err425+ return nil, err
426+ }
427+ var stderr limitedBuffer
428+ cmd.Stderr = &stderr
429+ if err := cmd.Start(); err != nil {
430+ return nil, err
431+ }
432+ started := true
433+ defer func() {
434+ if started {
435+ _ = cmd.Process.Kill()
436+ _ = cmd.Wait()
437+ }
438+ }()
439+
440+ line, err := bufio.NewReader(stdout).ReadString('\n')
441+ if err != nil {
442+ return nil, fmt.Errorf("udp bootstrap: %w: %s", err, strings.TrimSpace(stderr.String()))
443+ }
444+ fields := strings.Fields(strings.TrimSpace(line))
445+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
446+ return nil, fmt.Errorf("udp bootstrap: unexpected reply %q", strings.TrimSpace(line))
447+ }
448+ key, err := base64.StdEncoding.DecodeString(fields[2])
449+ if err != nil {
450+ return nil, err
451+ }
452+ host := sshTargetHost(d.target)
453+ peer, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, fields[1]))
454+ if err != nil {
455+ return nil, err
456+ }
457+ var lc net.ListenConfig
458+ pc, err := lc.ListenPacket(ctx, "udp", ":0")
459+ if err != nil {
460+ return nil, err
339 }461 }
340- // client.Attach owns and closes transport.462+ tr, err := dgram.NewTransport(pc, peer, key, 1, 2)
341- return client.Attach(ctx, transport, term.New(), intent, name)463+ if err != nil {
464+ _ = pc.Close()
465+ return nil, err
466+ }
467+ started = false
468+ return closeBothTransport{Transport: tr, close: func() error {
469+ _ = tr.Close()
470+ _ = cmd.Process.Kill()
471+ return cmd.Wait()
472+ }}, nil
473+}
474+
475+func sshTargetHost(target string) string {
476+ if at := strings.LastIndexByte(target, '@'); at >= 0 {
477+ target = target[at+1:]
478+ }
479+ if h, _, err := net.SplitHostPort(target); err == nil {
480+ return h
481+ }
482+ return target
483+}
484+
485+type closeBothTransport struct {
486+ ports.Transport
487+ close func() error
488+}
489+
490+func (t closeBothTransport) Close() error { return t.close() }
491+
492+func shellQuote(s string) string {
493+ return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
342 }494 }
343 495
344 func createDetachedLocalSession(ctx context.Context, name string) error {496 func createDetachedLocalSession(ctx context.Context, name string) error {
@@ -462,6 +614,73 @@ func runStdio(ctx context.Context) error {
462 return proxyTransports(ctx, stdio, transport)614 return proxyTransports(ctx, stdio, transport)
463 }615 }
464 616
617+// runUDPBootstrap is the hidden remote-side bootstrap used before switching to
618+// authenticated datagrams. It deliberately prints only an ephemeral UDP port and
619+// random session key to stdout so callers can fall back to _stdio if setup fails.
620+func runUDPBootstrap(ctx context.Context, session string) error {
621+ select {
622+ case <-ctx.Done():
623+ return ctx.Err()
624+ default:
625+ }
626+ var lc net.ListenConfig
627+ conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0")
628+ if err != nil {
629+ return err
630+ }
631+ defer func() { _ = conn.Close() }()
632+ key := make([]byte, 32)
633+ if _, err := rand.Read(key); err != nil {
634+ return err
635+ }
636+ daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
637+ if err != nil {
638+ return err
639+ }
640+ defer func() { _ = daemonTr.Close() }()
641+ dg, err := dgram.NewTransport(conn, nil, key, 2, 1)
642+ if err != nil {
643+ return err
644+ }
645+ defer func() { _ = dg.Close() }()
646+
647+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
648+ if !ok {
649+ return errors.New("vev: udp bootstrap did not get a UDP address")
650+ }
651+ if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {
652+ return err
653+ }
654+ _ = os.Stdout.Sync()
655+
656+ var dgramTr ports.Transport = dg
657+ if session != "" {
658+ dgramTr = preHelloNameTransport{Transport: dg, session: session}
659+ }
660+ return proxyTransports(ctx, dgramTr, daemonTr)
661+}
662+
663+type preHelloNameTransport struct {
664+ ports.Transport
665+ session string
666+}
667+
668+func (t preHelloNameTransport) Recv() (ports.Frame, error) {
669+ f, err := t.Transport.Recv()
670+ if err != nil || f.Type != ports.MsgHello || t.session == "" {
671+ return f, err
672+ }
673+ h, err := ports.UnmarshalHello(f.Payload)
674+ if err != nil {
675+ return f, nil
676+ }
677+ if h.Name == "" {
678+ h.Name = t.session
679+ f.Payload = ports.MarshalHello(h)
680+ }
681+ return f, nil
682+}
683+
465 func proxyTransports(ctx context.Context, a, b ports.Transport) error {684 func proxyTransports(ctx context.Context, a, b ports.Transport) error {
466 errCh := make(chan error, 2)685 errCh := make(chan error, 2)
467 copyFrames := func(dst, src ports.Transport) {686 copyFrames := func(dst, src ports.Transport) {
M internal/app/run_test.go +82 -0
@@ -386,3 +386,85 @@ func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
386386 t.Fatalf("detached name = %q, want scratch", gotName)
387387 }
388388 }
389+
390+type namedDialer struct{ name string }
391+
392+func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
393+ return nil, errors.New("not used")
394+}
395+
396+func TestSSHTargetHost(t *testing.T) {
397+ tests := []struct {
398+ target string
399+ want string
400+ }{
401+ {target: "example.com", want: "example.com"},
402+ {target: "user@example.com", want: "example.com"},
403+ {target: "example.com:2222", want: "example.com"},
404+ {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
405+ }
406+ for _, tt := range tests {
407+ t.Run(tt.target, func(t *testing.T) {
408+ if got := sshTargetHost(tt.target); got != tt.want {
409+ t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
410+ }
411+ })
412+ }
413+}
414+
415+func TestShellQuote(t *testing.T) {
416+ tests := map[string]string{
417+ "vev": "'vev'",
418+ "has space": "'has space'",
419+ "can't stop": "'can'\\''t stop'",
420+ "": "''",
421+ }
422+ for in, want := range tests {
423+ if got := shellQuote(in); got != want {
424+ t.Fatalf("shellQuote(%q) = %q, want %q", in, got, want)
425+ }
426+ }
427+}
428+
429+func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
430+ var gotTarget, gotSession, gotDialer string
431+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{
432+ remoteDialer: func(target, session string) ports.Dialer {
433+ gotTarget, gotSession = target, session
434+ return namedDialer{name: "remote"}
435+ },
436+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {
437+ gotDialer = d.(namedDialer).name
438+ if intent != ports.IntentAttach || name != "work" {
439+ t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
440+ }
441+ return nil
442+ },
443+ })
444+ if err != nil {
445+ t.Fatalf("runAttachWithDeps returned error: %v", err)
446+ }
447+ if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {
448+ t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)
449+ }
450+}
451+
452+func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
453+ var gotDialer string
454+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{
455+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
456+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {
457+ gotDialer = d.(namedDialer).name
458+ if intent != ports.IntentEphemeral || name != "" {
459+ t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
460+ }
461+ return nil
462+ },
463+ })
464+ if err != nil {
465+ t.Fatalf("runAttachWithDeps returned error: %v", err)
466+ }
467+ if gotDialer != "local" {
468+ t.Fatalf("local dialer = %q, want local", gotDialer)
469+ }
470+}
@@ -386,3 +386,85 @@ func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) {
386 t.Fatalf("detached name = %q, want scratch", gotName)386 t.Fatalf("detached name = %q, want scratch", gotName)
387 }387 }
388 }388 }
389+
390+type namedDialer struct{ name string }
391+
392+func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
393+ return nil, errors.New("not used")
394+}
395+
396+func TestSSHTargetHost(t *testing.T) {
397+ tests := []struct {
398+ target string
399+ want string
400+ }{
401+ {target: "example.com", want: "example.com"},
402+ {target: "user@example.com", want: "example.com"},
403+ {target: "example.com:2222", want: "example.com"},
404+ {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
405+ }
406+ for _, tt := range tests {
407+ t.Run(tt.target, func(t *testing.T) {
408+ if got := sshTargetHost(tt.target); got != tt.want {
409+ t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
410+ }
411+ })
412+ }
413+}
414+
415+func TestShellQuote(t *testing.T) {
416+ tests := map[string]string{
417+ "vev": "'vev'",
418+ "has space": "'has space'",
419+ "can't stop": "'can'\\''t stop'",
420+ "": "''",
421+ }
422+ for in, want := range tests {
423+ if got := shellQuote(in); got != want {
424+ t.Fatalf("shellQuote(%q) = %q, want %q", in, got, want)
425+ }
426+ }
427+}
428+
429+func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
430+ var gotTarget, gotSession, gotDialer string
431+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{
432+ remoteDialer: func(target, session string) ports.Dialer {
433+ gotTarget, gotSession = target, session
434+ return namedDialer{name: "remote"}
435+ },
436+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {
437+ gotDialer = d.(namedDialer).name
438+ if intent != ports.IntentAttach || name != "work" {
439+ t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
440+ }
441+ return nil
442+ },
443+ })
444+ if err != nil {
445+ t.Fatalf("runAttachWithDeps returned error: %v", err)
446+ }
447+ if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {
448+ t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)
449+ }
450+}
451+
452+func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
453+ var gotDialer string
454+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{
455+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
456+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {
457+ gotDialer = d.(namedDialer).name
458+ if intent != ports.IntentEphemeral || name != "" {
459+ t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
460+ }
461+ return nil
462+ },
463+ })
464+ if err != nil {
465+ t.Fatalf("runAttachWithDeps returned error: %v", err)
466+ }
467+ if gotDialer != "local" {
468+ t.Fatalf("local dialer = %q, want local", gotDialer)
469+ }
470+}
M internal/ports/frame.go +2 -1
@@ -1,7 +1,7 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 4
4+const ProtocolVersion uint16 = 5
55
66 // MsgType identifies the kind of payload carried by a Frame.
77 type MsgType uint8
@@ -18,6 +18,7 @@ const (
1818 MsgList MsgType = 6
1919 MsgKill MsgType = 7
2020 MsgTheme MsgType = 8
21+ MsgAck MsgType = 9
2122
2223 MsgWelcome MsgType = 16
2324 MsgError MsgType = 17
@@ -1,7 +1,7 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 44+const ProtocolVersion uint16 = 5
5 5
6 // MsgType identifies the kind of payload carried by a Frame.6 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint87 type MsgType uint8
@@ -18,6 +18,7 @@ const (
18 MsgList MsgType = 618 MsgList MsgType = 6
19 MsgKill MsgType = 719 MsgKill MsgType = 7
20 MsgTheme MsgType = 820 MsgTheme MsgType = 8
21+ MsgAck MsgType = 9
21 22
22 MsgWelcome MsgType = 1623 MsgWelcome MsgType = 16
23 MsgError MsgType = 1724 MsgError MsgType = 17
M internal/ports/frame_test.go +3 -2
@@ -3,8 +3,8 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 4 {
7- t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion)
6+ if ProtocolVersion != 5 {
7+ t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion)
88 }
99 }
1010
@@ -21,6 +21,7 @@ func TestMsgTypeUnique(t *testing.T) {
2121 {"MsgList", MsgList},
2222 {"MsgKill", MsgKill},
2323 {"MsgTheme", MsgTheme},
24+ {"MsgAck", MsgAck},
2425 {"MsgWelcome", MsgWelcome},
2526 {"MsgError", MsgError},
2627 {"MsgOutput", MsgOutput},
@@ -3,8 +3,8 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 4 {6+ if ProtocolVersion != 5 {
7- t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion)
8 }8 }
9 }9 }
10 10
@@ -21,6 +21,7 @@ func TestMsgTypeUnique(t *testing.T) {
21 {"MsgList", MsgList},21 {"MsgList", MsgList},
22 {"MsgKill", MsgKill},22 {"MsgKill", MsgKill},
23 {"MsgTheme", MsgTheme},23 {"MsgTheme", MsgTheme},
24+ {"MsgAck", MsgAck},
24 {"MsgWelcome", MsgWelcome},25 {"MsgWelcome", MsgWelcome},
25 {"MsgError", MsgError},26 {"MsgError", MsgError},
26 {"MsgOutput", MsgOutput},27 {"MsgOutput", MsgOutput},
M internal/ports/ports.go +6 -0
@@ -1,6 +1,7 @@
11 package ports
22
33 import (
4+ "context"
45 "io"
56 "time"
67
@@ -37,6 +38,11 @@ type Transport interface {
3738 Close() error
3839 }
3940
41+// Dialer establishes outbound Transport connections.
42+type Dialer interface {
43+ Dial(ctx context.Context) (Transport, error)
44+}
45+
4046 // Listener accepts incoming Transport connections.
4147 type Listener interface {
4248 Accept() (Transport, error)
@@ -1,6 +1,7 @@
1 package ports1 package ports
2 2
3 import (3 import (
4+ "context"
4 "io"5 "io"
5 "time"6 "time"
6 7
@@ -37,6 +38,11 @@ type Transport interface {
37 Close() error38 Close() error
38 }39 }
39 40
41+// Dialer establishes outbound Transport connections.
42+type Dialer interface {
43+ Dial(ctx context.Context) (Transport, error)
44+}
45+
40 // Listener accepts incoming Transport connections.46 // Listener accepts incoming Transport connections.
41 type Listener interface {47 type Listener interface {
42 Accept() (Transport, error)48 Accept() (Transport, error)
M internal/ports/wire.go +145 -19
@@ -28,6 +28,14 @@ const (
2828 IntentEphemeral uint8 = 0
2929 IntentNew uint8 = 1
3030 IntentAttach uint8 = 2
31+ IntentResume uint8 = 3
32+)
33+
34+// Capability bits advertised in Welcome.
35+const (
36+ CapabilityResume uint32 = 1 << 0
37+ CapabilityUDP uint32 = 1 << 1
38+ CapabilityPredict uint32 = 1 << 2
3139 )
3240
3341 // ErrorMsg codes.
@@ -48,17 +56,20 @@ const (
4856
4957 // Hello is sent by the client immediately after connecting.
5058 type Hello struct {
51- Version uint16
52- Intent uint8
53- Name string
54- Size domain.Size
55- TermEnv string
56- Cwd string
59+ Version uint16
60+ Intent uint8
61+ ClientID [16]byte
62+ ResumeToken uint64
63+ Name string
64+ Size domain.Size
65+ TermEnv string
66+ Cwd string
5767 }
5868
5969 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
6070 type Input struct {
61- Data []byte
71+ InputSeq uint64
72+ Data []byte
6273 }
6374
6475 // Resize notifies the daemon of a client-side terminal size change.
@@ -86,11 +97,18 @@ type Ping struct{}
8697 // Pong answers a Ping.
8798 type Pong struct{}
8899
100+// Ack acknowledges receipt/application of an output state number.
101+type Ack struct {
102+ AckedStateNum uint64
103+}
104+
89105 // Welcome is the daemon's reply to a successful Hello.
90106 type Welcome struct {
91- SessionID string
92- SessionName string
93- Ephemeral bool
107+ SessionID string
108+ SessionName string
109+ Ephemeral bool
110+ ResumeToken uint64
111+ Capabilities uint32
94112 }
95113
96114 // ErrorMsg reports a protocol- or session-level failure to the client.
@@ -101,7 +119,10 @@ type ErrorMsg struct {
101119
102120 // Output carries raw PTY output, destined for the client's terminal.
103121 type Output struct {
104- Data []byte
122+ BaseStateNum uint64
123+ NewStateNum uint64
124+ EchoAck uint64
125+ Data []byte
105126 }
106127
107128 // Detached tells a client it has been disconnected from its session and why.
@@ -149,6 +170,22 @@ func (w *payloadWriter) putUint16(v uint16) {
149170 w.b = append(w.b, tmp[:]...)
150171 }
151172
173+func (w *payloadWriter) putUint32(v uint32) {
174+ var tmp [4]byte
175+ binary.BigEndian.PutUint32(tmp[:], v)
176+ w.b = append(w.b, tmp[:]...)
177+}
178+
179+func (w *payloadWriter) putUint64(v uint64) {
180+ var tmp [8]byte
181+ binary.BigEndian.PutUint64(tmp[:], v)
182+ w.b = append(w.b, tmp[:]...)
183+}
184+
185+func (w *payloadWriter) putBytes(b []byte) {
186+ w.b = append(w.b, b...)
187+}
188+
152189 func (w *payloadWriter) putString(s string) {
153190 w.putUint16(uint16(len(s)))
154191 w.b = append(w.b, s...)
@@ -178,6 +215,33 @@ func (r *payloadReader) getUint16() (uint16, error) {
178215 return v, nil
179216 }
180217
218+func (r *payloadReader) getUint32() (uint32, error) {
219+ if len(r.b) < 4 {
220+ return 0, errShortPayload
221+ }
222+ v := binary.BigEndian.Uint32(r.b)
223+ r.b = r.b[4:]
224+ return v, nil
225+}
226+
227+func (r *payloadReader) getUint64() (uint64, error) {
228+ if len(r.b) < 8 {
229+ return 0, errShortPayload
230+ }
231+ v := binary.BigEndian.Uint64(r.b)
232+ r.b = r.b[8:]
233+ return v, nil
234+}
235+
236+func (r *payloadReader) getBytes(n int) ([]byte, error) {
237+ if len(r.b) < n {
238+ return nil, errShortPayload
239+ }
240+ b := append([]byte(nil), r.b[:n]...)
241+ r.b = r.b[n:]
242+ return b, nil
243+}
244+
181245 func (r *payloadReader) getString() (string, error) {
182246 n, err := r.getUint16()
183247 if err != nil {
@@ -220,6 +284,8 @@ func MarshalHello(h Hello) []byte {
220284 w := payloadWriter{}
221285 w.putUint16(h.Version)
222286 w.putUint8(h.Intent)
287+ w.putBytes(h.ClientID[:])
288+ w.putUint64(h.ResumeToken)
223289 w.putString(h.Name)
224290 w.putUint16(uint16(h.Size.Cols))
225291 w.putUint16(uint16(h.Size.Rows))
@@ -240,6 +306,14 @@ func UnmarshalHello(b []byte) (Hello, error) {
240306 if h.Intent, err = r.getUint8(); err != nil {
241307 return Hello{}, err
242308 }
309+ clientID, err := r.getBytes(len(h.ClientID))
310+ if err != nil {
311+ return Hello{}, err
312+ }
313+ copy(h.ClientID[:], clientID)
314+ if h.ResumeToken, err = r.getUint64(); err != nil {
315+ return Hello{}, err
316+ }
243317 if h.Name, err = r.getString(); err != nil {
244318 return Hello{}, err
245319 }
@@ -266,14 +340,21 @@ func UnmarshalHello(b []byte) (Hello, error) {
266340
267341 // MarshalInput encodes m into an Input message payload.
268342 func MarshalInput(m Input) []byte {
269- return append([]byte(nil), m.Data...)
343+ w := payloadWriter{}
344+ w.putUint64(m.InputSeq)
345+ w.putBytes(m.Data)
346+ return w.b
270347 }
271348
272-// UnmarshalInput decodes an Input message payload. The whole payload is the
273-// data; there is no separate length prefix.
349+// UnmarshalInput decodes an Input message payload. After the fixed input
350+// sequence, the rest of the payload is data; there is no length prefix.
274351 func UnmarshalInput(b []byte) (Input, error) {
275352 r := payloadReader{b: b}
276- return Input{Data: r.rest()}, nil
353+ seq, err := r.getUint64()
354+ if err != nil {
355+ return Input{}, err
356+ }
357+ return Input{InputSeq: seq, Data: r.rest()}, nil
277358 }
278359
279360 // MarshalResize encodes m into a Resize message payload.
@@ -405,6 +486,26 @@ func UnmarshalPong(b []byte) (Pong, error) {
405486 return Pong{}, nil
406487 }
407488
489+// MarshalAck encodes m into an Ack message payload.
490+func MarshalAck(m Ack) []byte {
491+ w := payloadWriter{}
492+ w.putUint64(m.AckedStateNum)
493+ return w.b
494+}
495+
496+// UnmarshalAck decodes an Ack message payload.
497+func UnmarshalAck(b []byte) (Ack, error) {
498+ r := payloadReader{b: b}
499+ acked, err := r.getUint64()
500+ if err != nil {
501+ return Ack{}, err
502+ }
503+ if err := r.done(); err != nil {
504+ return Ack{}, err
505+ }
506+ return Ack{AckedStateNum: acked}, nil
507+}
508+
408509 // MarshalWelcome encodes m into a Welcome message payload.
409510 func MarshalWelcome(m Welcome) []byte {
410511 w := payloadWriter{}
@@ -415,6 +516,8 @@ func MarshalWelcome(m Welcome) []byte {
415516 } else {
416517 w.putUint8(0)
417518 }
519+ w.putUint64(m.ResumeToken)
520+ w.putUint32(m.Capabilities)
418521 return w.b
419522 }
420523
@@ -435,6 +538,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
435538 return Welcome{}, err
436539 }
437540 m.Ephemeral = eph != 0
541+ if m.ResumeToken, err = r.getUint64(); err != nil {
542+ return Welcome{}, err
543+ }
544+ if m.Capabilities, err = r.getUint32(); err != nil {
545+ return Welcome{}, err
546+ }
438547 if err := r.done(); err != nil {
439548 return Welcome{}, err
440549 }
@@ -469,14 +578,31 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
469578
470579 // MarshalOutput encodes m into an Output message payload.
471580 func MarshalOutput(m Output) []byte {
472- return append([]byte(nil), m.Data...)
581+ w := payloadWriter{}
582+ w.putUint64(m.BaseStateNum)
583+ w.putUint64(m.NewStateNum)
584+ w.putUint64(m.EchoAck)
585+ w.putBytes(m.Data)
586+ return w.b
473587 }
474588
475-// UnmarshalOutput decodes an Output message payload. The whole payload is
476-// the data; there is no separate length prefix.
589+// UnmarshalOutput decodes an Output message payload. After fixed state
590+// numbers, the rest of the payload is data; there is no length prefix.
477591 func UnmarshalOutput(b []byte) (Output, error) {
478592 r := payloadReader{b: b}
479- return Output{Data: r.rest()}, nil
593+ base, err := r.getUint64()
594+ if err != nil {
595+ return Output{}, err
596+ }
597+ newState, err := r.getUint64()
598+ if err != nil {
599+ return Output{}, err
600+ }
601+ echoAck, err := r.getUint64()
602+ if err != nil {
603+ return Output{}, err
604+ }
605+ return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil
480606 }
481607
482608 // MarshalDetached encodes m into a Detached message payload.
@@ -28,6 +28,14 @@ const (
28 IntentEphemeral uint8 = 028 IntentEphemeral uint8 = 0
29 IntentNew uint8 = 129 IntentNew uint8 = 1
30 IntentAttach uint8 = 230 IntentAttach uint8 = 2
31+ IntentResume uint8 = 3
32+)
33+
34+// Capability bits advertised in Welcome.
35+const (
36+ CapabilityResume uint32 = 1 << 0
37+ CapabilityUDP uint32 = 1 << 1
38+ CapabilityPredict uint32 = 1 << 2
31 )39 )
32 40
33 // ErrorMsg codes.41 // ErrorMsg codes.
@@ -48,17 +56,20 @@ const (
48 56
49 // Hello is sent by the client immediately after connecting.57 // Hello is sent by the client immediately after connecting.
50 type Hello struct {58 type Hello struct {
51- Version uint1659+ Version uint16
52- Intent uint860+ Intent uint8
53- Name string61+ ClientID [16]byte
54- Size domain.Size62+ ResumeToken uint64
55- TermEnv string63+ Name string
56- Cwd string64+ Size domain.Size
65+ TermEnv string
66+ Cwd string
57 }67 }
58 68
59 // Input carries raw bytes typed/pasted by the client, destined for the PTY.69 // Input carries raw bytes typed/pasted by the client, destined for the PTY.
60 type Input struct {70 type Input struct {
61- Data []byte71+ InputSeq uint64
72+ Data []byte
62 }73 }
63 74
64 // Resize notifies the daemon of a client-side terminal size change.75 // Resize notifies the daemon of a client-side terminal size change.
@@ -86,11 +97,18 @@ type Ping struct{}
86 // Pong answers a Ping.97 // Pong answers a Ping.
87 type Pong struct{}98 type Pong struct{}
88 99
100+// Ack acknowledges receipt/application of an output state number.
101+type Ack struct {
102+ AckedStateNum uint64
103+}
104+
89 // Welcome is the daemon's reply to a successful Hello.105 // Welcome is the daemon's reply to a successful Hello.
90 type Welcome struct {106 type Welcome struct {
91- SessionID string107+ SessionID string
92- SessionName string108+ SessionName string
93- Ephemeral bool109+ Ephemeral bool
110+ ResumeToken uint64
111+ Capabilities uint32
94 }112 }
95 113
96 // ErrorMsg reports a protocol- or session-level failure to the client.114 // ErrorMsg reports a protocol- or session-level failure to the client.
@@ -101,7 +119,10 @@ type ErrorMsg struct {
101 119
102 // Output carries raw PTY output, destined for the client's terminal.120 // Output carries raw PTY output, destined for the client's terminal.
103 type Output struct {121 type Output struct {
104- Data []byte122+ BaseStateNum uint64
123+ NewStateNum uint64
124+ EchoAck uint64
125+ Data []byte
105 }126 }
106 127
107 // Detached tells a client it has been disconnected from its session and why.128 // Detached tells a client it has been disconnected from its session and why.
@@ -149,6 +170,22 @@ func (w *payloadWriter) putUint16(v uint16) {
149 w.b = append(w.b, tmp[:]...)170 w.b = append(w.b, tmp[:]...)
150 }171 }
151 172
173+func (w *payloadWriter) putUint32(v uint32) {
174+ var tmp [4]byte
175+ binary.BigEndian.PutUint32(tmp[:], v)
176+ w.b = append(w.b, tmp[:]...)
177+}
178+
179+func (w *payloadWriter) putUint64(v uint64) {
180+ var tmp [8]byte
181+ binary.BigEndian.PutUint64(tmp[:], v)
182+ w.b = append(w.b, tmp[:]...)
183+}
184+
185+func (w *payloadWriter) putBytes(b []byte) {
186+ w.b = append(w.b, b...)
187+}
188+
152 func (w *payloadWriter) putString(s string) {189 func (w *payloadWriter) putString(s string) {
153 w.putUint16(uint16(len(s)))190 w.putUint16(uint16(len(s)))
154 w.b = append(w.b, s...)191 w.b = append(w.b, s...)
@@ -178,6 +215,33 @@ func (r *payloadReader) getUint16() (uint16, error) {
178 return v, nil215 return v, nil
179 }216 }
180 217
218+func (r *payloadReader) getUint32() (uint32, error) {
219+ if len(r.b) < 4 {
220+ return 0, errShortPayload
221+ }
222+ v := binary.BigEndian.Uint32(r.b)
223+ r.b = r.b[4:]
224+ return v, nil
225+}
226+
227+func (r *payloadReader) getUint64() (uint64, error) {
228+ if len(r.b) < 8 {
229+ return 0, errShortPayload
230+ }
231+ v := binary.BigEndian.Uint64(r.b)
232+ r.b = r.b[8:]
233+ return v, nil
234+}
235+
236+func (r *payloadReader) getBytes(n int) ([]byte, error) {
237+ if len(r.b) < n {
238+ return nil, errShortPayload
239+ }
240+ b := append([]byte(nil), r.b[:n]...)
241+ r.b = r.b[n:]
242+ return b, nil
243+}
244+
181 func (r *payloadReader) getString() (string, error) {245 func (r *payloadReader) getString() (string, error) {
182 n, err := r.getUint16()246 n, err := r.getUint16()
183 if err != nil {247 if err != nil {
@@ -220,6 +284,8 @@ func MarshalHello(h Hello) []byte {
220 w := payloadWriter{}284 w := payloadWriter{}
221 w.putUint16(h.Version)285 w.putUint16(h.Version)
222 w.putUint8(h.Intent)286 w.putUint8(h.Intent)
287+ w.putBytes(h.ClientID[:])
288+ w.putUint64(h.ResumeToken)
223 w.putString(h.Name)289 w.putString(h.Name)
224 w.putUint16(uint16(h.Size.Cols))290 w.putUint16(uint16(h.Size.Cols))
225 w.putUint16(uint16(h.Size.Rows))291 w.putUint16(uint16(h.Size.Rows))
@@ -240,6 +306,14 @@ func UnmarshalHello(b []byte) (Hello, error) {
240 if h.Intent, err = r.getUint8(); err != nil {306 if h.Intent, err = r.getUint8(); err != nil {
241 return Hello{}, err307 return Hello{}, err
242 }308 }
309+ clientID, err := r.getBytes(len(h.ClientID))
310+ if err != nil {
311+ return Hello{}, err
312+ }
313+ copy(h.ClientID[:], clientID)
314+ if h.ResumeToken, err = r.getUint64(); err != nil {
315+ return Hello{}, err
316+ }
243 if h.Name, err = r.getString(); err != nil {317 if h.Name, err = r.getString(); err != nil {
244 return Hello{}, err318 return Hello{}, err
245 }319 }
@@ -266,14 +340,21 @@ func UnmarshalHello(b []byte) (Hello, error) {
266 340
267 // MarshalInput encodes m into an Input message payload.341 // MarshalInput encodes m into an Input message payload.
268 func MarshalInput(m Input) []byte {342 func MarshalInput(m Input) []byte {
269- return append([]byte(nil), m.Data...)343+ w := payloadWriter{}
344+ w.putUint64(m.InputSeq)
345+ w.putBytes(m.Data)
346+ return w.b
270 }347 }
271 348
272-// UnmarshalInput decodes an Input message payload. The whole payload is the349+// UnmarshalInput decodes an Input message payload. After the fixed input
273-// data; there is no separate length prefix.350+// sequence, the rest of the payload is data; there is no length prefix.
274 func UnmarshalInput(b []byte) (Input, error) {351 func UnmarshalInput(b []byte) (Input, error) {
275 r := payloadReader{b: b}352 r := payloadReader{b: b}
276- return Input{Data: r.rest()}, nil353+ seq, err := r.getUint64()
354+ if err != nil {
355+ return Input{}, err
356+ }
357+ return Input{InputSeq: seq, Data: r.rest()}, nil
277 }358 }
278 359
279 // MarshalResize encodes m into a Resize message payload.360 // MarshalResize encodes m into a Resize message payload.
@@ -405,6 +486,26 @@ func UnmarshalPong(b []byte) (Pong, error) {
405 return Pong{}, nil486 return Pong{}, nil
406 }487 }
407 488
489+// MarshalAck encodes m into an Ack message payload.
490+func MarshalAck(m Ack) []byte {
491+ w := payloadWriter{}
492+ w.putUint64(m.AckedStateNum)
493+ return w.b
494+}
495+
496+// UnmarshalAck decodes an Ack message payload.
497+func UnmarshalAck(b []byte) (Ack, error) {
498+ r := payloadReader{b: b}
499+ acked, err := r.getUint64()
500+ if err != nil {
501+ return Ack{}, err
502+ }
503+ if err := r.done(); err != nil {
504+ return Ack{}, err
505+ }
506+ return Ack{AckedStateNum: acked}, nil
507+}
508+
408 // MarshalWelcome encodes m into a Welcome message payload.509 // MarshalWelcome encodes m into a Welcome message payload.
409 func MarshalWelcome(m Welcome) []byte {510 func MarshalWelcome(m Welcome) []byte {
410 w := payloadWriter{}511 w := payloadWriter{}
@@ -415,6 +516,8 @@ func MarshalWelcome(m Welcome) []byte {
415 } else {516 } else {
416 w.putUint8(0)517 w.putUint8(0)
417 }518 }
519+ w.putUint64(m.ResumeToken)
520+ w.putUint32(m.Capabilities)
418 return w.b521 return w.b
419 }522 }
420 523
@@ -435,6 +538,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) {
435 return Welcome{}, err538 return Welcome{}, err
436 }539 }
437 m.Ephemeral = eph != 0540 m.Ephemeral = eph != 0
541+ if m.ResumeToken, err = r.getUint64(); err != nil {
542+ return Welcome{}, err
543+ }
544+ if m.Capabilities, err = r.getUint32(); err != nil {
545+ return Welcome{}, err
546+ }
438 if err := r.done(); err != nil {547 if err := r.done(); err != nil {
439 return Welcome{}, err548 return Welcome{}, err
440 }549 }
@@ -469,14 +578,31 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) {
469 578
470 // MarshalOutput encodes m into an Output message payload.579 // MarshalOutput encodes m into an Output message payload.
471 func MarshalOutput(m Output) []byte {580 func MarshalOutput(m Output) []byte {
472- return append([]byte(nil), m.Data...)581+ w := payloadWriter{}
582+ w.putUint64(m.BaseStateNum)
583+ w.putUint64(m.NewStateNum)
584+ w.putUint64(m.EchoAck)
585+ w.putBytes(m.Data)
586+ return w.b
473 }587 }
474 588
475-// UnmarshalOutput decodes an Output message payload. The whole payload is589+// UnmarshalOutput decodes an Output message payload. After fixed state
476-// the data; there is no separate length prefix.590+// numbers, the rest of the payload is data; there is no length prefix.
477 func UnmarshalOutput(b []byte) (Output, error) {591 func UnmarshalOutput(b []byte) (Output, error) {
478 r := payloadReader{b: b}592 r := payloadReader{b: b}
479- return Output{Data: r.rest()}, nil593+ base, err := r.getUint64()
594+ if err != nil {
595+ return Output{}, err
596+ }
597+ newState, err := r.getUint64()
598+ if err != nil {
599+ return Output{}, err
600+ }
601+ echoAck, err := r.getUint64()
602+ if err != nil {
603+ return Output{}, err
604+ }
605+ return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil
480 }606 }
481 607
482 // MarshalDetached encodes m into a Detached message payload.608 // MarshalDetached encodes m into a Detached message payload.
M internal/ports/wire_test.go +48 -25
@@ -60,14 +60,16 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
6060 {
6161 name: "typical",
6262 msg: Hello{
63- Version: 1,
64- Intent: IntentNew,
65- Name: "w0",
66- Size: domain.Size{Cols: 80, Rows: 24},
67- TermEnv: "xterm-256color",
68- Cwd: "/tmp/project",
63+ Version: 1,
64+ Intent: IntentNew,
65+ ClientID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
66+ ResumeToken: 0x0102030405060708,
67+ Name: "w0",
68+ Size: domain.Size{Cols: 80, Rows: 24},
69+ TermEnv: "xterm-256color",
70+ Cwd: "/tmp/project",
6971 },
70- want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74},
72+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74},
7173 },
7274 {
7375 name: "empty strings",
@@ -79,7 +81,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
7981 TermEnv: "",
8082 Cwd: "",
8183 },
82- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
84+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
8385 },
8486 }
8587
@@ -96,6 +98,10 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
9698 if !reflect.DeepEqual(back, tt.msg) {
9799 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
98100 }
101+ peek, ok := PeekHelloVersion(got)
102+ if !ok || peek != tt.msg.Version {
103+ t.Fatalf("PeekHelloVersion() = %d, %v; want %d, true", peek, ok, tt.msg.Version)
104+ }
99105 })
100106 }
101107
@@ -110,8 +116,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
110116 msg Input
111117 want []byte
112118 }{
113- {name: "data", msg: Input{Data: []byte("hi")}, want: []byte{0x68, 0x69}},
114- {name: "empty", msg: Input{Data: nil}, want: []byte{}},
119+ {name: "data", msg: Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}},
120+ {name: "empty", msg: Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
115121 }
116122
117123 for _, tt := range tests {
@@ -130,14 +136,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
130136 })
131137 }
132138
133- // Input is rest-of-payload: any byte sequence, including a truncated
134- // one, is a valid (if empty or partial) Data value. No error path
135- // exists to exercise beyond "never panics".
136- mustNotPanic(t, func() {
137- if _, err := UnmarshalInput([]byte{0x01}); err != nil {
138- t.Fatalf("UnmarshalInput() unexpected error = %v", err)
139- }
140- })
139+ full := MarshalInput(tests[0].msg)
140+ assertAllPrefixesFail(t, full[:8], UnmarshalInput)
141141 }
142142
143143 func TestThemeGoldenAndRoundTrip(t *testing.T) {
@@ -267,13 +267,13 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
267267 }{
268268 {
269269 name: "ephemeral",
270- msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true},
271- want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01},
270+ msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
271+ want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05},
272272 },
273273 {
274274 name: "non-ephemeral empty name",
275275 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
276- want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00},
276+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
277277 },
278278 }
279279
@@ -319,8 +319,8 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
319319 }
320320
321321 func TestOutputGoldenAndRoundTrip(t *testing.T) {
322- msg := Output{Data: []byte("hello\n")}
323- want := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
322+ msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")}
323+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
324324
325325 got := MarshalOutput(msg)
326326 if !bytes.Equal(got, want) {
@@ -333,6 +333,28 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
333333 if !reflect.DeepEqual(back, msg) {
334334 t.Fatalf("round trip = %#v, want %#v", back, msg)
335335 }
336+
337+ assertAllPrefixesFail(t, got[:24], UnmarshalOutput)
338+}
339+
340+func TestAckGoldenAndRoundTrip(t *testing.T) {
341+ msg := Ack{AckedStateNum: 0x0102030405060708}
342+ want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
343+
344+ got := MarshalAck(msg)
345+ if !bytes.Equal(got, want) {
346+ t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
347+ }
348+ back, err := UnmarshalAck(got)
349+ if err != nil {
350+ t.Fatalf("UnmarshalAck() error = %v", err)
351+ }
352+ if back != msg {
353+ t.Fatalf("round trip = %#v, want %#v", back, msg)
354+ }
355+
356+ assertAllPrefixesFail(t, got, UnmarshalAck)
357+ assertTrailingGarbageFails(t, got, UnmarshalAck)
336358 }
337359
338360 func TestDetachedGoldenAndRoundTrip(t *testing.T) {
@@ -469,9 +491,10 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
469491 IntentEphemeral: "IntentEphemeral",
470492 IntentNew: "IntentNew",
471493 IntentAttach: "IntentAttach",
494+ IntentResume: "IntentResume",
472495 }
473- if len(vals) != 3 {
474- t.Fatalf("expected 3 distinct intent values, got %d", len(vals))
496+ if len(vals) != 4 {
497+ t.Fatalf("expected 4 distinct intent values, got %d", len(vals))
475498 }
476499 })
477500
@@ -60,14 +60,16 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
60 {60 {
61 name: "typical",61 name: "typical",
62 msg: Hello{62 msg: Hello{
63- Version: 1,63+ Version: 1,
64- Intent: IntentNew,64+ Intent: IntentNew,
65- Name: "w0",65+ ClientID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
66- Size: domain.Size{Cols: 80, Rows: 24},66+ ResumeToken: 0x0102030405060708,
67- TermEnv: "xterm-256color",67+ Name: "w0",
68- Cwd: "/tmp/project",68+ Size: domain.Size{Cols: 80, Rows: 24},
69+ TermEnv: "xterm-256color",
70+ Cwd: "/tmp/project",
69 },71 },
70- want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74},72+ want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74},
71 },73 },
72 {74 {
73 name: "empty strings",75 name: "empty strings",
@@ -79,7 +81,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
79 TermEnv: "",81 TermEnv: "",
80 Cwd: "",82 Cwd: "",
81 },83 },
82- want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},84+ want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
83 },85 },
84 }86 }
85 87
@@ -96,6 +98,10 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) {
96 if !reflect.DeepEqual(back, tt.msg) {98 if !reflect.DeepEqual(back, tt.msg) {
97 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)99 t.Fatalf("round trip = %#v, want %#v", back, tt.msg)
98 }100 }
101+ peek, ok := PeekHelloVersion(got)
102+ if !ok || peek != tt.msg.Version {
103+ t.Fatalf("PeekHelloVersion() = %d, %v; want %d, true", peek, ok, tt.msg.Version)
104+ }
99 })105 })
100 }106 }
101 107
@@ -110,8 +116,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
110 msg Input116 msg Input
111 want []byte117 want []byte
112 }{118 }{
113- {name: "data", msg: Input{Data: []byte("hi")}, want: []byte{0x68, 0x69}},119+ {name: "data", msg: Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}},
114- {name: "empty", msg: Input{Data: nil}, want: []byte{}},120+ {name: "empty", msg: Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}},
115 }121 }
116 122
117 for _, tt := range tests {123 for _, tt := range tests {
@@ -130,14 +136,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) {
130 })136 })
131 }137 }
132 138
133- // Input is rest-of-payload: any byte sequence, including a truncated139+ full := MarshalInput(tests[0].msg)
134- // one, is a valid (if empty or partial) Data value. No error path140+ assertAllPrefixesFail(t, full[:8], UnmarshalInput)
135- // exists to exercise beyond "never panics".
136- mustNotPanic(t, func() {
137- if _, err := UnmarshalInput([]byte{0x01}); err != nil {
138- t.Fatalf("UnmarshalInput() unexpected error = %v", err)
139- }
140- })
141 }141 }
142 142
143 func TestThemeGoldenAndRoundTrip(t *testing.T) {143 func TestThemeGoldenAndRoundTrip(t *testing.T) {
@@ -267,13 +267,13 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) {
267 }{267 }{
268 {268 {
269 name: "ephemeral",269 name: "ephemeral",
270- msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true},270+ msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict},
271- want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01},271+ want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05},
272 },272 },
273 {273 {
274 name: "non-ephemeral empty name",274 name: "non-ephemeral empty name",
275 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},275 msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false},
276- want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00},276+ want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
277 },277 },
278 }278 }
279 279
@@ -319,8 +319,8 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) {
319 }319 }
320 320
321 func TestOutputGoldenAndRoundTrip(t *testing.T) {321 func TestOutputGoldenAndRoundTrip(t *testing.T) {
322- msg := Output{Data: []byte("hello\n")}322+ msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")}
323- want := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}323+ want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a}
324 324
325 got := MarshalOutput(msg)325 got := MarshalOutput(msg)
326 if !bytes.Equal(got, want) {326 if !bytes.Equal(got, want) {
@@ -333,6 +333,28 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) {
333 if !reflect.DeepEqual(back, msg) {333 if !reflect.DeepEqual(back, msg) {
334 t.Fatalf("round trip = %#v, want %#v", back, msg)334 t.Fatalf("round trip = %#v, want %#v", back, msg)
335 }335 }
336+
337+ assertAllPrefixesFail(t, got[:24], UnmarshalOutput)
338+}
339+
340+func TestAckGoldenAndRoundTrip(t *testing.T) {
341+ msg := Ack{AckedStateNum: 0x0102030405060708}
342+ want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}
343+
344+ got := MarshalAck(msg)
345+ if !bytes.Equal(got, want) {
346+ t.Fatalf("MarshalAck() = %#v, want %#v", got, want)
347+ }
348+ back, err := UnmarshalAck(got)
349+ if err != nil {
350+ t.Fatalf("UnmarshalAck() error = %v", err)
351+ }
352+ if back != msg {
353+ t.Fatalf("round trip = %#v, want %#v", back, msg)
354+ }
355+
356+ assertAllPrefixesFail(t, got, UnmarshalAck)
357+ assertTrailingGarbageFails(t, got, UnmarshalAck)
336 }358 }
337 359
338 func TestDetachedGoldenAndRoundTrip(t *testing.T) {360 func TestDetachedGoldenAndRoundTrip(t *testing.T) {
@@ -469,9 +491,10 @@ func TestMsgTypeConstantsDistinct(t *testing.T) {
469 IntentEphemeral: "IntentEphemeral",491 IntentEphemeral: "IntentEphemeral",
470 IntentNew: "IntentNew",492 IntentNew: "IntentNew",
471 IntentAttach: "IntentAttach",493 IntentAttach: "IntentAttach",
494+ IntentResume: "IntentResume",
472 }495 }
473- if len(vals) != 3 {496+ if len(vals) != 4 {
474- t.Fatalf("expected 3 distinct intent values, got %d", len(vals))497+ t.Fatalf("expected 4 distinct intent values, got %d", len(vals))
475 }498 }
476 })499 })
477 500
M internal/usecase/client/client.go +198 -45
@@ -9,12 +9,14 @@ package client
99
1010 import (
1111 "context"
12+ "crypto/rand"
1213 "errors"
1314 "fmt"
1415 "io"
1516 "log/slog"
1617 "os"
1718 "strings"
19+ "time"
1820
1921 "github.com/bnema/vev/internal/domain"
2022 "github.com/bnema/vev/internal/ports"
@@ -80,6 +82,16 @@ func (m milestones) missing() []string {
8082 return out
8183 }
8284
85+// processClientID identifies this client process across links. It is kept in
86+// memory only; a fresh process gets a fresh ID.
87+var processClientID = newClientID()
88+
89+func newClientID() [16]byte {
90+ var id [16]byte
91+ _, _ = rand.Read(id[:])
92+ return id
93+}
94+
8395 // stdinBufSize is the read buffer for the terminal input pump.
8496 const stdinBufSize = 4096
8597
@@ -87,32 +99,170 @@ const stdinBufSize = 4096
8799 // goroutine, decoupling the input/resize pumps from transport back-pressure.
88100 const sendQueueDepth = 64
89101
90-// Attach connects an already-dialed transport to the controlling terminal
91-// and runs the attach loop until the session detaches, the daemon
92-// disappears, or the context is cancelled.
93-//
94-// It owns transport for its lifetime and closes it before returning. The
95-// terminal is put into raw mode after a successful Welcome and restored on
96-// every exit path — normal return, error, or panic.
97-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) {
102+type reconnectBackoff struct {
103+ initial time.Duration
104+ max time.Duration
105+}
106+
107+var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
108+
109+const (
110+ statusReconnect = "\r\x1b[2Kreconnecting…"
111+ statusClear = "\r\x1b[2K"
112+)
113+
114+// Run connects via dialer and runs the attach client. It owns the terminal
115+// lifecycle above attach attempts so raw mode remains active while a live
116+// client process redials a lost link.
117+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent uint8, name string) (retErr error) {
98118 log := slog.Default().With("component", "client")
99- ms := milestones{dialed: true} // transport arrives already connected
119+ ms := milestones{}
100120
101- // Runs last: a single structured diagnostic on failure, to the log file
102- // (never the terminal). Console output while raw is forbidden.
103121 defer func() {
104122 if retErr != nil {
105123 log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())
106124 }
107125 }()
108126
109- // Attach owns the transport: close it on every exit path.
110- defer func() { _ = transport.Close() }()
127+ var restore func() error
128+ rawEntered := false
129+ defer func() {
130+ if rawEntered {
131+ if rerr := restore(); rerr != nil && retErr == nil {
132+ retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
133+ }
134+ }
135+ }()
136+ enterRaw := func() error {
137+ if rawEntered {
138+ return nil
139+ }
140+ var err error
141+ restore, err = term.EnterRaw()
142+ if err != nil {
143+ return fmt.Errorf("vev: entering raw mode: %w", err)
144+ }
145+ rawEntered = true
146+ ms.rawEntered = true
147+ return nil
148+ }
149+
150+ resumeToken := uint64(0)
151+ attemptIntent := intent
152+ backoff := defaultReconnectBackoff.initial
153+ showingStatus := false
154+ drawStatus := func() {
155+ if !rawEntered || showingStatus {
156+ return
157+ }
158+ _, _ = term.Out().Write([]byte(statusReconnect))
159+ _ = term.Flush()
160+ showingStatus = true
161+ }
162+ clearStatus := func() {
163+ if !showingStatus {
164+ return
165+ }
166+ _, _ = term.Out().Write([]byte(statusClear))
167+ _ = term.Flush()
168+ showingStatus = false
169+ }
170+
171+ for {
172+ transport, err := dialer.Dial(ctx)
173+ if err != nil {
174+ if resumeToken == 0 || ctx.Err() != nil {
175+ return err
176+ }
177+ drawStatus()
178+ if !sleepReconnect(ctx, backoff) {
179+ return ctx.Err()
180+ }
181+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
182+ attemptIntent = ports.IntentResume
183+ continue
184+ }
185+ ms.dialed = true
186+ backoff = defaultReconnectBackoff.initial
187+
188+ result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)
189+ _ = transport.Close()
190+ if result.resumeToken != 0 {
191+ resumeToken = result.resumeToken
192+ }
193+ if result.err == nil {
194+ clearStatus()
195+ return nil
196+ }
197+ if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
198+ clearStatus()
199+ return result.err
200+ }
201+ drawStatus()
202+ if !sleepReconnect(ctx, backoff) {
203+ clearStatus()
204+ return ctx.Err()
205+ }
206+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
207+ attemptIntent = ports.IntentResume
208+ }
209+}
210+
211+func sleepReconnect(ctx context.Context, d time.Duration) bool {
212+ t := time.NewTimer(d)
213+ defer t.Stop()
214+ select {
215+ case <-t.C:
216+ return true
217+ case <-ctx.Done():
218+ return false
219+ }
220+}
221+
222+func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
223+ cur *= 2
224+ if cur > limit {
225+ return limit
226+ }
227+ return cur
228+}
229+
230+func shouldReconnect(err error) bool {
231+ if err == nil {
232+ return false
233+ }
234+ if _, ok := errors.AsType[*ProtocolError](err); ok {
235+ return false
236+ }
237+ _, ok := errors.AsType[*DetachedError](err)
238+ return !ok
239+}
240+
241+// Attach connects an already-dialed transport to the controlling terminal
242+// and runs the attach loop until the session detaches, the daemon
243+// disappears, or the context is cancelled.
244+//
245+// It is kept as a compatibility wrapper for callers that still own dialing.
246+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error {
247+ return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name)
248+}
249+
250+type singleTransportDialer struct{ transport ports.Transport }
251+
252+func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
253+ return d.transport, nil
254+}
111255
256+type attachResult struct {
257+ resumeToken uint64
258+ err error
259+}
260+
261+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult {
112262 // 1. Handshake: send Hello with our size and TERM.
113263 size, err := term.Size()
114264 if err != nil {
115- return fmt.Errorf("vev: reading terminal size: %w", err)
265+ return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)}
116266 }
117267 cwd, err := os.Getwd()
118268 if err != nil {
@@ -121,47 +271,48 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
121271 colorTerm := strings.ToLower(os.Getenv("COLORTERM"))
122272 trueColor := colorTerm == "truecolor" || colorTerm == "24bit"
123273 hello := ports.Hello{
124- Version: ports.ProtocolVersion,
125- Intent: intent,
126- Name: name,
127- Size: size,
128- TermEnv: os.Getenv("TERM"),
129- Cwd: cwd,
274+ Version: ports.ProtocolVersion,
275+ Intent: intent,
276+ ClientID: clientID,
277+ ResumeToken: resumeToken,
278+ Name: name,
279+ Size: size,
280+ TermEnv: os.Getenv("TERM"),
281+ Cwd: cwd,
130282 }
131283 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
132- return fmt.Errorf("vev: sending hello: %w", err)
284+ return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
133285 }
134286 ms.helloSent = true
135287
136288 // 2. Await Welcome or a typed rejection.
137289 reply, err := transport.Recv()
138290 if err != nil {
139- return fmt.Errorf("vev: awaiting welcome: %w", err)
291+ return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)}
140292 }
141293 switch reply.Type {
142294 case ports.MsgWelcome:
295+ welcome, derr := ports.UnmarshalWelcome(reply.Payload)
296+ if derr != nil {
297+ return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)}
298+ }
299+ resumeToken = welcome.ResumeToken
143300 ms.welcomed = true
144301 case ports.MsgError:
145302 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
146303 if derr != nil {
147- return fmt.Errorf("vev: decoding error reply: %w", derr)
304+ return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)}
148305 }
149- return &ProtocolError{Code: em.Code, Text: em.Text}
306+ return attachResult{resumeToken: resumeToken, err: &ProtocolError{Code: em.Code, Text: em.Text}}
150307 default:
151- return fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)
308+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)}
152309 }
153310
154- // 3. Enter raw mode; restore on every subsequent exit path.
155- restore, err := term.EnterRaw()
156- if err != nil {
157- return fmt.Errorf("vev: entering raw mode: %w", err)
311+ // 3. Enter raw mode after Welcome; Run owns restoration.
312+ if err := enterRaw(); err != nil {
313+ return attachResult{resumeToken: resumeToken, err: err}
158314 }
159- ms.rawEntered = true
160- defer func() {
161- if rerr := restore(); rerr != nil && retErr == nil {
162- retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
163- }
164- }()
315+ clearStatus()
165316
166317 // 4. Derive a cancellable context so the pumps always unwind when the
167318 // loop returns. cancel runs first (LIFO): it signals the pumps; the
@@ -187,37 +338,37 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
187338 // context was cancelled. A queued sender error takes priority.
188339 select {
189340 case serr := <-sendErrCh:
190- return fmt.Errorf("vev: sending to daemon: %w", serr)
341+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
191342 default:
192- return nil
343+ return attachResult{resumeToken: resumeToken}
193344 }
194345 case r := <-recvCh:
195346 if r.err != nil {
196347 if errors.Is(r.err, io.EOF) {
197- return fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)
348+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)}
198349 }
199- return fmt.Errorf("vev: receiving from daemon: %w", r.err)
350+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)}
200351 }
201352 switch r.frame.Type {
202353 case ports.MsgOutput:
203354 o, derr := ports.UnmarshalOutput(r.frame.Payload)
204355 if derr != nil {
205- return fmt.Errorf("vev: decoding output: %w", derr)
356+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding output: %w", derr)}
206357 }
207358 if _, werr := term.Out().Write(o.Data); werr != nil {
208- return fmt.Errorf("vev: writing terminal output: %w", werr)
359+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: writing terminal output: %w", werr)}
209360 }
210361 if ferr := term.Flush(); ferr != nil {
211- return fmt.Errorf("vev: flushing terminal: %w", ferr)
362+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
212363 }
213364 ms.firstOutput = true
214365 case ports.MsgDetached:
215366 d, derr := ports.UnmarshalDetached(r.frame.Payload)
216367 if derr != nil {
217- return fmt.Errorf("vev: decoding detached: %w", derr)
368+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding detached: %w", derr)}
218369 }
219370 ms.detached = true
220- return detachedResult(d.Reason)
371+ return attachResult{resumeToken: resumeToken, err: detachedResult(d.Reason)}
221372 case ports.MsgPong:
222373 // Liveness reply; nothing to do.
223374 default:
@@ -290,6 +441,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
290441 func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {
291442 buf := make([]byte, stdinBufSize)
292443 var scanner theme.Scanner
444+ var inputSeq uint64
293445 current := ports.Theme{TrueColor: trueColor}
294446 send := func(frame ports.Frame) bool {
295447 select {
@@ -320,7 +472,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
320472 return
321473 }
322474 copyData := append([]byte(nil), data...)
323- ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})})
475+ inputSeq++
476+ ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
324477 })
325478 if !ok {
326479 return
@@ -9,12 +9,14 @@ package client
9 9
10 import (10 import (
11 "context"11 "context"
12+ "crypto/rand"
12 "errors"13 "errors"
13 "fmt"14 "fmt"
14 "io"15 "io"
15 "log/slog"16 "log/slog"
16 "os"17 "os"
17 "strings"18 "strings"
19+ "time"
18 20
19 "github.com/bnema/vev/internal/domain"21 "github.com/bnema/vev/internal/domain"
20 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
@@ -80,6 +82,16 @@ func (m milestones) missing() []string {
80 return out82 return out
81 }83 }
82 84
85+// processClientID identifies this client process across links. It is kept in
86+// memory only; a fresh process gets a fresh ID.
87+var processClientID = newClientID()
88+
89+func newClientID() [16]byte {
90+ var id [16]byte
91+ _, _ = rand.Read(id[:])
92+ return id
93+}
94+
83 // stdinBufSize is the read buffer for the terminal input pump.95 // stdinBufSize is the read buffer for the terminal input pump.
84 const stdinBufSize = 409696 const stdinBufSize = 4096
85 97
@@ -87,32 +99,170 @@ const stdinBufSize = 4096
87 // goroutine, decoupling the input/resize pumps from transport back-pressure.99 // goroutine, decoupling the input/resize pumps from transport back-pressure.
88 const sendQueueDepth = 64100 const sendQueueDepth = 64
89 101
90-// Attach connects an already-dialed transport to the controlling terminal102+type reconnectBackoff struct {
91-// and runs the attach loop until the session detaches, the daemon103+ initial time.Duration
92-// disappears, or the context is cancelled.104+ max time.Duration
93-//105+}
94-// It owns transport for its lifetime and closes it before returning. The106+
95-// terminal is put into raw mode after a successful Welcome and restored on107+var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
96-// every exit path — normal return, error, or panic.108+
97-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) {109+const (
110+ statusReconnect = "\r\x1b[2Kreconnecting…"
111+ statusClear = "\r\x1b[2K"
112+)
113+
114+// Run connects via dialer and runs the attach client. It owns the terminal
115+// lifecycle above attach attempts so raw mode remains active while a live
116+// client process redials a lost link.
117+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent uint8, name string) (retErr error) {
98 log := slog.Default().With("component", "client")118 log := slog.Default().With("component", "client")
99- ms := milestones{dialed: true} // transport arrives already connected119+ ms := milestones{}
100 120
101- // Runs last: a single structured diagnostic on failure, to the log file
102- // (never the terminal). Console output while raw is forbidden.
103 defer func() {121 defer func() {
104 if retErr != nil {122 if retErr != nil {
105 log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())123 log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())
106 }124 }
107 }()125 }()
108 126
109- // Attach owns the transport: close it on every exit path.127+ var restore func() error
110- defer func() { _ = transport.Close() }()128+ rawEntered := false
129+ defer func() {
130+ if rawEntered {
131+ if rerr := restore(); rerr != nil && retErr == nil {
132+ retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
133+ }
134+ }
135+ }()
136+ enterRaw := func() error {
137+ if rawEntered {
138+ return nil
139+ }
140+ var err error
141+ restore, err = term.EnterRaw()
142+ if err != nil {
143+ return fmt.Errorf("vev: entering raw mode: %w", err)
144+ }
145+ rawEntered = true
146+ ms.rawEntered = true
147+ return nil
148+ }
149+
150+ resumeToken := uint64(0)
151+ attemptIntent := intent
152+ backoff := defaultReconnectBackoff.initial
153+ showingStatus := false
154+ drawStatus := func() {
155+ if !rawEntered || showingStatus {
156+ return
157+ }
158+ _, _ = term.Out().Write([]byte(statusReconnect))
159+ _ = term.Flush()
160+ showingStatus = true
161+ }
162+ clearStatus := func() {
163+ if !showingStatus {
164+ return
165+ }
166+ _, _ = term.Out().Write([]byte(statusClear))
167+ _ = term.Flush()
168+ showingStatus = false
169+ }
170+
171+ for {
172+ transport, err := dialer.Dial(ctx)
173+ if err != nil {
174+ if resumeToken == 0 || ctx.Err() != nil {
175+ return err
176+ }
177+ drawStatus()
178+ if !sleepReconnect(ctx, backoff) {
179+ return ctx.Err()
180+ }
181+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
182+ attemptIntent = ports.IntentResume
183+ continue
184+ }
185+ ms.dialed = true
186+ backoff = defaultReconnectBackoff.initial
187+
188+ result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)
189+ _ = transport.Close()
190+ if result.resumeToken != 0 {
191+ resumeToken = result.resumeToken
192+ }
193+ if result.err == nil {
194+ clearStatus()
195+ return nil
196+ }
197+ if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
198+ clearStatus()
199+ return result.err
200+ }
201+ drawStatus()
202+ if !sleepReconnect(ctx, backoff) {
203+ clearStatus()
204+ return ctx.Err()
205+ }
206+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
207+ attemptIntent = ports.IntentResume
208+ }
209+}
210+
211+func sleepReconnect(ctx context.Context, d time.Duration) bool {
212+ t := time.NewTimer(d)
213+ defer t.Stop()
214+ select {
215+ case <-t.C:
216+ return true
217+ case <-ctx.Done():
218+ return false
219+ }
220+}
221+
222+func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
223+ cur *= 2
224+ if cur > limit {
225+ return limit
226+ }
227+ return cur
228+}
229+
230+func shouldReconnect(err error) bool {
231+ if err == nil {
232+ return false
233+ }
234+ if _, ok := errors.AsType[*ProtocolError](err); ok {
235+ return false
236+ }
237+ _, ok := errors.AsType[*DetachedError](err)
238+ return !ok
239+}
240+
241+// Attach connects an already-dialed transport to the controlling terminal
242+// and runs the attach loop until the session detaches, the daemon
243+// disappears, or the context is cancelled.
244+//
245+// It is kept as a compatibility wrapper for callers that still own dialing.
246+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error {
247+ return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name)
248+}
249+
250+type singleTransportDialer struct{ transport ports.Transport }
251+
252+func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
253+ return d.transport, nil
254+}
111 255
256+type attachResult struct {
257+ resumeToken uint64
258+ err error
259+}
260+
261+func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult {
112 // 1. Handshake: send Hello with our size and TERM.262 // 1. Handshake: send Hello with our size and TERM.
113 size, err := term.Size()263 size, err := term.Size()
114 if err != nil {264 if err != nil {
115- return fmt.Errorf("vev: reading terminal size: %w", err)265+ return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)}
116 }266 }
117 cwd, err := os.Getwd()267 cwd, err := os.Getwd()
118 if err != nil {268 if err != nil {
@@ -121,47 +271,48 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
121 colorTerm := strings.ToLower(os.Getenv("COLORTERM"))271 colorTerm := strings.ToLower(os.Getenv("COLORTERM"))
122 trueColor := colorTerm == "truecolor" || colorTerm == "24bit"272 trueColor := colorTerm == "truecolor" || colorTerm == "24bit"
123 hello := ports.Hello{273 hello := ports.Hello{
124- Version: ports.ProtocolVersion,274+ Version: ports.ProtocolVersion,
125- Intent: intent,275+ Intent: intent,
126- Name: name,276+ ClientID: clientID,
127- Size: size,277+ ResumeToken: resumeToken,
128- TermEnv: os.Getenv("TERM"),278+ Name: name,
129- Cwd: cwd,279+ Size: size,
280+ TermEnv: os.Getenv("TERM"),
281+ Cwd: cwd,
130 }282 }
131 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {283 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
132- return fmt.Errorf("vev: sending hello: %w", err)284+ return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
133 }285 }
134 ms.helloSent = true286 ms.helloSent = true
135 287
136 // 2. Await Welcome or a typed rejection.288 // 2. Await Welcome or a typed rejection.
137 reply, err := transport.Recv()289 reply, err := transport.Recv()
138 if err != nil {290 if err != nil {
139- return fmt.Errorf("vev: awaiting welcome: %w", err)291+ return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)}
140 }292 }
141 switch reply.Type {293 switch reply.Type {
142 case ports.MsgWelcome:294 case ports.MsgWelcome:
295+ welcome, derr := ports.UnmarshalWelcome(reply.Payload)
296+ if derr != nil {
297+ return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)}
298+ }
299+ resumeToken = welcome.ResumeToken
143 ms.welcomed = true300 ms.welcomed = true
144 case ports.MsgError:301 case ports.MsgError:
145 em, derr := ports.UnmarshalErrorMsg(reply.Payload)302 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
146 if derr != nil {303 if derr != nil {
147- return fmt.Errorf("vev: decoding error reply: %w", derr)304+ return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)}
148 }305 }
149- return &ProtocolError{Code: em.Code, Text: em.Text}306+ return attachResult{resumeToken: resumeToken, err: &ProtocolError{Code: em.Code, Text: em.Text}}
150 default:307 default:
151- return fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)308+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)}
152 }309 }
153 310
154- // 3. Enter raw mode; restore on every subsequent exit path.311+ // 3. Enter raw mode after Welcome; Run owns restoration.
155- restore, err := term.EnterRaw()312+ if err := enterRaw(); err != nil {
156- if err != nil {313+ return attachResult{resumeToken: resumeToken, err: err}
157- return fmt.Errorf("vev: entering raw mode: %w", err)
158 }314 }
159- ms.rawEntered = true315+ clearStatus()
160- defer func() {
161- if rerr := restore(); rerr != nil && retErr == nil {
162- retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
163- }
164- }()
165 316
166 // 4. Derive a cancellable context so the pumps always unwind when the317 // 4. Derive a cancellable context so the pumps always unwind when the
167 // loop returns. cancel runs first (LIFO): it signals the pumps; the318 // loop returns. cancel runs first (LIFO): it signals the pumps; the
@@ -187,37 +338,37 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
187 // context was cancelled. A queued sender error takes priority.338 // context was cancelled. A queued sender error takes priority.
188 select {339 select {
189 case serr := <-sendErrCh:340 case serr := <-sendErrCh:
190- return fmt.Errorf("vev: sending to daemon: %w", serr)341+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
191 default:342 default:
192- return nil343+ return attachResult{resumeToken: resumeToken}
193 }344 }
194 case r := <-recvCh:345 case r := <-recvCh:
195 if r.err != nil {346 if r.err != nil {
196 if errors.Is(r.err, io.EOF) {347 if errors.Is(r.err, io.EOF) {
197- return fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)348+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)}
198 }349 }
199- return fmt.Errorf("vev: receiving from daemon: %w", r.err)350+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)}
200 }351 }
201 switch r.frame.Type {352 switch r.frame.Type {
202 case ports.MsgOutput:353 case ports.MsgOutput:
203 o, derr := ports.UnmarshalOutput(r.frame.Payload)354 o, derr := ports.UnmarshalOutput(r.frame.Payload)
204 if derr != nil {355 if derr != nil {
205- return fmt.Errorf("vev: decoding output: %w", derr)356+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding output: %w", derr)}
206 }357 }
207 if _, werr := term.Out().Write(o.Data); werr != nil {358 if _, werr := term.Out().Write(o.Data); werr != nil {
208- return fmt.Errorf("vev: writing terminal output: %w", werr)359+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: writing terminal output: %w", werr)}
209 }360 }
210 if ferr := term.Flush(); ferr != nil {361 if ferr := term.Flush(); ferr != nil {
211- return fmt.Errorf("vev: flushing terminal: %w", ferr)362+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
212 }363 }
213 ms.firstOutput = true364 ms.firstOutput = true
214 case ports.MsgDetached:365 case ports.MsgDetached:
215 d, derr := ports.UnmarshalDetached(r.frame.Payload)366 d, derr := ports.UnmarshalDetached(r.frame.Payload)
216 if derr != nil {367 if derr != nil {
217- return fmt.Errorf("vev: decoding detached: %w", derr)368+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding detached: %w", derr)}
218 }369 }
219 ms.detached = true370 ms.detached = true
220- return detachedResult(d.Reason)371+ return attachResult{resumeToken: resumeToken, err: detachedResult(d.Reason)}
221 case ports.MsgPong:372 case ports.MsgPong:
222 // Liveness reply; nothing to do.373 // Liveness reply; nothing to do.
223 default:374 default:
@@ -290,6 +441,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
290 func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {441 func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {
291 buf := make([]byte, stdinBufSize)442 buf := make([]byte, stdinBufSize)
292 var scanner theme.Scanner443 var scanner theme.Scanner
444+ var inputSeq uint64
293 current := ports.Theme{TrueColor: trueColor}445 current := ports.Theme{TrueColor: trueColor}
294 send := func(frame ports.Frame) bool {446 send := func(frame ports.Frame) bool {
295 select {447 select {
@@ -320,7 +472,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
320 return472 return
321 }473 }
322 copyData := append([]byte(nil), data...)474 copyData := append([]byte(nil), data...)
323- ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})})475+ inputSeq++
476+ ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
324 })477 })
325 if !ok {478 if !ok {
326 return479 return
M internal/usecase/client/client_test.go +185 -0
@@ -530,3 +530,188 @@ func TestAttachForwardsResize(t *testing.T) {
530530 t.Fatal("resize was not forwarded")
531531 }
532532 }
533+
534+type testDialer struct {
535+ tr ports.Transport
536+ err error
537+ calls atomic.Int32
538+}
539+
540+func (d *testDialer) Dial(context.Context) (ports.Transport, error) {
541+ d.calls.Add(1)
542+ return d.tr, d.err
543+}
544+
545+func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
546+ var out bytes.Buffer
547+ var restoreCount atomic.Int32
548+ resizeCh := make(chan domain.Size)
549+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
550+ defer in.unblock()
551+
552+ tr := portsmocks.NewMockTransport(t)
553+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
554+ boom := errors.New("connection reset")
555+ unblock := scriptRecv(tr,
556+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
557+ recvItem{err: boom},
558+ )
559+ defer unblock()
560+ tr.EXPECT().Close().Return(nil).Once()
561+ d := &testDialer{tr: tr}
562+
563+ err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
564+ require.Error(t, err)
565+ require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
566+}
567+
568+func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
569+ tm := portsmocks.NewMockTerminal(t)
570+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
571+ // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
572+
573+ tr := portsmocks.NewMockTransport(t)
574+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
575+ tr.EXPECT().Recv().Return(
576+ frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
577+ nil,
578+ ).Once()
579+ tr.EXPECT().Close().Return(nil).Once()
580+ d := &testDialer{tr: tr}
581+
582+ err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main")
583+ require.Error(t, err)
584+ var pe *client.ProtocolError
585+ require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
586+ require.Equal(t, int32(1), d.calls.Load())
587+}
588+
589+func TestRunPhaseASingleAttempt(t *testing.T) {
590+ dialErr := errors.New("dial failed")
591+ d := &testDialer{err: dialErr}
592+ tm := portsmocks.NewMockTerminal(t)
593+
594+ err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
595+ require.ErrorIs(t, err, dialErr)
596+ require.Equal(t, int32(1), d.calls.Load(), "Phase A Run must not retry")
597+}
598+
599+type sequenceDialer struct {
600+ trs []ports.Transport
601+ errs []error
602+ calls atomic.Int32
603+}
604+
605+func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
606+ i := int(d.calls.Add(1)) - 1
607+ if i < len(d.errs) && d.errs[i] != nil {
608+ return nil, d.errs[i]
609+ }
610+ if i >= len(d.trs) {
611+ return nil, io.EOF
612+ }
613+ return d.trs[i], nil
614+}
615+
616+type recordingTransport struct {
617+ recvs []recvItem
618+ sends []ports.Frame
619+ closed atomic.Int32
620+}
621+
622+func (t *recordingTransport) Send(f ports.Frame) error {
623+ t.sends = append(t.sends, f)
624+ return nil
625+}
626+
627+func (t *recordingTransport) Recv() (ports.Frame, error) {
628+ if len(t.recvs) == 0 {
629+ return ports.Frame{}, io.EOF
630+ }
631+ it := t.recvs[0]
632+ t.recvs = t.recvs[1:]
633+ return it.f, it.err
634+}
635+
636+func (t *recordingTransport) Close() error {
637+ t.closed.Add(1)
638+ return nil
639+}
640+
641+type runTerminal struct {
642+ in *blockingReader
643+ out bytes.Buffer
644+ rawCount atomic.Int32
645+ restoreCount atomic.Int32
646+ resizeCh chan domain.Size
647+}
648+
649+func newRunTerminal() *runTerminal {
650+ return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)}
651+}
652+
653+func (t *runTerminal) EnterRaw() (func() error, error) {
654+ t.rawCount.Add(1)
655+ return func() error { t.restoreCount.Add(1); return nil }, nil
656+}
657+func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
658+func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
659+func (t *runTerminal) In() io.Reader { return t.in }
660+func (t *runTerminal) Out() io.Writer { return &t.out }
661+func (t *runTerminal) Flush() error { return nil }
662+
663+func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
664+ t.Helper()
665+ require.NotEmpty(t, tr.sends)
666+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
667+ h, err := ports.UnmarshalHello(tr.sends[0].Payload)
668+ require.NoError(t, err)
669+ return h
670+}
671+
672+func welcomeFrame(token uint64) ports.Frame {
673+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
674+}
675+
676+func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
677+ term := newRunTerminal()
678+ defer term.in.unblock()
679+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
680+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
681+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
682+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
683+
684+ err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")
685+ require.NoError(t, err)
686+ require.Equal(t, int32(3), d.calls.Load())
687+ require.Equal(t, int32(1), term.rawCount.Load())
688+ require.Equal(t, int32(1), term.restoreCount.Load())
689+
690+ h1 := helloFromSend(t, tr1)
691+ h2 := helloFromSend(t, tr2)
692+ h3 := helloFromSend(t, tr3)
693+ require.Equal(t, ports.IntentAttach, h1.Intent)
694+ require.Zero(t, h1.ResumeToken)
695+ require.Equal(t, ports.IntentResume, h2.Intent)
696+ require.Equal(t, uint64(11), h2.ResumeToken)
697+ require.Equal(t, ports.IntentResume, h3.Intent)
698+ require.Equal(t, uint64(22), h3.ResumeToken)
699+ require.Equal(t, h1.ClientID, h2.ClientID)
700+ require.Equal(t, h1.ClientID, h3.ClientID)
701+ require.Contains(t, term.out.String(), "reconnecting…")
702+ require.Contains(t, term.out.String(), "\x1b[2K")
703+}
704+
705+func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
706+ term := newRunTerminal()
707+ defer term.in.unblock()
708+ tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
709+ d := &sequenceDialer{trs: []ports.Transport{tr}}
710+
711+ err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")
712+ require.Error(t, err)
713+ var de *client.DetachedError
714+ require.True(t, errors.As(err, &de))
715+ require.Equal(t, int32(1), d.calls.Load())
716+ require.Equal(t, int32(1), term.restoreCount.Load())
717+}
@@ -530,3 +530,188 @@ func TestAttachForwardsResize(t *testing.T) {
530 t.Fatal("resize was not forwarded")530 t.Fatal("resize was not forwarded")
531 }531 }
532 }532 }
533+
534+type testDialer struct {
535+ tr ports.Transport
536+ err error
537+ calls atomic.Int32
538+}
539+
540+func (d *testDialer) Dial(context.Context) (ports.Transport, error) {
541+ d.calls.Add(1)
542+ return d.tr, d.err
543+}
544+
545+func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
546+ var out bytes.Buffer
547+ var restoreCount atomic.Int32
548+ resizeCh := make(chan domain.Size)
549+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
550+ defer in.unblock()
551+
552+ tr := portsmocks.NewMockTransport(t)
553+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
554+ boom := errors.New("connection reset")
555+ unblock := scriptRecv(tr,
556+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
557+ recvItem{err: boom},
558+ )
559+ defer unblock()
560+ tr.EXPECT().Close().Return(nil).Once()
561+ d := &testDialer{tr: tr}
562+
563+ err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
564+ require.Error(t, err)
565+ require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
566+}
567+
568+func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
569+ tm := portsmocks.NewMockTerminal(t)
570+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
571+ // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
572+
573+ tr := portsmocks.NewMockTransport(t)
574+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
575+ tr.EXPECT().Recv().Return(
576+ frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
577+ nil,
578+ ).Once()
579+ tr.EXPECT().Close().Return(nil).Once()
580+ d := &testDialer{tr: tr}
581+
582+ err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main")
583+ require.Error(t, err)
584+ var pe *client.ProtocolError
585+ require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
586+ require.Equal(t, int32(1), d.calls.Load())
587+}
588+
589+func TestRunPhaseASingleAttempt(t *testing.T) {
590+ dialErr := errors.New("dial failed")
591+ d := &testDialer{err: dialErr}
592+ tm := portsmocks.NewMockTerminal(t)
593+
594+ err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
595+ require.ErrorIs(t, err, dialErr)
596+ require.Equal(t, int32(1), d.calls.Load(), "Phase A Run must not retry")
597+}
598+
599+type sequenceDialer struct {
600+ trs []ports.Transport
601+ errs []error
602+ calls atomic.Int32
603+}
604+
605+func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
606+ i := int(d.calls.Add(1)) - 1
607+ if i < len(d.errs) && d.errs[i] != nil {
608+ return nil, d.errs[i]
609+ }
610+ if i >= len(d.trs) {
611+ return nil, io.EOF
612+ }
613+ return d.trs[i], nil
614+}
615+
616+type recordingTransport struct {
617+ recvs []recvItem
618+ sends []ports.Frame
619+ closed atomic.Int32
620+}
621+
622+func (t *recordingTransport) Send(f ports.Frame) error {
623+ t.sends = append(t.sends, f)
624+ return nil
625+}
626+
627+func (t *recordingTransport) Recv() (ports.Frame, error) {
628+ if len(t.recvs) == 0 {
629+ return ports.Frame{}, io.EOF
630+ }
631+ it := t.recvs[0]
632+ t.recvs = t.recvs[1:]
633+ return it.f, it.err
634+}
635+
636+func (t *recordingTransport) Close() error {
637+ t.closed.Add(1)
638+ return nil
639+}
640+
641+type runTerminal struct {
642+ in *blockingReader
643+ out bytes.Buffer
644+ rawCount atomic.Int32
645+ restoreCount atomic.Int32
646+ resizeCh chan domain.Size
647+}
648+
649+func newRunTerminal() *runTerminal {
650+ return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)}
651+}
652+
653+func (t *runTerminal) EnterRaw() (func() error, error) {
654+ t.rawCount.Add(1)
655+ return func() error { t.restoreCount.Add(1); return nil }, nil
656+}
657+func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
658+func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
659+func (t *runTerminal) In() io.Reader { return t.in }
660+func (t *runTerminal) Out() io.Writer { return &t.out }
661+func (t *runTerminal) Flush() error { return nil }
662+
663+func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
664+ t.Helper()
665+ require.NotEmpty(t, tr.sends)
666+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
667+ h, err := ports.UnmarshalHello(tr.sends[0].Payload)
668+ require.NoError(t, err)
669+ return h
670+}
671+
672+func welcomeFrame(token uint64) ports.Frame {
673+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
674+}
675+
676+func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
677+ term := newRunTerminal()
678+ defer term.in.unblock()
679+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
680+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
681+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
682+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
683+
684+ err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")
685+ require.NoError(t, err)
686+ require.Equal(t, int32(3), d.calls.Load())
687+ require.Equal(t, int32(1), term.rawCount.Load())
688+ require.Equal(t, int32(1), term.restoreCount.Load())
689+
690+ h1 := helloFromSend(t, tr1)
691+ h2 := helloFromSend(t, tr2)
692+ h3 := helloFromSend(t, tr3)
693+ require.Equal(t, ports.IntentAttach, h1.Intent)
694+ require.Zero(t, h1.ResumeToken)
695+ require.Equal(t, ports.IntentResume, h2.Intent)
696+ require.Equal(t, uint64(11), h2.ResumeToken)
697+ require.Equal(t, ports.IntentResume, h3.Intent)
698+ require.Equal(t, uint64(22), h3.ResumeToken)
699+ require.Equal(t, h1.ClientID, h2.ClientID)
700+ require.Equal(t, h1.ClientID, h3.ClientID)
701+ require.Contains(t, term.out.String(), "reconnecting…")
702+ require.Contains(t, term.out.String(), "\x1b[2K")
703+}
704+
705+func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
706+ term := newRunTerminal()
707+ defer term.in.unblock()
708+ tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
709+ d := &sequenceDialer{trs: []ports.Transport{tr}}
710+
711+ err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")
712+ require.Error(t, err)
713+ var de *client.DetachedError
714+ require.True(t, errors.As(err, &de))
715+ require.Equal(t, int32(1), d.calls.Load())
716+ require.Equal(t, int32(1), term.restoreCount.Load())
717+}
M internal/usecase/daemon/client.go +82 -11
@@ -27,6 +27,7 @@ package daemon
2727 import (
2828 "errors"
2929 "sync"
30+ "sync/atomic"
3031 "time"
3132
3233 "github.com/bnema/vev/internal/domain"
@@ -44,6 +45,13 @@ import (
4445 type attachedClient struct {
4546 tr ports.Transport
4647 rend *renderer.Renderer
48+ clientID [16]byte
49+ resumeCapable bool
50+ resumeToken uint64
51+ parked bool
52+ ackedStateNum uint64
53+ nextStateNum uint64
54+ echoAck atomic.Uint64
4755 bars barCache // only touched while sendMu is held
4856 size domain.Size
4957 keys *keys.Router
@@ -75,6 +83,7 @@ type attachedClient struct {
7583 themeMu sync.Mutex
7684 theme themeui.Theme
7785 lastCursor cursorOut
86+ linkMu sync.Mutex
7887 sendMu sync.Mutex
7988 }
8089
@@ -156,11 +165,42 @@ func (ac *attachedClient) promptActive() bool {
156165 return ac.prompt != nil
157166 }
158167
168+func (ac *attachedClient) transport() ports.Transport {
169+ ac.linkMu.Lock()
170+ defer ac.linkMu.Unlock()
171+ return ac.tr
172+}
173+
174+func (ac *attachedClient) replaceTransport(tr ports.Transport) {
175+ ac.linkMu.Lock()
176+ ac.tr = tr
177+ ac.linkMu.Unlock()
178+}
179+
180+func (ac *attachedClient) closeTransport() error {
181+ tr := ac.transport()
182+ if tr == nil {
183+ return nil
184+ }
185+ return tr.Close()
186+}
187+
188+func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
189+ if tr == nil {
190+ return nil
191+ }
192+ return tr.Close()
193+}
194+
159195 // send serialises a frame onto the client's transport.
160196 func (ac *attachedClient) send(f ports.Frame) error {
161197 ac.sendMu.Lock()
162198 defer ac.sendMu.Unlock()
163- return ac.tr.Send(f)
199+ tr := ac.transport()
200+ if tr == nil {
201+ return errors.New("client transport is nil")
202+ }
203+ return tr.Send(f)
164204 }
165205
166206 // boundedSend sends f to ac with a deadline watchdog: if the send (including
@@ -188,7 +228,7 @@ func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
188228 return err
189229 default:
190230 }
191- _ = ac.tr.Close()
231+ _ = ac.closeTransport()
192232 return errors.New("send timed out")
193233 }
194234 }
@@ -217,7 +257,7 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
217257 go func() {
218258 defer close(done)
219259 d.boundedSend(ac, frameDetached(reason))
220- _ = ac.tr.Close()
260+ _ = ac.closeTransport()
221261 }()
222262 }
223263
@@ -238,11 +278,23 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
238278 return snapshot
239279 }
240280
241-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) {
281+type attachClientOptions struct {
282+ clientID [16]byte
283+ resumeCapable bool
284+}
285+
286+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
287+ resumeToken := uint64(0)
288+ if opts.resumeCapable {
289+ resumeToken = newResumeToken()
290+ }
242291 ac := &attachedClient{
243- tr: tr,
244- rend: renderer.New(renderer.Capabilities{}),
245- size: sz,
292+ tr: tr,
293+ rend: renderer.New(renderer.Capabilities{}),
294+ size: sz,
295+ clientID: opts.clientID,
296+ resumeCapable: opts.resumeCapable,
297+ resumeToken: resumeToken,
246298 }
247299 ac.setSession(sess)
248300 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
@@ -333,7 +385,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
333385 if sess == nil {
334386 return
335387 }
336- f, err := ac.tr.Recv()
388+ tr := ac.transport()
389+ if tr == nil {
390+ return
391+ }
392+ f, err := tr.Recv()
337393 if err != nil {
338394 d.clientGone(sess, ac, false)
339395 return
@@ -345,7 +401,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
345401 switch f.Type {
346402 case ports.MsgInput:
347403 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
348- d.handleInput(sess, ac, in.Data)
404+ d.handleSequencedInput(sess, ac, in.InputSeq, in.Data)
349405 }
350406 case ports.MsgResize:
351407 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
@@ -358,6 +414,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
358414 case ports.MsgDetach:
359415 d.clientGone(sess, ac, true)
360416 return
417+ case ports.MsgAck:
418+ if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
419+ ac.ackedStateNum = ack.AckedStateNum
420+ }
361421 case ports.MsgPing:
362422 _ = ac.send(framePong())
363423 default:
@@ -381,6 +441,11 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
381441 if !ephemeral {
382442 d.refreshSessionCwd(sess)
383443 }
444+ oldTr := ac.transport()
445+ if !explicit && d.parkAttachment(sess, ac) {
446+ _ = ac.closeCapturedTransport(oldTr)
447+ return
448+ }
384449 d.resetScreenDefaultColors(sess)
385450 if explicit {
386451 // Synchronous so the ack is delivered before the transport closes
@@ -389,7 +454,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
389454 // connWg.Wait.
390455 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
391456 }
392- _ = ac.tr.Close()
457+ _ = ac.closeCapturedTransport(oldTr)
393458 if ephemeral {
394459 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
395460 }
@@ -464,7 +529,13 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
464529 if sess.detachIfCurrent(ac) {
465530 d.unregisterPreview(ac)
466531 d.resetScreenDefaultColors(sess)
467- _ = ac.tr.Close()
532+ oldTr := ac.transport()
533+ if d.parkAttachment(sess, ac) {
534+ _ = ac.closeCapturedTransport(oldTr)
535+ d.log.Warn("parked client after send error", "session", sess.name)
536+ return
537+ }
538+ _ = ac.closeCapturedTransport(oldTr)
468539 d.log.Warn("detached client after send error", "session", sess.name)
469540 sess.mu.Lock()
470541 ephemeral := sess.ephemeral
@@ -27,6 +27,7 @@ package daemon
27 import (27 import (
28 "errors"28 "errors"
29 "sync"29 "sync"
30+ "sync/atomic"
30 "time"31 "time"
31 32
32 "github.com/bnema/vev/internal/domain"33 "github.com/bnema/vev/internal/domain"
@@ -44,6 +45,13 @@ import (
44 type attachedClient struct {45 type attachedClient struct {
45 tr ports.Transport46 tr ports.Transport
46 rend *renderer.Renderer47 rend *renderer.Renderer
48+ clientID [16]byte
49+ resumeCapable bool
50+ resumeToken uint64
51+ parked bool
52+ ackedStateNum uint64
53+ nextStateNum uint64
54+ echoAck atomic.Uint64
47 bars barCache // only touched while sendMu is held55 bars barCache // only touched while sendMu is held
48 size domain.Size56 size domain.Size
49 keys *keys.Router57 keys *keys.Router
@@ -75,6 +83,7 @@ type attachedClient struct {
75 themeMu sync.Mutex83 themeMu sync.Mutex
76 theme themeui.Theme84 theme themeui.Theme
77 lastCursor cursorOut85 lastCursor cursorOut
86+ linkMu sync.Mutex
78 sendMu sync.Mutex87 sendMu sync.Mutex
79 }88 }
80 89
@@ -156,11 +165,42 @@ func (ac *attachedClient) promptActive() bool {
156 return ac.prompt != nil165 return ac.prompt != nil
157 }166 }
158 167
168+func (ac *attachedClient) transport() ports.Transport {
169+ ac.linkMu.Lock()
170+ defer ac.linkMu.Unlock()
171+ return ac.tr
172+}
173+
174+func (ac *attachedClient) replaceTransport(tr ports.Transport) {
175+ ac.linkMu.Lock()
176+ ac.tr = tr
177+ ac.linkMu.Unlock()
178+}
179+
180+func (ac *attachedClient) closeTransport() error {
181+ tr := ac.transport()
182+ if tr == nil {
183+ return nil
184+ }
185+ return tr.Close()
186+}
187+
188+func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
189+ if tr == nil {
190+ return nil
191+ }
192+ return tr.Close()
193+}
194+
159 // send serialises a frame onto the client's transport.195 // send serialises a frame onto the client's transport.
160 func (ac *attachedClient) send(f ports.Frame) error {196 func (ac *attachedClient) send(f ports.Frame) error {
161 ac.sendMu.Lock()197 ac.sendMu.Lock()
162 defer ac.sendMu.Unlock()198 defer ac.sendMu.Unlock()
163- return ac.tr.Send(f)199+ tr := ac.transport()
200+ if tr == nil {
201+ return errors.New("client transport is nil")
202+ }
203+ return tr.Send(f)
164 }204 }
165 205
166 // boundedSend sends f to ac with a deadline watchdog: if the send (including206 // boundedSend sends f to ac with a deadline watchdog: if the send (including
@@ -188,7 +228,7 @@ func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
188 return err228 return err
189 default:229 default:
190 }230 }
191- _ = ac.tr.Close()231+ _ = ac.closeTransport()
192 return errors.New("send timed out")232 return errors.New("send timed out")
193 }233 }
194 }234 }
@@ -217,7 +257,7 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
217 go func() {257 go func() {
218 defer close(done)258 defer close(done)
219 d.boundedSend(ac, frameDetached(reason))259 d.boundedSend(ac, frameDetached(reason))
220- _ = ac.tr.Close()260+ _ = ac.closeTransport()
221 }()261 }()
222 }262 }
223 263
@@ -238,11 +278,23 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
238 return snapshot278 return snapshot
239 }279 }
240 280
241-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) {281+type attachClientOptions struct {
282+ clientID [16]byte
283+ resumeCapable bool
284+}
285+
286+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
287+ resumeToken := uint64(0)
288+ if opts.resumeCapable {
289+ resumeToken = newResumeToken()
290+ }
242 ac := &attachedClient{291 ac := &attachedClient{
243- tr: tr,292+ tr: tr,
244- rend: renderer.New(renderer.Capabilities{}),293+ rend: renderer.New(renderer.Capabilities{}),
245- size: sz,294+ size: sz,
295+ clientID: opts.clientID,
296+ resumeCapable: opts.resumeCapable,
297+ resumeToken: resumeToken,
246 }298 }
247 ac.setSession(sess)299 ac.setSession(sess)
248 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})300 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
@@ -333,7 +385,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
333 if sess == nil {385 if sess == nil {
334 return386 return
335 }387 }
336- f, err := ac.tr.Recv()388+ tr := ac.transport()
389+ if tr == nil {
390+ return
391+ }
392+ f, err := tr.Recv()
337 if err != nil {393 if err != nil {
338 d.clientGone(sess, ac, false)394 d.clientGone(sess, ac, false)
339 return395 return
@@ -345,7 +401,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
345 switch f.Type {401 switch f.Type {
346 case ports.MsgInput:402 case ports.MsgInput:
347 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {403 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
348- d.handleInput(sess, ac, in.Data)404+ d.handleSequencedInput(sess, ac, in.InputSeq, in.Data)
349 }405 }
350 case ports.MsgResize:406 case ports.MsgResize:
351 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {407 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
@@ -358,6 +414,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
358 case ports.MsgDetach:414 case ports.MsgDetach:
359 d.clientGone(sess, ac, true)415 d.clientGone(sess, ac, true)
360 return416 return
417+ case ports.MsgAck:
418+ if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
419+ ac.ackedStateNum = ack.AckedStateNum
420+ }
361 case ports.MsgPing:421 case ports.MsgPing:
362 _ = ac.send(framePong())422 _ = ac.send(framePong())
363 default:423 default:
@@ -381,6 +441,11 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
381 if !ephemeral {441 if !ephemeral {
382 d.refreshSessionCwd(sess)442 d.refreshSessionCwd(sess)
383 }443 }
444+ oldTr := ac.transport()
445+ if !explicit && d.parkAttachment(sess, ac) {
446+ _ = ac.closeCapturedTransport(oldTr)
447+ return
448+ }
384 d.resetScreenDefaultColors(sess)449 d.resetScreenDefaultColors(sess)
385 if explicit {450 if explicit {
386 // Synchronous so the ack is delivered before the transport closes451 // Synchronous so the ack is delivered before the transport closes
@@ -389,7 +454,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
389 // connWg.Wait.454 // connWg.Wait.
390 d.boundedSend(ac, frameDetached(ports.ReasonDetach))455 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
391 }456 }
392- _ = ac.tr.Close()457+ _ = ac.closeCapturedTransport(oldTr)
393 if ephemeral {458 if ephemeral {
394 _ = d.killSession(sess, ports.ReasonSessionKilled, false)459 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
395 }460 }
@@ -464,7 +529,13 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
464 if sess.detachIfCurrent(ac) {529 if sess.detachIfCurrent(ac) {
465 d.unregisterPreview(ac)530 d.unregisterPreview(ac)
466 d.resetScreenDefaultColors(sess)531 d.resetScreenDefaultColors(sess)
467- _ = ac.tr.Close()532+ oldTr := ac.transport()
533+ if d.parkAttachment(sess, ac) {
534+ _ = ac.closeCapturedTransport(oldTr)
535+ d.log.Warn("parked client after send error", "session", sess.name)
536+ return
537+ }
538+ _ = ac.closeCapturedTransport(oldTr)
468 d.log.Warn("detached client after send error", "session", sess.name)539 d.log.Warn("detached client after send error", "session", sess.name)
469 sess.mu.Lock()540 sess.mu.Lock()
470 ephemeral := sess.ephemeral541 ephemeral := sess.ephemeral
M internal/usecase/daemon/daemon.go +49 -4
@@ -62,6 +62,8 @@ const defaultScrollbackRows = 10_000
6262 // the in-flight send. Teardown is never gated on a client draining its socket.
6363 const detachNotifyTimeout = time.Second
6464
65+const resumeParkGrace = 60 * time.Second
66+
6567 // defaultSize is used when a client's Hello carries no valid dimensions.
6668 var defaultSize = domain.Size{Cols: 80, Rows: 24}
6769
@@ -82,6 +84,7 @@ type Daemon struct {
8284 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent
8385 // with Wait.
8486 notifies []chan struct{}
87+ parked map[uint64]*parkedAttachment
8588
8689 attnMu sync.Mutex
8790 animFrame int
@@ -117,6 +120,14 @@ type Daemon struct {
117120 connWg sync.WaitGroup // per-connection handler goroutines
118121 }
119122
123+type parkedAttachment struct {
124+ sess *session
125+ ac *attachedClient
126+ timer ports.Timer
127+ done chan struct{}
128+ doneOnce sync.Once
129+}
130+
120131 // session is a single multiplexed session. It owns one or more full-screen
121132
122133 type stoppedSession struct {
@@ -169,6 +180,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
169180 d := &Daemon{
170181 sessions: make(map[domain.SessionID]*session),
171182 stopped: make(map[string]stoppedSession),
183+ parked: make(map[uint64]*parkedAttachment),
172184 ptys: ptys,
173185 clock: clock,
174186 log: log,
@@ -270,6 +282,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
270282 func (d *Daemon) shutdownAll(reason uint8) {
271283 d.mu.Lock()
272284 d.closing = true
285+ for token, parked := range d.parked {
286+ d.removeParkedLocked(token, parked)
287+ }
273288 snapshot := d.sessionsSnapshotLocked()
274289 empty := len(snapshot) == 0
275290 d.mu.Unlock()
@@ -425,7 +440,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
425440 return
426441 }
427442
428- if err := ac.send(frameWelcome(sess)); err != nil {
443+ if err := ac.send(frameWelcome(sess, ac)); err != nil {
429444 d.clientGone(sess, ac, false)
430445 return
431446 }
@@ -460,6 +475,33 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
460475 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
461476 }
462477 switch h.Intent {
478+ case ports.IntentResume:
479+ if sess, ac, ok, err := d.resumeParkedLocked(h, tr, sz); ok || err != nil {
480+ d.mu.Unlock()
481+ return sess, ac, err
482+ }
483+ // Resume miss/expiry falls back to normal attach semantics.
484+ sess := d.findByNameLocked(h.Name)
485+ if sess == nil {
486+ stopped, ok := d.stopped[h.Name]
487+ if !ok || stopped.purging {
488+ d.mu.Unlock()
489+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
490+ }
491+ cwd := platform.DirOrHome(stopped.cwd)
492+ var err error
493+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
494+ if err != nil {
495+ d.mu.Unlock()
496+ return nil, nil, err
497+ }
498+ }
499+ d.purgeParkedForSessionLocked(sess)
500+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
501+ d.mu.Unlock()
502+ d.detachReplacedClient(old)
503+ return sess, ac, nil
504+
463505 case ports.IntentEphemeral:
464506 name := d.allocEphemeralNameLocked()
465507 sess, err := d.createSessionLocked(name, true, h.Cwd, sz)
@@ -467,7 +509,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
467509 d.mu.Unlock()
468510 return nil, nil, err
469511 }
470- ac, old := d.attachClient(sess, tr, sz)
512+ d.purgeParkedForSessionLocked(sess)
513+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID})
471514 d.mu.Unlock()
472515 d.detachReplacedClient(old)
473516 return sess, ac, nil
@@ -486,7 +529,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
486529 d.mu.Unlock()
487530 return nil, nil, err
488531 }
489- ac, old := d.attachClient(sess, tr, sz)
532+ d.purgeParkedForSessionLocked(sess)
533+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
490534 d.mu.Unlock()
491535 d.detachReplacedClient(old)
492536 return sess, ac, nil
@@ -507,7 +551,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
507551 return nil, nil, err
508552 }
509553 }
510- ac, old := d.attachClient(sess, tr, sz)
554+ d.purgeParkedForSessionLocked(sess)
555+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
511556 d.mu.Unlock()
512557 d.detachReplacedClient(old)
513558 return sess, ac, nil
@@ -62,6 +62,8 @@ const defaultScrollbackRows = 10_000
62 // the in-flight send. Teardown is never gated on a client draining its socket.62 // the in-flight send. Teardown is never gated on a client draining its socket.
63 const detachNotifyTimeout = time.Second63 const detachNotifyTimeout = time.Second
64 64
65+const resumeParkGrace = 60 * time.Second
66+
65 // defaultSize is used when a client's Hello carries no valid dimensions.67 // defaultSize is used when a client's Hello carries no valid dimensions.
66 var defaultSize = domain.Size{Cols: 80, Rows: 24}68 var defaultSize = domain.Size{Cols: 80, Rows: 24}
67 69
@@ -82,6 +84,7 @@ type Daemon struct {
82 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent84 // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent
83 // with Wait.85 // with Wait.
84 notifies []chan struct{}86 notifies []chan struct{}
87+ parked map[uint64]*parkedAttachment
85 88
86 attnMu sync.Mutex89 attnMu sync.Mutex
87 animFrame int90 animFrame int
@@ -117,6 +120,14 @@ type Daemon struct {
117 connWg sync.WaitGroup // per-connection handler goroutines120 connWg sync.WaitGroup // per-connection handler goroutines
118 }121 }
119 122
123+type parkedAttachment struct {
124+ sess *session
125+ ac *attachedClient
126+ timer ports.Timer
127+ done chan struct{}
128+ doneOnce sync.Once
129+}
130+
120 // session is a single multiplexed session. It owns one or more full-screen131 // session is a single multiplexed session. It owns one or more full-screen
121 132
122 type stoppedSession struct {133 type stoppedSession struct {
@@ -169,6 +180,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
169 d := &Daemon{180 d := &Daemon{
170 sessions: make(map[domain.SessionID]*session),181 sessions: make(map[domain.SessionID]*session),
171 stopped: make(map[string]stoppedSession),182 stopped: make(map[string]stoppedSession),
183+ parked: make(map[uint64]*parkedAttachment),
172 ptys: ptys,184 ptys: ptys,
173 clock: clock,185 clock: clock,
174 log: log,186 log: log,
@@ -270,6 +282,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error {
270 func (d *Daemon) shutdownAll(reason uint8) {282 func (d *Daemon) shutdownAll(reason uint8) {
271 d.mu.Lock()283 d.mu.Lock()
272 d.closing = true284 d.closing = true
285+ for token, parked := range d.parked {
286+ d.removeParkedLocked(token, parked)
287+ }
273 snapshot := d.sessionsSnapshotLocked()288 snapshot := d.sessionsSnapshotLocked()
274 empty := len(snapshot) == 0289 empty := len(snapshot) == 0
275 d.mu.Unlock()290 d.mu.Unlock()
@@ -425,7 +440,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
425 return440 return
426 }441 }
427 442
428- if err := ac.send(frameWelcome(sess)); err != nil {443+ if err := ac.send(frameWelcome(sess, ac)); err != nil {
429 d.clientGone(sess, ac, false)444 d.clientGone(sess, ac, false)
430 return445 return
431 }446 }
@@ -460,6 +475,33 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
460 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}475 return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
461 }476 }
462 switch h.Intent {477 switch h.Intent {
478+ case ports.IntentResume:
479+ if sess, ac, ok, err := d.resumeParkedLocked(h, tr, sz); ok || err != nil {
480+ d.mu.Unlock()
481+ return sess, ac, err
482+ }
483+ // Resume miss/expiry falls back to normal attach semantics.
484+ sess := d.findByNameLocked(h.Name)
485+ if sess == nil {
486+ stopped, ok := d.stopped[h.Name]
487+ if !ok || stopped.purging {
488+ d.mu.Unlock()
489+ return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name}
490+ }
491+ cwd := platform.DirOrHome(stopped.cwd)
492+ var err error
493+ sess, err = d.createSessionLocked(h.Name, false, cwd, sz)
494+ if err != nil {
495+ d.mu.Unlock()
496+ return nil, nil, err
497+ }
498+ }
499+ d.purgeParkedForSessionLocked(sess)
500+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
501+ d.mu.Unlock()
502+ d.detachReplacedClient(old)
503+ return sess, ac, nil
504+
463 case ports.IntentEphemeral:505 case ports.IntentEphemeral:
464 name := d.allocEphemeralNameLocked()506 name := d.allocEphemeralNameLocked()
465 sess, err := d.createSessionLocked(name, true, h.Cwd, sz)507 sess, err := d.createSessionLocked(name, true, h.Cwd, sz)
@@ -467,7 +509,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
467 d.mu.Unlock()509 d.mu.Unlock()
468 return nil, nil, err510 return nil, nil, err
469 }511 }
470- ac, old := d.attachClient(sess, tr, sz)512+ d.purgeParkedForSessionLocked(sess)
513+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID})
471 d.mu.Unlock()514 d.mu.Unlock()
472 d.detachReplacedClient(old)515 d.detachReplacedClient(old)
473 return sess, ac, nil516 return sess, ac, nil
@@ -486,7 +529,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
486 d.mu.Unlock()529 d.mu.Unlock()
487 return nil, nil, err530 return nil, nil, err
488 }531 }
489- ac, old := d.attachClient(sess, tr, sz)532+ d.purgeParkedForSessionLocked(sess)
533+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
490 d.mu.Unlock()534 d.mu.Unlock()
491 d.detachReplacedClient(old)535 d.detachReplacedClient(old)
492 return sess, ac, nil536 return sess, ac, nil
@@ -507,7 +551,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl
507 return nil, nil, err551 return nil, nil, err
508 }552 }
509 }553 }
510- ac, old := d.attachClient(sess, tr, sz)554+ d.purgeParkedForSessionLocked(sess)
555+ ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true})
511 d.mu.Unlock()556 d.mu.Unlock()
512 d.detachReplacedClient(old)557 d.detachReplacedClient(old)
513 return sess, ac, nil558 return sess, ac, nil
M internal/usecase/daemon/input.go +12 -0
@@ -34,6 +34,18 @@ import (
3434 "github.com/bnema/vev/internal/usecase/mouse"
3535 )
3636
37+func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) {
38+ d.handleInput(sess, ac, data)
39+ if seq > 0 {
40+ for {
41+ cur := ac.echoAck.Load()
42+ if seq <= cur || ac.echoAck.CompareAndSwap(cur, seq) {
43+ break
44+ }
45+ }
46+ }
47+}
48+
3749 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
3850 ac.mouseScan.Scan(data,
3951 func(ev mouse.Event) { d.handleMouse(ac, ev) },
@@ -34,6 +34,18 @@ import (
34 "github.com/bnema/vev/internal/usecase/mouse"34 "github.com/bnema/vev/internal/usecase/mouse"
35 )35 )
36 36
37+func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) {
38+ d.handleInput(sess, ac, data)
39+ if seq > 0 {
40+ for {
41+ cur := ac.echoAck.Load()
42+ if seq <= cur || ac.echoAck.CompareAndSwap(cur, seq) {
43+ break
44+ }
45+ }
46+ }
47+}
48+
37 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {49 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
38 ac.mouseScan.Scan(data,50 ac.mouseScan.Scan(data,
39 func(ev mouse.Event) { d.handleMouse(ac, ev) },51 func(ev mouse.Event) { d.handleMouse(ac, ev) },
M internal/usecase/daemon/render.go +8 -1
@@ -26,6 +26,7 @@ package daemon
2626
2727 import (
2828 "bytes"
29+ "errors"
2930 "strconv"
3031 "time"
3132
@@ -381,7 +382,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
381382 if err == nil {
382383 data = append(data, cursorTail...)
383384 if len(data) > 0 {
384- serr = ac.tr.Send(frameOutput(data))
385+ tr := ac.transport()
386+ if tr == nil {
387+ serr = errors.New("client transport is nil")
388+ } else {
389+ ac.nextStateNum++
390+ serr = tr.Send(frameOutputState(data, ac.nextStateNum, ac.echoAck.Load()))
391+ }
385392 }
386393 }
387394 ac.sendMu.Unlock()
@@ -26,6 +26,7 @@ package daemon
26 26
27 import (27 import (
28 "bytes"28 "bytes"
29+ "errors"
29 "strconv"30 "strconv"
30 "time"31 "time"
31 32
@@ -381,7 +382,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
381 if err == nil {382 if err == nil {
382 data = append(data, cursorTail...)383 data = append(data, cursorTail...)
383 if len(data) > 0 {384 if len(data) > 0 {
384- serr = ac.tr.Send(frameOutput(data))385+ tr := ac.transport()
386+ if tr == nil {
387+ serr = errors.New("client transport is nil")
388+ } else {
389+ ac.nextStateNum++
390+ serr = tr.Send(frameOutputState(data, ac.nextStateNum, ac.echoAck.Load()))
391+ }
385 }392 }
386 }393 }
387 ac.sendMu.Unlock()394 ac.sendMu.Unlock()
M internal/usecase/daemon/render_test.go +14 -0
@@ -384,6 +384,20 @@ func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) {
384384 require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained")
385385 }
386386
387+func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) {
388+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
389+ sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
390+ ac := &attachedClient{rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 10, Rows: 4}}
391+ sess.client = ac
392+ ac.setSession(sess)
393+ d := newTestDaemon(t, nil, stubClock{})
394+
395+ tb.focusedPane().screen.Write([]byte("x"))
396+ d.render(sess, tb, tb.focusedPane())
397+
398+ require.Zero(t, ac.nextStateNum)
399+}
400+
387401 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
388402 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
389403 left := win.focusedPane()
@@ -384,6 +384,20 @@ func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) {
384 require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained")384 require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained")
385 }385 }
386 386
387+func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) {
388+ tb := newTab(nil, domain.Size{Cols: 10, Rows: 3})
389+ sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0}
390+ ac := &attachedClient{rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 10, Rows: 4}}
391+ sess.client = ac
392+ ac.setSession(sess)
393+ d := newTestDaemon(t, nil, stubClock{})
394+
395+ tb.focusedPane().screen.Write([]byte("x"))
396+ d.render(sess, tb, tb.focusedPane())
397+
398+ require.Zero(t, ac.nextStateNum)
399+}
400+
387 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {401 func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) {
388 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})402 win := newTab(nil, domain.Size{Cols: 41, Rows: 4})
389 left := win.focusedPane()403 left := win.focusedPane()
A internal/usecase/daemon/resume.go +148 -0
@@ -0,0 +1,148 @@
1+package daemon
2+
3+import (
4+ "crypto/rand"
5+ "encoding/binary"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+func newResumeToken() uint64 {
12+ var b [8]byte
13+ if _, err := rand.Read(b[:]); err != nil {
14+ return 0
15+ }
16+ return binary.BigEndian.Uint64(b[:])
17+}
18+
19+func (d *Daemon) nextResumeTokenLocked() uint64 {
20+ for {
21+ tok := newResumeToken()
22+ if tok == 0 {
23+ continue
24+ }
25+ if _, exists := d.parked[tok]; !exists {
26+ return tok
27+ }
28+ }
29+}
30+
31+func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
32+ sess.mu.Lock()
33+ ephemeral := sess.ephemeral
34+ sess.mu.Unlock()
35+ if ephemeral || !ac.resumeCapable {
36+ return false
37+ }
38+ if ac.resumeToken == 0 {
39+ d.mu.Lock()
40+ if d.closing {
41+ d.mu.Unlock()
42+ return false
43+ }
44+ ac.resumeToken = d.nextResumeTokenLocked()
45+ d.mu.Unlock()
46+ }
47+ return true
48+}
49+
50+func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
51+ if !d.prepareParkAttachment(sess, ac) {
52+ return false
53+ }
54+ d.mu.Lock()
55+ if d.closing {
56+ d.mu.Unlock()
57+ return false
58+ }
59+ token := ac.resumeToken
60+ if old := d.parked[token]; old != nil {
61+ old.timer.Stop()
62+ old.closeDone()
63+ }
64+ timer := d.clock.NewTimer(resumeParkGrace)
65+ parked := &parkedAttachment{sess: sess, ac: ac, timer: timer, done: make(chan struct{})}
66+ ac.parked = true
67+ d.parked[token] = parked
68+ d.mu.Unlock()
69+
70+ go func(token uint64, parked *parkedAttachment) {
71+ select {
72+ case <-timer.C():
73+ d.expireParked(token, parked)
74+ case <-parked.done:
75+ }
76+ }(token, parked)
77+ return true
78+}
79+
80+func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
81+ d.mu.Lock()
82+ if d.parked[token] == parked {
83+ d.removeParkedLocked(token, parked)
84+ }
85+ d.mu.Unlock()
86+}
87+
88+// removeParkedLocked invalidates one parked attachment. Caller holds d.mu and
89+// has verified d.parked[token] still points at parked when that matters.
90+func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
91+ delete(d.parked, token)
92+ parked.ac.resumeToken = 0
93+ parked.ac.parked = false
94+ if parked.timer != nil {
95+ parked.timer.Stop()
96+ }
97+ parked.closeDone()
98+}
99+
100+func (p *parkedAttachment) closeDone() {
101+ if p.done == nil {
102+ return
103+ }
104+ p.doneOnce.Do(func() { close(p.done) })
105+}
106+
107+// purgeParkedForSessionLocked invalidates every parked token for sess. Caller
108+// holds d.mu. Use before killing or normally attaching to a session so stale
109+// resume tokens cannot resurrect or replace an active attachment.
110+func (d *Daemon) purgeParkedForSessionLocked(sess *session) {
111+ for token, parked := range d.parked {
112+ if parked.sess == sess {
113+ d.removeParkedLocked(token, parked)
114+ }
115+ }
116+}
117+
118+func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) {
119+ parked := d.parked[h.ResumeToken]
120+ if parked == nil || h.ResumeToken == 0 {
121+ return nil, nil, false, nil
122+ }
123+ sess := parked.sess
124+ registered := d.sessions[sess.id] == sess
125+ sess.mu.Lock()
126+ active := sess.client != nil
127+ sess.mu.Unlock()
128+ if !registered || active {
129+ d.removeParkedLocked(h.ResumeToken, parked)
130+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
131+ }
132+ ac := parked.ac
133+ if ac.clientID != h.ClientID {
134+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
135+ }
136+ delete(d.parked, h.ResumeToken)
137+ parked.timer.Stop()
138+ parked.closeDone()
139+ ac.replaceTransport(tr)
140+ ac.size = sz
141+ ac.resumeToken = d.nextResumeTokenLocked()
142+ ac.parked = false
143+ ac.setSession(sess)
144+ sess.mu.Lock()
145+ sess.client = ac
146+ sess.mu.Unlock()
147+ return sess, ac, true, nil
148+}
@@ -0,0 +1,148 @@
1+package daemon
2+
3+import (
4+ "crypto/rand"
5+ "encoding/binary"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+)
10+
11+func newResumeToken() uint64 {
12+ var b [8]byte
13+ if _, err := rand.Read(b[:]); err != nil {
14+ return 0
15+ }
16+ return binary.BigEndian.Uint64(b[:])
17+}
18+
19+func (d *Daemon) nextResumeTokenLocked() uint64 {
20+ for {
21+ tok := newResumeToken()
22+ if tok == 0 {
23+ continue
24+ }
25+ if _, exists := d.parked[tok]; !exists {
26+ return tok
27+ }
28+ }
29+}
30+
31+func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
32+ sess.mu.Lock()
33+ ephemeral := sess.ephemeral
34+ sess.mu.Unlock()
35+ if ephemeral || !ac.resumeCapable {
36+ return false
37+ }
38+ if ac.resumeToken == 0 {
39+ d.mu.Lock()
40+ if d.closing {
41+ d.mu.Unlock()
42+ return false
43+ }
44+ ac.resumeToken = d.nextResumeTokenLocked()
45+ d.mu.Unlock()
46+ }
47+ return true
48+}
49+
50+func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
51+ if !d.prepareParkAttachment(sess, ac) {
52+ return false
53+ }
54+ d.mu.Lock()
55+ if d.closing {
56+ d.mu.Unlock()
57+ return false
58+ }
59+ token := ac.resumeToken
60+ if old := d.parked[token]; old != nil {
61+ old.timer.Stop()
62+ old.closeDone()
63+ }
64+ timer := d.clock.NewTimer(resumeParkGrace)
65+ parked := &parkedAttachment{sess: sess, ac: ac, timer: timer, done: make(chan struct{})}
66+ ac.parked = true
67+ d.parked[token] = parked
68+ d.mu.Unlock()
69+
70+ go func(token uint64, parked *parkedAttachment) {
71+ select {
72+ case <-timer.C():
73+ d.expireParked(token, parked)
74+ case <-parked.done:
75+ }
76+ }(token, parked)
77+ return true
78+}
79+
80+func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
81+ d.mu.Lock()
82+ if d.parked[token] == parked {
83+ d.removeParkedLocked(token, parked)
84+ }
85+ d.mu.Unlock()
86+}
87+
88+// removeParkedLocked invalidates one parked attachment. Caller holds d.mu and
89+// has verified d.parked[token] still points at parked when that matters.
90+func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
91+ delete(d.parked, token)
92+ parked.ac.resumeToken = 0
93+ parked.ac.parked = false
94+ if parked.timer != nil {
95+ parked.timer.Stop()
96+ }
97+ parked.closeDone()
98+}
99+
100+func (p *parkedAttachment) closeDone() {
101+ if p.done == nil {
102+ return
103+ }
104+ p.doneOnce.Do(func() { close(p.done) })
105+}
106+
107+// purgeParkedForSessionLocked invalidates every parked token for sess. Caller
108+// holds d.mu. Use before killing or normally attaching to a session so stale
109+// resume tokens cannot resurrect or replace an active attachment.
110+func (d *Daemon) purgeParkedForSessionLocked(sess *session) {
111+ for token, parked := range d.parked {
112+ if parked.sess == sess {
113+ d.removeParkedLocked(token, parked)
114+ }
115+ }
116+}
117+
118+func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) {
119+ parked := d.parked[h.ResumeToken]
120+ if parked == nil || h.ResumeToken == 0 {
121+ return nil, nil, false, nil
122+ }
123+ sess := parked.sess
124+ registered := d.sessions[sess.id] == sess
125+ sess.mu.Lock()
126+ active := sess.client != nil
127+ sess.mu.Unlock()
128+ if !registered || active {
129+ d.removeParkedLocked(h.ResumeToken, parked)
130+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
131+ }
132+ ac := parked.ac
133+ if ac.clientID != h.ClientID {
134+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
135+ }
136+ delete(d.parked, h.ResumeToken)
137+ parked.timer.Stop()
138+ parked.closeDone()
139+ ac.replaceTransport(tr)
140+ ac.size = sz
141+ ac.resumeToken = d.nextResumeTokenLocked()
142+ ac.parked = false
143+ ac.setSession(sess)
144+ sess.mu.Lock()
145+ sess.client = ac
146+ sess.mu.Unlock()
147+ return sess, ac, true, nil
148+}
A internal/usecase/daemon/resume_test.go +245 -0
@@ -0,0 +1,245 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+type closeTrackingTransport struct {
16+ mu sync.Mutex
17+ closed bool
18+}
19+
20+func (t *closeTrackingTransport) Send(ports.Frame) error { return nil }
21+func (t *closeTrackingTransport) Recv() (ports.Frame, error) {
22+ return ports.Frame{}, errors.New("closed")
23+}
24+func (t *closeTrackingTransport) Close() error {
25+ t.mu.Lock()
26+ t.closed = true
27+ t.mu.Unlock()
28+ return nil
29+}
30+func (t *closeTrackingTransport) Closed() bool {
31+ t.mu.Lock()
32+ defer t.mu.Unlock()
33+ return t.closed
34+}
35+
36+func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
37+ return ports.Hello{
38+ Version: ports.ProtocolVersion,
39+ Intent: intent,
40+ ClientID: [16]byte{1, 2, 3, 4},
41+ ResumeToken: token,
42+ Name: name,
43+ Size: domain.Size{Cols: 80, Rows: 24},
44+ TermEnv: "xterm-256color",
45+ }
46+}
47+
48+func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
49+ pty, release := newBlockingPTY(t)
50+ defer release()
51+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
52+
53+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
54+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
55+ require.NoError(t, err)
56+ token := ac.resumeToken
57+ require.NotZero(t, token)
58+
59+ d.clientGone(sess, ac, false)
60+ require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
61+ require.Nil(t, sess.client)
62+ d.mu.Lock()
63+ _, parked := d.parked[token]
64+ d.mu.Unlock()
65+ require.True(t, parked, "named resume-capable link loss is parked")
66+
67+ pty2, release2 := newBlockingPTY(t)
68+ defer release2()
69+ d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{})
70+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
71+ es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2)
72+ require.NoError(t, err)
73+ d2.clientGone(es, eac, false)
74+ require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session")
75+}
76+
77+func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
78+ pty, release := newBlockingPTY(t)
79+ defer release()
80+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
81+
82+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
83+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
84+ require.NoError(t, err)
85+ oldToken := ac.resumeToken
86+ d.clientGone(sess, ac, false)
87+
88+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
89+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", oldToken), tr2)
90+ require.NoError(t, err)
91+ require.Same(t, sess, resumedSess)
92+ require.Same(t, ac, resumedAC)
93+ require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")
94+ require.Same(t, resumedAC, sess.client)
95+}
96+
97+func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
98+ pty, release := newBlockingPTY(t)
99+ defer release()
100+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
101+
102+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
103+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
104+ require.NoError(t, err)
105+ token := ac.resumeToken
106+ d.clientGone(sess, ac, false)
107+
108+ wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
109+ wrongClient.ClientID = [16]byte{9, 9, 9, 9}
110+ d.mu.Lock()
111+ _, _, ok, err := d.resumeParkedLocked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
112+ _, stillParked := d.parked[token]
113+ d.mu.Unlock()
114+ require.Error(t, err)
115+ require.False(t, ok)
116+ require.True(t, stillParked, "mismatched client must not consume parked token")
117+ require.Equal(t, token, ac.resumeToken)
118+ require.True(t, ac.parked)
119+
120+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
121+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
122+ require.NoError(t, err)
123+ require.Same(t, sess, resumedSess)
124+ require.Same(t, ac, resumedAC)
125+}
126+
127+func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.T) {
128+ pty, release := newBlockingPTY(t)
129+ defer release()
130+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
131+
132+ oldTr := &closeTrackingTransport{}
133+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
134+ require.NoError(t, err)
135+ token := ac.resumeToken
136+ require.True(t, sess.detachIfCurrent(ac))
137+ require.True(t, d.parkAttachment(sess, ac))
138+
139+ newTr := &closeTrackingTransport{}
140+ d.mu.Lock()
141+ resumedSess, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})
142+ d.mu.Unlock()
143+ require.NoError(t, err)
144+ require.True(t, ok)
145+ require.Same(t, sess, resumedSess)
146+ require.Same(t, ac, resumedAC)
147+
148+ _ = ac.closeCapturedTransport(oldTr)
149+ require.True(t, oldTr.Closed(), "old transport is closed")
150+ require.False(t, newTr.Closed(), "newly rebound transport is not closed by old cleanup")
151+ require.Same(t, newTr, ac.transport())
152+}
153+
154+func TestExplicitDetachDoesNotPark(t *testing.T) {
155+ pty, release := newBlockingPTY(t)
156+ defer release()
157+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
158+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
159+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
160+ require.NoError(t, err)
161+ d.clientGone(sess, ac, true)
162+ d.mu.Lock()
163+ parked := len(d.parked)
164+ d.mu.Unlock()
165+ require.Zero(t, parked)
166+}
167+
168+func TestParkExpiryAndShutdownCleanup(t *testing.T) {
169+ clk := &signalClock{timers: make(chan *signalTimer, 8)}
170+ pty, release := newBlockingPTY(t)
171+ defer release()
172+ d := newTestDaemon(t, newFactory(t, pty), clk)
173+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
174+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
175+ require.NoError(t, err)
176+ token := ac.resumeToken
177+ d.clientGone(sess, ac, false)
178+ timer := <-clk.timers
179+ timer.ch <- clk.Now()
180+ require.Eventually(t, func() bool {
181+ d.mu.Lock()
182+ defer d.mu.Unlock()
183+ _, ok := d.parked[token]
184+ return !ok
185+ }, 2*time.Second, 10*time.Millisecond)
186+
187+ pty2, release2 := newBlockingPTY(t)
188+ defer release2()
189+ d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{})
190+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
191+ sess2, ac2, err := d2.route(helloResumeCapable(ports.IntentNew, "other", 0), tr2)
192+ require.NoError(t, err)
193+ d2.clientGone(sess2, ac2, false)
194+ d2.shutdownAll(ports.ReasonServerShutdown)
195+ d2.mu.Lock()
196+ parked := len(d2.parked)
197+ d2.mu.Unlock()
198+ require.Zero(t, parked)
199+}
200+
201+func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
202+ pty, release := newBlockingPTY(t)
203+ defer release()
204+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
205+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
206+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
207+ require.NoError(t, err)
208+ token := ac.resumeToken
209+ d.clientGone(sess, ac, false)
210+
211+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
212+ d.mu.Lock()
213+ _, parked := d.parked[token]
214+ _, _, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
215+ d.mu.Unlock()
216+ require.False(t, parked, "killSession purges parked token")
217+ require.NoError(t, err)
218+ require.False(t, ok, "killed session cannot be resumed")
219+}
220+
221+func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
222+ pty, release := newBlockingPTY(t)
223+ defer release()
224+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
225+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
226+ sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
227+ require.NoError(t, err)
228+ token := oldAC.resumeToken
229+ d.clientGone(sess, oldAC, false)
230+
231+ activeTr := &closeTrackingTransport{}
232+ _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr)
233+ require.NoError(t, err)
234+ require.NotSame(t, oldAC, activeAC)
235+
236+ d.mu.Lock()
237+ _, parked := d.parked[token]
238+ _, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
239+ d.mu.Unlock()
240+ require.False(t, parked, "normal attach invalidates stale parked token")
241+ require.NoError(t, err)
242+ require.False(t, ok)
243+ require.Nil(t, resumedAC)
244+ require.Same(t, activeAC, sess.client)
245+}
@@ -0,0 +1,245 @@
1+package daemon
2+
3+import (
4+ "errors"
5+ "sync"
6+ "testing"
7+ "time"
8+
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+)
14+
15+type closeTrackingTransport struct {
16+ mu sync.Mutex
17+ closed bool
18+}
19+
20+func (t *closeTrackingTransport) Send(ports.Frame) error { return nil }
21+func (t *closeTrackingTransport) Recv() (ports.Frame, error) {
22+ return ports.Frame{}, errors.New("closed")
23+}
24+func (t *closeTrackingTransport) Close() error {
25+ t.mu.Lock()
26+ t.closed = true
27+ t.mu.Unlock()
28+ return nil
29+}
30+func (t *closeTrackingTransport) Closed() bool {
31+ t.mu.Lock()
32+ defer t.mu.Unlock()
33+ return t.closed
34+}
35+
36+func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
37+ return ports.Hello{
38+ Version: ports.ProtocolVersion,
39+ Intent: intent,
40+ ClientID: [16]byte{1, 2, 3, 4},
41+ ResumeToken: token,
42+ Name: name,
43+ Size: domain.Size{Cols: 80, Rows: 24},
44+ TermEnv: "xterm-256color",
45+ }
46+}
47+
48+func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
49+ pty, release := newBlockingPTY(t)
50+ defer release()
51+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
52+
53+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
54+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
55+ require.NoError(t, err)
56+ token := ac.resumeToken
57+ require.NotZero(t, token)
58+
59+ d.clientGone(sess, ac, false)
60+ require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
61+ require.Nil(t, sess.client)
62+ d.mu.Lock()
63+ _, parked := d.parked[token]
64+ d.mu.Unlock()
65+ require.True(t, parked, "named resume-capable link loss is parked")
66+
67+ pty2, release2 := newBlockingPTY(t)
68+ defer release2()
69+ d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{})
70+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
71+ es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2)
72+ require.NoError(t, err)
73+ d2.clientGone(es, eac, false)
74+ require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session")
75+}
76+
77+func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
78+ pty, release := newBlockingPTY(t)
79+ defer release()
80+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
81+
82+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
83+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
84+ require.NoError(t, err)
85+ oldToken := ac.resumeToken
86+ d.clientGone(sess, ac, false)
87+
88+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
89+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", oldToken), tr2)
90+ require.NoError(t, err)
91+ require.Same(t, sess, resumedSess)
92+ require.Same(t, ac, resumedAC)
93+ require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")
94+ require.Same(t, resumedAC, sess.client)
95+}
96+
97+func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
98+ pty, release := newBlockingPTY(t)
99+ defer release()
100+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
101+
102+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
103+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
104+ require.NoError(t, err)
105+ token := ac.resumeToken
106+ d.clientGone(sess, ac, false)
107+
108+ wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
109+ wrongClient.ClientID = [16]byte{9, 9, 9, 9}
110+ d.mu.Lock()
111+ _, _, ok, err := d.resumeParkedLocked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
112+ _, stillParked := d.parked[token]
113+ d.mu.Unlock()
114+ require.Error(t, err)
115+ require.False(t, ok)
116+ require.True(t, stillParked, "mismatched client must not consume parked token")
117+ require.Equal(t, token, ac.resumeToken)
118+ require.True(t, ac.parked)
119+
120+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
121+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
122+ require.NoError(t, err)
123+ require.Same(t, sess, resumedSess)
124+ require.Same(t, ac, resumedAC)
125+}
126+
127+func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.T) {
128+ pty, release := newBlockingPTY(t)
129+ defer release()
130+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
131+
132+ oldTr := &closeTrackingTransport{}
133+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
134+ require.NoError(t, err)
135+ token := ac.resumeToken
136+ require.True(t, sess.detachIfCurrent(ac))
137+ require.True(t, d.parkAttachment(sess, ac))
138+
139+ newTr := &closeTrackingTransport{}
140+ d.mu.Lock()
141+ resumedSess, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})
142+ d.mu.Unlock()
143+ require.NoError(t, err)
144+ require.True(t, ok)
145+ require.Same(t, sess, resumedSess)
146+ require.Same(t, ac, resumedAC)
147+
148+ _ = ac.closeCapturedTransport(oldTr)
149+ require.True(t, oldTr.Closed(), "old transport is closed")
150+ require.False(t, newTr.Closed(), "newly rebound transport is not closed by old cleanup")
151+ require.Same(t, newTr, ac.transport())
152+}
153+
154+func TestExplicitDetachDoesNotPark(t *testing.T) {
155+ pty, release := newBlockingPTY(t)
156+ defer release()
157+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
158+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
159+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
160+ require.NoError(t, err)
161+ d.clientGone(sess, ac, true)
162+ d.mu.Lock()
163+ parked := len(d.parked)
164+ d.mu.Unlock()
165+ require.Zero(t, parked)
166+}
167+
168+func TestParkExpiryAndShutdownCleanup(t *testing.T) {
169+ clk := &signalClock{timers: make(chan *signalTimer, 8)}
170+ pty, release := newBlockingPTY(t)
171+ defer release()
172+ d := newTestDaemon(t, newFactory(t, pty), clk)
173+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
174+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
175+ require.NoError(t, err)
176+ token := ac.resumeToken
177+ d.clientGone(sess, ac, false)
178+ timer := <-clk.timers
179+ timer.ch <- clk.Now()
180+ require.Eventually(t, func() bool {
181+ d.mu.Lock()
182+ defer d.mu.Unlock()
183+ _, ok := d.parked[token]
184+ return !ok
185+ }, 2*time.Second, 10*time.Millisecond)
186+
187+ pty2, release2 := newBlockingPTY(t)
188+ defer release2()
189+ d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{})
190+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
191+ sess2, ac2, err := d2.route(helloResumeCapable(ports.IntentNew, "other", 0), tr2)
192+ require.NoError(t, err)
193+ d2.clientGone(sess2, ac2, false)
194+ d2.shutdownAll(ports.ReasonServerShutdown)
195+ d2.mu.Lock()
196+ parked := len(d2.parked)
197+ d2.mu.Unlock()
198+ require.Zero(t, parked)
199+}
200+
201+func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
202+ pty, release := newBlockingPTY(t)
203+ defer release()
204+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
205+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
206+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
207+ require.NoError(t, err)
208+ token := ac.resumeToken
209+ d.clientGone(sess, ac, false)
210+
211+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
212+ d.mu.Lock()
213+ _, parked := d.parked[token]
214+ _, _, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
215+ d.mu.Unlock()
216+ require.False(t, parked, "killSession purges parked token")
217+ require.NoError(t, err)
218+ require.False(t, ok, "killed session cannot be resumed")
219+}
220+
221+func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
222+ pty, release := newBlockingPTY(t)
223+ defer release()
224+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
225+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
226+ sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
227+ require.NoError(t, err)
228+ token := oldAC.resumeToken
229+ d.clientGone(sess, oldAC, false)
230+
231+ activeTr := &closeTrackingTransport{}
232+ _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr)
233+ require.NoError(t, err)
234+ require.NotSame(t, oldAC, activeAC)
235+
236+ d.mu.Lock()
237+ _, parked := d.parked[token]
238+ _, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
239+ d.mu.Unlock()
240+ require.False(t, parked, "normal attach invalidates stale parked token")
241+ require.NoError(t, err)
242+ require.False(t, ok)
243+ require.Nil(t, resumedAC)
244+ require.Same(t, activeAC, sess.client)
245+}
M internal/usecase/daemon/session.go +1 -0
@@ -415,6 +415,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
415415 return nil
416416 }
417417 delete(d.sessions, sess.id)
418+ d.purgeParkedForSessionLocked(sess)
418419 sess.mu.Lock()
419420 stoppedName := sess.name
420421 stoppedCwd := sess.cwd
@@ -415,6 +415,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error {
415 return nil415 return nil
416 }416 }
417 delete(d.sessions, sess.id)417 delete(d.sessions, sess.id)
418+ d.purgeParkedForSessionLocked(sess)
418 sess.mu.Lock()419 sess.mu.Lock()
419 stoppedName := sess.name420 stoppedName := sess.name
420 stoppedCwd := sess.cwd421 stoppedCwd := sess.cwd
M internal/usecase/daemon/status_test.go +3 -3
@@ -102,7 +102,7 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
102102 })
103103 tr, _ := newCapturingTransport(t)
104104
105- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
105+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
106106
107107 var got []byte
108108 tb := sess.activeTab()
@@ -151,7 +151,7 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
151151 require.True(t, sess.detachIfCurrent(ac))
152152
153153 tr, _ := newCapturingTransport(t)
154- newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
154+ newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
155155 d.applyTheme(sess, newAC, ports.Theme{
156156 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
157157 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
@@ -191,7 +191,7 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
191191 d, sess, old, _ := newManualSessionWithPTYs(t, p)
192192 defer release()
193193 tr, _ := newCapturingTransport(t)
194- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})
194+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
195195
196196 d.applyTheme(sess, old, ports.Theme{
197197 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
@@ -102,7 +102,7 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) {
102 })102 })
103 tr, _ := newCapturingTransport(t)103 tr, _ := newCapturingTransport(t)
104 104
105- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})105+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
106 106
107 var got []byte107 var got []byte
108 tb := sess.activeTab()108 tb := sess.activeTab()
@@ -151,7 +151,7 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) {
151 require.True(t, sess.detachIfCurrent(ac))151 require.True(t, sess.detachIfCurrent(ac))
152 152
153 tr, _ := newCapturingTransport(t)153 tr, _ := newCapturingTransport(t)
154- newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})154+ newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
155 d.applyTheme(sess, newAC, ports.Theme{155 d.applyTheme(sess, newAC, ports.Theme{
156 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},156 HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22},
157 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},157 HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25},
@@ -191,7 +191,7 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) {
191 d, sess, old, _ := newManualSessionWithPTYs(t, p)191 d, sess, old, _ := newManualSessionWithPTYs(t, p)
192 defer release()192 defer release()
193 tr, _ := newCapturingTransport(t)193 tr, _ := newCapturingTransport(t)
194- d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24})194+ d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{})
195 195
196 d.applyTheme(sess, old, ports.Theme{196 d.applyTheme(sess, old, ports.Theme{
197 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},197 HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3},
M internal/usecase/daemon/wire.go +21 -7
@@ -28,12 +28,18 @@ import (
2828 "github.com/bnema/vev/internal/ports"
2929 )
3030
31-func frameWelcome(s *session) ports.Frame {
32- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{
33- SessionID: string(s.id),
34- SessionName: s.name,
35- Ephemeral: s.ephemeral,
36- })}
31+func frameWelcome(s *session, ac *attachedClient) ports.Frame {
32+ w := ports.Welcome{
33+ SessionID: string(s.id),
34+ SessionName: s.name,
35+ Ephemeral: s.ephemeral,
36+ Capabilities: ports.CapabilityResume,
37+ ResumeToken: ac.resumeToken,
38+ }
39+ if ac.resumeCapable && ac.resumeToken == 0 {
40+ w.ResumeToken = 0
41+ }
42+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
3743 }
3844
3945 func frameError(code uint16, text string) ports.Frame {
@@ -41,7 +47,15 @@ func frameError(code uint16, text string) ports.Frame {
4147 }
4248
4349 func frameOutput(b []byte) ports.Frame {
44- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})}
50+ return frameOutputState(b, 0)
51+}
52+
53+func frameOutputState(b []byte, state uint64, echoAck ...uint64) ports.Frame {
54+ ack := uint64(0)
55+ if len(echoAck) > 0 {
56+ ack = echoAck[0]
57+ }
58+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{NewStateNum: state, EchoAck: ack, Data: b})}
4559 }
4660
4761 func frameDetached(reason uint8) ports.Frame {
@@ -28,12 +28,18 @@ import (
28 "github.com/bnema/vev/internal/ports"28 "github.com/bnema/vev/internal/ports"
29 )29 )
30 30
31-func frameWelcome(s *session) ports.Frame {31+func frameWelcome(s *session, ac *attachedClient) ports.Frame {
32- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{32+ w := ports.Welcome{
33- SessionID: string(s.id),33+ SessionID: string(s.id),
34- SessionName: s.name,34+ SessionName: s.name,
35- Ephemeral: s.ephemeral,35+ Ephemeral: s.ephemeral,
36- })}36+ Capabilities: ports.CapabilityResume,
37+ ResumeToken: ac.resumeToken,
38+ }
39+ if ac.resumeCapable && ac.resumeToken == 0 {
40+ w.ResumeToken = 0
41+ }
42+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
37 }43 }
38 44
39 func frameError(code uint16, text string) ports.Frame {45 func frameError(code uint16, text string) ports.Frame {
@@ -41,7 +47,15 @@ func frameError(code uint16, text string) ports.Frame {
41 }47 }
42 48
43 func frameOutput(b []byte) ports.Frame {49 func frameOutput(b []byte) ports.Frame {
44- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})}50+ return frameOutputState(b, 0)
51+}
52+
53+func frameOutputState(b []byte, state uint64, echoAck ...uint64) ports.Frame {
54+ ack := uint64(0)
55+ if len(echoAck) > 0 {
56+ ack = echoAck[0]
57+ }
58+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{NewStateNum: state, EchoAck: ack, Data: b})}
45 }59 }
46 60
47 func frameDetached(reason uint8) ports.Frame {61 func frameDetached(reason uint8) ports.Frame {
A pkg/dgram/dgram.go +247 -0
@@ -0,0 +1,247 @@
1+// Package dgram provides stdlib-only helpers for authenticated datagram
2+// framing, replay protection, and MTU-sized fragmentation.
3+package dgram
4+
5+import (
6+ "crypto/aes"
7+ "crypto/cipher"
8+ "encoding/binary"
9+ "errors"
10+ "fmt"
11+)
12+
13+const (
14+ KeySize = 32
15+ NonceSize = 12
16+ HeaderSize = 4 + 8
17+ DefaultMTU = 1200
18+ fragmentHdr = 8 + 2 + 2 + 2 + 2 // seq, idx, count, total, reserved
19+)
20+
21+var (
22+ ErrReplay = errors.New("dgram: replayed packet")
23+ ErrDirection = errors.New("dgram: unexpected direction")
24+ ErrFragment = errors.New("dgram: invalid fragment")
25+)
26+
27+type Codec struct {
28+ gcm cipher.AEAD
29+}
30+
31+func NewCodec(key []byte) (*Codec, error) {
32+ if len(key) != KeySize {
33+ return nil, fmt.Errorf("dgram: key must be %d bytes", KeySize)
34+ }
35+ block, err := aes.NewCipher(key)
36+ if err != nil {
37+ return nil, err
38+ }
39+ gcm, err := cipher.NewGCM(block)
40+ if err != nil {
41+ return nil, err
42+ }
43+ return &Codec{gcm: gcm}, nil
44+}
45+
46+func (c *Codec) Overhead() int { return c.gcm.Overhead() }
47+
48+func (c *Codec) Seal(direction uint32, counter uint64, plaintext, aad []byte) []byte {
49+ var nonce [NonceSize]byte
50+ binary.BigEndian.PutUint32(nonce[0:4], direction)
51+ binary.BigEndian.PutUint64(nonce[4:12], counter)
52+ out := make([]byte, HeaderSize, HeaderSize+len(plaintext)+c.gcm.Overhead())
53+ binary.BigEndian.PutUint32(out[0:4], direction)
54+ binary.BigEndian.PutUint64(out[4:12], counter)
55+ return c.gcm.Seal(out, nonce[:], plaintext, aad)
56+}
57+
58+func (c *Codec) Open(packet []byte, wantDirection uint32, aad []byte, replay *ReplayWindow) (uint64, []byte, error) {
59+ if len(packet) < HeaderSize+c.gcm.Overhead() {
60+ return 0, nil, errors.New("dgram: packet too short")
61+ }
62+ dir := binary.BigEndian.Uint32(packet[0:4])
63+ if dir != wantDirection {
64+ return 0, nil, ErrDirection
65+ }
66+ ctr := binary.BigEndian.Uint64(packet[4:12])
67+ if replay != nil && !replay.Check(ctr) {
68+ return 0, nil, ErrReplay
69+ }
70+ var nonce [NonceSize]byte
71+ copy(nonce[:], packet[:HeaderSize])
72+ pt, err := c.gcm.Open(nil, nonce[:], packet[HeaderSize:], aad)
73+ if err != nil {
74+ return 0, nil, err
75+ }
76+ if replay != nil {
77+ replay.Accept(ctr)
78+ }
79+ return ctr, pt, nil
80+}
81+
82+type ReplayWindow struct {
83+ max uint64
84+ seen uint64
85+ init bool
86+}
87+
88+func NewReplayWindow() *ReplayWindow { return &ReplayWindow{} }
89+
90+func (w *ReplayWindow) Check(counter uint64) bool {
91+ if !w.init {
92+ return true
93+ }
94+ if counter > w.max {
95+ return true
96+ }
97+ delta := w.max - counter
98+ if delta >= 64 {
99+ return false
100+ }
101+ return (w.seen & (uint64(1) << delta)) == 0
102+}
103+
104+func (w *ReplayWindow) Accept(counter uint64) {
105+ if !w.init {
106+ w.max, w.seen, w.init = counter, 1, true
107+ return
108+ }
109+ if counter > w.max {
110+ shift := counter - w.max
111+ if shift >= 64 {
112+ w.seen = 1
113+ } else {
114+ w.seen = (w.seen << shift) | 1
115+ }
116+ w.max = counter
117+ return
118+ }
119+ delta := w.max - counter
120+ if delta < 64 {
121+ w.seen |= uint64(1) << delta
122+ }
123+}
124+
125+type Fragment struct {
126+ Seq uint64
127+ Index uint16
128+ Count uint16
129+ Data []byte
130+}
131+
132+func FragmentPayload(seq uint64, payload []byte, mtu int) ([]Fragment, error) {
133+ if mtu <= fragmentHdr {
134+ return nil, ErrFragment
135+ }
136+ max := mtu - fragmentHdr
137+ count := (len(payload) + max - 1) / max
138+ if count == 0 {
139+ count = 1
140+ }
141+ if count > 0xffff {
142+ return nil, ErrFragment
143+ }
144+ out := make([]Fragment, count)
145+ for i := 0; i < count; i++ {
146+ start := i * max
147+ end := min(len(payload), start+max)
148+ out[i] = Fragment{Seq: seq, Index: uint16(i), Count: uint16(count), Data: append([]byte(nil), payload[start:end]...)}
149+ }
150+ return out, nil
151+}
152+
153+func MarshalFragment(f Fragment) ([]byte, error) {
154+ if f.Count == 0 || f.Index >= f.Count {
155+ return nil, ErrFragment
156+ }
157+ b := make([]byte, fragmentHdr+len(f.Data))
158+ binary.BigEndian.PutUint64(b[0:8], f.Seq)
159+ binary.BigEndian.PutUint16(b[8:10], f.Index)
160+ binary.BigEndian.PutUint16(b[10:12], f.Count)
161+ binary.BigEndian.PutUint16(b[12:14], uint16(len(f.Data)))
162+ copy(b[fragmentHdr:], f.Data)
163+ return b, nil
164+}
165+
166+func UnmarshalFragment(b []byte) (Fragment, error) {
167+ if len(b) < fragmentHdr {
168+ return Fragment{}, ErrFragment
169+ }
170+ f := Fragment{Seq: binary.BigEndian.Uint64(b[0:8]), Index: binary.BigEndian.Uint16(b[8:10]), Count: binary.BigEndian.Uint16(b[10:12])}
171+ n := int(binary.BigEndian.Uint16(b[12:14]))
172+ if f.Count == 0 || f.Index >= f.Count || len(b[fragmentHdr:]) != n {
173+ return Fragment{}, ErrFragment
174+ }
175+ f.Data = append([]byte(nil), b[fragmentHdr:]...)
176+ return f, nil
177+}
178+
179+const maxReassemblyInflight = 1024
180+
181+type Reassembler struct {
182+ inflight map[uint64]*assembly
183+ order []uint64
184+}
185+type assembly struct {
186+ count uint16
187+ got int
188+ parts [][]byte
189+}
190+
191+func NewReassembler() *Reassembler { return &Reassembler{inflight: make(map[uint64]*assembly)} }
192+func (r *Reassembler) Add(f Fragment) ([]byte, bool, error) {
193+ if f.Count == 0 || f.Index >= f.Count {
194+ return nil, false, ErrFragment
195+ }
196+ a := r.inflight[f.Seq]
197+ if a == nil {
198+ r.evictOldest()
199+ a = &assembly{count: f.Count, parts: make([][]byte, f.Count)}
200+ r.inflight[f.Seq] = a
201+ r.order = append(r.order, f.Seq)
202+ }
203+ if a.count != f.Count {
204+ r.remove(f.Seq)
205+ return nil, false, ErrFragment
206+ }
207+ if a.parts[f.Index] != nil {
208+ return nil, false, nil
209+ }
210+ a.parts[f.Index] = append([]byte(nil), f.Data...)
211+ a.got++
212+ if a.got != int(a.count) {
213+ return nil, false, nil
214+ }
215+ var total int
216+ for _, p := range a.parts {
217+ total += len(p)
218+ }
219+ out := make([]byte, 0, total)
220+ for _, p := range a.parts {
221+ out = append(out, p...)
222+ }
223+ r.remove(f.Seq)
224+ return out, true, nil
225+}
226+
227+func (r *Reassembler) evictOldest() {
228+ for len(r.order) >= maxReassemblyInflight {
229+ seq := r.order[0]
230+ r.order = r.order[1:]
231+ if _, ok := r.inflight[seq]; ok {
232+ delete(r.inflight, seq)
233+ return
234+ }
235+ }
236+}
237+
238+func (r *Reassembler) remove(seq uint64) {
239+ delete(r.inflight, seq)
240+ for i, s := range r.order {
241+ if s == seq {
242+ copy(r.order[i:], r.order[i+1:])
243+ r.order = r.order[:len(r.order)-1]
244+ return
245+ }
246+ }
247+}
@@ -0,0 +1,247 @@
1+// Package dgram provides stdlib-only helpers for authenticated datagram
2+// framing, replay protection, and MTU-sized fragmentation.
3+package dgram
4+
5+import (
6+ "crypto/aes"
7+ "crypto/cipher"
8+ "encoding/binary"
9+ "errors"
10+ "fmt"
11+)
12+
13+const (
14+ KeySize = 32
15+ NonceSize = 12
16+ HeaderSize = 4 + 8
17+ DefaultMTU = 1200
18+ fragmentHdr = 8 + 2 + 2 + 2 + 2 // seq, idx, count, total, reserved
19+)
20+
21+var (
22+ ErrReplay = errors.New("dgram: replayed packet")
23+ ErrDirection = errors.New("dgram: unexpected direction")
24+ ErrFragment = errors.New("dgram: invalid fragment")
25+)
26+
27+type Codec struct {
28+ gcm cipher.AEAD
29+}
30+
31+func NewCodec(key []byte) (*Codec, error) {
32+ if len(key) != KeySize {
33+ return nil, fmt.Errorf("dgram: key must be %d bytes", KeySize)
34+ }
35+ block, err := aes.NewCipher(key)
36+ if err != nil {
37+ return nil, err
38+ }
39+ gcm, err := cipher.NewGCM(block)
40+ if err != nil {
41+ return nil, err
42+ }
43+ return &Codec{gcm: gcm}, nil
44+}
45+
46+func (c *Codec) Overhead() int { return c.gcm.Overhead() }
47+
48+func (c *Codec) Seal(direction uint32, counter uint64, plaintext, aad []byte) []byte {
49+ var nonce [NonceSize]byte
50+ binary.BigEndian.PutUint32(nonce[0:4], direction)
51+ binary.BigEndian.PutUint64(nonce[4:12], counter)
52+ out := make([]byte, HeaderSize, HeaderSize+len(plaintext)+c.gcm.Overhead())
53+ binary.BigEndian.PutUint32(out[0:4], direction)
54+ binary.BigEndian.PutUint64(out[4:12], counter)
55+ return c.gcm.Seal(out, nonce[:], plaintext, aad)
56+}
57+
58+func (c *Codec) Open(packet []byte, wantDirection uint32, aad []byte, replay *ReplayWindow) (uint64, []byte, error) {
59+ if len(packet) < HeaderSize+c.gcm.Overhead() {
60+ return 0, nil, errors.New("dgram: packet too short")
61+ }
62+ dir := binary.BigEndian.Uint32(packet[0:4])
63+ if dir != wantDirection {
64+ return 0, nil, ErrDirection
65+ }
66+ ctr := binary.BigEndian.Uint64(packet[4:12])
67+ if replay != nil && !replay.Check(ctr) {
68+ return 0, nil, ErrReplay
69+ }
70+ var nonce [NonceSize]byte
71+ copy(nonce[:], packet[:HeaderSize])
72+ pt, err := c.gcm.Open(nil, nonce[:], packet[HeaderSize:], aad)
73+ if err != nil {
74+ return 0, nil, err
75+ }
76+ if replay != nil {
77+ replay.Accept(ctr)
78+ }
79+ return ctr, pt, nil
80+}
81+
82+type ReplayWindow struct {
83+ max uint64
84+ seen uint64
85+ init bool
86+}
87+
88+func NewReplayWindow() *ReplayWindow { return &ReplayWindow{} }
89+
90+func (w *ReplayWindow) Check(counter uint64) bool {
91+ if !w.init {
92+ return true
93+ }
94+ if counter > w.max {
95+ return true
96+ }
97+ delta := w.max - counter
98+ if delta >= 64 {
99+ return false
100+ }
101+ return (w.seen & (uint64(1) << delta)) == 0
102+}
103+
104+func (w *ReplayWindow) Accept(counter uint64) {
105+ if !w.init {
106+ w.max, w.seen, w.init = counter, 1, true
107+ return
108+ }
109+ if counter > w.max {
110+ shift := counter - w.max
111+ if shift >= 64 {
112+ w.seen = 1
113+ } else {
114+ w.seen = (w.seen << shift) | 1
115+ }
116+ w.max = counter
117+ return
118+ }
119+ delta := w.max - counter
120+ if delta < 64 {
121+ w.seen |= uint64(1) << delta
122+ }
123+}
124+
125+type Fragment struct {
126+ Seq uint64
127+ Index uint16
128+ Count uint16
129+ Data []byte
130+}
131+
132+func FragmentPayload(seq uint64, payload []byte, mtu int) ([]Fragment, error) {
133+ if mtu <= fragmentHdr {
134+ return nil, ErrFragment
135+ }
136+ max := mtu - fragmentHdr
137+ count := (len(payload) + max - 1) / max
138+ if count == 0 {
139+ count = 1
140+ }
141+ if count > 0xffff {
142+ return nil, ErrFragment
143+ }
144+ out := make([]Fragment, count)
145+ for i := 0; i < count; i++ {
146+ start := i * max
147+ end := min(len(payload), start+max)
148+ out[i] = Fragment{Seq: seq, Index: uint16(i), Count: uint16(count), Data: append([]byte(nil), payload[start:end]...)}
149+ }
150+ return out, nil
151+}
152+
153+func MarshalFragment(f Fragment) ([]byte, error) {
154+ if f.Count == 0 || f.Index >= f.Count {
155+ return nil, ErrFragment
156+ }
157+ b := make([]byte, fragmentHdr+len(f.Data))
158+ binary.BigEndian.PutUint64(b[0:8], f.Seq)
159+ binary.BigEndian.PutUint16(b[8:10], f.Index)
160+ binary.BigEndian.PutUint16(b[10:12], f.Count)
161+ binary.BigEndian.PutUint16(b[12:14], uint16(len(f.Data)))
162+ copy(b[fragmentHdr:], f.Data)
163+ return b, nil
164+}
165+
166+func UnmarshalFragment(b []byte) (Fragment, error) {
167+ if len(b) < fragmentHdr {
168+ return Fragment{}, ErrFragment
169+ }
170+ f := Fragment{Seq: binary.BigEndian.Uint64(b[0:8]), Index: binary.BigEndian.Uint16(b[8:10]), Count: binary.BigEndian.Uint16(b[10:12])}
171+ n := int(binary.BigEndian.Uint16(b[12:14]))
172+ if f.Count == 0 || f.Index >= f.Count || len(b[fragmentHdr:]) != n {
173+ return Fragment{}, ErrFragment
174+ }
175+ f.Data = append([]byte(nil), b[fragmentHdr:]...)
176+ return f, nil
177+}
178+
179+const maxReassemblyInflight = 1024
180+
181+type Reassembler struct {
182+ inflight map[uint64]*assembly
183+ order []uint64
184+}
185+type assembly struct {
186+ count uint16
187+ got int
188+ parts [][]byte
189+}
190+
191+func NewReassembler() *Reassembler { return &Reassembler{inflight: make(map[uint64]*assembly)} }
192+func (r *Reassembler) Add(f Fragment) ([]byte, bool, error) {
193+ if f.Count == 0 || f.Index >= f.Count {
194+ return nil, false, ErrFragment
195+ }
196+ a := r.inflight[f.Seq]
197+ if a == nil {
198+ r.evictOldest()
199+ a = &assembly{count: f.Count, parts: make([][]byte, f.Count)}
200+ r.inflight[f.Seq] = a
201+ r.order = append(r.order, f.Seq)
202+ }
203+ if a.count != f.Count {
204+ r.remove(f.Seq)
205+ return nil, false, ErrFragment
206+ }
207+ if a.parts[f.Index] != nil {
208+ return nil, false, nil
209+ }
210+ a.parts[f.Index] = append([]byte(nil), f.Data...)
211+ a.got++
212+ if a.got != int(a.count) {
213+ return nil, false, nil
214+ }
215+ var total int
216+ for _, p := range a.parts {
217+ total += len(p)
218+ }
219+ out := make([]byte, 0, total)
220+ for _, p := range a.parts {
221+ out = append(out, p...)
222+ }
223+ r.remove(f.Seq)
224+ return out, true, nil
225+}
226+
227+func (r *Reassembler) evictOldest() {
228+ for len(r.order) >= maxReassemblyInflight {
229+ seq := r.order[0]
230+ r.order = r.order[1:]
231+ if _, ok := r.inflight[seq]; ok {
232+ delete(r.inflight, seq)
233+ return
234+ }
235+ }
236+}
237+
238+func (r *Reassembler) remove(seq uint64) {
239+ delete(r.inflight, seq)
240+ for i, s := range r.order {
241+ if s == seq {
242+ copy(r.order[i:], r.order[i+1:])
243+ r.order = r.order[:len(r.order)-1]
244+ return
245+ }
246+ }
247+}
A pkg/dgram/dgram_test.go +140 -0
@@ -0,0 +1,140 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "testing"
7+)
8+
9+func testCodec(t *testing.T) *Codec {
10+ t.Helper()
11+ key := bytes.Repeat([]byte{7}, KeySize)
12+ c, err := NewCodec(key)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ return c
17+}
18+
19+func TestCodecOverheadMatchesSealedPacketGrowth(t *testing.T) {
20+ c := testCodec(t)
21+ plaintext := []byte("hello")
22+ pkt := c.Seal(1, 1, plaintext, nil)
23+ if got, want := len(pkt), HeaderSize+len(plaintext)+c.Overhead(); got != want {
24+ t.Fatalf("sealed packet length = %d, want %d", got, want)
25+ }
26+}
27+
28+func TestSealOpen(t *testing.T) {
29+ c := testCodec(t)
30+ pkt := c.Seal(1, 42, []byte("hello"), []byte("aad"))
31+ ctr, pt, err := c.Open(pkt, 1, []byte("aad"), NewReplayWindow())
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if ctr != 42 || string(pt) != "hello" {
36+ t.Fatalf("ctr=%d pt=%q", ctr, pt)
37+ }
38+ if _, _, err := c.Open(pkt, 2, []byte("aad"), nil); !errors.Is(err, ErrDirection) {
39+ t.Fatalf("wrong direction err=%v", err)
40+ }
41+}
42+
43+func TestReplayReject(t *testing.T) {
44+ c := testCodec(t)
45+ rw := NewReplayWindow()
46+ pkt := c.Seal(1, 1, []byte("x"), nil)
47+ if _, _, err := c.Open(pkt, 1, nil, rw); err != nil {
48+ t.Fatal(err)
49+ }
50+ if _, _, err := c.Open(pkt, 1, nil, rw); !errors.Is(err, ErrReplay) {
51+ t.Fatalf("err=%v", err)
52+ }
53+ old := c.Seal(1, 0, []byte("old"), nil)
54+ if _, _, err := c.Open(old, 1, nil, rw); err != nil {
55+ t.Fatalf("within window should pass: %v", err)
56+ }
57+}
58+
59+func TestFragmentation(t *testing.T) {
60+ payload := bytes.Repeat([]byte("abcdef"), 400)
61+ frags, err := FragmentPayload(9, payload, 100)
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if len(frags) < 2 {
66+ t.Fatalf("got %d fragments", len(frags))
67+ }
68+ for _, f := range frags {
69+ b, err := MarshalFragment(f)
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ g, err := UnmarshalFragment(b)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ if g.Seq != f.Seq || g.Index != f.Index || !bytes.Equal(g.Data, f.Data) {
78+ t.Fatalf("bad roundtrip")
79+ }
80+ }
81+}
82+
83+func TestReassemblyDisorderDuplicates(t *testing.T) {
84+ payload := []byte("abcdefghijklmnopqrstuvwxyz")
85+ frags, err := FragmentPayload(3, payload, 30)
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ r := NewReassembler()
90+ order := []int{1, 0, 1}
91+ var got []byte
92+ var done bool
93+ for _, idx := range order {
94+ out, ok, err := r.Add(frags[idx])
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if ok {
99+ got, done = out, true
100+ }
101+ }
102+ if !done || !bytes.Equal(got, payload) {
103+ t.Fatalf("done=%v got=%q", done, got)
104+ }
105+}
106+
107+func TestReassemblyCountMismatchRemovesStaleAssembly(t *testing.T) {
108+ r := NewReassembler()
109+ if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 2, Data: []byte("stale")}); err != nil {
110+ t.Fatal(err)
111+ }
112+ if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 3, Data: []byte("a")}); !errors.Is(err, ErrFragment) {
113+ t.Fatalf("err=%v, want ErrFragment", err)
114+ }
115+ for _, f := range []Fragment{
116+ {Seq: 7, Index: 0, Count: 3, Data: []byte("a")},
117+ {Seq: 7, Index: 1, Count: 3, Data: []byte("b")},
118+ {Seq: 7, Index: 2, Count: 3, Data: []byte("c")},
119+ } {
120+ out, ok, err := r.Add(f)
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ if ok && string(out) != "abc" {
125+ t.Fatalf("out=%q", out)
126+ }
127+ }
128+}
129+
130+func TestReassemblyInflightBounded(t *testing.T) {
131+ r := NewReassembler()
132+ for i := range uint64(maxReassemblyInflight + 10) {
133+ if _, _, err := r.Add(Fragment{Seq: i, Index: 0, Count: 2, Data: []byte("x")}); err != nil {
134+ t.Fatal(err)
135+ }
136+ }
137+ if len(r.inflight) > maxReassemblyInflight {
138+ t.Fatalf("inflight=%d, want <= %d", len(r.inflight), maxReassemblyInflight)
139+ }
140+}
@@ -0,0 +1,140 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "testing"
7+)
8+
9+func testCodec(t *testing.T) *Codec {
10+ t.Helper()
11+ key := bytes.Repeat([]byte{7}, KeySize)
12+ c, err := NewCodec(key)
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ return c
17+}
18+
19+func TestCodecOverheadMatchesSealedPacketGrowth(t *testing.T) {
20+ c := testCodec(t)
21+ plaintext := []byte("hello")
22+ pkt := c.Seal(1, 1, plaintext, nil)
23+ if got, want := len(pkt), HeaderSize+len(plaintext)+c.Overhead(); got != want {
24+ t.Fatalf("sealed packet length = %d, want %d", got, want)
25+ }
26+}
27+
28+func TestSealOpen(t *testing.T) {
29+ c := testCodec(t)
30+ pkt := c.Seal(1, 42, []byte("hello"), []byte("aad"))
31+ ctr, pt, err := c.Open(pkt, 1, []byte("aad"), NewReplayWindow())
32+ if err != nil {
33+ t.Fatal(err)
34+ }
35+ if ctr != 42 || string(pt) != "hello" {
36+ t.Fatalf("ctr=%d pt=%q", ctr, pt)
37+ }
38+ if _, _, err := c.Open(pkt, 2, []byte("aad"), nil); !errors.Is(err, ErrDirection) {
39+ t.Fatalf("wrong direction err=%v", err)
40+ }
41+}
42+
43+func TestReplayReject(t *testing.T) {
44+ c := testCodec(t)
45+ rw := NewReplayWindow()
46+ pkt := c.Seal(1, 1, []byte("x"), nil)
47+ if _, _, err := c.Open(pkt, 1, nil, rw); err != nil {
48+ t.Fatal(err)
49+ }
50+ if _, _, err := c.Open(pkt, 1, nil, rw); !errors.Is(err, ErrReplay) {
51+ t.Fatalf("err=%v", err)
52+ }
53+ old := c.Seal(1, 0, []byte("old"), nil)
54+ if _, _, err := c.Open(old, 1, nil, rw); err != nil {
55+ t.Fatalf("within window should pass: %v", err)
56+ }
57+}
58+
59+func TestFragmentation(t *testing.T) {
60+ payload := bytes.Repeat([]byte("abcdef"), 400)
61+ frags, err := FragmentPayload(9, payload, 100)
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if len(frags) < 2 {
66+ t.Fatalf("got %d fragments", len(frags))
67+ }
68+ for _, f := range frags {
69+ b, err := MarshalFragment(f)
70+ if err != nil {
71+ t.Fatal(err)
72+ }
73+ g, err := UnmarshalFragment(b)
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+ if g.Seq != f.Seq || g.Index != f.Index || !bytes.Equal(g.Data, f.Data) {
78+ t.Fatalf("bad roundtrip")
79+ }
80+ }
81+}
82+
83+func TestReassemblyDisorderDuplicates(t *testing.T) {
84+ payload := []byte("abcdefghijklmnopqrstuvwxyz")
85+ frags, err := FragmentPayload(3, payload, 30)
86+ if err != nil {
87+ t.Fatal(err)
88+ }
89+ r := NewReassembler()
90+ order := []int{1, 0, 1}
91+ var got []byte
92+ var done bool
93+ for _, idx := range order {
94+ out, ok, err := r.Add(frags[idx])
95+ if err != nil {
96+ t.Fatal(err)
97+ }
98+ if ok {
99+ got, done = out, true
100+ }
101+ }
102+ if !done || !bytes.Equal(got, payload) {
103+ t.Fatalf("done=%v got=%q", done, got)
104+ }
105+}
106+
107+func TestReassemblyCountMismatchRemovesStaleAssembly(t *testing.T) {
108+ r := NewReassembler()
109+ if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 2, Data: []byte("stale")}); err != nil {
110+ t.Fatal(err)
111+ }
112+ if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 3, Data: []byte("a")}); !errors.Is(err, ErrFragment) {
113+ t.Fatalf("err=%v, want ErrFragment", err)
114+ }
115+ for _, f := range []Fragment{
116+ {Seq: 7, Index: 0, Count: 3, Data: []byte("a")},
117+ {Seq: 7, Index: 1, Count: 3, Data: []byte("b")},
118+ {Seq: 7, Index: 2, Count: 3, Data: []byte("c")},
119+ } {
120+ out, ok, err := r.Add(f)
121+ if err != nil {
122+ t.Fatal(err)
123+ }
124+ if ok && string(out) != "abc" {
125+ t.Fatalf("out=%q", out)
126+ }
127+ }
128+}
129+
130+func TestReassemblyInflightBounded(t *testing.T) {
131+ r := NewReassembler()
132+ for i := range uint64(maxReassemblyInflight + 10) {
133+ if _, _, err := r.Add(Fragment{Seq: i, Index: 0, Count: 2, Data: []byte("x")}); err != nil {
134+ t.Fatal(err)
135+ }
136+ }
137+ if len(r.inflight) > maxReassemblyInflight {
138+ t.Fatalf("inflight=%d, want <= %d", len(r.inflight), maxReassemblyInflight)
139+ }
140+}
A pkg/renderer/ack_test.go +85 -0
@@ -0,0 +1,85 @@
1+package renderer
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestAdvanceOnAckRediffsScrollDamageUntilAcked(t *testing.T) {
9+ r := New(Capabilities{AdvancePolicy: AdvanceOnAck})
10+ frame := NewFrame(4, 3)
11+ fillFrame(frame, []string{"AAAA", "BBBB", "CCCC"})
12+ if out, err := r.Draw(frame, nil); err != nil {
13+ t.Fatal(err)
14+ } else if len(out) == 0 {
15+ t.Fatal("initial draw empty")
16+ }
17+ if err := r.Ack(frame); err != nil {
18+ t.Fatal(err)
19+ }
20+
21+ scrolled := NewFrame(4, 3)
22+ copy(scrolled.Cells[0:8], frame.Cells[4:12])
23+ for i := 8; i < 12; i++ {
24+ scrolled.Cells[i] = BlankCell()
25+ }
26+ scrolled.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()})
27+ damage := []Damage{
28+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
29+ {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
30+ }
31+
32+ first, err := r.Draw(scrolled, damage)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ again, err := r.Draw(scrolled, damage)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ for name, out := range map[string][]byte{"first": first, "again": again} {
41+ if !strings.Contains(string(out), "\x1b[1;3r") || !strings.Contains(string(out), "N") {
42+ t.Fatalf("%s scroll draw did not include scroll region and exposed row: %q", name, out)
43+ }
44+ }
45+ if err := r.Ack(scrolled); err != nil {
46+ t.Fatal(err)
47+ }
48+ afterAck, err := r.Draw(scrolled, nil)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ if len(afterAck) != 0 {
53+ t.Fatalf("after ack got %q", afterAck)
54+ }
55+}
56+
57+func TestAdvanceOnAckRediffsLostOutput(t *testing.T) {
58+ r := New(Capabilities{AdvancePolicy: AdvanceOnAck})
59+ f := NewFrame(3, 1)
60+ fillFrame(f, []string{"abc"})
61+ first, err := r.Draw(f, nil)
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if len(first) == 0 {
66+ t.Fatal("first draw empty")
67+ }
68+ again, err := r.Draw(f, nil)
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ if len(again) == 0 {
73+ t.Fatal("lost unacked output was not re-diffed")
74+ }
75+ if err := r.Ack(f); err != nil {
76+ t.Fatal(err)
77+ }
78+ afterAck, err := r.Draw(f, nil)
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ if len(afterAck) != 0 {
83+ t.Fatalf("after ack got %q", afterAck)
84+ }
85+}
@@ -0,0 +1,85 @@
1+package renderer
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestAdvanceOnAckRediffsScrollDamageUntilAcked(t *testing.T) {
9+ r := New(Capabilities{AdvancePolicy: AdvanceOnAck})
10+ frame := NewFrame(4, 3)
11+ fillFrame(frame, []string{"AAAA", "BBBB", "CCCC"})
12+ if out, err := r.Draw(frame, nil); err != nil {
13+ t.Fatal(err)
14+ } else if len(out) == 0 {
15+ t.Fatal("initial draw empty")
16+ }
17+ if err := r.Ack(frame); err != nil {
18+ t.Fatal(err)
19+ }
20+
21+ scrolled := NewFrame(4, 3)
22+ copy(scrolled.Cells[0:8], frame.Cells[4:12])
23+ for i := 8; i < 12; i++ {
24+ scrolled.Cells[i] = BlankCell()
25+ }
26+ scrolled.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()})
27+ damage := []Damage{
28+ {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1},
29+ {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1},
30+ }
31+
32+ first, err := r.Draw(scrolled, damage)
33+ if err != nil {
34+ t.Fatal(err)
35+ }
36+ again, err := r.Draw(scrolled, damage)
37+ if err != nil {
38+ t.Fatal(err)
39+ }
40+ for name, out := range map[string][]byte{"first": first, "again": again} {
41+ if !strings.Contains(string(out), "\x1b[1;3r") || !strings.Contains(string(out), "N") {
42+ t.Fatalf("%s scroll draw did not include scroll region and exposed row: %q", name, out)
43+ }
44+ }
45+ if err := r.Ack(scrolled); err != nil {
46+ t.Fatal(err)
47+ }
48+ afterAck, err := r.Draw(scrolled, nil)
49+ if err != nil {
50+ t.Fatal(err)
51+ }
52+ if len(afterAck) != 0 {
53+ t.Fatalf("after ack got %q", afterAck)
54+ }
55+}
56+
57+func TestAdvanceOnAckRediffsLostOutput(t *testing.T) {
58+ r := New(Capabilities{AdvancePolicy: AdvanceOnAck})
59+ f := NewFrame(3, 1)
60+ fillFrame(f, []string{"abc"})
61+ first, err := r.Draw(f, nil)
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+ if len(first) == 0 {
66+ t.Fatal("first draw empty")
67+ }
68+ again, err := r.Draw(f, nil)
69+ if err != nil {
70+ t.Fatal(err)
71+ }
72+ if len(again) == 0 {
73+ t.Fatal("lost unacked output was not re-diffed")
74+ }
75+ if err := r.Ack(f); err != nil {
76+ t.Fatal(err)
77+ }
78+ afterAck, err := r.Draw(f, nil)
79+ if err != nil {
80+ t.Fatal(err)
81+ }
82+ if len(afterAck) != 0 {
83+ t.Fatalf("after ack got %q", afterAck)
84+ }
85+}
M pkg/renderer/renderer.go +40 -6
@@ -12,8 +12,16 @@ var bufferPool = sync.Pool{
1212 New: func() any { return new(bytes.Buffer) },
1313 }
1414
15+type AdvancePolicy uint8
16+
17+const (
18+ AdvanceOnSend AdvancePolicy = iota
19+ AdvanceOnAck
20+)
21+
1522 type Capabilities struct {
1623 SynchronizedOutput bool
24+ AdvancePolicy AdvancePolicy
1725 }
1826
1927 type Renderer struct {
@@ -64,7 +72,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
6472
6573 if !knownSameDimensions {
6674 r.writeFull(buf, frame, &st)
67- r.replaceShadow(frame)
75+ r.advanceShadow(frame)
6876 if r.caps.SynchronizedOutput {
6977 buf.WriteString(SyncEndCSI)
7078 }
@@ -73,7 +81,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
7381
7482 if needsFull(damage) {
7583 r.writeDamage(buf, frame, nil, nil, &st)
76- r.replaceShadow(frame)
84+ r.advanceShadow(frame)
7785 if r.caps.SynchronizedOutput {
7886 buf.WriteString(SyncEndCSI)
7987 }
@@ -85,9 +93,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
8593 // emitScrollUp resets the SGR pen to default (matching st's initial
8694 // pen) but leaves the cursor wherever the DECSTBM restore put it —
8795 // terminal-dependent, so cursor tracking stays invalidated.
88- r.applyScroll(scroll)
96+ if r.caps.AdvancePolicy == AdvanceOnSend {
97+ r.applyScroll(scroll)
98+ }
8999 r.writeDamage(buf, frame, damage, &scroll, &st)
90- r.syncDamage(frame, damage, &scroll)
100+ r.advanceDamage(frame, damage, &scroll)
91101 if r.caps.SynchronizedOutput {
92102 buf.WriteString(SyncEndCSI)
93103 }
@@ -99,7 +109,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
99109 // partial damage and leaving the terminal/shadow stale.
100110 if hasScrollDamage(damage) {
101111 r.writeFull(buf, frame, &st)
102- r.replaceShadow(frame)
112+ r.advanceShadow(frame)
103113 if r.caps.SynchronizedOutput {
104114 buf.WriteString(SyncEndCSI)
105115 }
@@ -107,7 +117,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
107117 }
108118
109119 r.writeDamage(buf, frame, damage, nil, &st)
110- r.syncDamage(frame, damage, nil)
120+ r.advanceDamage(frame, damage, nil)
111121 if r.caps.SynchronizedOutput {
112122 buf.WriteString(SyncEndCSI)
113123 }
@@ -208,6 +218,30 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool {
208218 return false
209219 }
210220
221+// Ack advances an AdvanceOnAck renderer's shadow after the caller knows the
222+// bytes for frame reached the terminal. It is harmless for AdvanceOnSend.
223+func (r *Renderer) Ack(frame Frame) error {
224+ if err := frame.Validate(); err != nil {
225+ return err
226+ }
227+ r.replaceShadow(frame)
228+ return nil
229+}
230+
231+func (r *Renderer) advanceShadow(frame Frame) {
232+ if r.caps.AdvancePolicy == AdvanceOnAck {
233+ return
234+ }
235+ r.replaceShadow(frame)
236+}
237+
238+func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) {
239+ if r.caps.AdvancePolicy == AdvanceOnAck {
240+ return
241+ }
242+ r.syncDamage(frame, damage, scroll)
243+}
244+
211245 func (r *Renderer) replaceShadow(frame Frame) {
212246 r.width = frame.Width
213247 r.height = frame.Height
@@ -12,8 +12,16 @@ var bufferPool = sync.Pool{
12 New: func() any { return new(bytes.Buffer) },12 New: func() any { return new(bytes.Buffer) },
13 }13 }
14 14
15+type AdvancePolicy uint8
16+
17+const (
18+ AdvanceOnSend AdvancePolicy = iota
19+ AdvanceOnAck
20+)
21+
15 type Capabilities struct {22 type Capabilities struct {
16 SynchronizedOutput bool23 SynchronizedOutput bool
24+ AdvancePolicy AdvancePolicy
17 }25 }
18 26
19 type Renderer struct {27 type Renderer struct {
@@ -64,7 +72,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
64 72
65 if !knownSameDimensions {73 if !knownSameDimensions {
66 r.writeFull(buf, frame, &st)74 r.writeFull(buf, frame, &st)
67- r.replaceShadow(frame)75+ r.advanceShadow(frame)
68 if r.caps.SynchronizedOutput {76 if r.caps.SynchronizedOutput {
69 buf.WriteString(SyncEndCSI)77 buf.WriteString(SyncEndCSI)
70 }78 }
@@ -73,7 +81,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
73 81
74 if needsFull(damage) {82 if needsFull(damage) {
75 r.writeDamage(buf, frame, nil, nil, &st)83 r.writeDamage(buf, frame, nil, nil, &st)
76- r.replaceShadow(frame)84+ r.advanceShadow(frame)
77 if r.caps.SynchronizedOutput {85 if r.caps.SynchronizedOutput {
78 buf.WriteString(SyncEndCSI)86 buf.WriteString(SyncEndCSI)
79 }87 }
@@ -85,9 +93,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
85 // emitScrollUp resets the SGR pen to default (matching st's initial93 // emitScrollUp resets the SGR pen to default (matching st's initial
86 // pen) but leaves the cursor wherever the DECSTBM restore put it —94 // pen) but leaves the cursor wherever the DECSTBM restore put it —
87 // terminal-dependent, so cursor tracking stays invalidated.95 // terminal-dependent, so cursor tracking stays invalidated.
88- r.applyScroll(scroll)96+ if r.caps.AdvancePolicy == AdvanceOnSend {
97+ r.applyScroll(scroll)
98+ }
89 r.writeDamage(buf, frame, damage, &scroll, &st)99 r.writeDamage(buf, frame, damage, &scroll, &st)
90- r.syncDamage(frame, damage, &scroll)100+ r.advanceDamage(frame, damage, &scroll)
91 if r.caps.SynchronizedOutput {101 if r.caps.SynchronizedOutput {
92 buf.WriteString(SyncEndCSI)102 buf.WriteString(SyncEndCSI)
93 }103 }
@@ -99,7 +109,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
99 // partial damage and leaving the terminal/shadow stale.109 // partial damage and leaving the terminal/shadow stale.
100 if hasScrollDamage(damage) {110 if hasScrollDamage(damage) {
101 r.writeFull(buf, frame, &st)111 r.writeFull(buf, frame, &st)
102- r.replaceShadow(frame)112+ r.advanceShadow(frame)
103 if r.caps.SynchronizedOutput {113 if r.caps.SynchronizedOutput {
104 buf.WriteString(SyncEndCSI)114 buf.WriteString(SyncEndCSI)
105 }115 }
@@ -107,7 +117,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) {
107 }117 }
108 118
109 r.writeDamage(buf, frame, damage, nil, &st)119 r.writeDamage(buf, frame, damage, nil, &st)
110- r.syncDamage(frame, damage, nil)120+ r.advanceDamage(frame, damage, nil)
111 if r.caps.SynchronizedOutput {121 if r.caps.SynchronizedOutput {
112 buf.WriteString(SyncEndCSI)122 buf.WriteString(SyncEndCSI)
113 }123 }
@@ -208,6 +218,30 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool {
208 return false218 return false
209 }219 }
210 220
221+// Ack advances an AdvanceOnAck renderer's shadow after the caller knows the
222+// bytes for frame reached the terminal. It is harmless for AdvanceOnSend.
223+func (r *Renderer) Ack(frame Frame) error {
224+ if err := frame.Validate(); err != nil {
225+ return err
226+ }
227+ r.replaceShadow(frame)
228+ return nil
229+}
230+
231+func (r *Renderer) advanceShadow(frame Frame) {
232+ if r.caps.AdvancePolicy == AdvanceOnAck {
233+ return
234+ }
235+ r.replaceShadow(frame)
236+}
237+
238+func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) {
239+ if r.caps.AdvancePolicy == AdvanceOnAck {
240+ return
241+ }
242+ r.syncDamage(frame, damage, scroll)
243+}
244+
211 func (r *Renderer) replaceShadow(frame Frame) {245 func (r *Renderer) replaceShadow(frame Frame) {
212 r.width = frame.Width246 r.width = frame.Width
213 r.height = frame.Height247 r.height = frame.Height

Keyboard shortcuts

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

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

Branches 12

Tags 11

↑↓ moveenter openesc close