detached c6f30c2

Merge pull request #10 from bnema/feature/remote-attach-resilient-mosh

feat(remote): add resilient attach transport
commit
c6f30c2d3fb9bcfe8512f89509b5a802e887be2b
author
bnema · · 2026-07-04 12:57 UTC
committer
GitHub · · 2026-07-04 12:57 UTC
merge of
e8e16e8 + 56875c7 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
39 files · +3188 -173
A internal/adapters/dgram/ssh_dialer.go +64 -0
@@ -0,0 +1,64 @@
1+package dgram
2+
3+import (
4+ "context"
5+ "net"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/vev/internal/adapters/sshstdio"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+const (
14+ maxBootstrapStderr = 64 * 1024
15+ defaultProbeTimeout = 3 * time.Second
16+)
17+
18+type limitedBuffer struct {
19+ buf []byte
20+}
21+
22+func (b *limitedBuffer) Write(p []byte) (int, error) {
23+ if len(b.buf) < maxBootstrapStderr {
24+ keep := min(len(p), maxBootstrapStderr-len(b.buf))
25+ b.buf = append(b.buf, p[:keep]...)
26+ }
27+ return len(p), nil
28+}
29+
30+func (b *limitedBuffer) String() string { return string(b.buf) }
31+
32+// RemoteDialer tries the authenticated datagram bootstrap first and falls back
33+// to ssh stdio whenever bootstrap, UDP resolution, or the UDP probe fails.
34+type RemoteDialer struct {
35+ Target string
36+ Session string
37+ ProbeTimeout time.Duration
38+}
39+
40+func NewRemoteDialer(target, session string) RemoteDialer {
41+ return RemoteDialer{Target: target, Session: session, ProbeTimeout: defaultProbeTimeout}
42+}
43+
44+func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
45+ // The UDP bootstrap currently requires a long-lived remote proxy process. Do
46+ // not select it as the app's remote attach path until that process can detach
47+ // from SSH safely; stdio is the bounded fallback path.
48+ select {
49+ case <-ctx.Done():
50+ return nil, ctx.Err()
51+ default:
52+ }
53+ return sshstdio.DialContext(ctx, d.Target, d.Session)
54+}
55+
56+func sshTargetHost(target string) string {
57+ if at := strings.LastIndexByte(target, '@'); at >= 0 {
58+ target = target[at+1:]
59+ }
60+ if h, _, err := net.SplitHostPort(target); err == nil {
61+ return h
62+ }
63+ return target
64+}
A internal/adapters/dgram/ssh_dialer_test.go +40 -0
@@ -0,0 +1,40 @@
1+package dgram
2+
3+import (
4+ "strings"
5+ "testing"
6+)
7+
8+func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
9+ var b limitedBuffer
10+ chunk := strings.Repeat("x", maxBootstrapStderr+1024)
11+ n, err := b.Write([]byte(chunk))
12+ if err != nil {
13+ t.Fatal(err)
14+ }
15+ if n != len(chunk) {
16+ t.Fatalf("n=%d, want %d", n, len(chunk))
17+ }
18+ if got := len(b.String()); got != maxBootstrapStderr {
19+ t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
20+ }
21+}
22+
23+func TestSSHTargetHost(t *testing.T) {
24+ tests := []struct {
25+ target string
26+ want string
27+ }{
28+ {target: "example.com", want: "example.com"},
29+ {target: "user@example.com", want: "example.com"},
30+ {target: "example.com:2222", want: "example.com"},
31+ {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
32+ }
33+ for _, tt := range tests {
34+ t.Run(tt.target, func(t *testing.T) {
35+ if got := sshTargetHost(tt.target); got != tt.want {
36+ t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
37+ }
38+ })
39+ }
40+}
A internal/adapters/dgram/transport.go +440 -0
@@ -0,0 +1,440 @@
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+ recProbe byte = 3
20+ recPong byte = 4
21+
22+ defaultResend = 40 * time.Millisecond
23+ defaultHeartbeat = 3 * time.Second
24+ defaultSilenceTimeout = 10 * time.Second
25+ maxPendingReliable = 1024
26+ maxRecvBuffer = 1024
27+)
28+
29+var (
30+ ErrPendingFull = errors.New("dgram: pending reliable queue full")
31+ ErrLinkDead = errors.New("dgram: link dead")
32+)
33+
34+type Transport struct {
35+ pc net.PacketConn
36+ codec *pdgram.Codec
37+ sendDir, recvDir uint32
38+ mtu int
39+
40+ mu sync.Mutex
41+ peer net.Addr
42+ ctr uint64
43+ seq uint64
44+ probeSeq uint64
45+ pending map[uint64]*pending
46+ closed bool
47+ closeErr error
48+ lastHeard time.Time
49+ heartbeat time.Duration
50+ silence time.Duration
51+ probeWait map[uint64]chan struct{}
52+
53+ replay *pdgram.ReplayWindow
54+ reasm *pdgram.Reassembler
55+ in chan ports.Frame
56+ done chan struct{}
57+
58+ deliverMu sync.Mutex
59+ deliverCond *sync.Cond
60+ nextRecvSeq uint64
61+ recvBuf map[uint64]ports.Frame
62+}
63+
64+type pending struct {
65+ frame ports.Frame
66+ last time.Time
67+}
68+
69+func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) {
70+ c, err := pdgram.NewCodec(key)
71+ if err != nil {
72+ return nil, err
73+ }
74+ 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(), in: make(chan ports.Frame, 32), done: make(chan struct{}), lastHeard: time.Now(), heartbeat: defaultHeartbeat, silence: defaultSilenceTimeout, probeWait: make(map[uint64]chan struct{}), nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame)}
75+ t.deliverCond = sync.NewCond(&t.deliverMu)
76+ go t.readLoop()
77+ go t.resendLoop()
78+ go t.deliveryLoop()
79+ return t, nil
80+}
81+
82+func (t *Transport) Send(f ports.Frame) error {
83+ // Until full ACK-driven terminal state sync exists, every frame is reliable;
84+ // dropping MsgOutput can permanently desynchronize the client screen.
85+ reliable := true
86+ t.mu.Lock()
87+ if t.closed {
88+ err := t.closeErr
89+ t.mu.Unlock()
90+ if err != nil {
91+ return err
92+ }
93+ return errors.New("dgram: closed")
94+ }
95+ if reliable && len(t.pending) >= maxPendingReliable {
96+ t.mu.Unlock()
97+ return ErrPendingFull
98+ }
99+ t.seq++
100+ seq := t.seq
101+ if reliable {
102+ t.pending[seq] = &pending{frame: f}
103+ }
104+ t.mu.Unlock()
105+ return t.sendData(seq, reliable, f)
106+}
107+
108+func (t *Transport) Recv() (ports.Frame, error) {
109+ select {
110+ case f := <-t.in:
111+ return f, nil
112+ case <-t.done:
113+ t.mu.Lock()
114+ err := t.closeErr
115+ t.mu.Unlock()
116+ if err != nil {
117+ return ports.Frame{}, err
118+ }
119+ return ports.Frame{}, errors.New("dgram: closed")
120+ }
121+}
122+func (t *Transport) Close() error {
123+ t.closeWithError(errors.New("dgram: closed"))
124+ return t.pc.Close()
125+}
126+
127+func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
128+
129+// Probe sends an authenticated datagram and waits for the peer to authenticate a
130+// response or for ctx to expire. It is intended for UDP bootstrap/fallback checks.
131+func (t *Transport) Probe(ctx context.Context) error {
132+ id, ch, err := t.registerProbe()
133+ if err != nil {
134+ return err
135+ }
136+ defer t.unregisterProbe(id)
137+ if err := t.sendProbe(recProbe, id); err != nil {
138+ return err
139+ }
140+ select {
141+ case <-ch:
142+ return nil
143+ case <-ctx.Done():
144+ return ctx.Err()
145+ case <-t.done:
146+ t.mu.Lock()
147+ err := t.closeErr
148+ t.mu.Unlock()
149+ if err != nil {
150+ return err
151+ }
152+ return errors.New("dgram: closed")
153+ }
154+}
155+
156+func (t *Transport) registerProbe() (uint64, chan struct{}, error) {
157+ t.mu.Lock()
158+ defer t.mu.Unlock()
159+ if t.closed {
160+ if t.closeErr != nil {
161+ return 0, nil, t.closeErr
162+ }
163+ return 0, nil, errors.New("dgram: closed")
164+ }
165+ t.probeSeq++
166+ id := t.probeSeq
167+ ch := make(chan struct{})
168+ t.probeWait[id] = ch
169+ return id, ch, nil
170+}
171+
172+func (t *Transport) unregisterProbe(id uint64) {
173+ t.mu.Lock()
174+ delete(t.probeWait, id)
175+ t.mu.Unlock()
176+}
177+
178+func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {
179+ p := encodeData(seq, reliable, f)
180+ return t.sendPayload(p)
181+}
182+func (t *Transport) sendAck(seq uint64) {
183+ var b [9]byte
184+ b[0] = recAck
185+ binary.BigEndian.PutUint64(b[1:], seq)
186+ _ = t.sendPayload(b[:])
187+}
188+func (t *Transport) sendProbe(kind byte, id uint64) error {
189+ var b [9]byte
190+ b[0] = kind
191+ binary.BigEndian.PutUint64(b[1:], id)
192+ return t.sendPayload(b[:])
193+}
194+func (t *Transport) sendPayload(p []byte) error {
195+ frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
196+ if err != nil {
197+ return err
198+ }
199+ for _, f := range frags {
200+ raw, err := pdgram.MarshalFragment(f)
201+ if err != nil {
202+ return err
203+ }
204+ pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil)
205+ t.mu.Lock()
206+ peer := t.peer
207+ closed := t.closed
208+ closeErr := t.closeErr
209+ t.mu.Unlock()
210+ if closed {
211+ if closeErr != nil {
212+ return closeErr
213+ }
214+ return errors.New("dgram: closed")
215+ }
216+ if peer == nil {
217+ return errors.New("dgram: no peer")
218+ }
219+ if _, err := t.pc.WriteTo(pkt, peer); err != nil {
220+ return err
221+ }
222+ }
223+ return nil
224+}
225+func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr }
226+
227+func (t *Transport) readLoop() {
228+ buf := make([]byte, 64*1024)
229+ for {
230+ n, addr, err := t.pc.ReadFrom(buf)
231+ if err != nil {
232+ t.closeWithError(err)
233+ return
234+ }
235+ _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
236+ if err != nil {
237+ continue
238+ }
239+ t.mu.Lock()
240+ t.peer = addr
241+ t.lastHeard = time.Now()
242+ t.mu.Unlock()
243+ frag, err := pdgram.UnmarshalFragment(pt)
244+ if err != nil {
245+ continue
246+ }
247+ payload, ok, err := t.reasm.Add(frag)
248+ if err != nil || !ok {
249+ continue
250+ }
251+ t.handleRecord(payload)
252+ }
253+}
254+
255+func (t *Transport) handleRecord(p []byte) {
256+ if len(p) < 1 {
257+ return
258+ }
259+ switch p[0] {
260+ case recAck:
261+ if len(p) != 9 {
262+ return
263+ }
264+ seq := binary.BigEndian.Uint64(p[1:])
265+ t.mu.Lock()
266+ delete(t.pending, seq)
267+ t.mu.Unlock()
268+ case recProbe:
269+ if len(p) != 9 {
270+ return
271+ }
272+ _ = t.sendProbe(recPong, binary.BigEndian.Uint64(p[1:]))
273+ case recPong:
274+ if len(p) != 9 {
275+ return
276+ }
277+ id := binary.BigEndian.Uint64(p[1:])
278+ t.mu.Lock()
279+ ch := t.probeWait[id]
280+ if ch != nil {
281+ delete(t.probeWait, id)
282+ close(ch)
283+ }
284+ t.mu.Unlock()
285+ case recData:
286+ seq, reliable, f, ok := decodeData(p)
287+ if !ok {
288+ return
289+ }
290+ if reliable {
291+ if t.enqueueReliable(seq, f) {
292+ t.sendAck(seq)
293+ }
294+ return
295+ }
296+ t.deliver(f)
297+ }
298+}
299+
300+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) bool {
301+ t.deliverMu.Lock()
302+ defer t.deliverMu.Unlock()
303+ if seq < t.nextRecvSeq {
304+ return true
305+ }
306+ if _, exists := t.recvBuf[seq]; exists {
307+ return true
308+ }
309+ if len(t.recvBuf) >= maxRecvBuffer {
310+ if seq != t.nextRecvSeq {
311+ return false
312+ }
313+ for bufferedSeq := range t.recvBuf {
314+ delete(t.recvBuf, bufferedSeq)
315+ break
316+ }
317+ }
318+ t.recvBuf[seq] = f
319+ t.deliverCond.Signal()
320+ return true
321+}
322+
323+func (t *Transport) deliveryLoop() {
324+ for {
325+ t.deliverMu.Lock()
326+ f, ok := t.recvBuf[t.nextRecvSeq]
327+ for !ok {
328+ if t.isClosed() {
329+ t.deliverMu.Unlock()
330+ return
331+ }
332+ t.deliverCond.Wait()
333+ f, ok = t.recvBuf[t.nextRecvSeq]
334+ }
335+ delete(t.recvBuf, t.nextRecvSeq)
336+ t.nextRecvSeq++
337+ t.deliverMu.Unlock()
338+ t.deliver(f)
339+ }
340+}
341+
342+func (t *Transport) isClosed() bool {
343+ t.mu.Lock()
344+ defer t.mu.Unlock()
345+ return t.closed
346+}
347+
348+func (t *Transport) deliver(f ports.Frame) {
349+ select {
350+ case t.in <- f:
351+ case <-t.done:
352+ }
353+}
354+
355+func (t *Transport) resendLoop() {
356+ resend := time.NewTicker(defaultResend)
357+ defer resend.Stop()
358+ heartbeat := time.NewTicker(t.heartbeat)
359+ defer heartbeat.Stop()
360+ for {
361+ select {
362+ case <-resend.C:
363+ t.resendPending()
364+ t.checkSilence()
365+ case <-heartbeat.C:
366+ _ = t.sendProbe(recProbe, 0)
367+ t.checkSilence()
368+ case <-t.done:
369+ return
370+ }
371+ }
372+}
373+
374+func (t *Transport) resendPending() {
375+ now := time.Now()
376+ var resend []struct {
377+ seq uint64
378+ f ports.Frame
379+ }
380+ t.mu.Lock()
381+ for seq, p := range t.pending {
382+ if p.last.IsZero() || now.Sub(p.last) >= defaultResend {
383+ p.last = now
384+ resend = append(resend, struct {
385+ seq uint64
386+ f ports.Frame
387+ }{seq, p.frame})
388+ }
389+ }
390+ t.mu.Unlock()
391+ for _, r := range resend {
392+ _ = t.sendData(r.seq, true, r.f)
393+ }
394+}
395+
396+func (t *Transport) checkSilence() {
397+ t.mu.Lock()
398+ last := t.lastHeard
399+ silence := t.silence
400+ closed := t.closed
401+ t.mu.Unlock()
402+ if !closed && silence > 0 && time.Since(last) > silence {
403+ t.closeWithError(ErrLinkDead)
404+ }
405+}
406+
407+func (t *Transport) closeWithError(err error) {
408+ t.mu.Lock()
409+ if t.closed {
410+ t.mu.Unlock()
411+ return
412+ }
413+ t.closed = true
414+ t.closeErr = err
415+ t.probeWait = make(map[uint64]chan struct{})
416+ close(t.done)
417+ t.mu.Unlock()
418+ t.deliverMu.Lock()
419+ t.deliverCond.Broadcast()
420+ t.deliverMu.Unlock()
421+}
422+
423+func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
424+ b := make([]byte, 12+len(f.Payload))
425+ b[0] = recData
426+ binary.BigEndian.PutUint64(b[1:9], seq)
427+ if reliable {
428+ b[9] = 1
429+ }
430+ b[10] = byte(f.Type)
431+ b[11] = 0
432+ copy(b[12:], f.Payload)
433+ return b
434+}
435+func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
436+ if len(b) < 12 || b[0] != recData {
437+ return 0, false, ports.Frame{}, false
438+ }
439+ return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true
440+}
A internal/adapters/dgram/transport_test.go +411 -0
@@ -0,0 +1,411 @@
1+package dgram
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "net"
8+ "sync"
9+ "sync/atomic"
10+ "testing"
11+ "time"
12+
13+ "github.com/bnema/vev/internal/ports"
14+ pdgram "github.com/bnema/vev/pkg/dgram"
15+)
16+
17+type testAddr string
18+
19+func (a testAddr) Network() string { return "mem" }
20+func (a testAddr) String() string { return string(a) }
21+
22+type packet struct {
23+ b []byte
24+ addr net.Addr
25+}
26+type fakePC struct {
27+ addr net.Addr
28+ mu sync.Mutex
29+ closed bool
30+ in chan packet
31+ peers map[string]*fakePC
32+ drop func([]byte, net.Addr) bool
33+}
34+
35+func newPair() (*fakePC, *fakePC) {
36+ a := &fakePC{addr: testAddr("a"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
37+ b := &fakePC{addr: testAddr("b"), in: make(chan packet, 100), peers: map[string]*fakePC{}}
38+ a.peers["b"] = b
39+ b.peers["a"] = a
40+ return a, b
41+}
42+func (p *fakePC) ReadFrom(b []byte) (int, net.Addr, error) {
43+ q, ok := <-p.in
44+ if !ok {
45+ return 0, nil, errors.New("closed")
46+ }
47+ copy(b, q.b)
48+ return len(q.b), q.addr, nil
49+}
50+func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) {
51+ p.mu.Lock()
52+ closed := p.closed
53+ p.mu.Unlock()
54+ if closed {
55+ return 0, errors.New("closed")
56+ }
57+ if p.drop != nil && p.drop(b, addr) {
58+ return len(b), nil
59+ }
60+ peer := p.peers[addr.String()]
61+ peer.mu.Lock()
62+ peerClosed := peer.closed
63+ peer.mu.Unlock()
64+ if peerClosed {
65+ return 0, errors.New("peer closed")
66+ }
67+ peer.in <- packet{append([]byte(nil), b...), p.addr}
68+ return len(b), nil
69+}
70+func (p *fakePC) Close() error {
71+ p.mu.Lock()
72+ defer p.mu.Unlock()
73+ if !p.closed {
74+ p.closed = true
75+ close(p.in)
76+ }
77+ return nil
78+}
79+func (p *fakePC) LocalAddr() net.Addr { return p.addr }
80+func (p *fakePC) SetDeadline(time.Time) error { return nil }
81+func (p *fakePC) SetReadDeadline(time.Time) error { return nil }
82+func (p *fakePC) SetWriteDeadline(time.Time) error { return nil }
83+
84+func key() []byte { return bytes.Repeat([]byte{1}, pdgram.KeySize) }
85+
86+func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) {
87+ aPC, bPC := newPair()
88+ var droppedAck atomic.Bool
89+ bPC.drop = func(_ []byte, addr net.Addr) bool {
90+ return addr.String() == "a" && droppedAck.CompareAndSwap(false, true)
91+ }
92+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
97+ if err != nil {
98+ t.Fatal(err)
99+ }
100+ defer func() {
101+ if err := a.Close(); err != nil {
102+ t.Errorf("close a: %v", err)
103+ }
104+ }()
105+ defer func() {
106+ if err := b.Close(); err != nil {
107+ t.Errorf("close b: %v", err)
108+ }
109+ }()
110+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
111+ t.Fatal(err)
112+ }
113+ got := recvWithin(t, b, time.Second)
114+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !droppedAck.Load() {
115+ t.Fatalf("got=%+v droppedAck=%v", got, droppedAck.Load())
116+ }
117+ if got, ok := recvMaybe(b, 3*defaultResend); ok {
118+ t.Fatalf("duplicate reliable frame delivered: %+v", got)
119+ }
120+}
121+
122+func TestReliableInputRetransmitsUntilAck(t *testing.T) {
123+ aPC, bPC := newPair()
124+ var dropped atomic.Bool
125+ aPC.drop = func(_ []byte, addr net.Addr) bool {
126+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
127+ }
128+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1)
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ defer func() {
137+ if err := a.Close(); err != nil {
138+ t.Errorf("close a: %v", err)
139+ }
140+ }()
141+ defer func() {
142+ if err := b.Close(); err != nil {
143+ t.Errorf("close b: %v", err)
144+ }
145+ }()
146+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil {
147+ t.Fatal(err)
148+ }
149+ got := recvWithin(t, b, time.Second)
150+ if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() {
151+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
152+ }
153+}
154+
155+func TestAuthenticatedOnlyRehome(t *testing.T) {
156+ aPC, bPC := newPair()
157+ a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2)
158+ if err != nil {
159+ t.Fatal(err)
160+ }
161+ defer func() {
162+ if err := a.Close(); err != nil {
163+ t.Errorf("close a: %v", err)
164+ }
165+ }()
166+ _ = bPC
167+ old := a.Peer().String()
168+ aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")}
169+ time.Sleep(30 * time.Millisecond)
170+ if a.Peer().String() != old {
171+ t.Fatalf("unauthenticated packet changed peer to %v", a.Peer())
172+ }
173+ c, _ := pdgram.NewCodec(key())
174+ rec := encodeData(7, false, ports.Frame{Type: ports.MsgPing})
175+ frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU)
176+ raw, _ := pdgram.MarshalFragment(frags[0])
177+ aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")}
178+ time.Sleep(30 * time.Millisecond)
179+ if a.Peer().String() != "evil" {
180+ t.Fatalf("authenticated packet did not rehome: %v", a.Peer())
181+ }
182+}
183+
184+func TestOutputRetransmitsUntilAck(t *testing.T) {
185+ aPC, bPC := newPair()
186+ var dropped atomic.Bool
187+ aPC.drop = func(_ []byte, addr net.Addr) bool {
188+ return addr.String() == "b" && dropped.CompareAndSwap(false, true)
189+ }
190+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
191+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
192+ defer func() {
193+ if err := a.Close(); err != nil {
194+ t.Errorf("close a: %v", err)
195+ }
196+ }()
197+ defer func() {
198+ if err := b.Close(); err != nil {
199+ t.Errorf("close b: %v", err)
200+ }
201+ }()
202+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil {
203+ t.Fatal(err)
204+ }
205+ got := recvWithin(t, b, time.Second)
206+ if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() {
207+ t.Fatalf("got=%+v dropped=%v", got, dropped.Load())
208+ }
209+}
210+
211+func TestReliableDeliveryPreservesSequenceOrder(t *testing.T) {
212+ aPC, bPC := newPair()
213+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
214+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
215+ defer func() { _ = a.Close() }()
216+ defer func() { _ = b.Close() }()
217+
218+ for i := 0; i < 100; i++ {
219+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
220+ t.Fatal(err)
221+ }
222+ }
223+ for i := 0; i < 100; i++ {
224+ got := recvWithin(t, b, time.Second)
225+ if got.Type != ports.MsgOutput || len(got.Payload) != 1 || got.Payload[0] != byte(i) {
226+ t.Fatalf("frame %d delivered out of order: %+v", i, got)
227+ }
228+ }
229+}
230+
231+func TestAckProcessingContinuesWhenConsumerBackpressured(t *testing.T) {
232+ aPC, bPC := newPair()
233+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
234+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
235+ defer func() { _ = a.Close() }()
236+ defer func() { _ = b.Close() }()
237+
238+ for i := 0; i < cap(b.in)+20; i++ {
239+ if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}); err != nil {
240+ t.Fatal(err)
241+ }
242+ }
243+ deadline := time.Now().Add(time.Second)
244+ for time.Now().Before(deadline) {
245+ a.mu.Lock()
246+ pending := len(a.pending)
247+ a.mu.Unlock()
248+ if pending == 0 {
249+ return
250+ }
251+ time.Sleep(10 * time.Millisecond)
252+ }
253+ a.mu.Lock()
254+ pending := len(a.pending)
255+ a.mu.Unlock()
256+ t.Fatalf("acks blocked behind undrained consumer; pending=%d", pending)
257+}
258+
259+func TestProbeTimeoutAndSuccess(t *testing.T) {
260+ aPC, bPC := newPair()
261+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
262+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
263+ defer func() { _ = a.Close() }()
264+ defer func() { _ = b.Close() }()
265+
266+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
267+ if err := a.Probe(ctx); err != nil {
268+ cancel()
269+ t.Fatalf("probe success: %v", err)
270+ }
271+ cancel()
272+
273+ bPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "a" }
274+ ctx, cancel = context.WithTimeout(context.Background(), 50*time.Millisecond)
275+ defer cancel()
276+ if err := a.Probe(ctx); !errors.Is(err, context.DeadlineExceeded) {
277+ t.Fatalf("probe timeout err=%v, want context deadline", err)
278+ }
279+}
280+
281+func TestRecvUnblocksWhenPeerSilent(t *testing.T) {
282+ aPC, bPC := newPair()
283+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
284+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
285+ defer func() { _ = a.Close() }()
286+ defer func() { _ = b.Close() }()
287+ a.mu.Lock()
288+ a.silence = 75 * time.Millisecond
289+ a.lastHeard = time.Now().Add(-time.Second)
290+ a.mu.Unlock()
291+
292+ _, err := recvErrWithin(t, a, time.Second)
293+ if !errors.Is(err, ErrLinkDead) {
294+ t.Fatalf("Recv err=%v, want ErrLinkDead", err)
295+ }
296+}
297+
298+func TestPendingReliableQueueBounded(t *testing.T) {
299+ aPC, bPC := newPair()
300+ aPC.drop = func(_ []byte, addr net.Addr) bool { return addr.String() == "b" }
301+ a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2)
302+ b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1)
303+ defer func() { _ = a.Close() }()
304+ defer func() { _ = b.Close() }()
305+
306+ for i := 0; i < maxPendingReliable; i++ {
307+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte{byte(i)}}); err != nil {
308+ t.Fatalf("send %d: %v", i, err)
309+ }
310+ }
311+ if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("full")}); !errors.Is(err, ErrPendingFull) {
312+ t.Fatalf("send past bound err=%v, want ErrPendingFull", err)
313+ }
314+ a.mu.Lock()
315+ pending := len(a.pending)
316+ a.mu.Unlock()
317+ if pending > maxPendingReliable {
318+ t.Fatalf("pending=%d, want <= %d", pending, maxPendingReliable)
319+ }
320+}
321+
322+func TestReliableRecvBufferBoundedForFarFutureSequences(t *testing.T) {
323+ tr := &Transport{nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), done: make(chan struct{})}
324+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
325+ for i := 0; i < maxRecvBuffer+100; i++ {
326+ tr.enqueueReliable(uint64(1000+i), ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}})
327+ }
328+ if got := len(tr.recvBuf); got != maxRecvBuffer {
329+ t.Fatalf("recvBuf len=%d, want %d", got, maxRecvBuffer)
330+ }
331+ tr.enqueueReliable(1, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")})
332+ if _, ok := tr.recvBuf[1]; !ok {
333+ t.Fatalf("next expected sequence was dropped when far-future buffer was full")
334+ }
335+ if got := len(tr.recvBuf); got > maxRecvBuffer {
336+ t.Fatalf("recvBuf len=%d, want <= %d", got, maxRecvBuffer)
337+ }
338+}
339+
340+func TestReliableFullRecvBufferDoesNotAckDroppedFarFutureFrame(t *testing.T) {
341+ aPC, bPC := newPair()
342+ codec, err := pdgram.NewCodec(key())
343+ if err != nil {
344+ t.Fatal(err)
345+ }
346+ tr := &Transport{pc: aPC, peer: testAddr("b"), codec: codec, sendDir: 1, mtu: pdgram.DefaultMTU, nextRecvSeq: 1, recvBuf: make(map[uint64]ports.Frame), done: make(chan struct{})}
347+ tr.deliverCond = sync.NewCond(&tr.deliverMu)
348+ defer func() { _ = aPC.Close() }()
349+ defer func() { _ = bPC.Close() }()
350+
351+ for i := 0; i < maxRecvBuffer; i++ {
352+ tr.recvBuf[uint64(1000+i)] = ports.Frame{Type: ports.MsgOutput, Payload: []byte{byte(i)}}
353+ }
354+ tr.handleRecord(encodeData(9999, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("dropped")}))
355+ select {
356+ case pkt := <-bPC.in:
357+ t.Fatalf("unexpected ACK packet for dropped frame: %x", pkt.b)
358+ case <-time.After(25 * time.Millisecond):
359+ }
360+
361+ tr.handleRecord(encodeData(1, true, ports.Frame{Type: ports.MsgOutput, Payload: []byte("next")}))
362+ select {
363+ case <-bPC.in:
364+ case <-time.After(time.Second):
365+ t.Fatal("expected ACK for buffered contiguous frame")
366+ }
367+}
368+
369+func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
370+ ch := make(chan ports.Frame, 1)
371+ go func() { f, _ := tr.Recv(); ch <- f }()
372+ select {
373+ case f := <-ch:
374+ return f, true
375+ case <-time.After(d):
376+ return ports.Frame{}, false
377+ }
378+}
379+
380+func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
381+ t.Helper()
382+ ch := make(chan ports.Frame, 1)
383+ go func() { f, _ := tr.Recv(); ch <- f }()
384+ select {
385+ case f := <-ch:
386+ return f
387+ case <-time.After(d):
388+ t.Fatal("timeout")
389+ return ports.Frame{}
390+ }
391+}
392+
393+func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (ports.Frame, error) {
394+ t.Helper()
395+ type result struct {
396+ f ports.Frame
397+ err error
398+ }
399+ ch := make(chan result, 1)
400+ go func() {
401+ f, err := tr.Recv()
402+ ch <- result{f: f, err: err}
403+ }()
404+ select {
405+ case r := <-ch:
406+ return r.f, r.err
407+ case <-time.After(d):
408+ t.Fatal("timeout")
409+ return ports.Frame{}, nil
410+ }
411+}
M internal/adapters/sshstdio/transport.go +18 -2
@@ -6,6 +6,7 @@ package sshstdio
6 6
7 import (7 import (
8 "bytes"8 "bytes"
9+ "context"
9 "encoding/binary"10 "encoding/binary"
10 "errors"11 "errors"
11 "fmt"12 "fmt"
@@ -130,7 +131,13 @@ type CommandSpec struct {
130 // local shell. OpenSSH sends the remote command as one string for the remote131 // local shell. OpenSSH sends the remote command as one string for the remote
131 // user's shell to interpret, so every remote argv word is POSIX single-quoted.132 // user's shell to interpret, so every remote argv word is POSIX single-quoted.
132 func BuildCommand(target, session string) CommandSpec {133 func BuildCommand(target, session string) CommandSpec {
133- remote := []string{shellQuote("vev"), shellQuote("_stdio")}134+ return BuildCommandForMode(target, "_stdio", session)
135+}
136+
137+// BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
138+// remote mode such as _stdio or _udp-bootstrap.
139+func BuildCommandForMode(target, mode, session string) CommandSpec {
140+ remote := []string{shellQuote("vev"), shellQuote(mode)}
134 if session != "" {141 if session != "" {
135 remote = append(remote, shellQuote(session))142 remote = append(remote, shellQuote(session))
136 }143 }
@@ -146,8 +153,17 @@ func shellQuote(s string) string {
146 // child process' stdio. The subprocess is started with exec.Command argv, never153 // child process' stdio. The subprocess is started with exec.Command argv, never
147 // through a shell.154 // through a shell.
148 func Dial(target, session string) (ports.Transport, error) {155 func Dial(target, session string) (ports.Transport, error) {
156+ return DialContext(context.Background(), target, session)
157+}
158+
159+// DialContext is like Dial, but the context is propagated to ssh startup so a
160+// canceled attach attempt interrupts the local ssh process.
161+func DialContext(ctx context.Context, target, session string) (ports.Transport, error) {
162+ if err := ctx.Err(); err != nil {
163+ return nil, err
164+ }
149 spec := BuildCommand(target, session)165 spec := BuildCommand(target, session)
150- cmd := exec.Command(spec.Path, spec.Args...)166+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
151 stdin, err := cmd.StdinPipe()167 stdin, err := cmd.StdinPipe()
152 if err != nil {168 if err != nil {
153 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)169 return nil, fmt.Errorf("sshstdio: stdin pipe: %w", err)
M internal/adapters/sshstdio/transport_test.go +23 -0
@@ -2,6 +2,7 @@ package sshstdio
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
5 "errors"6 "errors"
6 "io"7 "io"
7 "os/exec"8 "os/exec"
@@ -43,6 +44,19 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
43 }44 }
44 }45 }
45 46
47+func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
48+ got := BuildCommandForMode("user@example.com", "_udp-bootstrap", "work")
49+ want := []string{"--", "user@example.com", "'vev' '_udp-bootstrap' 'work'"}
50+ if got.Path != "ssh" {
51+ t.Fatalf("Path = %q, want ssh", got.Path)
52+ }
53+ for i := range want {
54+ if got.Args[i] != want[i] {
55+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], want[i], got.Args)
56+ }
57+ }
58+}
59+
46 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {60 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
47 clientRead, serverWrite := io.Pipe()61 clientRead, serverWrite := io.Pipe()
48 serverRead, clientWrite := io.Pipe()62 serverRead, clientWrite := io.Pipe()
@@ -102,6 +116,15 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) {
102 }116 }
103 }117 }
104 118
119+func TestDialContextCanceledBeforeStart(t *testing.T) {
120+ ctx, cancel := context.WithCancel(context.Background())
121+ cancel()
122+ _, err := DialContext(ctx, "example.com", "work")
123+ if !errors.Is(err, context.Canceled) {
124+ t.Fatalf("DialContext error = %v, want context.Canceled", err)
125+ }
126+}
127+
105 func TestProcessCloser(t *testing.T) {128 func TestProcessCloser(t *testing.T) {
106 tests := []struct {129 tests := []struct {
107 name string130 name string
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -102,7 +102,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
102 102
103 select {103 select {
104 case got := <-captured:104 case got := <-captured:
105- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit105+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
106 if string(got) != want {106 if string(got) != want {
107 t.Fatalf("captured escapes = %q, want %q", got, want)107 t.Fatalf("captured escapes = %q, want %q", got, want)
108 }108 }
M internal/adapters/term/terminal.go +12 -10
@@ -21,14 +21,16 @@ var _ ports.Terminal = (*Terminal)(nil)
21 // Escape sequences for alt-screen and cursor visibility control. All21 // Escape sequences for alt-screen and cursor visibility control. All
22 // emissions go through the batched writer and are explicitly flushed.22 // emissions go through the batched writer and are explicitly flushed.
23 const (23 const (
24- altScreenEnter = "\x1b[?1049h"24+ altScreenEnter = "\x1b[?1049h"
25- altScreenExit = "\x1b[?1049l"25+ altScreenExit = "\x1b[?1049l"
26- cursorHide = "\x1b[?25l"26+ cursorHide = "\x1b[?25l"
27- cursorShow = "\x1b[?25h"27+ cursorShow = "\x1b[?25h"
28- cursorStyleDefault = "\x1b[0 q"28+ cursorStyleDefault = "\x1b[0 q"
29- mouseEnable = "\x1b[?1002h\x1b[?1006h"29+ mouseEnable = "\x1b[?1002h\x1b[?1006h"
30- mouseDisable = "\x1b[?1002l\x1b[?1006l"30+ mouseDisable = "\x1b[?1002l\x1b[?1006l"
31- oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"31+ bracketedPasteEnable = "\x1b[?2004h"
32+ bracketedPasteDisable = "\x1b[?2004l"
33+ oscColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
32 )34 )
33 35
34 // bufSize is the batched writer's buffer capacity.36 // bufSize is the batched writer's buffer capacity.
@@ -105,7 +107,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
105 }107 }
106 t.orig = old108 t.orig = old
107 109
108- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + oscColorQuery); err != nil {110+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery); err != nil {
109 _ = t.restoreRawLocked()111 _ = t.restoreRawLocked()
110 return nil, fmt.Errorf("term: enter alt screen: %w", err)112 return nil, fmt.Errorf("term: enter alt screen: %w", err)
111 }113 }
@@ -139,7 +141,7 @@ func (t *Terminal) restore() error {
139 t.mu.Lock()141 t.mu.Lock()
140 defer t.mu.Unlock()142 defer t.mu.Unlock()
141 143
142- _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + altScreenExit)144+ _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit)
143 ferr := t.bw.Flush()145 ferr := t.bw.Flush()
144 146
145 rerr := t.restoreRawLocked()147 rerr := t.restoreRawLocked()
M internal/adapters/term/terminal_test.go +2 -2
@@ -60,7 +60,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
60 _ = outW.Close()60 _ = outW.Close()
61 <-done61 <-done
62 62
63- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
64 if got := captured.String(); got != want {64 if got := captured.String(); got != want {
65 t.Fatalf("captured escapes = %q, want %q", got, want)65 t.Fatalf("captured escapes = %q, want %q", got, want)
66 }66 }
@@ -101,7 +101,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and101 // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
102 // cursor/alt-screen exits exactly once, regardless of how many times102 // cursor/alt-screen exits exactly once, regardless of how many times
103 // EnterRaw/restore were called.103 // EnterRaw/restore were called.
104- want := altScreenEnter + cursorHide + mouseEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + altScreenExit104+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + altScreenExit
105 if got := captured.String(); got != want {105 if got := captured.String(); got != want {
106 t.Fatalf("captured escapes = %q, want %q", got, want)106 t.Fatalf("captured escapes = %q, want %q", got, want)
107 }107 }
M internal/app/run.go +119 -15
@@ -6,10 +6,13 @@ package app
6 6
7 import (7 import (
8 "context"8 "context"
9+ "crypto/rand"
10+ "encoding/base64"
9 "errors"11 "errors"
10 "fmt"12 "fmt"
11 "io"13 "io"
12 "log/slog"14 "log/slog"
15+ "net"
13 "net/http"16 "net/http"
14 _ "net/http/pprof"17 _ "net/http/pprof"
15 "os"18 "os"
@@ -19,6 +22,7 @@ import (
19 "time"22 "time"
20 23
21 "github.com/bnema/vev/internal/adapters/clock"24 "github.com/bnema/vev/internal/adapters/clock"
25+ "github.com/bnema/vev/internal/adapters/dgram"
22 "github.com/bnema/vev/internal/adapters/ipc"26 "github.com/bnema/vev/internal/adapters/ipc"
23 "github.com/bnema/vev/internal/adapters/pty"27 "github.com/bnema/vev/internal/adapters/pty"
24 "github.com/bnema/vev/internal/adapters/sshstdio"28 "github.com/bnema/vev/internal/adapters/sshstdio"
@@ -42,6 +46,7 @@ const (
42 kindKill46 kindKill
43 kindDaemon47 kindDaemon
44 kindStdio48 kindStdio
49+ kindUDPBootstrap
45 kindHelp50 kindHelp
46 kindVersion51 kindVersion
47 )52 )
@@ -116,6 +121,15 @@ func parseArgs(args []string) (command, error) {
116 cmd.name = args[1]121 cmd.name = args[1]
117 }122 }
118 return cmd, nil123 return cmd, nil
124+ case "_udp-bootstrap":
125+ if len(args) > 2 {
126+ return command{}, usagef("`_udp-bootstrap` accepts at most one session name")
127+ }
128+ cmd := command{kind: kindUDPBootstrap}
129+ if len(args) == 2 {
130+ cmd.name = args[1]
131+ }
132+ return cmd, nil
119 case "new":133 case "new":
120 if len(args) < 2 || args[1] == "" {134 if len(args) < 2 || args[1] == "" {
121 return command{}, usagef("`new` requires a session name")135 return command{}, usagef("`new` requires a session name")
@@ -185,6 +199,8 @@ func dispatch(ctx context.Context, cmd command) error {
185 return runDaemon()199 return runDaemon()
186 case kindStdio:200 case kindStdio:
187 return runStdio(ctx)201 return runStdio(ctx)
202+ case kindUDPBootstrap:
203+ return runUDPBootstrap(ctx, cmd.name)
188 case kindList:204 case kindList:
189 return runList(ctx)205 return runList(ctx)
190 case kindKill:206 case kindKill:
@@ -252,13 +268,24 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
252 defer func() { _ = logFile.Close() }()268 defer func() { _ = logFile.Close() }()
253 269
254 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{270 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{
255- attachLocal: attachLocalDaemon,271+ localDialer: defaultLocalDialer,
272+ remoteDialer: defaultRemoteDialer,
273+ runClient: client.Run,
256 createDetached: createDetachedLocalSession,274 createDetached: createDetachedLocalSession,
257 })275 })
258 }276 }
259 277
278+func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
279+
280+func defaultRemoteDialer(target, session string) ports.Dialer {
281+ return dgram.NewRemoteDialer(target, session)
282+}
283+
260 type runAttachDeps struct {284 type runAttachDeps struct {
261- attachLocal func(context.Context, uint8, string) error285+ attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests
286+ localDialer func() ports.Dialer
287+ remoteDialer func(target, session string) ports.Dialer
288+ runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string) error
262 createDetached func(context.Context, string) error289 createDetached func(context.Context, string) error
263 }290 }
264 291
@@ -270,18 +297,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")297 return errors.New("vev: sessions should be nested with care; unset VEV to force")
271 }298 }
272 299
300+ runClient := deps.runClient
301+ if runClient == nil {
302+ runClient = client.Run
303+ }
273 if remoteTarget != "" {304 if remoteTarget != "" {
274- transport, err := sshstdio.Dial(remoteTarget, name)305+ remoteDialer := deps.remoteDialer
275- if err != nil {306+ if remoteDialer == nil {
276- return err307+ remoteDialer = defaultRemoteDialer
277 }308 }
278- // client.Attach owns and closes transport.309+ return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), intent, name)
279- return client.Attach(ctx, transport, term.New(), intent, name)
280 }310 }
281 311
312+ attachLocal := deps.attachLocal
313+ if attachLocal == nil {
314+ localDialer := deps.localDialer
315+ if localDialer == nil {
316+ localDialer = defaultLocalDialer
317+ }
318+ attachLocal = func(ctx context.Context, intent uint8, name string) error {
319+ return runClient(ctx, localDialer(), term.New(), intent, name)
320+ }
321+ }
282 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{322 return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{
283 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),323 confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr),
284- attach: deps.attachLocal,324+ attach: attachLocal,
285 killDaemon: requestDaemonStop,325 killDaemon: requestDaemonStop,
286 settleAfterKill: waitForDaemonStop,326 settleAfterKill: waitForDaemonStop,
287 })327 })
@@ -332,13 +372,10 @@ func isDaemonVersionDrift(err error) bool {
332 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal372 return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal
333 }373 }
334 374
335-func attachLocalDaemon(ctx context.Context, intent uint8, name string) error {375+type localDaemonDialer struct{ dir string }
336- transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)376+
337- if err != nil {377+func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
338- return err378+ return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
339- }
340- // client.Attach owns and closes transport.
341- return client.Attach(ctx, transport, term.New(), intent, name)
342 }379 }
343 380
344 func createDetachedLocalSession(ctx context.Context, name string) error {381 func createDetachedLocalSession(ctx context.Context, name string) error {
@@ -462,6 +499,73 @@ func runStdio(ctx context.Context) error {
462 return proxyTransports(ctx, stdio, transport)499 return proxyTransports(ctx, stdio, transport)
463 }500 }
464 501
502+// runUDPBootstrap is the hidden remote-side bootstrap used before switching to
503+// authenticated datagrams. It deliberately prints only an ephemeral UDP port and
504+// random session key to stdout so callers can fall back to _stdio if setup fails.
505+func runUDPBootstrap(ctx context.Context, session string) error {
506+ select {
507+ case <-ctx.Done():
508+ return ctx.Err()
509+ default:
510+ }
511+ var lc net.ListenConfig
512+ conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0")
513+ if err != nil {
514+ return err
515+ }
516+ defer func() { _ = conn.Close() }()
517+ key := make([]byte, 32)
518+ if _, err := rand.Read(key); err != nil {
519+ return err
520+ }
521+ daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
522+ if err != nil {
523+ return err
524+ }
525+ defer func() { _ = daemonTr.Close() }()
526+ dg, err := dgram.NewTransport(conn, nil, key, 2, 1)
527+ if err != nil {
528+ return err
529+ }
530+ defer func() { _ = dg.Close() }()
531+
532+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
533+ if !ok {
534+ return errors.New("vev: udp bootstrap did not get a UDP address")
535+ }
536+ if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {
537+ return err
538+ }
539+ _ = os.Stdout.Sync()
540+
541+ var dgramTr ports.Transport = dg
542+ if session != "" {
543+ dgramTr = preHelloNameTransport{Transport: dg, session: session}
544+ }
545+ return proxyTransports(ctx, dgramTr, daemonTr)
546+}
547+
548+type preHelloNameTransport struct {
549+ ports.Transport
550+ session string
551+}
552+
553+func (t preHelloNameTransport) Recv() (ports.Frame, error) {
554+ f, err := t.Transport.Recv()
555+ if err != nil || f.Type != ports.MsgHello || t.session == "" {
556+ return f, err
557+ }
558+ h, err := ports.UnmarshalHello(f.Payload)
559+ if err != nil {
560+ return f, nil
561+ }
562+ if h.Name == "" {
563+ h.Name = t.session
564+ f.Payload = ports.MarshalHello(h)
565+ }
566+ return f, nil
567+}
568+
465 func proxyTransports(ctx context.Context, a, b ports.Transport) error {569 func proxyTransports(ctx context.Context, a, b ports.Transport) error {
466 errCh := make(chan error, 2)570 errCh := make(chan error, 2)
467 copyFrames := func(dst, src ports.Transport) {571 copyFrames := func(dst, src ports.Transport) {
M internal/app/run_test.go +49 -0
@@ -386,3 +386,52 @@ 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 TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
397+ var gotTarget, gotSession, gotDialer string
398+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{
399+ remoteDialer: func(target, session string) ports.Dialer {
400+ gotTarget, gotSession = target, session
401+ return namedDialer{name: "remote"}
402+ },
403+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {
404+ gotDialer = d.(namedDialer).name
405+ if intent != ports.IntentAttach || name != "work" {
406+ t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
407+ }
408+ return nil
409+ },
410+ })
411+ if err != nil {
412+ t.Fatalf("runAttachWithDeps returned error: %v", err)
413+ }
414+ if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {
415+ t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)
416+ }
417+}
418+
419+func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
420+ var gotDialer string
421+ err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{
422+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
423+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error {
424+ gotDialer = d.(namedDialer).name
425+ if intent != ports.IntentEphemeral || name != "" {
426+ t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name)
427+ }
428+ return nil
429+ },
430+ })
431+ if err != nil {
432+ t.Fatalf("runAttachWithDeps returned error: %v", err)
433+ }
434+ if gotDialer != "local" {
435+ t.Fatalf("local dialer = %q, want local", gotDialer)
436+ }
437+}
M internal/ports/frame.go +2 -1
@@ -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
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/mocks/mocks.go +90 -0
@@ -5,6 +5,7 @@
5 package portsmocks5 package portsmocks
6 6
7 import (7 import (
8+ "context"
8 "io"9 "io"
9 "time"10 "time"
10 11
@@ -957,6 +958,95 @@ func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) erro
957 return _c958 return _c
958 }959 }
959 960
961+// NewMockDialer creates a new instance of MockDialer. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
962+// The first argument is typically a *testing.T value.
963+func NewMockDialer(t interface {
964+ mock.TestingT
965+ Cleanup(func())
966+}) *MockDialer {
967+ mock := &MockDialer{}
968+ mock.Mock.Test(t)
969+
970+ t.Cleanup(func() { mock.AssertExpectations(t) })
971+
972+ return mock
973+}
974+
975+// MockDialer is an autogenerated mock type for the Dialer type
976+type MockDialer struct {
977+ mock.Mock
978+}
979+
980+type MockDialer_Expecter struct {
981+ mock *mock.Mock
982+}
983+
984+func (_m *MockDialer) EXPECT() *MockDialer_Expecter {
985+ return &MockDialer_Expecter{mock: &_m.Mock}
986+}
987+
988+// Dial provides a mock function for the type MockDialer
989+func (_mock *MockDialer) Dial(ctx context.Context) (ports.Transport, error) {
990+ ret := _mock.Called(ctx)
991+
992+ if len(ret) == 0 {
993+ panic("no return value specified for Dial")
994+ }
995+
996+ var r0 ports.Transport
997+ var r1 error
998+ if returnFunc, ok := ret.Get(0).(func(context.Context) (ports.Transport, error)); ok {
999+ return returnFunc(ctx)
1000+ }
1001+ if returnFunc, ok := ret.Get(0).(func(context.Context) ports.Transport); ok {
1002+ r0 = returnFunc(ctx)
1003+ } else {
1004+ if ret.Get(0) != nil {
1005+ r0 = ret.Get(0).(ports.Transport)
1006+ }
1007+ }
1008+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1009+ r1 = returnFunc(ctx)
1010+ } else {
1011+ r1 = ret.Error(1)
1012+ }
1013+ return r0, r1
1014+}
1015+
1016+// MockDialer_Dial_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Dial'
1017+type MockDialer_Dial_Call struct {
1018+ *mock.Call
1019+}
1020+
1021+// Dial is a helper method to define mock.On call
1022+// - ctx context.Context
1023+func (_e *MockDialer_Expecter) Dial(ctx any) *MockDialer_Dial_Call {
1024+ return &MockDialer_Dial_Call{Call: _e.mock.On("Dial", ctx)}
1025+}
1026+
1027+func (_c *MockDialer_Dial_Call) Run(run func(ctx context.Context)) *MockDialer_Dial_Call {
1028+ _c.Call.Run(func(args mock.Arguments) {
1029+ var arg0 context.Context
1030+ if args[0] != nil {
1031+ arg0 = args[0].(context.Context)
1032+ }
1033+ run(
1034+ arg0,
1035+ )
1036+ })
1037+ return _c
1038+}
1039+
1040+func (_c *MockDialer_Dial_Call) Return(transport ports.Transport, err error) *MockDialer_Dial_Call {
1041+ _c.Call.Return(transport, err)
1042+ return _c
1043+}
1044+
1045+func (_c *MockDialer_Dial_Call) RunAndReturn(run func(ctx context.Context) (ports.Transport, error)) *MockDialer_Dial_Call {
1046+ _c.Call.Return(run)
1047+ return _c
1048+}
1049+
960 // NewMockListener creates a new instance of MockListener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.1050 // NewMockListener creates a new instance of MockListener. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
961 // The first argument is typically a *testing.T value.1051 // The first argument is typically a *testing.T value.
962 func NewMockListener(t interface {1052 func NewMockListener(t interface {
M internal/ports/ports.go +6 -0
@@ -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 (
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) {
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 +205 -45
@@ -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,18 @@ 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+ if _, err := rand.Read(id[:]); err != nil {
92+ panic("crypto/rand failed generating client ID: " + err.Error())
93+ }
94+ return id
95+}
96+
83 // stdinBufSize is the read buffer for the terminal input pump.97 // stdinBufSize is the read buffer for the terminal input pump.
84 const stdinBufSize = 409698 const stdinBufSize = 4096
85 99
@@ -87,32 +101,175 @@ const stdinBufSize = 4096
87 // goroutine, decoupling the input/resize pumps from transport back-pressure.101 // goroutine, decoupling the input/resize pumps from transport back-pressure.
88 const sendQueueDepth = 64102 const sendQueueDepth = 64
89 103
90-// Attach connects an already-dialed transport to the controlling terminal104+type reconnectBackoff struct {
91-// and runs the attach loop until the session detaches, the daemon105+ initial time.Duration
92-// disappears, or the context is cancelled.106+ max time.Duration
93-//107+}
94-// It owns transport for its lifetime and closes it before returning. The108+
95-// terminal is put into raw mode after a successful Welcome and restored on109+var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
96-// every exit path — normal return, error, or panic.110+
97-func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) {111+var reconnectSleep = sleepReconnect
112+
113+const (
114+ statusReconnect = "\r\x1b[2Kreconnecting…"
115+ statusClear = "\r\x1b[2K"
116+)
117+
118+// Run connects via dialer and runs the attach client. It owns the terminal
119+// lifecycle above attach attempts so raw mode remains active while a live
120+// client process redials a lost link.
121+func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent uint8, name string) (retErr error) {
98 log := slog.Default().With("component", "client")122 log := slog.Default().With("component", "client")
99- ms := milestones{dialed: true} // transport arrives already connected123+ ms := milestones{}
100 124
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() {125 defer func() {
104 if retErr != nil {126 if retErr != nil {
105 log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())127 log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing())
106 }128 }
107 }()129 }()
108 130
109- // Attach owns the transport: close it on every exit path.131+ var restore func() error
110- defer func() { _ = transport.Close() }()132+ rawEntered := false
133+ defer func() {
134+ if rawEntered {
135+ if rerr := restore(); rerr != nil && retErr == nil {
136+ retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
137+ }
138+ }
139+ }()
140+ enterRaw := func() error {
141+ if rawEntered {
142+ return nil
143+ }
144+ var err error
145+ restore, err = term.EnterRaw()
146+ if err != nil {
147+ return fmt.Errorf("vev: entering raw mode: %w", err)
148+ }
149+ rawEntered = true
150+ ms.rawEntered = true
151+ return nil
152+ }
153+
154+ resumeToken := uint64(0)
155+ attemptIntent := intent
156+ backoff := defaultReconnectBackoff.initial
157+ showingStatus := false
158+ drawStatus := func() {
159+ if !rawEntered || showingStatus {
160+ return
161+ }
162+ _, _ = term.Out().Write([]byte(statusReconnect))
163+ _ = term.Flush()
164+ showingStatus = true
165+ }
166+ clearStatus := func() {
167+ if !showingStatus {
168+ return
169+ }
170+ _, _ = term.Out().Write([]byte(statusClear))
171+ _ = term.Flush()
172+ showingStatus = false
173+ }
174+
175+ for {
176+ transport, err := dialer.Dial(ctx)
177+ if err != nil {
178+ if resumeToken == 0 || ctx.Err() != nil {
179+ return err
180+ }
181+ drawStatus()
182+ if !reconnectSleep(ctx, backoff) {
183+ return ctx.Err()
184+ }
185+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
186+ attemptIntent = ports.IntentResume
187+ continue
188+ }
189+ ms.dialed = true
190+
191+ result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)
192+ if result.welcomed {
193+ backoff = defaultReconnectBackoff.initial
194+ }
195+ _ = transport.Close()
196+ if result.resumeToken != 0 {
197+ resumeToken = result.resumeToken
198+ }
199+ if result.err == nil {
200+ clearStatus()
201+ return nil
202+ }
203+ if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil {
204+ clearStatus()
205+ return result.err
206+ }
207+ drawStatus()
208+ if !reconnectSleep(ctx, backoff) {
209+ clearStatus()
210+ return ctx.Err()
211+ }
212+ backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
213+ attemptIntent = ports.IntentResume
214+ }
215+}
216+
217+func sleepReconnect(ctx context.Context, d time.Duration) bool {
218+ t := time.NewTimer(d)
219+ defer t.Stop()
220+ select {
221+ case <-t.C:
222+ return true
223+ case <-ctx.Done():
224+ return false
225+ }
226+}
227+
228+func nextReconnectBackoff(cur, limit time.Duration) time.Duration {
229+ cur *= 2
230+ if cur > limit {
231+ return limit
232+ }
233+ return cur
234+}
235+
236+func shouldReconnect(err error) bool {
237+ if err == nil {
238+ return false
239+ }
240+ if _, ok := errors.AsType[*ProtocolError](err); ok {
241+ return false
242+ }
243+ _, ok := errors.AsType[*DetachedError](err)
244+ return !ok
245+}
246+
247+// Attach connects an already-dialed transport to the controlling terminal
248+// and runs the attach loop until the session detaches, the daemon
249+// disappears, or the context is cancelled.
250+//
251+// It is kept as a compatibility wrapper for callers that still own dialing.
252+func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error {
253+ return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name)
254+}
255+
256+type singleTransportDialer struct{ transport ports.Transport }
257+
258+func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
259+ return d.transport, nil
260+}
261+
262+type attachResult struct {
263+ resumeToken uint64
264+ welcomed bool
265+ err error
266+}
111 267
268+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.269 // 1. Handshake: send Hello with our size and TERM.
113 size, err := term.Size()270 size, err := term.Size()
114 if err != nil {271 if err != nil {
115- return fmt.Errorf("vev: reading terminal size: %w", err)272+ return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)}
116 }273 }
117 cwd, err := os.Getwd()274 cwd, err := os.Getwd()
118 if err != nil {275 if err != nil {
@@ -121,47 +278,48 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
121 colorTerm := strings.ToLower(os.Getenv("COLORTERM"))278 colorTerm := strings.ToLower(os.Getenv("COLORTERM"))
122 trueColor := colorTerm == "truecolor" || colorTerm == "24bit"279 trueColor := colorTerm == "truecolor" || colorTerm == "24bit"
123 hello := ports.Hello{280 hello := ports.Hello{
124- Version: ports.ProtocolVersion,281+ Version: ports.ProtocolVersion,
125- Intent: intent,282+ Intent: intent,
126- Name: name,283+ ClientID: clientID,
127- Size: size,284+ ResumeToken: resumeToken,
128- TermEnv: os.Getenv("TERM"),285+ Name: name,
129- Cwd: cwd,286+ Size: size,
287+ TermEnv: os.Getenv("TERM"),
288+ Cwd: cwd,
130 }289 }
131 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {290 if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil {
132- return fmt.Errorf("vev: sending hello: %w", err)291+ return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)}
133 }292 }
134 ms.helloSent = true293 ms.helloSent = true
135 294
136 // 2. Await Welcome or a typed rejection.295 // 2. Await Welcome or a typed rejection.
137 reply, err := transport.Recv()296 reply, err := transport.Recv()
138 if err != nil {297 if err != nil {
139- return fmt.Errorf("vev: awaiting welcome: %w", err)298+ return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)}
140 }299 }
141 switch reply.Type {300 switch reply.Type {
142 case ports.MsgWelcome:301 case ports.MsgWelcome:
302+ welcome, derr := ports.UnmarshalWelcome(reply.Payload)
303+ if derr != nil {
304+ return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)}
305+ }
306+ resumeToken = welcome.ResumeToken
143 ms.welcomed = true307 ms.welcomed = true
144 case ports.MsgError:308 case ports.MsgError:
145 em, derr := ports.UnmarshalErrorMsg(reply.Payload)309 em, derr := ports.UnmarshalErrorMsg(reply.Payload)
146 if derr != nil {310 if derr != nil {
147- return fmt.Errorf("vev: decoding error reply: %w", derr)311+ return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)}
148 }312 }
149- return &ProtocolError{Code: em.Code, Text: em.Text}313+ return attachResult{resumeToken: resumeToken, err: &ProtocolError{Code: em.Code, Text: em.Text}}
150 default:314 default:
151- return fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)315+ return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)}
152 }316 }
153 317
154- // 3. Enter raw mode; restore on every subsequent exit path.318+ // 3. Enter raw mode after Welcome; Run owns restoration.
155- restore, err := term.EnterRaw()319+ if err := enterRaw(); err != nil {
156- if err != nil {320+ return attachResult{resumeToken: resumeToken, welcomed: true, err: err}
157- return fmt.Errorf("vev: entering raw mode: %w", err)
158 }321 }
159- ms.rawEntered = true322+ clearStatus()
160- defer func() {
161- if rerr := restore(); rerr != nil && retErr == nil {
162- retErr = fmt.Errorf("vev: restoring terminal: %w", rerr)
163- }
164- }()
165 323
166 // 4. Derive a cancellable context so the pumps always unwind when the324 // 4. Derive a cancellable context so the pumps always unwind when the
167 // loop returns. cancel runs first (LIFO): it signals the pumps; the325 // loop returns. cancel runs first (LIFO): it signals the pumps; the
@@ -187,37 +345,37 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal,
187 // context was cancelled. A queued sender error takes priority.345 // context was cancelled. A queued sender error takes priority.
188 select {346 select {
189 case serr := <-sendErrCh:347 case serr := <-sendErrCh:
190- return fmt.Errorf("vev: sending to daemon: %w", serr)348+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
191 default:349 default:
192- return nil350+ return attachResult{resumeToken: resumeToken, welcomed: true}
193 }351 }
194 case r := <-recvCh:352 case r := <-recvCh:
195 if r.err != nil {353 if r.err != nil {
196 if errors.Is(r.err, io.EOF) {354 if errors.Is(r.err, io.EOF) {
197- return fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)355+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)}
198 }356 }
199- return fmt.Errorf("vev: receiving from daemon: %w", r.err)357+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)}
200 }358 }
201 switch r.frame.Type {359 switch r.frame.Type {
202 case ports.MsgOutput:360 case ports.MsgOutput:
203 o, derr := ports.UnmarshalOutput(r.frame.Payload)361 o, derr := ports.UnmarshalOutput(r.frame.Payload)
204 if derr != nil {362 if derr != nil {
205- return fmt.Errorf("vev: decoding output: %w", derr)363+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding output: %w", derr)}
206 }364 }
207 if _, werr := term.Out().Write(o.Data); werr != nil {365 if _, werr := term.Out().Write(o.Data); werr != nil {
208- return fmt.Errorf("vev: writing terminal output: %w", werr)366+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: writing terminal output: %w", werr)}
209 }367 }
210 if ferr := term.Flush(); ferr != nil {368 if ferr := term.Flush(); ferr != nil {
211- return fmt.Errorf("vev: flushing terminal: %w", ferr)369+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
212 }370 }
213 ms.firstOutput = true371 ms.firstOutput = true
214 case ports.MsgDetached:372 case ports.MsgDetached:
215 d, derr := ports.UnmarshalDetached(r.frame.Payload)373 d, derr := ports.UnmarshalDetached(r.frame.Payload)
216 if derr != nil {374 if derr != nil {
217- return fmt.Errorf("vev: decoding detached: %w", derr)375+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}
218 }376 }
219 ms.detached = true377 ms.detached = true
220- return detachedResult(d.Reason)378+ return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}
221 case ports.MsgPong:379 case ports.MsgPong:
222 // Liveness reply; nothing to do.380 // Liveness reply; nothing to do.
223 default:381 default:
@@ -290,6 +448,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) {448 func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) {
291 buf := make([]byte, stdinBufSize)449 buf := make([]byte, stdinBufSize)
292 var scanner theme.Scanner450 var scanner theme.Scanner
451+ var inputSeq uint64
293 current := ports.Theme{TrueColor: trueColor}452 current := ports.Theme{TrueColor: trueColor}
294 send := func(frame ports.Frame) bool {453 send := func(frame ports.Frame) bool {
295 select {454 select {
@@ -320,7 +479,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out
320 return479 return
321 }480 }
322 copyData := append([]byte(nil), data...)481 copyData := append([]byte(nil), data...)
323- ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})})482+ inputSeq++
483+ ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
324 })484 })
325 if !ok {485 if !ok {
326 return486 return
M internal/usecase/client/client_test.go +175 -0
@@ -530,3 +530,178 @@ 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+func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
535+ var out bytes.Buffer
536+ var restoreCount atomic.Int32
537+ resizeCh := make(chan domain.Size)
538+ tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh)
539+ defer in.unblock()
540+
541+ tr := portsmocks.NewMockTransport(t)
542+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
543+ boom := errors.New("connection reset")
544+ unblock := scriptRecv(tr,
545+ recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
546+ recvItem{err: boom},
547+ )
548+ defer unblock()
549+ tr.EXPECT().Close().Return(nil).Once()
550+ d := portsmocks.NewMockDialer(t)
551+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
552+
553+ err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
554+ require.Error(t, err)
555+ require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors")
556+}
557+
558+func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
559+ tm := portsmocks.NewMockTerminal(t)
560+ tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
561+ // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
562+
563+ tr := portsmocks.NewMockTransport(t)
564+ tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
565+ tr.EXPECT().Recv().Return(
566+ frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
567+ nil,
568+ ).Once()
569+ tr.EXPECT().Close().Return(nil).Once()
570+ d := portsmocks.NewMockDialer(t)
571+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
572+
573+ err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main")
574+ require.Error(t, err)
575+ var pe *client.ProtocolError
576+ require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
577+}
578+
579+func TestRunPhaseASingleAttempt(t *testing.T) {
580+ dialErr := errors.New("dial failed")
581+ d := portsmocks.NewMockDialer(t)
582+ d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
583+ tm := portsmocks.NewMockTerminal(t)
584+
585+ err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
586+ require.ErrorIs(t, err, dialErr)
587+}
588+
589+type sequenceDialer struct {
590+ trs []ports.Transport
591+ errs []error
592+ calls atomic.Int32
593+}
594+
595+func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) {
596+ i := int(d.calls.Add(1)) - 1
597+ if i < len(d.errs) && d.errs[i] != nil {
598+ return nil, d.errs[i]
599+ }
600+ if i >= len(d.trs) {
601+ return nil, io.EOF
602+ }
603+ return d.trs[i], nil
604+}
605+
606+type recordingTransport struct {
607+ recvs []recvItem
608+ sends []ports.Frame
609+ closed atomic.Int32
610+}
611+
612+func (t *recordingTransport) Send(f ports.Frame) error {
613+ t.sends = append(t.sends, f)
614+ return nil
615+}
616+
617+func (t *recordingTransport) Recv() (ports.Frame, error) {
618+ if len(t.recvs) == 0 {
619+ return ports.Frame{}, io.EOF
620+ }
621+ it := t.recvs[0]
622+ t.recvs = t.recvs[1:]
623+ return it.f, it.err
624+}
625+
626+func (t *recordingTransport) Close() error {
627+ t.closed.Add(1)
628+ return nil
629+}
630+
631+type runTerminal struct {
632+ in *blockingReader
633+ out bytes.Buffer
634+ rawCount atomic.Int32
635+ restoreCount atomic.Int32
636+ resizeCh chan domain.Size
637+}
638+
639+func newRunTerminal() *runTerminal {
640+ return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)}
641+}
642+
643+func (t *runTerminal) EnterRaw() (func() error, error) {
644+ t.rawCount.Add(1)
645+ return func() error { t.restoreCount.Add(1); return nil }, nil
646+}
647+func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
648+func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
649+func (t *runTerminal) In() io.Reader { return t.in }
650+func (t *runTerminal) Out() io.Writer { return &t.out }
651+func (t *runTerminal) Flush() error { return nil }
652+
653+func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello {
654+ t.Helper()
655+ require.NotEmpty(t, tr.sends)
656+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
657+ h, err := ports.UnmarshalHello(tr.sends[0].Payload)
658+ require.NoError(t, err)
659+ return h
660+}
661+
662+func welcomeFrame(token uint64) ports.Frame {
663+ return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume}))
664+}
665+
666+func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) {
667+ term := newRunTerminal()
668+ defer term.in.unblock()
669+ tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}}
670+ tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}}
671+ tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}}
672+ d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}}
673+
674+ err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")
675+ require.NoError(t, err)
676+ require.Equal(t, int32(3), d.calls.Load())
677+ require.Equal(t, int32(1), term.rawCount.Load())
678+ require.Equal(t, int32(1), term.restoreCount.Load())
679+
680+ h1 := helloFromSend(t, tr1)
681+ h2 := helloFromSend(t, tr2)
682+ h3 := helloFromSend(t, tr3)
683+ require.Equal(t, ports.IntentAttach, h1.Intent)
684+ require.Zero(t, h1.ResumeToken)
685+ require.Equal(t, ports.IntentResume, h2.Intent)
686+ require.Equal(t, uint64(11), h2.ResumeToken)
687+ require.Equal(t, ports.IntentResume, h3.Intent)
688+ require.Equal(t, uint64(22), h3.ResumeToken)
689+ require.Equal(t, h1.ClientID, h2.ClientID)
690+ require.Equal(t, h1.ClientID, h3.ClientID)
691+ require.Contains(t, term.out.String(), "reconnecting…")
692+ require.Contains(t, term.out.String(), "\x1b[2K")
693+}
694+
695+func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
696+ term := newRunTerminal()
697+ defer term.in.unblock()
698+ tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}}
699+ d := &sequenceDialer{trs: []ports.Transport{tr}}
700+
701+ err := client.Run(context.Background(), d, term, ports.IntentAttach, "main")
702+ require.Error(t, err)
703+ var de *client.DetachedError
704+ require.True(t, errors.As(err, &de))
705+ require.Equal(t, int32(1), d.calls.Load())
706+ require.Equal(t, int32(1), term.restoreCount.Load())
707+}
M internal/usecase/daemon/client.go +167 -19
@@ -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,12 @@ 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+ nextStateNum uint64
53+ echoAck atomic.Uint64
47 bars barCache // only touched while sendMu is held54 bars barCache // only touched while sendMu is held
48 size domain.Size55 size domain.Size
49 keys *keys.Router56 keys *keys.Router
@@ -75,6 +82,7 @@ type attachedClient struct {
75 themeMu sync.Mutex82 themeMu sync.Mutex
76 theme themeui.Theme83 theme themeui.Theme
77 lastCursor cursorOut84 lastCursor cursorOut
85+ linkMu sync.Mutex
78 sendMu sync.Mutex86 sendMu sync.Mutex
79 }87 }
80 88
@@ -156,11 +164,62 @@ func (ac *attachedClient) promptActive() bool {
156 return ac.prompt != nil164 return ac.prompt != nil
157 }165 }
158 166
167+func (ac *attachedClient) transport() ports.Transport {
168+ ac.linkMu.Lock()
169+ defer ac.linkMu.Unlock()
170+ return ac.tr
171+}
172+
173+func (ac *attachedClient) replaceTransport(tr ports.Transport) {
174+ ac.linkMu.Lock()
175+ ac.tr = tr
176+ ac.linkMu.Unlock()
177+}
178+
179+func (ac *attachedClient) closeTransport() error {
180+ tr := ac.transport()
181+ if tr == nil {
182+ return nil
183+ }
184+ return tr.Close()
185+}
186+
187+func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
188+ if tr == nil {
189+ return nil
190+ }
191+ return tr.Close()
192+}
193+
194+func (ac *attachedClient) transportIs(tr ports.Transport) bool {
195+ ac.linkMu.Lock()
196+ defer ac.linkMu.Unlock()
197+ return ac.tr == tr
198+}
199+
200+func (ac *attachedClient) currentTransportIs(tr ports.Transport) bool {
201+ return tr != nil && ac.transportIs(tr)
202+}
203+
204+func (ac *attachedClient) nextOutputFrameLocked(b []byte) ports.Frame {
205+ ac.nextStateNum++
206+ return frameOutputState(b, ac.nextStateNum, ac.echoAck.Load())
207+}
208+
159 // send serialises a frame onto the client's transport.209 // send serialises a frame onto the client's transport.
160 func (ac *attachedClient) send(f ports.Frame) error {210 func (ac *attachedClient) send(f ports.Frame) error {
211+ _, err := ac.sendTransport(f)
212+ return err
213+}
214+
215+func (ac *attachedClient) sendTransport(f ports.Frame) (ports.Transport, error) {
161 ac.sendMu.Lock()216 ac.sendMu.Lock()
162 defer ac.sendMu.Unlock()217 defer ac.sendMu.Unlock()
163- return ac.tr.Send(f)218+ tr := ac.transport()
219+ if tr == nil {
220+ return nil, errors.New("client transport is nil")
221+ }
222+ return tr, tr.Send(f)
164 }223 }
165 224
166 // boundedSend sends f to ac with a deadline watchdog: if the send (including225 // boundedSend sends f to ac with a deadline watchdog: if the send (including
@@ -173,23 +232,68 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
173 }232 }
174 233
175 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {234 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
235+ tr, err := d.boundedSendWith(ac, func(capture func(ports.Transport)) error {
236+ tr, err := ac.sendTransport(f)
237+ capture(tr)
238+ return err
239+ })
240+ if err != nil && errors.Is(err, errSendTimedOut) {
241+ _ = ac.closeCapturedTransport(tr)
242+ }
243+ return err
244+}
245+
246+func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
247+ _, err := d.boundedSendOutputErrTransport(ac, b)
248+ return err
249+}
250+
251+func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
252+ return d.boundedSendWith(ac, func(capture func(ports.Transport)) error {
253+ ac.sendMu.Lock()
254+ defer ac.sendMu.Unlock()
255+ tr := ac.transport()
256+ capture(tr)
257+ if tr == nil {
258+ return errors.New("client transport is nil")
259+ }
260+ return tr.Send(ac.nextOutputFrameLocked(b))
261+ })
262+}
263+
264+var errSendTimedOut = errors.New("send timed out")
265+
266+func (d *Daemon) boundedSendWith(ac *attachedClient, send func(capture func(ports.Transport)) error) (ports.Transport, error) {
176 timer := d.clock.NewTimer(detachNotifyTimeout)267 timer := d.clock.NewTimer(detachNotifyTimeout)
177 result := make(chan error, 1)268 result := make(chan error, 1)
269+ var (
270+ capturedMu sync.Mutex
271+ captured ports.Transport
272+ )
273+ capture := func(tr ports.Transport) {
274+ capturedMu.Lock()
275+ captured = tr
276+ capturedMu.Unlock()
277+ }
278+ capturedTransport := func() ports.Transport {
279+ capturedMu.Lock()
280+ defer capturedMu.Unlock()
281+ return captured
282+ }
178 go func() {283 go func() {
179- result <- ac.send(f)284+ result <- send(capture)
180 }()285 }()
181 select {286 select {
182 case err := <-result:287 case err := <-result:
183 timer.Stop()288 timer.Stop()
184- return err289+ return capturedTransport(), err
185 case <-timer.C():290 case <-timer.C():
186 select {291 select {
187 case err := <-result:292 case err := <-result:
188- return err293+ return capturedTransport(), err
189 default:294 default:
190 }295 }
191- _ = ac.tr.Close()296+ return capturedTransport(), errSendTimedOut
192- return errors.New("send timed out")
193 }297 }
194 }298 }
195 299
@@ -217,7 +321,7 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) {
217 go func() {321 go func() {
218 defer close(done)322 defer close(done)
219 d.boundedSend(ac, frameDetached(reason))323 d.boundedSend(ac, frameDetached(reason))
220- _ = ac.tr.Close()324+ _ = ac.closeTransport()
221 }()325 }()
222 }326 }
223 327
@@ -238,11 +342,23 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} {
238 return snapshot342 return snapshot
239 }343 }
240 344
241-func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) {345+type attachClientOptions struct {
346+ clientID [16]byte
347+ resumeCapable bool
348+}
349+
350+func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
351+ resumeToken := uint64(0)
352+ if opts.resumeCapable {
353+ resumeToken = d.nextResumeTokenLocked()
354+ }
242 ac := &attachedClient{355 ac := &attachedClient{
243- tr: tr,356+ tr: tr,
244- rend: renderer.New(renderer.Capabilities{}),357+ rend: renderer.New(renderer.Capabilities{}),
245- size: sz,358+ size: sz,
359+ clientID: opts.clientID,
360+ resumeCapable: opts.resumeCapable,
361+ resumeToken: resumeToken,
246 }362 }
247 ac.setSession(sess)363 ac.setSession(sess)
248 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})364 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac})
@@ -328,14 +444,24 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
328 // runConnLoop is the per-connection input router: it pumps client messages444 // runConnLoop is the per-connection input router: it pumps client messages
329 // until detach, EOF, or a transport error.445 // until detach, EOF, or a transport error.
330 func (d *Daemon) runConnLoop(ac *attachedClient) {446 func (d *Daemon) runConnLoop(ac *attachedClient) {
447+ tr := ac.transport()
448+ if tr == nil {
449+ return
450+ }
331 for {451 for {
452+ if !ac.currentTransportIs(tr) {
453+ return
454+ }
332 sess := ac.currentSession()455 sess := ac.currentSession()
333 if sess == nil {456 if sess == nil {
334 return457 return
335 }458 }
336- f, err := ac.tr.Recv()459+ f, err := tr.Recv()
337 if err != nil {460 if err != nil {
338- d.clientGone(sess, ac, false)461+ d.clientGone(sess, ac, tr, false)
462+ return
463+ }
464+ if !ac.currentTransportIs(tr) {
339 return465 return
340 }466 }
341 sess = ac.currentSession()467 sess = ac.currentSession()
@@ -345,7 +471,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
345 switch f.Type {471 switch f.Type {
346 case ports.MsgInput:472 case ports.MsgInput:
347 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {473 if in, derr := ports.UnmarshalInput(f.Payload); derr == nil {
348- d.handleInput(sess, ac, in.Data)474+ d.handleSequencedInput(sess, ac, in.InputSeq, in.Data)
349 }475 }
350 case ports.MsgResize:476 case ports.MsgResize:
351 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {477 if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil {
@@ -356,8 +482,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
356 d.applyTheme(sess, ac, th)482 d.applyTheme(sess, ac, th)
357 }483 }
358 case ports.MsgDetach:484 case ports.MsgDetach:
359- d.clientGone(sess, ac, true)485+ d.clientGone(sess, ac, tr, true)
360 return486 return
487+ case ports.MsgAck:
488+ // Output state ACKs are reserved for the datagram renderer path; the
489+ // reliable transport daemon has no state reader today.
361 case ports.MsgPing:490 case ports.MsgPing:
362 _ = ac.send(framePong())491 _ = ac.send(framePong())
363 default:492 default:
@@ -370,7 +499,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
370 // clientGone detaches ac if it is still the session's current client. An499 // clientGone detaches ac if it is still the session's current client. An
371 // ephemeral session dies with its client; a named one survives headless. When500 // ephemeral session dies with its client; a named one survives headless. When
372 501
373-func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {502+func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
503+ if failed != nil && !ac.currentTransportIs(failed) {
504+ return // stale connection loop; a newer transport owns this client
505+ }
374 if !sess.detachIfCurrent(ac) {506 if !sess.detachIfCurrent(ac) {
375 return // already displaced by a newer client; nothing to do507 return // already displaced by a newer client; nothing to do
376 }508 }
@@ -381,6 +513,14 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
381 if !ephemeral {513 if !ephemeral {
382 d.refreshSessionCwd(sess)514 d.refreshSessionCwd(sess)
383 }515 }
516+ oldTr := failed
517+ if oldTr == nil {
518+ oldTr = ac.transport()
519+ }
520+ if !explicit && d.parkAttachment(sess, ac) {
521+ _ = ac.closeCapturedTransport(oldTr)
522+ return
523+ }
384 d.resetScreenDefaultColors(sess)524 d.resetScreenDefaultColors(sess)
385 if explicit {525 if explicit {
386 // Synchronous so the ack is delivered before the transport closes526 // Synchronous so the ack is delivered before the transport closes
@@ -389,7 +529,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
389 // connWg.Wait.529 // connWg.Wait.
390 d.boundedSend(ac, frameDetached(ports.ReasonDetach))530 d.boundedSend(ac, frameDetached(ports.ReasonDetach))
391 }531 }
392- _ = ac.tr.Close()532+ _ = ac.closeCapturedTransport(oldTr)
393 if ephemeral {533 if ephemeral {
394 _ = d.killSession(sess, ports.ReasonSessionKilled, false)534 _ = d.killSession(sess, ports.ReasonSessionKilled, false)
395 }535 }
@@ -460,11 +600,19 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
460 // detachOnSendError drops a client whose transport failed. Like every other600 // detachOnSendError drops a client whose transport failed. Like every other
461 // detach path, losing the client kills an ephemeral session (its lifetime is601 // detach path, losing the client kills an ephemeral session (its lifetime is
462 // its client's); a named session keeps running headless.602 // its client's); a named session keeps running headless.
463-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {603+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
604+ if failed != nil && !ac.currentTransportIs(failed) {
605+ return
606+ }
464 if sess.detachIfCurrent(ac) {607 if sess.detachIfCurrent(ac) {
465 d.unregisterPreview(ac)608 d.unregisterPreview(ac)
466 d.resetScreenDefaultColors(sess)609 d.resetScreenDefaultColors(sess)
467- _ = ac.tr.Close()610+ if d.parkAttachment(sess, ac) {
611+ _ = ac.closeCapturedTransport(failed)
612+ d.log.Warn("parked client after send error", "session", sess.name)
613+ return
614+ }
615+ _ = ac.closeCapturedTransport(failed)
468 d.log.Warn("detached client after send error", "session", sess.name)616 d.log.Warn("detached client after send error", "session", sess.name)
469 sess.mu.Lock()617 sess.mu.Lock()
470 ephemeral := sess.ephemeral618 ephemeral := sess.ephemeral
M internal/usecase/daemon/copymode.go +3 -2
@@ -252,8 +252,9 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
252 if copyOut && text != "" {252 if copyOut && text != "" {
253 chunks := scopy.OSC52(text)253 chunks := scopy.OSC52(text)
254 for _, chunk := range chunks {254 for _, chunk := range chunks {
255- if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {255+ failed, err := d.boundedSendOutputErrTransport(ac, chunk)
256- d.detachOnSendError(sess, ac)256+ if err != nil {
257+ d.detachOnSendError(sess, ac, failed)
257 return258 return
258 }259 }
259 }260 }
M internal/usecase/daemon/copymode_test.go +29 -0
@@ -23,6 +23,35 @@ import (
23 23
24 // --- test doubles -----------------------------------------------------------24 // --- test doubles -----------------------------------------------------------
25 25
26+func TestBoundedSendOutputErrTransportReturnsTransportUsedBySend(t *testing.T) {
27+ d := newTestDaemon(t, nil, stubClock{})
28+ ac := &attachedClient{}
29+ replacement := &closeTrackingTransport{}
30+ sendErr := errors.New("send failed")
31+ failed := &swapErrorTransport{ac: ac, replacement: replacement, err: sendErr}
32+ ac.replaceTransport(failed)
33+
34+ used, err := d.boundedSendOutputErrTransport(ac, []byte("copy"))
35+
36+ require.ErrorIs(t, err, sendErr)
37+ require.Same(t, failed, used)
38+ require.Same(t, replacement, ac.transport())
39+}
40+
41+type swapErrorTransport struct {
42+ ac *attachedClient
43+ replacement ports.Transport
44+ err error
45+}
46+
47+func (t *swapErrorTransport) Send(ports.Frame) error {
48+ t.ac.replaceTransport(t.replacement)
49+ return t.err
50+}
51+
52+func (t *swapErrorTransport) Recv() (ports.Frame, error) { return ports.Frame{}, io.EOF }
53+func (t *swapErrorTransport) Close() error { return nil }
54+
26 // stubClock returns timers whose channel never fires, so a scheduler under it55 // stubClock returns timers whose channel never fires, so a scheduler under it
27 // blocks in its debounce loop until the session context is cancelled. Used by56 // blocks in its debounce loop until the session context is cancelled. Used by
28 57
M internal/usecase/daemon/daemon.go +50 -5
@@ -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,8 +440,8 @@ 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, tr, false)
430 return445 return
431 }446 }
432 d.firstPaint(sess, ac, h.Size)447 d.firstPaint(sess, ac, h.Size)
@@ -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 +8 -0
@@ -34,6 +34,14 @@ 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, _ uint64, data []byte) {
38+ // Do not acknowledge client-side echo prediction here: input has only been
39+ // accepted/routed, not necessarily echoed by the PTY and incorporated into a
40+ // rendered screen state. Until prediction is implemented against rendered
41+ // output state, EchoAck must remain conservative.
42+ d.handleInput(sess, ac, data)
43+}
44+
37 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {45 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
38 ac.mouseScan.Scan(data,46 ac.mouseScan.Scan(data,
39 func(ev mouse.Event) { d.handleMouse(ac, ev) },47 func(ev mouse.Event) { d.handleMouse(ac, ev) },
M internal/usecase/daemon/input_test.go +15 -0
@@ -79,6 +79,21 @@ func TestAltCForwardsToPTY(t *testing.T) {
79 releasePTY()79 releasePTY()
80 }80 }
81 81
82+func TestBracketedMultilinePasteForwardsDelimitersAndNewlines(t *testing.T) {
83+ writes := make(chan []byte, 1)
84+ p, releasePTY := newBlockingPTYWithWrites(t, writes)
85+ d, sess, ac, _ := newManualSessionWithPTYs(t, p)
86+ pane := sess.tabs[0].focusedPane()
87+ pane.screen.Write([]byte("\x1b[?2004h"))
88+ require.True(t, pane.screen.BracketedPasteMode())
89+
90+ paste := []byte("\x1b[200~first line\nsecond line\n\x1b[201~")
91+ d.handleInput(sess, ac, paste)
92+
93+ require.Equal(t, paste, <-writes)
94+ releasePTY()
95+}
96+
82 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {97 func TestPaletteNextPreviousSwitchActiveTab(t *testing.T) {
83 cases := []struct {98 cases := []struct {
84 name string99 name string
M internal/usecase/daemon/palette.go +1 -1
@@ -217,7 +217,7 @@ func (e paletteExec) PrevTab() error {
217 }217 }
218 218
219 func (e paletteExec) Detach() error {219 func (e paletteExec) Detach() error {
220- e.d.clientGone(e.sess, e.ac, true)220+ e.d.clientGone(e.sess, e.ac, e.ac.transport(), true)
221 return nil221 return nil
222 }222 }
223 223
M internal/usecase/daemon/render.go +10 -2
@@ -26,10 +26,12 @@ package daemon
26 26
27 import (27 import (
28 "bytes"28 "bytes"
29+ "errors"
29 "strconv"30 "strconv"
30 "time"31 "time"
31 32
32 "github.com/bnema/vev/internal/domain"33 "github.com/bnema/vev/internal/domain"
34+ "github.com/bnema/vev/internal/ports"
33 scopy "github.com/bnema/vev/internal/usecase/copy"35 scopy "github.com/bnema/vev/internal/usecase/copy"
34 "github.com/bnema/vev/internal/usecase/layout"36 "github.com/bnema/vev/internal/usecase/layout"
35 themeui "github.com/bnema/vev/internal/usecase/theme"37 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -378,10 +380,16 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
378 tb.mu.Unlock()380 tb.mu.Unlock()
379 381
380 var serr error382 var serr error
383+ var sendTr ports.Transport
381 if err == nil {384 if err == nil {
382 data = append(data, cursorTail...)385 data = append(data, cursorTail...)
383 if len(data) > 0 {386 if len(data) > 0 {
384- serr = ac.tr.Send(frameOutput(data))387+ sendTr = ac.transport()
388+ if sendTr == nil {
389+ serr = errors.New("client transport is nil")
390+ } else {
391+ serr = sendTr.Send(ac.nextOutputFrameLocked(data))
392+ }
385 }393 }
386 }394 }
387 ac.sendMu.Unlock()395 ac.sendMu.Unlock()
@@ -391,7 +399,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
391 return399 return
392 }400 }
393 if serr != nil {401 if serr != nil {
394- d.detachOnSendError(sess, ac)402+ d.detachOnSendError(sess, ac, sendTr)
395 }403 }
396 }404 }
397 405
M internal/usecase/daemon/render_test.go +14 -0
@@ -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 +147 -0
@@ -0,0 +1,147 @@
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+ panic("crypto/rand failed generating resume token: " + err.Error())
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+ d.mu.Lock()
39+ defer d.mu.Unlock()
40+ if d.closing {
41+ return false
42+ }
43+ if ac.resumeToken == 0 {
44+ ac.resumeToken = d.nextResumeTokenLocked()
45+ }
46+ return true
47+}
48+
49+func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
50+ if !d.prepareParkAttachment(sess, ac) {
51+ return false
52+ }
53+ d.mu.Lock()
54+ if d.closing {
55+ d.mu.Unlock()
56+ return false
57+ }
58+ token := ac.resumeToken
59+ if old := d.parked[token]; old != nil {
60+ old.timer.Stop()
61+ old.closeDone()
62+ }
63+ timer := d.clock.NewTimer(resumeParkGrace)
64+ parked := &parkedAttachment{sess: sess, ac: ac, timer: timer, done: make(chan struct{})}
65+ ac.parked = true
66+ d.parked[token] = parked
67+ d.mu.Unlock()
68+
69+ go func(token uint64, parked *parkedAttachment) {
70+ select {
71+ case <-timer.C():
72+ d.expireParked(token, parked)
73+ case <-parked.done:
74+ }
75+ }(token, parked)
76+ return true
77+}
78+
79+func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
80+ d.mu.Lock()
81+ if d.parked[token] == parked {
82+ d.removeParkedLocked(token, parked)
83+ }
84+ d.mu.Unlock()
85+}
86+
87+// removeParkedLocked invalidates one parked attachment. Caller holds d.mu and
88+// has verified d.parked[token] still points at parked when that matters.
89+func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
90+ delete(d.parked, token)
91+ parked.ac.resumeToken = 0
92+ parked.ac.parked = false
93+ if parked.timer != nil {
94+ parked.timer.Stop()
95+ }
96+ parked.closeDone()
97+}
98+
99+func (p *parkedAttachment) closeDone() {
100+ if p.done == nil {
101+ return
102+ }
103+ p.doneOnce.Do(func() { close(p.done) })
104+}
105+
106+// purgeParkedForSessionLocked invalidates every parked token for sess. Caller
107+// holds d.mu. Use before killing or normally attaching to a session so stale
108+// resume tokens cannot resurrect or replace an active attachment.
109+func (d *Daemon) purgeParkedForSessionLocked(sess *session) {
110+ for token, parked := range d.parked {
111+ if parked.sess == sess {
112+ d.removeParkedLocked(token, parked)
113+ }
114+ }
115+}
116+
117+func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) {
118+ parked := d.parked[h.ResumeToken]
119+ if parked == nil || h.ResumeToken == 0 {
120+ return nil, nil, false, nil
121+ }
122+ sess := parked.sess
123+ registered := d.sessions[sess.id] == sess
124+ sess.mu.Lock()
125+ active := sess.client != nil
126+ sess.mu.Unlock()
127+ if !registered || active {
128+ d.removeParkedLocked(h.ResumeToken, parked)
129+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
130+ }
131+ ac := parked.ac
132+ if ac.clientID != h.ClientID {
133+ return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
134+ }
135+ delete(d.parked, h.ResumeToken)
136+ parked.timer.Stop()
137+ parked.closeDone()
138+ ac.replaceTransport(tr)
139+ ac.size = sz
140+ ac.resumeToken = d.nextResumeTokenLocked()
141+ ac.parked = false
142+ ac.setSession(sess)
143+ sess.mu.Lock()
144+ sess.client = ac
145+ sess.mu.Unlock()
146+ return sess, ac, true, nil
147+}
A internal/usecase/daemon/resume_test.go +306 -0
@@ -0,0 +1,306 @@
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+ sends []ports.Frame
19+}
20+
21+func (t *closeTrackingTransport) Send(f ports.Frame) error {
22+ t.mu.Lock()
23+ defer t.mu.Unlock()
24+ t.sends = append(t.sends, f)
25+ return nil
26+}
27+func (t *closeTrackingTransport) Recv() (ports.Frame, error) {
28+ return ports.Frame{}, errors.New("closed")
29+}
30+func (t *closeTrackingTransport) Close() error {
31+ t.mu.Lock()
32+ t.closed = true
33+ t.mu.Unlock()
34+ return nil
35+}
36+func (t *closeTrackingTransport) Closed() bool {
37+ t.mu.Lock()
38+ defer t.mu.Unlock()
39+ return t.closed
40+}
41+
42+func (t *closeTrackingTransport) Sends() []ports.Frame {
43+ t.mu.Lock()
44+ defer t.mu.Unlock()
45+ return append([]ports.Frame(nil), t.sends...)
46+}
47+
48+func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
49+ return ports.Hello{
50+ Version: ports.ProtocolVersion,
51+ Intent: intent,
52+ ClientID: [16]byte{1, 2, 3, 4},
53+ ResumeToken: token,
54+ Name: name,
55+ Size: domain.Size{Cols: 80, Rows: 24},
56+ TermEnv: "xterm-256color",
57+ }
58+}
59+
60+func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
61+ pty, release := newBlockingPTY(t)
62+ defer release()
63+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
64+
65+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
66+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
67+ require.NoError(t, err)
68+ token := ac.resumeToken
69+ require.NotZero(t, token)
70+
71+ d.clientGone(sess, ac, ac.transport(), false)
72+ require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
73+ require.Nil(t, sess.client)
74+ d.mu.Lock()
75+ _, parked := d.parked[token]
76+ d.mu.Unlock()
77+ require.True(t, parked, "named resume-capable link loss is parked")
78+
79+ pty2, release2 := newBlockingPTY(t)
80+ defer release2()
81+ d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{})
82+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
83+ es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2)
84+ require.NoError(t, err)
85+ d2.clientGone(es, eac, eac.transport(), false)
86+ require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session")
87+}
88+
89+func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
90+ pty, release := newBlockingPTY(t)
91+ defer release()
92+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
93+
94+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
95+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
96+ require.NoError(t, err)
97+ oldToken := ac.resumeToken
98+ d.clientGone(sess, ac, ac.transport(), false)
99+
100+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
101+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", oldToken), tr2)
102+ require.NoError(t, err)
103+ require.Same(t, sess, resumedSess)
104+ require.Same(t, ac, resumedAC)
105+ require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token")
106+ require.Same(t, resumedAC, sess.client)
107+}
108+
109+func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
110+ pty, release := newBlockingPTY(t)
111+ defer release()
112+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
113+
114+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
115+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
116+ require.NoError(t, err)
117+ token := ac.resumeToken
118+ d.clientGone(sess, ac, ac.transport(), false)
119+
120+ wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
121+ wrongClient.ClientID = [16]byte{9, 9, 9, 9}
122+ d.mu.Lock()
123+ _, _, ok, err := d.resumeParkedLocked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
124+ _, stillParked := d.parked[token]
125+ d.mu.Unlock()
126+ require.Error(t, err)
127+ require.False(t, ok)
128+ require.True(t, stillParked, "mismatched client must not consume parked token")
129+ require.Equal(t, token, ac.resumeToken)
130+ require.True(t, ac.parked)
131+
132+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
133+ resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2)
134+ require.NoError(t, err)
135+ require.Same(t, sess, resumedSess)
136+ require.Same(t, ac, resumedAC)
137+}
138+
139+func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.T) {
140+ pty, release := newBlockingPTY(t)
141+ defer release()
142+ d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{})
143+
144+ oldTr := &closeTrackingTransport{}
145+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
146+ require.NoError(t, err)
147+ token := ac.resumeToken
148+ require.True(t, sess.detachIfCurrent(ac))
149+ require.True(t, d.parkAttachment(sess, ac))
150+
151+ newTr := &closeTrackingTransport{}
152+ d.mu.Lock()
153+ resumedSess, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24})
154+ d.mu.Unlock()
155+ require.NoError(t, err)
156+ require.True(t, ok)
157+ require.Same(t, sess, resumedSess)
158+ require.Same(t, ac, resumedAC)
159+
160+ _ = ac.closeCapturedTransport(oldTr)
161+ require.True(t, oldTr.Closed(), "old transport is closed")
162+ require.False(t, newTr.Closed(), "newly rebound transport is not closed by old cleanup")
163+ require.Same(t, newTr, ac.transport())
164+}
165+
166+func TestExplicitDetachDoesNotPark(t *testing.T) {
167+ pty, release := newBlockingPTY(t)
168+ defer release()
169+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
170+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
171+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
172+ require.NoError(t, err)
173+ d.clientGone(sess, ac, ac.transport(), true)
174+ d.mu.Lock()
175+ parked := len(d.parked)
176+ d.mu.Unlock()
177+ require.Zero(t, parked)
178+}
179+
180+func TestParkExpiryAndShutdownCleanup(t *testing.T) {
181+ clk := &signalClock{timers: make(chan *signalTimer, 8)}
182+ pty, release := newBlockingPTY(t)
183+ defer release()
184+ d := newTestDaemon(t, newFactory(t, pty), clk)
185+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
186+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
187+ require.NoError(t, err)
188+ token := ac.resumeToken
189+ d.clientGone(sess, ac, ac.transport(), false)
190+ timer := <-clk.timers
191+ timer.ch <- clk.Now()
192+ require.Eventually(t, func() bool {
193+ d.mu.Lock()
194+ defer d.mu.Unlock()
195+ _, ok := d.parked[token]
196+ return !ok
197+ }, 2*time.Second, 10*time.Millisecond)
198+
199+ pty2, release2 := newBlockingPTY(t)
200+ defer release2()
201+ d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{})
202+ tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
203+ sess2, ac2, err := d2.route(helloResumeCapable(ports.IntentNew, "other", 0), tr2)
204+ require.NoError(t, err)
205+ d2.clientGone(sess2, ac2, ac2.transport(), false)
206+ d2.shutdownAll(ports.ReasonServerShutdown)
207+ d2.mu.Lock()
208+ parked := len(d2.parked)
209+ d2.mu.Unlock()
210+ require.Zero(t, parked)
211+}
212+
213+func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
214+ pty, release := newBlockingPTY(t)
215+ defer release()
216+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
217+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
218+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
219+ require.NoError(t, err)
220+ token := ac.resumeToken
221+ d.clientGone(sess, ac, ac.transport(), false)
222+
223+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
224+ d.mu.Lock()
225+ _, parked := d.parked[token]
226+ _, _, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
227+ d.mu.Unlock()
228+ require.False(t, parked, "killSession purges parked token")
229+ require.NoError(t, err)
230+ require.False(t, ok, "killed session cannot be resumed")
231+}
232+
233+func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
234+ pty, release := newBlockingPTY(t)
235+ defer release()
236+ d := newTestDaemon(t, newFactory(t, pty), stubClock{})
237+ tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
238+ sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
239+ require.NoError(t, err)
240+ token := oldAC.resumeToken
241+ d.clientGone(sess, oldAC, oldAC.transport(), false)
242+
243+ activeTr := &closeTrackingTransport{}
244+ _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr)
245+ require.NoError(t, err)
246+ require.NotSame(t, oldAC, activeAC)
247+
248+ d.mu.Lock()
249+ _, parked := d.parked[token]
250+ _, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24})
251+ d.mu.Unlock()
252+ require.False(t, parked, "normal attach invalidates stale parked token")
253+ require.NoError(t, err)
254+ require.False(t, ok)
255+ require.Nil(t, resumedAC)
256+ require.Same(t, activeAC, sess.client)
257+}
258+
259+func TestStaleClientGoneDoesNotDetachOrCloseFreshTransport(t *testing.T) {
260+ p, release := newBlockingPTY(t)
261+ defer release()
262+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
263+
264+ oldTr := &closeTrackingTransport{}
265+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr)
266+ require.NoError(t, err)
267+ freshTr := &closeTrackingTransport{}
268+ ac.replaceTransport(freshTr)
269+
270+ d.clientGone(sess, ac, oldTr, false)
271+
272+ require.Same(t, ac, sess.client, "stale connection must not detach current client")
273+ require.False(t, oldTr.Closed(), "stale transport is owned by its own loop/handler")
274+ require.False(t, freshTr.Closed(), "fresh resumed transport must not be closed by stale loop")
275+}
276+
277+func TestOutputStateNumberingIsSharedAndMonotone(t *testing.T) {
278+ d := newTestDaemon(t, nil, stubClock{})
279+ tr := &closeTrackingTransport{}
280+ ac := &attachedClient{tr: tr}
281+
282+ require.NoError(t, d.boundedSendOutputErr(ac, []byte("copy")))
283+ require.NoError(t, d.boundedSendOutputErr(ac, []byte("more")))
284+
285+ sends := tr.Sends()
286+ require.Len(t, sends, 2)
287+ first, err := ports.UnmarshalOutput(sends[0].Payload)
288+ require.NoError(t, err)
289+ second, err := ports.UnmarshalOutput(sends[1].Payload)
290+ require.NoError(t, err)
291+ require.Equal(t, uint64(1), first.NewStateNum)
292+ require.Equal(t, uint64(2), second.NewStateNum)
293+}
294+
295+func TestSequencedInputDoesNotPrematurelyEchoAck(t *testing.T) {
296+ p, release := newBlockingPTY(t)
297+ defer release()
298+ d := newTestDaemon(t, newFactory(t, p), stubClock{})
299+ tr := &closeTrackingTransport{}
300+ sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
301+ require.NoError(t, err)
302+
303+ d.handleSequencedInput(sess, ac, 42, []byte("x"))
304+
305+ require.Zero(t, ac.echoAck.Load())
306+}
M internal/usecase/daemon/session.go +1 -0
@@ -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 +5 -5
@@ -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()
@@ -127,7 +127,7 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
127 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))127 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
128 require.NotEmpty(t, before)128 require.NotEmpty(t, before)
129 129
130- d.clientGone(sess, ac, true)130+ d.clientGone(sess, ac, ac.transport(), true)
131 131
132 var got []byte132 var got []byte
133 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }133 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
@@ -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},
@@ -177,7 +177,7 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
177 HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},177 HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
178 })178 })
179 179
180- d.detachOnSendError(sess, ac)180+ d.detachOnSendError(sess, ac, ac.transport())
181 181
182 tb := sess.activeTab()182 tb := sess.activeTab()
183 var got []byte183 var got []byte
@@ -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 +11 -8
@@ -28,20 +28,23 @@ 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+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
37 }40 }
38 41
39 func frameError(code uint16, text string) ports.Frame {42 func frameError(code uint16, text string) ports.Frame {
40 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}43 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
41 }44 }
42 45
43-func frameOutput(b []byte) ports.Frame {46+func frameOutputState(b []byte, state uint64, echoAck uint64) ports.Frame {
44- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})}47+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{NewStateNum: state, EchoAck: echoAck, Data: b})}
45 }48 }
46 49
47 func frameDetached(reason uint8) ports.Frame {50 func frameDetached(reason uint8) ports.Frame {
A pkg/dgram/dgram.go +249 -0
@@ -0,0 +1,249 @@
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+const replayWindowSize = maxFragmentCount
83+
84+type ReplayWindow struct {
85+ max uint64
86+ seen map[uint64]struct{}
87+ init bool
88+}
89+
90+func NewReplayWindow() *ReplayWindow { return &ReplayWindow{} }
91+
92+func (w *ReplayWindow) Check(counter uint64) bool {
93+ if !w.init {
94+ return true
95+ }
96+ if counter > w.max {
97+ return true
98+ }
99+ if w.max-counter >= uint64(replayWindowSize) {
100+ return false
101+ }
102+ _, ok := w.seen[counter]
103+ return !ok
104+}
105+
106+func (w *ReplayWindow) Accept(counter uint64) {
107+ if w.seen == nil {
108+ w.seen = make(map[uint64]struct{})
109+ }
110+ if !w.init {
111+ w.max, w.init = counter, true
112+ }
113+ if counter > w.max {
114+ w.max = counter
115+ }
116+ w.seen[counter] = struct{}{}
117+ for seen := range w.seen {
118+ if w.max-seen >= uint64(replayWindowSize) {
119+ delete(w.seen, seen)
120+ }
121+ }
122+}
123+
124+type Fragment struct {
125+ Seq uint64
126+ Index uint16
127+ Count uint16
128+ Data []byte
129+}
130+
131+func FragmentPayload(seq uint64, payload []byte, mtu int) ([]Fragment, error) {
132+ if mtu <= fragmentHdr {
133+ return nil, ErrFragment
134+ }
135+ maxData := mtu - fragmentHdr
136+ count := (len(payload) + maxData - 1) / maxData
137+ if count == 0 {
138+ count = 1
139+ }
140+ if count > maxFragmentCount {
141+ return nil, ErrFragment
142+ }
143+ out := make([]Fragment, count)
144+ for i := 0; i < count; i++ {
145+ start := i * maxData
146+ end := min(len(payload), start+maxData)
147+ out[i] = Fragment{Seq: seq, Index: uint16(i), Count: uint16(count), Data: append([]byte(nil), payload[start:end]...)}
148+ }
149+ return out, nil
150+}
151+
152+func MarshalFragment(f Fragment) ([]byte, error) {
153+ if f.Count == 0 || f.Index >= f.Count {
154+ return nil, ErrFragment
155+ }
156+ b := make([]byte, fragmentHdr+len(f.Data))
157+ binary.BigEndian.PutUint64(b[0:8], f.Seq)
158+ binary.BigEndian.PutUint16(b[8:10], f.Index)
159+ binary.BigEndian.PutUint16(b[10:12], f.Count)
160+ binary.BigEndian.PutUint16(b[12:14], uint16(len(f.Data)))
161+ copy(b[fragmentHdr:], f.Data)
162+ return b, nil
163+}
164+
165+func UnmarshalFragment(b []byte) (Fragment, error) {
166+ if len(b) < fragmentHdr {
167+ return Fragment{}, ErrFragment
168+ }
169+ f := Fragment{Seq: binary.BigEndian.Uint64(b[0:8]), Index: binary.BigEndian.Uint16(b[8:10]), Count: binary.BigEndian.Uint16(b[10:12])}
170+ n := int(binary.BigEndian.Uint16(b[12:14]))
171+ if f.Count == 0 || f.Index >= f.Count || len(b[fragmentHdr:]) != n {
172+ return Fragment{}, ErrFragment
173+ }
174+ f.Data = append([]byte(nil), b[fragmentHdr:]...)
175+ return f, nil
176+}
177+
178+const (
179+ maxFragmentCount = 1024
180+ maxReassemblyInflight = 1024
181+)
182+
183+type Reassembler struct {
184+ inflight map[uint64]*assembly
185+ order []uint64
186+}
187+type assembly struct {
188+ count uint16
189+ got int
190+ parts [][]byte
191+}
192+
193+func NewReassembler() *Reassembler { return &Reassembler{inflight: make(map[uint64]*assembly)} }
194+func (r *Reassembler) Add(f Fragment) ([]byte, bool, error) {
195+ if f.Count == 0 || int(f.Count) > maxFragmentCount || f.Index >= f.Count {
196+ return nil, false, ErrFragment
197+ }
198+ a := r.inflight[f.Seq]
199+ if a == nil {
200+ r.evictOldest()
201+ a = &assembly{count: f.Count, parts: make([][]byte, f.Count)}
202+ r.inflight[f.Seq] = a
203+ r.order = append(r.order, f.Seq)
204+ }
205+ if a.count != f.Count {
206+ r.remove(f.Seq)
207+ return nil, false, ErrFragment
208+ }
209+ if a.parts[f.Index] != nil {
210+ return nil, false, nil
211+ }
212+ a.parts[f.Index] = append([]byte(nil), f.Data...)
213+ a.got++
214+ if a.got != int(a.count) {
215+ return nil, false, nil
216+ }
217+ var total int
218+ for _, p := range a.parts {
219+ total += len(p)
220+ }
221+ out := make([]byte, 0, total)
222+ for _, p := range a.parts {
223+ out = append(out, p...)
224+ }
225+ r.remove(f.Seq)
226+ return out, true, nil
227+}
228+
229+func (r *Reassembler) evictOldest() {
230+ for len(r.order) >= maxReassemblyInflight {
231+ seq := r.order[0]
232+ r.order = r.order[1:]
233+ if _, ok := r.inflight[seq]; ok {
234+ delete(r.inflight, seq)
235+ return
236+ }
237+ }
238+}
239+
240+func (r *Reassembler) remove(seq uint64) {
241+ delete(r.inflight, seq)
242+ for i, s := range r.order {
243+ if s == seq {
244+ copy(r.order[i:], r.order[i+1:])
245+ r.order = r.order[:len(r.order)-1]
246+ return
247+ }
248+ }
249+}
A pkg/dgram/dgram_test.go +176 -0
@@ -0,0 +1,176 @@
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 TestReplayWindowAllowsLargeFragmentReordering(t *testing.T) {
60+ c := testCodec(t)
61+ rw := NewReplayWindow()
62+ last := uint64(maxFragmentCount)
63+ if _, _, err := c.Open(c.Seal(1, last, []byte("last"), nil), 1, nil, rw); err != nil {
64+ t.Fatal(err)
65+ }
66+ if _, _, err := c.Open(c.Seal(1, 1, []byte("first"), nil), 1, nil, rw); err != nil {
67+ t.Fatalf("fragment within max fragment window rejected: %v", err)
68+ }
69+ if _, _, err := c.Open(c.Seal(1, last+2, []byte("advance"), nil), 1, nil, rw); err != nil {
70+ t.Fatal(err)
71+ }
72+ tooOld := c.Seal(1, 0, []byte("old"), nil)
73+ if _, _, err := c.Open(tooOld, 1, nil, rw); !errors.Is(err, ErrReplay) {
74+ t.Fatalf("err=%v, want ErrReplay", err)
75+ }
76+}
77+
78+func TestFragmentation(t *testing.T) {
79+ payload := bytes.Repeat([]byte("abcdef"), 400)
80+ frags, err := FragmentPayload(9, payload, 100)
81+ if err != nil {
82+ t.Fatal(err)
83+ }
84+ if len(frags) < 2 {
85+ t.Fatalf("got %d fragments", len(frags))
86+ }
87+ for _, f := range frags {
88+ b, err := MarshalFragment(f)
89+ if err != nil {
90+ t.Fatal(err)
91+ }
92+ g, err := UnmarshalFragment(b)
93+ if err != nil {
94+ t.Fatal(err)
95+ }
96+ if g.Seq != f.Seq || g.Index != f.Index || !bytes.Equal(g.Data, f.Data) {
97+ t.Fatalf("bad roundtrip")
98+ }
99+ }
100+}
101+
102+func TestReassemblyDisorderDuplicates(t *testing.T) {
103+ payload := []byte("abcdefghijklmnopqrstuvwxyz")
104+ frags, err := FragmentPayload(3, payload, 30)
105+ if err != nil {
106+ t.Fatal(err)
107+ }
108+ r := NewReassembler()
109+ order := []int{1, 0, 1}
110+ var got []byte
111+ var done bool
112+ for _, idx := range order {
113+ out, ok, err := r.Add(frags[idx])
114+ if err != nil {
115+ t.Fatal(err)
116+ }
117+ if ok {
118+ got, done = out, true
119+ }
120+ }
121+ if !done || !bytes.Equal(got, payload) {
122+ t.Fatalf("done=%v got=%q", done, got)
123+ }
124+}
125+
126+func TestReassemblyRejectsOversizedCountBeforeInsert(t *testing.T) {
127+ r := NewReassembler()
128+ if _, _, err := r.Add(Fragment{Seq: 99, Index: 0, Count: uint16(maxFragmentCount + 1), Data: []byte("x")}); !errors.Is(err, ErrFragment) {
129+ t.Fatalf("err=%v, want ErrFragment", err)
130+ }
131+ if len(r.inflight) != 0 || len(r.order) != 0 {
132+ t.Fatalf("oversized fragment was inserted: inflight=%d order=%d", len(r.inflight), len(r.order))
133+ }
134+}
135+
136+func TestFragmentPayloadRejectsOverMaxFragmentCount(t *testing.T) {
137+ payload := bytes.Repeat([]byte("x"), maxFragmentCount+1)
138+ if _, err := FragmentPayload(1, payload, fragmentHdr+1); !errors.Is(err, ErrFragment) {
139+ t.Fatalf("err=%v, want ErrFragment", err)
140+ }
141+}
142+
143+func TestReassemblyCountMismatchRemovesStaleAssembly(t *testing.T) {
144+ r := NewReassembler()
145+ if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 2, Data: []byte("stale")}); err != nil {
146+ t.Fatal(err)
147+ }
148+ if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 3, Data: []byte("a")}); !errors.Is(err, ErrFragment) {
149+ t.Fatalf("err=%v, want ErrFragment", err)
150+ }
151+ for _, f := range []Fragment{
152+ {Seq: 7, Index: 0, Count: 3, Data: []byte("a")},
153+ {Seq: 7, Index: 1, Count: 3, Data: []byte("b")},
154+ {Seq: 7, Index: 2, Count: 3, Data: []byte("c")},
155+ } {
156+ out, ok, err := r.Add(f)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ if ok && string(out) != "abc" {
161+ t.Fatalf("out=%q", out)
162+ }
163+ }
164+}
165+
166+func TestReassemblyInflightBounded(t *testing.T) {
167+ r := NewReassembler()
168+ for i := range uint64(maxReassemblyInflight + 10) {
169+ if _, _, err := r.Add(Fragment{Seq: i, Index: 0, Count: 2, Data: []byte("x")}); err != nil {
170+ t.Fatal(err)
171+ }
172+ }
173+ if len(r.inflight) > maxReassemblyInflight {
174+ t.Fatalf("inflight=%d, want <= %d", len(r.inflight), maxReassemblyInflight)
175+ }
176+}
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+}
M pkg/renderer/renderer.go +40 -6
@@ -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
M pkg/vt/screen.go +9 -1
@@ -65,6 +65,7 @@ type Screen struct {
65 cursorStyleSet bool65 cursorStyleSet bool
66 mouseMode int66 mouseMode int
67 mouseSGR bool67 mouseSGR bool
68+ bracketedPaste bool
68 }69 }
69 70
70 func NewScreen(width, height int) *Screen {71 func NewScreen(width, height int) *Screen {
@@ -128,6 +129,10 @@ func (s *Screen) ClearDamage() { s.damage = s.damage[:0] }
128 // is currently enabled by the child process.129 // is currently enabled by the child process.
129 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }130 func (s *Screen) SyncUpdateActive() bool { return s.syncUpdateActive }
130 131
132+// BracketedPasteMode reports whether DEC private mode 2004 is currently enabled
133+// by the child process.
134+func (s *Screen) BracketedPasteMode() bool { return s.bracketedPaste }
135+
131 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).136 // ForceSyncEnd forcibly leaves DEC private mode 2026 (synchronized update).
132 // Hosts use this as a safety valve if a child enters synchronized update mode137 // Hosts use this as a safety valve if a child enters synchronized update mode
133 // and never sends the matching end sequence.138 // and never sends the matching end sequence.
@@ -906,6 +911,7 @@ func (s *Screen) reset() {
906 s.cursorStyleSet = false911 s.cursorStyleSet = false
907 s.mouseMode = 0912 s.mouseMode = 0
908 s.mouseSGR = false913 s.mouseSGR = false
914+ s.bracketedPaste = false
909 s.resetScrollRegion()915 s.resetScrollRegion()
910 s.fullRedraw()916 s.fullRedraw()
911 }917 }
@@ -966,7 +972,9 @@ func (s *Screen) setMode(private bool, parts []int, enabled bool) {
966 }972 }
967 case 1006:973 case 1006:
968 s.mouseSGR = enabled974 s.mouseSGR = enabled
969- case 1, 1004, 1005, 2004:975+ case 2004:
976+ s.bracketedPaste = enabled
977+ case 1, 1004, 1005:
970 // Trackable terminal modes that do not directly affect the current978 // Trackable terminal modes that do not directly affect the current
971 // cell model yet. Consuming them prevents mode bytes from leaking.979 // cell model yet. Consuming them prevents mode bytes from leaking.
972 continue980 continue
M pkg/vt/screen_test.go +9 -2
@@ -1352,10 +1352,10 @@ func TestResize(t *testing.T) {
1352 },1352 },
1353 },1353 },
1354 {1354 {
1355- name: "style mouse and cursor modes survive",1355+ name: "style mouse cursor and bracketed paste modes survive",
1356 run: func(t *testing.T) {1356 run: func(t *testing.T) {
1357 s := NewScreen(5, 2)1357 s := NewScreen(5, 2)
1358- s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?25l\x1b[3 q"))1358+ s.Write([]byte("\x1b[1m\x1b[?1000h\x1b[?1006h\x1b[?2004h\x1b[?25l\x1b[3 q"))
1359 1359
1360 s.Resize(6, 3)1360 s.Resize(6, 3)
1361 1361
@@ -1365,12 +1365,19 @@ func TestResize(t *testing.T) {
1365 if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {1365 if mode, sgr := s.MouseMode(); mode != 1000 || !sgr {
1366 t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)1366 t.Fatalf("mouse mode = (%d,%v), want (1000,true)", mode, sgr)
1367 }1367 }
1368+ if !s.BracketedPasteMode() {
1369+ t.Fatal("bracketed paste mode was reset")
1370+ }
1368 if s.CursorVisible() {1371 if s.CursorVisible() {
1369 t.Fatal("cursor visibility was reset")1372 t.Fatal("cursor visibility was reset")
1370 }1373 }
1371 if style, ok := s.CursorStyle(); style != 3 || !ok {1374 if style, ok := s.CursorStyle(); style != 3 || !ok {
1372 t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)1375 t.Fatalf("cursor style = (%d,%v), want (3,true)", style, ok)
1373 }1376 }
1377+ s.Write([]byte("\x1b[?2004l"))
1378+ if s.BracketedPasteMode() {
1379+ t.Fatal("bracketed paste mode remained enabled after reset")
1380+ }
1374 },1381 },
1375 },1382 },
1376 }1383 }

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