detached 14aa864

fix(remote): harden resilient attach transport

commit
14aa864a92a4ae98c4ccfc20a9fec48a02a02ef5
author
brice · · 2026-07-04 11:59 UTC
parent
75dd680
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
22 files · +760 -352
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.Dial(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+}
@@ -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.Dial(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+}
@@ -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+}
M internal/adapters/dgram/transport.go +232 -78
@@ -14,9 +14,20 @@ import (
1414 )
1515
1616 const (
17- recData byte = 1
18- recAck byte = 2
19- defaultResend = 40 * time.Millisecond
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+)
27+
28+var (
29+ ErrPendingFull = errors.New("dgram: pending reliable queue full")
30+ ErrLinkDead = errors.New("dgram: link dead")
2031 )
2132
2233 type Transport struct {
@@ -25,19 +36,28 @@ type Transport struct {
2536 sendDir, recvDir uint32
2637 mtu int
2738
28- mu sync.Mutex
29- peer net.Addr
30- ctr uint64
31- seq uint64
32- pending map[uint64]*pending
33- closed bool
39+ mu sync.Mutex
40+ peer net.Addr
41+ ctr uint64
42+ seq uint64
43+ probeSeq uint64
44+ pending map[uint64]*pending
45+ closed bool
46+ closeErr error
47+ lastHeard time.Time
48+ heartbeat time.Duration
49+ silence time.Duration
50+ probeWait map[uint64]chan struct{}
51+
52+ replay *pdgram.ReplayWindow
53+ reasm *pdgram.Reassembler
54+ in chan ports.Frame
55+ done chan struct{}
3456
35- replay *pdgram.ReplayWindow
36- reasm *pdgram.Reassembler
37- delivered map[uint64]struct{}
38- deliveredMax uint64
39- in chan ports.Frame
40- done chan struct{}
57+ deliverMu sync.Mutex
58+ deliverCond *sync.Cond
59+ nextRecvSeq uint64
60+ recvBuf map[uint64]ports.Frame
4161 }
4262
4363 type pending struct {
@@ -50,9 +70,11 @@ func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir
5070 if err != nil {
5171 return nil, err
5272 }
53- t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: pdgram.DefaultMTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), delivered: make(map[uint64]struct{}), in: make(chan ports.Frame, 32), done: make(chan struct{})}
73+ 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)}
74+ t.deliverCond = sync.NewCond(&t.deliverMu)
5475 go t.readLoop()
5576 go t.resendLoop()
77+ go t.deliveryLoop()
5678 return t, nil
5779 }
5880
@@ -61,6 +83,18 @@ func (t *Transport) Send(f ports.Frame) error {
6183 // dropping MsgOutput can permanently desynchronize the client screen.
6284 reliable := true
6385 t.mu.Lock()
86+ if t.closed {
87+ err := t.closeErr
88+ t.mu.Unlock()
89+ if err != nil {
90+ return err
91+ }
92+ return errors.New("dgram: closed")
93+ }
94+ if reliable && len(t.pending) >= maxPendingReliable {
95+ t.mu.Unlock()
96+ return ErrPendingFull
97+ }
6498 t.seq++
6599 seq := t.seq
66100 if reliable {
@@ -75,21 +109,71 @@ func (t *Transport) Recv() (ports.Frame, error) {
75109 case f := <-t.in:
76110 return f, nil
77111 case <-t.done:
112+ t.mu.Lock()
113+ err := t.closeErr
114+ t.mu.Unlock()
115+ if err != nil {
116+ return ports.Frame{}, err
117+ }
78118 return ports.Frame{}, errors.New("dgram: closed")
79119 }
80120 }
81121 func (t *Transport) Close() error {
82- t.mu.Lock()
83- if !t.closed {
84- t.closed = true
85- close(t.done)
86- }
87- t.mu.Unlock()
122+ t.closeWithError(errors.New("dgram: closed"))
88123 return t.pc.Close()
89124 }
90125
91126 func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
92127
128+// Probe sends an authenticated datagram and waits for the peer to authenticate a
129+// response or for ctx to expire. It is intended for UDP bootstrap/fallback checks.
130+func (t *Transport) Probe(ctx context.Context) error {
131+ id, ch, err := t.registerProbe()
132+ if err != nil {
133+ return err
134+ }
135+ defer t.unregisterProbe(id)
136+ if err := t.sendProbe(recProbe, id); err != nil {
137+ return err
138+ }
139+ select {
140+ case <-ch:
141+ return nil
142+ case <-ctx.Done():
143+ return ctx.Err()
144+ case <-t.done:
145+ t.mu.Lock()
146+ err := t.closeErr
147+ t.mu.Unlock()
148+ if err != nil {
149+ return err
150+ }
151+ return errors.New("dgram: closed")
152+ }
153+}
154+
155+func (t *Transport) registerProbe() (uint64, chan struct{}, error) {
156+ t.mu.Lock()
157+ defer t.mu.Unlock()
158+ if t.closed {
159+ if t.closeErr != nil {
160+ return 0, nil, t.closeErr
161+ }
162+ return 0, nil, errors.New("dgram: closed")
163+ }
164+ t.probeSeq++
165+ id := t.probeSeq
166+ ch := make(chan struct{})
167+ t.probeWait[id] = ch
168+ return id, ch, nil
169+}
170+
171+func (t *Transport) unregisterProbe(id uint64) {
172+ t.mu.Lock()
173+ delete(t.probeWait, id)
174+ t.mu.Unlock()
175+}
176+
93177 func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {
94178 p := encodeData(seq, reliable, f)
95179 return t.sendPayload(p)
@@ -100,6 +184,12 @@ func (t *Transport) sendAck(seq uint64) {
100184 binary.BigEndian.PutUint64(b[1:], seq)
101185 _ = t.sendPayload(b[:])
102186 }
187+func (t *Transport) sendProbe(kind byte, id uint64) error {
188+ var b [9]byte
189+ b[0] = kind
190+ binary.BigEndian.PutUint64(b[1:], id)
191+ return t.sendPayload(b[:])
192+}
103193 func (t *Transport) sendPayload(p []byte) error {
104194 frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
105195 if err != nil {
@@ -114,8 +204,12 @@ func (t *Transport) sendPayload(p []byte) error {
114204 t.mu.Lock()
115205 peer := t.peer
116206 closed := t.closed
207+ closeErr := t.closeErr
117208 t.mu.Unlock()
118209 if closed {
210+ if closeErr != nil {
211+ return closeErr
212+ }
119213 return errors.New("dgram: closed")
120214 }
121215 if peer == nil {
@@ -134,6 +228,7 @@ func (t *Transport) readLoop() {
134228 for {
135229 n, addr, err := t.pc.ReadFrom(buf)
136230 if err != nil {
231+ t.closeWithError(err)
137232 return
138233 }
139234 _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
@@ -142,6 +237,7 @@ func (t *Transport) readLoop() {
142237 }
143238 t.mu.Lock()
144239 t.peer = addr
240+ t.lastHeard = time.Now()
145241 t.mu.Unlock()
146242 frag, err := pdgram.UnmarshalFragment(pt)
147243 if err != nil {
@@ -168,6 +264,23 @@ func (t *Transport) handleRecord(p []byte) {
168264 t.mu.Lock()
169265 delete(t.pending, seq)
170266 t.mu.Unlock()
267+ case recProbe:
268+ if len(p) != 9 {
269+ return
270+ }
271+ _ = t.sendProbe(recPong, binary.BigEndian.Uint64(p[1:]))
272+ case recPong:
273+ if len(p) != 9 {
274+ return
275+ }
276+ id := binary.BigEndian.Uint64(p[1:])
277+ t.mu.Lock()
278+ ch := t.probeWait[id]
279+ if ch != nil {
280+ delete(t.probeWait, id)
281+ close(ch)
282+ }
283+ t.mu.Unlock()
171284 case recData:
172285 seq, reliable, f, ok := decodeData(p)
173286 if !ok {
@@ -175,14 +288,51 @@ func (t *Transport) handleRecord(p []byte) {
175288 }
176289 if reliable {
177290 t.sendAck(seq)
178- if !t.markReliableDelivered(seq) {
291+ t.enqueueReliable(seq, f)
292+ return
293+ }
294+ t.deliver(f)
295+ }
296+}
297+
298+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) {
299+ t.deliverMu.Lock()
300+ defer t.deliverMu.Unlock()
301+ if seq < t.nextRecvSeq {
302+ return
303+ }
304+ if _, exists := t.recvBuf[seq]; exists {
305+ return
306+ }
307+ t.recvBuf[seq] = f
308+ t.deliverCond.Signal()
309+}
310+
311+func (t *Transport) deliveryLoop() {
312+ for {
313+ t.deliverMu.Lock()
314+ f, ok := t.recvBuf[t.nextRecvSeq]
315+ for !ok {
316+ if t.isClosed() {
317+ t.deliverMu.Unlock()
179318 return
180319 }
320+ t.deliverCond.Wait()
321+ f, ok = t.recvBuf[t.nextRecvSeq]
181322 }
182- go t.deliver(f)
323+ delete(t.recvBuf, t.nextRecvSeq)
324+ t.nextRecvSeq++
325+ t.deliverMu.Unlock()
326+ t.deliver(f)
183327 }
184328 }
185329
330+func (t *Transport) isClosed() bool {
331+ t.mu.Lock()
332+ defer t.mu.Unlock()
333+ return t.closed
334+}
335+
186336 func (t *Transport) deliver(f ports.Frame) {
187337 select {
188338 case t.in <- f:
@@ -190,57 +340,72 @@ func (t *Transport) deliver(f ports.Frame) {
190340 }
191341 }
192342
193-func (t *Transport) markReliableDelivered(seq uint64) bool {
194- t.mu.Lock()
195- defer t.mu.Unlock()
196- if _, ok := t.delivered[seq]; ok {
197- return false
198- }
199- t.delivered[seq] = struct{}{}
200- if seq > t.deliveredMax {
201- t.deliveredMax = seq
202- }
203- const window = 1024
204- if t.deliveredMax > window {
205- cutoff := t.deliveredMax - window
206- for s := range t.delivered {
207- if s < cutoff {
208- delete(t.delivered, s)
209- }
343+func (t *Transport) resendLoop() {
344+ resend := time.NewTicker(defaultResend)
345+ defer resend.Stop()
346+ heartbeat := time.NewTicker(t.heartbeat)
347+ defer heartbeat.Stop()
348+ for {
349+ select {
350+ case <-resend.C:
351+ t.resendPending()
352+ t.checkSilence()
353+ case <-heartbeat.C:
354+ _ = t.sendProbe(recProbe, 0)
355+ t.checkSilence()
356+ case <-t.done:
357+ return
210358 }
211359 }
212- return true
213360 }
214361
215-func (t *Transport) resendLoop() {
216- tick := time.NewTicker(defaultResend)
217- defer tick.Stop()
218- for {
219- select {
220- case <-tick.C:
221- now := time.Now()
222- var resend []struct {
362+func (t *Transport) resendPending() {
363+ now := time.Now()
364+ var resend []struct {
365+ seq uint64
366+ f ports.Frame
367+ }
368+ t.mu.Lock()
369+ for seq, p := range t.pending {
370+ if p.last.IsZero() || now.Sub(p.last) >= defaultResend {
371+ p.last = now
372+ resend = append(resend, struct {
223373 seq uint64
224374 f ports.Frame
225- }
226- t.mu.Lock()
227- for seq, p := range t.pending {
228- if p.last.IsZero() || now.Sub(p.last) >= defaultResend {
229- p.last = now
230- resend = append(resend, struct {
231- seq uint64
232- f ports.Frame
233- }{seq, p.frame})
234- }
235- }
236- t.mu.Unlock()
237- for _, r := range resend {
238- _ = t.sendData(r.seq, true, r.f)
239- }
240- case <-t.done:
241- return
375+ }{seq, p.frame})
242376 }
243377 }
378+ t.mu.Unlock()
379+ for _, r := range resend {
380+ _ = t.sendData(r.seq, true, r.f)
381+ }
382+}
383+
384+func (t *Transport) checkSilence() {
385+ t.mu.Lock()
386+ last := t.lastHeard
387+ silence := t.silence
388+ closed := t.closed
389+ t.mu.Unlock()
390+ if !closed && silence > 0 && time.Since(last) > silence {
391+ t.closeWithError(ErrLinkDead)
392+ }
393+}
394+
395+func (t *Transport) closeWithError(err error) {
396+ t.mu.Lock()
397+ if t.closed {
398+ t.mu.Unlock()
399+ return
400+ }
401+ t.closed = true
402+ t.closeErr = err
403+ t.probeWait = make(map[uint64]chan struct{})
404+ close(t.done)
405+ t.mu.Unlock()
406+ t.deliverMu.Lock()
407+ t.deliverCond.Broadcast()
408+ t.deliverMu.Unlock()
244409 }
245410
246411 func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
@@ -261,14 +426,3 @@ func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
261426 }
262427 return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true
263428 }
264-
265-type Dialer struct {
266- PC net.PacketConn
267- Peer net.Addr
268- Key []byte
269- SendDir, RecvDir uint32
270-}
271-
272-func (d Dialer) Dial(ctx context.Context) (ports.Transport, error) {
273- return NewTransport(d.PC, d.Peer, d.Key, d.SendDir, d.RecvDir)
274-}
@@ -14,9 +14,20 @@ import (
14 )14 )
15 15
16 const (16 const (
17- recData byte = 117+ recData byte = 1
18- recAck byte = 218+ recAck byte = 2
19- defaultResend = 40 * time.Millisecond19+ 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+)
27+
28+var (
29+ ErrPendingFull = errors.New("dgram: pending reliable queue full")
30+ ErrLinkDead = errors.New("dgram: link dead")
20 )31 )
21 32
22 type Transport struct {33 type Transport struct {
@@ -25,19 +36,28 @@ type Transport struct {
25 sendDir, recvDir uint3236 sendDir, recvDir uint32
26 mtu int37 mtu int
27 38
28- mu sync.Mutex39+ mu sync.Mutex
29- peer net.Addr40+ peer net.Addr
30- ctr uint6441+ ctr uint64
31- seq uint6442+ seq uint64
32- pending map[uint64]*pending43+ probeSeq uint64
33- closed bool44+ pending map[uint64]*pending
45+ closed bool
46+ closeErr error
47+ lastHeard time.Time
48+ heartbeat time.Duration
49+ silence time.Duration
50+ probeWait map[uint64]chan struct{}
51+
52+ replay *pdgram.ReplayWindow
53+ reasm *pdgram.Reassembler
54+ in chan ports.Frame
55+ done chan struct{}
34 56
35- replay *pdgram.ReplayWindow57+ deliverMu sync.Mutex
36- reasm *pdgram.Reassembler58+ deliverCond *sync.Cond
37- delivered map[uint64]struct{}59+ nextRecvSeq uint64
38- deliveredMax uint6460+ recvBuf map[uint64]ports.Frame
39- in chan ports.Frame
40- done chan struct{}
41 }61 }
42 62
43 type pending struct {63 type pending struct {
@@ -50,9 +70,11 @@ func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir
50 if err != nil {70 if err != nil {
51 return nil, err71 return nil, err
52 }72 }
53- t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: pdgram.DefaultMTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), delivered: make(map[uint64]struct{}), in: make(chan ports.Frame, 32), done: make(chan struct{})}73+ 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)}
74+ t.deliverCond = sync.NewCond(&t.deliverMu)
54 go t.readLoop()75 go t.readLoop()
55 go t.resendLoop()76 go t.resendLoop()
77+ go t.deliveryLoop()
56 return t, nil78 return t, nil
57 }79 }
58 80
@@ -61,6 +83,18 @@ func (t *Transport) Send(f ports.Frame) error {
61 // dropping MsgOutput can permanently desynchronize the client screen.83 // dropping MsgOutput can permanently desynchronize the client screen.
62 reliable := true84 reliable := true
63 t.mu.Lock()85 t.mu.Lock()
86+ if t.closed {
87+ err := t.closeErr
88+ t.mu.Unlock()
89+ if err != nil {
90+ return err
91+ }
92+ return errors.New("dgram: closed")
93+ }
94+ if reliable && len(t.pending) >= maxPendingReliable {
95+ t.mu.Unlock()
96+ return ErrPendingFull
97+ }
64 t.seq++98 t.seq++
65 seq := t.seq99 seq := t.seq
66 if reliable {100 if reliable {
@@ -75,21 +109,71 @@ func (t *Transport) Recv() (ports.Frame, error) {
75 case f := <-t.in:109 case f := <-t.in:
76 return f, nil110 return f, nil
77 case <-t.done:111 case <-t.done:
112+ t.mu.Lock()
113+ err := t.closeErr
114+ t.mu.Unlock()
115+ if err != nil {
116+ return ports.Frame{}, err
117+ }
78 return ports.Frame{}, errors.New("dgram: closed")118 return ports.Frame{}, errors.New("dgram: closed")
79 }119 }
80 }120 }
81 func (t *Transport) Close() error {121 func (t *Transport) Close() error {
82- t.mu.Lock()122+ t.closeWithError(errors.New("dgram: closed"))
83- if !t.closed {
84- t.closed = true
85- close(t.done)
86- }
87- t.mu.Unlock()
88 return t.pc.Close()123 return t.pc.Close()
89 }124 }
90 125
91 func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }126 func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
92 127
128+// Probe sends an authenticated datagram and waits for the peer to authenticate a
129+// response or for ctx to expire. It is intended for UDP bootstrap/fallback checks.
130+func (t *Transport) Probe(ctx context.Context) error {
131+ id, ch, err := t.registerProbe()
132+ if err != nil {
133+ return err
134+ }
135+ defer t.unregisterProbe(id)
136+ if err := t.sendProbe(recProbe, id); err != nil {
137+ return err
138+ }
139+ select {
140+ case <-ch:
141+ return nil
142+ case <-ctx.Done():
143+ return ctx.Err()
144+ case <-t.done:
145+ t.mu.Lock()
146+ err := t.closeErr
147+ t.mu.Unlock()
148+ if err != nil {
149+ return err
150+ }
151+ return errors.New("dgram: closed")
152+ }
153+}
154+
155+func (t *Transport) registerProbe() (uint64, chan struct{}, error) {
156+ t.mu.Lock()
157+ defer t.mu.Unlock()
158+ if t.closed {
159+ if t.closeErr != nil {
160+ return 0, nil, t.closeErr
161+ }
162+ return 0, nil, errors.New("dgram: closed")
163+ }
164+ t.probeSeq++
165+ id := t.probeSeq
166+ ch := make(chan struct{})
167+ t.probeWait[id] = ch
168+ return id, ch, nil
169+}
170+
171+func (t *Transport) unregisterProbe(id uint64) {
172+ t.mu.Lock()
173+ delete(t.probeWait, id)
174+ t.mu.Unlock()
175+}
176+
93 func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {177 func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error {
94 p := encodeData(seq, reliable, f)178 p := encodeData(seq, reliable, f)
95 return t.sendPayload(p)179 return t.sendPayload(p)
@@ -100,6 +184,12 @@ func (t *Transport) sendAck(seq uint64) {
100 binary.BigEndian.PutUint64(b[1:], seq)184 binary.BigEndian.PutUint64(b[1:], seq)
101 _ = t.sendPayload(b[:])185 _ = t.sendPayload(b[:])
102 }186 }
187+func (t *Transport) sendProbe(kind byte, id uint64) error {
188+ var b [9]byte
189+ b[0] = kind
190+ binary.BigEndian.PutUint64(b[1:], id)
191+ return t.sendPayload(b[:])
192+}
103 func (t *Transport) sendPayload(p []byte) error {193 func (t *Transport) sendPayload(p []byte) error {
104 frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())194 frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead())
105 if err != nil {195 if err != nil {
@@ -114,8 +204,12 @@ func (t *Transport) sendPayload(p []byte) error {
114 t.mu.Lock()204 t.mu.Lock()
115 peer := t.peer205 peer := t.peer
116 closed := t.closed206 closed := t.closed
207+ closeErr := t.closeErr
117 t.mu.Unlock()208 t.mu.Unlock()
118 if closed {209 if closed {
210+ if closeErr != nil {
211+ return closeErr
212+ }
119 return errors.New("dgram: closed")213 return errors.New("dgram: closed")
120 }214 }
121 if peer == nil {215 if peer == nil {
@@ -134,6 +228,7 @@ func (t *Transport) readLoop() {
134 for {228 for {
135 n, addr, err := t.pc.ReadFrom(buf)229 n, addr, err := t.pc.ReadFrom(buf)
136 if err != nil {230 if err != nil {
231+ t.closeWithError(err)
137 return232 return
138 }233 }
139 _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)234 _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay)
@@ -142,6 +237,7 @@ func (t *Transport) readLoop() {
142 }237 }
143 t.mu.Lock()238 t.mu.Lock()
144 t.peer = addr239 t.peer = addr
240+ t.lastHeard = time.Now()
145 t.mu.Unlock()241 t.mu.Unlock()
146 frag, err := pdgram.UnmarshalFragment(pt)242 frag, err := pdgram.UnmarshalFragment(pt)
147 if err != nil {243 if err != nil {
@@ -168,6 +264,23 @@ func (t *Transport) handleRecord(p []byte) {
168 t.mu.Lock()264 t.mu.Lock()
169 delete(t.pending, seq)265 delete(t.pending, seq)
170 t.mu.Unlock()266 t.mu.Unlock()
267+ case recProbe:
268+ if len(p) != 9 {
269+ return
270+ }
271+ _ = t.sendProbe(recPong, binary.BigEndian.Uint64(p[1:]))
272+ case recPong:
273+ if len(p) != 9 {
274+ return
275+ }
276+ id := binary.BigEndian.Uint64(p[1:])
277+ t.mu.Lock()
278+ ch := t.probeWait[id]
279+ if ch != nil {
280+ delete(t.probeWait, id)
281+ close(ch)
282+ }
283+ t.mu.Unlock()
171 case recData:284 case recData:
172 seq, reliable, f, ok := decodeData(p)285 seq, reliable, f, ok := decodeData(p)
173 if !ok {286 if !ok {
@@ -175,14 +288,51 @@ func (t *Transport) handleRecord(p []byte) {
175 }288 }
176 if reliable {289 if reliable {
177 t.sendAck(seq)290 t.sendAck(seq)
178- if !t.markReliableDelivered(seq) {291+ t.enqueueReliable(seq, f)
292+ return
293+ }
294+ t.deliver(f)
295+ }
296+}
297+
298+func (t *Transport) enqueueReliable(seq uint64, f ports.Frame) {
299+ t.deliverMu.Lock()
300+ defer t.deliverMu.Unlock()
301+ if seq < t.nextRecvSeq {
302+ return
303+ }
304+ if _, exists := t.recvBuf[seq]; exists {
305+ return
306+ }
307+ t.recvBuf[seq] = f
308+ t.deliverCond.Signal()
309+}
310+
311+func (t *Transport) deliveryLoop() {
312+ for {
313+ t.deliverMu.Lock()
314+ f, ok := t.recvBuf[t.nextRecvSeq]
315+ for !ok {
316+ if t.isClosed() {
317+ t.deliverMu.Unlock()
179 return318 return
180 }319 }
320+ t.deliverCond.Wait()
321+ f, ok = t.recvBuf[t.nextRecvSeq]
181 }322 }
182- go t.deliver(f)323+ delete(t.recvBuf, t.nextRecvSeq)
324+ t.nextRecvSeq++
325+ t.deliverMu.Unlock()
326+ t.deliver(f)
183 }327 }
184 }328 }
185 329
330+func (t *Transport) isClosed() bool {
331+ t.mu.Lock()
332+ defer t.mu.Unlock()
333+ return t.closed
334+}
335+
186 func (t *Transport) deliver(f ports.Frame) {336 func (t *Transport) deliver(f ports.Frame) {
187 select {337 select {
188 case t.in <- f:338 case t.in <- f:
@@ -190,57 +340,72 @@ func (t *Transport) deliver(f ports.Frame) {
190 }340 }
191 }341 }
192 342
193-func (t *Transport) markReliableDelivered(seq uint64) bool {343+func (t *Transport) resendLoop() {
194- t.mu.Lock()344+ resend := time.NewTicker(defaultResend)
195- defer t.mu.Unlock()345+ defer resend.Stop()
196- if _, ok := t.delivered[seq]; ok {346+ heartbeat := time.NewTicker(t.heartbeat)
197- return false347+ defer heartbeat.Stop()
198- }348+ for {
199- t.delivered[seq] = struct{}{}349+ select {
200- if seq > t.deliveredMax {350+ case <-resend.C:
201- t.deliveredMax = seq351+ t.resendPending()
202- }352+ t.checkSilence()
203- const window = 1024353+ case <-heartbeat.C:
204- if t.deliveredMax > window {354+ _ = t.sendProbe(recProbe, 0)
205- cutoff := t.deliveredMax - window355+ t.checkSilence()
206- for s := range t.delivered {356+ case <-t.done:
207- if s < cutoff {357+ return
208- delete(t.delivered, s)
209- }
210 }358 }
211 }359 }
212- return true
213 }360 }
214 361
215-func (t *Transport) resendLoop() {362+func (t *Transport) resendPending() {
216- tick := time.NewTicker(defaultResend)363+ now := time.Now()
217- defer tick.Stop()364+ var resend []struct {
218- for {365+ seq uint64
219- select {366+ f ports.Frame
220- case <-tick.C:367+ }
221- now := time.Now()368+ t.mu.Lock()
222- var resend []struct {369+ for seq, p := range t.pending {
370+ if p.last.IsZero() || now.Sub(p.last) >= defaultResend {
371+ p.last = now
372+ resend = append(resend, struct {
223 seq uint64373 seq uint64
224 f ports.Frame374 f ports.Frame
225- }375+ }{seq, p.frame})
226- t.mu.Lock()
227- for seq, p := range t.pending {
228- if p.last.IsZero() || now.Sub(p.last) >= defaultResend {
229- p.last = now
230- resend = append(resend, struct {
231- seq uint64
232- f ports.Frame
233- }{seq, p.frame})
234- }
235- }
236- t.mu.Unlock()
237- for _, r := range resend {
238- _ = t.sendData(r.seq, true, r.f)
239- }
240- case <-t.done:
241- return
242 }376 }
243 }377 }
378+ t.mu.Unlock()
379+ for _, r := range resend {
380+ _ = t.sendData(r.seq, true, r.f)
381+ }
382+}
383+
384+func (t *Transport) checkSilence() {
385+ t.mu.Lock()
386+ last := t.lastHeard
387+ silence := t.silence
388+ closed := t.closed
389+ t.mu.Unlock()
390+ if !closed && silence > 0 && time.Since(last) > silence {
391+ t.closeWithError(ErrLinkDead)
392+ }
393+}
394+
395+func (t *Transport) closeWithError(err error) {
396+ t.mu.Lock()
397+ if t.closed {
398+ t.mu.Unlock()
399+ return
400+ }
401+ t.closed = true
402+ t.closeErr = err
403+ t.probeWait = make(map[uint64]chan struct{})
404+ close(t.done)
405+ t.mu.Unlock()
406+ t.deliverMu.Lock()
407+ t.deliverCond.Broadcast()
408+ t.deliverMu.Unlock()
244 }409 }
245 410
246 func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {411 func encodeData(seq uint64, reliable bool, f ports.Frame) []byte {
@@ -261,14 +426,3 @@ func decodeData(b []byte) (uint64, bool, ports.Frame, bool) {
261 }426 }
262 return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true427 return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true
263 }428 }
264-
265-type Dialer struct {
266- PC net.PacketConn
267- Peer net.Addr
268- Key []byte
269- SendDir, RecvDir uint32
270-}
271-
272-func (d Dialer) Dial(ctx context.Context) (ports.Transport, error) {
273- return NewTransport(d.PC, d.Peer, d.Key, d.SendDir, d.RecvDir)
274-}
M internal/adapters/dgram/transport_test.go +132 -0
@@ -2,6 +2,7 @@ package dgram
22
33 import (
44 "bytes"
5+ "context"
56 "errors"
67 "net"
78 "sync"
@@ -207,6 +208,117 @@ func TestOutputRetransmitsUntilAck(t *testing.T) {
207208 }
208209 }
209210
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+
210322 func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
211323 ch := make(chan ports.Frame, 1)
212324 go func() { f, _ := tr.Recv(); ch <- f }()
@@ -230,3 +342,23 @@ func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
230342 return ports.Frame{}
231343 }
232344 }
345+
346+func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (ports.Frame, error) {
347+ t.Helper()
348+ type result struct {
349+ f ports.Frame
350+ err error
351+ }
352+ ch := make(chan result, 1)
353+ go func() {
354+ f, err := tr.Recv()
355+ ch <- result{f: f, err: err}
356+ }()
357+ select {
358+ case r := <-ch:
359+ return r.f, r.err
360+ case <-time.After(d):
361+ t.Fatal("timeout")
362+ return ports.Frame{}, nil
363+ }
364+}
@@ -2,6 +2,7 @@ package dgram
2 2
3 import (3 import (
4 "bytes"4 "bytes"
5+ "context"
5 "errors"6 "errors"
6 "net"7 "net"
7 "sync"8 "sync"
@@ -207,6 +208,117 @@ func TestOutputRetransmitsUntilAck(t *testing.T) {
207 }208 }
208 }209 }
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+
210 func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {322 func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) {
211 ch := make(chan ports.Frame, 1)323 ch := make(chan ports.Frame, 1)
212 go func() { f, _ := tr.Recv(); ch <- f }()324 go func() { f, _ := tr.Recv(); ch <- f }()
@@ -230,3 +342,23 @@ func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame {
230 return ports.Frame{}342 return ports.Frame{}
231 }343 }
232 }344 }
345+
346+func recvErrWithin(t *testing.T, tr *Transport, d time.Duration) (ports.Frame, error) {
347+ t.Helper()
348+ type result struct {
349+ f ports.Frame
350+ err error
351+ }
352+ ch := make(chan result, 1)
353+ go func() {
354+ f, err := tr.Recv()
355+ ch <- result{f: f, err: err}
356+ }()
357+ select {
358+ case r := <-ch:
359+ return r.f, r.err
360+ case <-time.After(d):
361+ t.Fatal("timeout")
362+ return ports.Frame{}, nil
363+ }
364+}
M internal/adapters/sshstdio/transport.go +7 -1
@@ -130,7 +130,13 @@ type CommandSpec struct {
130130 // local shell. OpenSSH sends the remote command as one string for the remote
131131 // user's shell to interpret, so every remote argv word is POSIX single-quoted.
132132 func BuildCommand(target, session string) CommandSpec {
133- remote := []string{shellQuote("vev"), shellQuote("_stdio")}
133+ return BuildCommandForMode(target, "_stdio", session)
134+}
135+
136+// BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
137+// remote mode such as _stdio or _udp-bootstrap.
138+func BuildCommandForMode(target, mode, session string) CommandSpec {
139+ remote := []string{shellQuote("vev"), shellQuote(mode)}
134140 if session != "" {
135141 remote = append(remote, shellQuote(session))
136142 }
@@ -130,7 +130,13 @@ type CommandSpec struct {
130 // local shell. OpenSSH sends the remote command as one string for the remote130 // 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.131 // user's shell to interpret, so every remote argv word is POSIX single-quoted.
132 func BuildCommand(target, session string) CommandSpec {132 func BuildCommand(target, session string) CommandSpec {
133- remote := []string{shellQuote("vev"), shellQuote("_stdio")}133+ return BuildCommandForMode(target, "_stdio", session)
134+}
135+
136+// BuildCommandForMode constructs the local ssh subprocess argv for a hidden vev
137+// remote mode such as _stdio or _udp-bootstrap.
138+func BuildCommandForMode(target, mode, session string) CommandSpec {
139+ remote := []string{shellQuote("vev"), shellQuote(mode)}
134 if session != "" {140 if session != "" {
135 remote = append(remote, shellQuote(session))141 remote = append(remote, shellQuote(session))
136 }142 }
M internal/adapters/sshstdio/transport_test.go +13 -0
@@ -43,6 +43,19 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
4343 }
4444 }
4545
46+func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
47+ got := BuildCommandForMode("user@example.com", "_udp-bootstrap", "work")
48+ want := []string{"--", "user@example.com", "'vev' '_udp-bootstrap' 'work'"}
49+ if got.Path != "ssh" {
50+ t.Fatalf("Path = %q, want ssh", got.Path)
51+ }
52+ for i := range want {
53+ if got.Args[i] != want[i] {
54+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], want[i], got.Args)
55+ }
56+ }
57+}
58+
4659 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
4760 clientRead, serverWrite := io.Pipe()
4861 serverRead, clientWrite := io.Pipe()
@@ -43,6 +43,19 @@ func TestBuildCommandUsesExecArgs(t *testing.T) {
43 }43 }
44 }44 }
45 45
46+func TestBuildCommandForModeUsesCanonicalSSHArgs(t *testing.T) {
47+ got := BuildCommandForMode("user@example.com", "_udp-bootstrap", "work")
48+ want := []string{"--", "user@example.com", "'vev' '_udp-bootstrap' 'work'"}
49+ if got.Path != "ssh" {
50+ t.Fatalf("Path = %q, want ssh", got.Path)
51+ }
52+ for i := range want {
53+ if got.Args[i] != want[i] {
54+ t.Fatalf("Args[%d] = %q, want %q (all args %q)", i, got.Args[i], want[i], got.Args)
55+ }
56+ }
57+}
58+
46 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {59 func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) {
47 clientRead, serverWrite := io.Pipe()60 clientRead, serverWrite := io.Pipe()
48 serverRead, clientWrite := io.Pipe()61 serverRead, clientWrite := io.Pipe()
M internal/app/remote_test.go +1 -32
@@ -1,37 +1,6 @@
11 package app
22
3-import (
4- "context"
5- "strings"
6- "testing"
7-)
8-
9-func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
10- var b limitedBuffer
11- chunk := strings.Repeat("x", maxBootstrapStderr+1024)
12- n, err := b.Write([]byte(chunk))
13- if err != nil {
14- t.Fatal(err)
15- }
16- if n != len(chunk) {
17- t.Fatalf("n=%d, want %d", n, len(chunk))
18- }
19- if got := len(b.String()); got != maxBootstrapStderr {
20- t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
21- }
22-}
23-
24-func TestRemoteDatagramDialerReportsUDPAndStdioFailures(t *testing.T) {
25- t.Setenv("PATH", t.TempDir())
26- _, err := (remoteDatagramDialer{target: "example.invalid"}).Dial(context.Background())
27- if err == nil {
28- t.Fatal("expected dial error")
29- }
30- msg := err.Error()
31- if !strings.Contains(msg, "datagram dial failed") || !strings.Contains(msg, "stdio fallback failed") {
32- t.Fatalf("error %q does not include both failures", msg)
33- }
34-}
3+import "testing"
354
365 func TestParseRemoteAttachTarget(t *testing.T) {
376 tests := []struct {
@@ -1,37 +1,6 @@
1 package app1 package app
2 2
3-import (3+import "testing"
4- "context"
5- "strings"
6- "testing"
7-)
8-
9-func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
10- var b limitedBuffer
11- chunk := strings.Repeat("x", maxBootstrapStderr+1024)
12- n, err := b.Write([]byte(chunk))
13- if err != nil {
14- t.Fatal(err)
15- }
16- if n != len(chunk) {
17- t.Fatalf("n=%d, want %d", n, len(chunk))
18- }
19- if got := len(b.String()); got != maxBootstrapStderr {
20- t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr)
21- }
22-}
23-
24-func TestRemoteDatagramDialerReportsUDPAndStdioFailures(t *testing.T) {
25- t.Setenv("PATH", t.TempDir())
26- _, err := (remoteDatagramDialer{target: "example.invalid"}).Dial(context.Background())
27- if err == nil {
28- t.Fatal("expected dial error")
29- }
30- msg := err.Error()
31- if !strings.Contains(msg, "datagram dial failed") || !strings.Contains(msg, "stdio fallback failed") {
32- t.Fatalf("error %q does not include both failures", msg)
33- }
34-}
35 4
36 func TestParseRemoteAttachTarget(t *testing.T) {5 func TestParseRemoteAttachTarget(t *testing.T) {
37 tests := []struct {6 tests := []struct {
M internal/app/run.go +1 -116
@@ -5,7 +5,6 @@
55 package app
66
77 import (
8- "bufio"
98 "context"
109 "crypto/rand"
1110 "encoding/base64"
@@ -17,9 +16,7 @@ import (
1716 "net/http"
1817 _ "net/http/pprof"
1918 "os"
20- "os/exec"
2119 "os/signal"
22- "strings"
2320 "syscall"
2421 "text/tabwriter"
2522 "time"
@@ -281,7 +278,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
281278 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
282279
283280 func defaultRemoteDialer(target, session string) ports.Dialer {
284- return remoteDatagramDialer{target: target, session: session}
281+ return dgram.NewRemoteDialer(target, session)
285282 }
286283
287284 type runAttachDeps struct {
@@ -381,118 +378,6 @@ func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
381378 return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
382379 }
383380
384-const maxBootstrapStderr = 64 * 1024
385-
386-type limitedBuffer struct {
387- buf []byte
388-}
389-
390-func (b *limitedBuffer) Write(p []byte) (int, error) {
391- if len(b.buf) < maxBootstrapStderr {
392- keep := min(len(p), maxBootstrapStderr-len(b.buf))
393- b.buf = append(b.buf, p[:keep]...)
394- }
395- return len(p), nil
396-}
397-
398-func (b *limitedBuffer) String() string { return string(b.buf) }
399-
400-type remoteDatagramDialer struct {
401- target string
402- session string
403-}
404-
405-func (d remoteDatagramDialer) Dial(ctx context.Context) (ports.Transport, error) {
406- tr, udpErr := d.dialDatagram(ctx)
407- if udpErr == nil {
408- return tr, nil
409- }
410- tr, stdioErr := sshstdio.Dial(d.target, d.session)
411- if stdioErr != nil {
412- return nil, fmt.Errorf("datagram dial failed: %w; stdio fallback failed: %w", udpErr, stdioErr)
413- }
414- return tr, nil
415-}
416-
417-func (d remoteDatagramDialer) dialDatagram(ctx context.Context) (ports.Transport, error) {
418- remote := []string{shellQuote("vev"), shellQuote("_udp-bootstrap")}
419- if d.session != "" {
420- remote = append(remote, shellQuote(d.session))
421- }
422- cmd := exec.CommandContext(ctx, "ssh", "--", d.target, strings.Join(remote, " "))
423- stdout, err := cmd.StdoutPipe()
424- if err != nil {
425- return nil, err
426- }
427- var stderr limitedBuffer
428- cmd.Stderr = &stderr
429- if err := cmd.Start(); err != nil {
430- return nil, err
431- }
432- started := true
433- defer func() {
434- if started {
435- _ = cmd.Process.Kill()
436- _ = cmd.Wait()
437- }
438- }()
439-
440- line, err := bufio.NewReader(stdout).ReadString('\n')
441- if err != nil {
442- return nil, fmt.Errorf("udp bootstrap: %w: %s", err, strings.TrimSpace(stderr.String()))
443- }
444- fields := strings.Fields(strings.TrimSpace(line))
445- if len(fields) != 3 || fields[0] != "VEV-UDP" {
446- return nil, fmt.Errorf("udp bootstrap: unexpected reply %q", strings.TrimSpace(line))
447- }
448- key, err := base64.StdEncoding.DecodeString(fields[2])
449- if err != nil {
450- return nil, err
451- }
452- host := sshTargetHost(d.target)
453- peer, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, fields[1]))
454- if err != nil {
455- return nil, err
456- }
457- var lc net.ListenConfig
458- pc, err := lc.ListenPacket(ctx, "udp", ":0")
459- if err != nil {
460- return nil, err
461- }
462- tr, err := dgram.NewTransport(pc, peer, key, 1, 2)
463- if err != nil {
464- _ = pc.Close()
465- return nil, err
466- }
467- started = false
468- return closeBothTransport{Transport: tr, close: func() error {
469- _ = tr.Close()
470- _ = cmd.Process.Kill()
471- return cmd.Wait()
472- }}, nil
473-}
474-
475-func sshTargetHost(target string) string {
476- if at := strings.LastIndexByte(target, '@'); at >= 0 {
477- target = target[at+1:]
478- }
479- if h, _, err := net.SplitHostPort(target); err == nil {
480- return h
481- }
482- return target
483-}
484-
485-type closeBothTransport struct {
486- ports.Transport
487- close func() error
488-}
489-
490-func (t closeBothTransport) Close() error { return t.close() }
491-
492-func shellQuote(s string) string {
493- return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
494-}
495-
496381 func createDetachedLocalSession(ctx context.Context, name string) error {
497382 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
498383 if err != nil {
@@ -5,7 +5,6 @@
5 package app5 package app
6 6
7 import (7 import (
8- "bufio"
9 "context"8 "context"
10 "crypto/rand"9 "crypto/rand"
11 "encoding/base64"10 "encoding/base64"
@@ -17,9 +16,7 @@ import (
17 "net/http"16 "net/http"
18 _ "net/http/pprof"17 _ "net/http/pprof"
19 "os"18 "os"
20- "os/exec"
21 "os/signal"19 "os/signal"
22- "strings"
23 "syscall"20 "syscall"
24 "text/tabwriter"21 "text/tabwriter"
25 "time"22 "time"
@@ -281,7 +278,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
281 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }278 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
282 279
283 func defaultRemoteDialer(target, session string) ports.Dialer {280 func defaultRemoteDialer(target, session string) ports.Dialer {
284- return remoteDatagramDialer{target: target, session: session}281+ return dgram.NewRemoteDialer(target, session)
285 }282 }
286 283
287 type runAttachDeps struct {284 type runAttachDeps struct {
@@ -381,118 +378,6 @@ func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) {
381 return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)378 return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff)
382 }379 }
383 380
384-const maxBootstrapStderr = 64 * 1024
385-
386-type limitedBuffer struct {
387- buf []byte
388-}
389-
390-func (b *limitedBuffer) Write(p []byte) (int, error) {
391- if len(b.buf) < maxBootstrapStderr {
392- keep := min(len(p), maxBootstrapStderr-len(b.buf))
393- b.buf = append(b.buf, p[:keep]...)
394- }
395- return len(p), nil
396-}
397-
398-func (b *limitedBuffer) String() string { return string(b.buf) }
399-
400-type remoteDatagramDialer struct {
401- target string
402- session string
403-}
404-
405-func (d remoteDatagramDialer) Dial(ctx context.Context) (ports.Transport, error) {
406- tr, udpErr := d.dialDatagram(ctx)
407- if udpErr == nil {
408- return tr, nil
409- }
410- tr, stdioErr := sshstdio.Dial(d.target, d.session)
411- if stdioErr != nil {
412- return nil, fmt.Errorf("datagram dial failed: %w; stdio fallback failed: %w", udpErr, stdioErr)
413- }
414- return tr, nil
415-}
416-
417-func (d remoteDatagramDialer) dialDatagram(ctx context.Context) (ports.Transport, error) {
418- remote := []string{shellQuote("vev"), shellQuote("_udp-bootstrap")}
419- if d.session != "" {
420- remote = append(remote, shellQuote(d.session))
421- }
422- cmd := exec.CommandContext(ctx, "ssh", "--", d.target, strings.Join(remote, " "))
423- stdout, err := cmd.StdoutPipe()
424- if err != nil {
425- return nil, err
426- }
427- var stderr limitedBuffer
428- cmd.Stderr = &stderr
429- if err := cmd.Start(); err != nil {
430- return nil, err
431- }
432- started := true
433- defer func() {
434- if started {
435- _ = cmd.Process.Kill()
436- _ = cmd.Wait()
437- }
438- }()
439-
440- line, err := bufio.NewReader(stdout).ReadString('\n')
441- if err != nil {
442- return nil, fmt.Errorf("udp bootstrap: %w: %s", err, strings.TrimSpace(stderr.String()))
443- }
444- fields := strings.Fields(strings.TrimSpace(line))
445- if len(fields) != 3 || fields[0] != "VEV-UDP" {
446- return nil, fmt.Errorf("udp bootstrap: unexpected reply %q", strings.TrimSpace(line))
447- }
448- key, err := base64.StdEncoding.DecodeString(fields[2])
449- if err != nil {
450- return nil, err
451- }
452- host := sshTargetHost(d.target)
453- peer, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, fields[1]))
454- if err != nil {
455- return nil, err
456- }
457- var lc net.ListenConfig
458- pc, err := lc.ListenPacket(ctx, "udp", ":0")
459- if err != nil {
460- return nil, err
461- }
462- tr, err := dgram.NewTransport(pc, peer, key, 1, 2)
463- if err != nil {
464- _ = pc.Close()
465- return nil, err
466- }
467- started = false
468- return closeBothTransport{Transport: tr, close: func() error {
469- _ = tr.Close()
470- _ = cmd.Process.Kill()
471- return cmd.Wait()
472- }}, nil
473-}
474-
475-func sshTargetHost(target string) string {
476- if at := strings.LastIndexByte(target, '@'); at >= 0 {
477- target = target[at+1:]
478- }
479- if h, _, err := net.SplitHostPort(target); err == nil {
480- return h
481- }
482- return target
483-}
484-
485-type closeBothTransport struct {
486- ports.Transport
487- close func() error
488-}
489-
490-func (t closeBothTransport) Close() error { return t.close() }
491-
492-func shellQuote(s string) string {
493- return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
494-}
495-
496 func createDetachedLocalSession(ctx context.Context, name string) error {381 func createDetachedLocalSession(ctx context.Context, name string) error {
497 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)382 transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff)
498 if err != nil {383 if err != nil {
M internal/app/run_test.go +0 -33
@@ -393,39 +393,6 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
393393 return nil, errors.New("not used")
394394 }
395395
396-func TestSSHTargetHost(t *testing.T) {
397- tests := []struct {
398- target string
399- want string
400- }{
401- {target: "example.com", want: "example.com"},
402- {target: "user@example.com", want: "example.com"},
403- {target: "example.com:2222", want: "example.com"},
404- {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
405- }
406- for _, tt := range tests {
407- t.Run(tt.target, func(t *testing.T) {
408- if got := sshTargetHost(tt.target); got != tt.want {
409- t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
410- }
411- })
412- }
413-}
414-
415-func TestShellQuote(t *testing.T) {
416- tests := map[string]string{
417- "vev": "'vev'",
418- "has space": "'has space'",
419- "can't stop": "'can'\\''t stop'",
420- "": "''",
421- }
422- for in, want := range tests {
423- if got := shellQuote(in); got != want {
424- t.Fatalf("shellQuote(%q) = %q, want %q", in, got, want)
425- }
426- }
427-}
428-
429396 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
430397 var gotTarget, gotSession, gotDialer string
431398 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{
@@ -393,39 +393,6 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
393 return nil, errors.New("not used")393 return nil, errors.New("not used")
394 }394 }
395 395
396-func TestSSHTargetHost(t *testing.T) {
397- tests := []struct {
398- target string
399- want string
400- }{
401- {target: "example.com", want: "example.com"},
402- {target: "user@example.com", want: "example.com"},
403- {target: "example.com:2222", want: "example.com"},
404- {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"},
405- }
406- for _, tt := range tests {
407- t.Run(tt.target, func(t *testing.T) {
408- if got := sshTargetHost(tt.target); got != tt.want {
409- t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want)
410- }
411- })
412- }
413-}
414-
415-func TestShellQuote(t *testing.T) {
416- tests := map[string]string{
417- "vev": "'vev'",
418- "has space": "'has space'",
419- "can't stop": "'can'\\''t stop'",
420- "": "''",
421- }
422- for in, want := range tests {
423- if got := shellQuote(in); got != want {
424- t.Fatalf("shellQuote(%q) = %q, want %q", in, got, want)
425- }
426- }
427-}
428-
429 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {396 func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
430 var gotTarget, gotSession, gotDialer string397 var gotTarget, gotSession, gotDialer string
431 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{398 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{
M internal/ports/mocks/mocks.go +90 -0
@@ -5,6 +5,7 @@
55 package portsmocks
66
77 import (
8+ "context"
89 "io"
910 "time"
1011
@@ -957,6 +958,95 @@ func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) erro
957958 return _c
958959 }
959960
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+
9601050 // 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.
9611051 // The first argument is typically a *testing.T value.
9621052 func NewMockListener(t interface {
@@ -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/usecase/client/client.go +21 -14
@@ -88,7 +88,9 @@ var processClientID = newClientID()
8888
8989 func newClientID() [16]byte {
9090 var id [16]byte
91- _, _ = rand.Read(id[:])
91+ if _, err := rand.Read(id[:]); err != nil {
92+ panic("crypto/rand failed generating client ID: " + err.Error())
93+ }
9294 return id
9395 }
9496
@@ -106,6 +108,8 @@ type reconnectBackoff struct {
106108
107109 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
108110
111+var reconnectSleep = sleepReconnect
112+
109113 const (
110114 statusReconnect = "\r\x1b[2Kreconnecting…"
111115 statusClear = "\r\x1b[2K"
@@ -175,7 +179,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
175179 return err
176180 }
177181 drawStatus()
178- if !sleepReconnect(ctx, backoff) {
182+ if !reconnectSleep(ctx, backoff) {
179183 return ctx.Err()
180184 }
181185 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
@@ -183,9 +187,11 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
183187 continue
184188 }
185189 ms.dialed = true
186- backoff = defaultReconnectBackoff.initial
187190
188191 result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)
192+ if result.welcomed {
193+ backoff = defaultReconnectBackoff.initial
194+ }
189195 _ = transport.Close()
190196 if result.resumeToken != 0 {
191197 resumeToken = result.resumeToken
@@ -199,7 +205,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
199205 return result.err
200206 }
201207 drawStatus()
202- if !sleepReconnect(ctx, backoff) {
208+ if !reconnectSleep(ctx, backoff) {
203209 clearStatus()
204210 return ctx.Err()
205211 }
@@ -255,6 +261,7 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
255261
256262 type attachResult struct {
257263 resumeToken uint64
264+ welcomed bool
258265 err error
259266 }
260267
@@ -310,7 +317,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
310317
311318 // 3. Enter raw mode after Welcome; Run owns restoration.
312319 if err := enterRaw(); err != nil {
313- return attachResult{resumeToken: resumeToken, err: err}
320+ return attachResult{resumeToken: resumeToken, welcomed: true, err: err}
314321 }
315322 clearStatus()
316323
@@ -338,37 +345,37 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
338345 // context was cancelled. A queued sender error takes priority.
339346 select {
340347 case serr := <-sendErrCh:
341- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
348+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
342349 default:
343- return attachResult{resumeToken: resumeToken}
350+ return attachResult{resumeToken: resumeToken, welcomed: true}
344351 }
345352 case r := <-recvCh:
346353 if r.err != nil {
347354 if errors.Is(r.err, io.EOF) {
348- return attachResult{resumeToken: resumeToken, err: 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)}
349356 }
350- return attachResult{resumeToken: resumeToken, err: 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)}
351358 }
352359 switch r.frame.Type {
353360 case ports.MsgOutput:
354361 o, derr := ports.UnmarshalOutput(r.frame.Payload)
355362 if derr != nil {
356- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding output: %w", derr)}
363+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding output: %w", derr)}
357364 }
358365 if _, werr := term.Out().Write(o.Data); werr != nil {
359- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: writing terminal output: %w", werr)}
366+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: writing terminal output: %w", werr)}
360367 }
361368 if ferr := term.Flush(); ferr != nil {
362- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
369+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
363370 }
364371 ms.firstOutput = true
365372 case ports.MsgDetached:
366373 d, derr := ports.UnmarshalDetached(r.frame.Payload)
367374 if derr != nil {
368- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding detached: %w", derr)}
375+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}
369376 }
370377 ms.detached = true
371- return attachResult{resumeToken: resumeToken, err: detachedResult(d.Reason)}
378+ return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}
372379 case ports.MsgPong:
373380 // Liveness reply; nothing to do.
374381 default:
@@ -88,7 +88,9 @@ var processClientID = newClientID()
88 88
89 func newClientID() [16]byte {89 func newClientID() [16]byte {
90 var id [16]byte90 var id [16]byte
91- _, _ = rand.Read(id[:])91+ if _, err := rand.Read(id[:]); err != nil {
92+ panic("crypto/rand failed generating client ID: " + err.Error())
93+ }
92 return id94 return id
93 }95 }
94 96
@@ -106,6 +108,8 @@ type reconnectBackoff struct {
106 108
107 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}109 var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second}
108 110
111+var reconnectSleep = sleepReconnect
112+
109 const (113 const (
110 statusReconnect = "\r\x1b[2Kreconnecting…"114 statusReconnect = "\r\x1b[2Kreconnecting…"
111 statusClear = "\r\x1b[2K"115 statusClear = "\r\x1b[2K"
@@ -175,7 +179,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
175 return err179 return err
176 }180 }
177 drawStatus()181 drawStatus()
178- if !sleepReconnect(ctx, backoff) {182+ if !reconnectSleep(ctx, backoff) {
179 return ctx.Err()183 return ctx.Err()
180 }184 }
181 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)185 backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max)
@@ -183,9 +187,11 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
183 continue187 continue
184 }188 }
185 ms.dialed = true189 ms.dialed = true
186- backoff = defaultReconnectBackoff.initial
187 190
188 result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)191 result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus)
192+ if result.welcomed {
193+ backoff = defaultReconnectBackoff.initial
194+ }
189 _ = transport.Close()195 _ = transport.Close()
190 if result.resumeToken != 0 {196 if result.resumeToken != 0 {
191 resumeToken = result.resumeToken197 resumeToken = result.resumeToken
@@ -199,7 +205,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u
199 return result.err205 return result.err
200 }206 }
201 drawStatus()207 drawStatus()
202- if !sleepReconnect(ctx, backoff) {208+ if !reconnectSleep(ctx, backoff) {
203 clearStatus()209 clearStatus()
204 return ctx.Err()210 return ctx.Err()
205 }211 }
@@ -255,6 +261,7 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) {
255 261
256 type attachResult struct {262 type attachResult struct {
257 resumeToken uint64263 resumeToken uint64
264+ welcomed bool
258 err error265 err error
259 }266 }
260 267
@@ -310,7 +317,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
310 317
311 // 3. Enter raw mode after Welcome; Run owns restoration.318 // 3. Enter raw mode after Welcome; Run owns restoration.
312 if err := enterRaw(); err != nil {319 if err := enterRaw(); err != nil {
313- return attachResult{resumeToken: resumeToken, err: err}320+ return attachResult{resumeToken: resumeToken, welcomed: true, err: err}
314 }321 }
315 clearStatus()322 clearStatus()
316 323
@@ -338,37 +345,37 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi
338 // context was cancelled. A queued sender error takes priority.345 // context was cancelled. A queued sender error takes priority.
339 select {346 select {
340 case serr := <-sendErrCh:347 case serr := <-sendErrCh:
341- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: sending to daemon: %w", serr)}348+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: sending to daemon: %w", serr)}
342 default:349 default:
343- return attachResult{resumeToken: resumeToken}350+ return attachResult{resumeToken: resumeToken, welcomed: true}
344 }351 }
345 case r := <-recvCh:352 case r := <-recvCh:
346 if r.err != nil {353 if r.err != nil {
347 if errors.Is(r.err, io.EOF) {354 if errors.Is(r.err, io.EOF) {
348- return attachResult{resumeToken: resumeToken, err: 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)}
349 }356 }
350- return attachResult{resumeToken: resumeToken, err: 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)}
351 }358 }
352 switch r.frame.Type {359 switch r.frame.Type {
353 case ports.MsgOutput:360 case ports.MsgOutput:
354 o, derr := ports.UnmarshalOutput(r.frame.Payload)361 o, derr := ports.UnmarshalOutput(r.frame.Payload)
355 if derr != nil {362 if derr != nil {
356- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding output: %w", derr)}363+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding output: %w", derr)}
357 }364 }
358 if _, werr := term.Out().Write(o.Data); werr != nil {365 if _, werr := term.Out().Write(o.Data); werr != nil {
359- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: writing terminal output: %w", werr)}366+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: writing terminal output: %w", werr)}
360 }367 }
361 if ferr := term.Flush(); ferr != nil {368 if ferr := term.Flush(); ferr != nil {
362- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}369+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: flushing terminal: %w", ferr)}
363 }370 }
364 ms.firstOutput = true371 ms.firstOutput = true
365 case ports.MsgDetached:372 case ports.MsgDetached:
366 d, derr := ports.UnmarshalDetached(r.frame.Payload)373 d, derr := ports.UnmarshalDetached(r.frame.Payload)
367 if derr != nil {374 if derr != nil {
368- return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding detached: %w", derr)}375+ return attachResult{resumeToken: resumeToken, welcomed: true, err: fmt.Errorf("vev: decoding detached: %w", derr)}
369 }376 }
370 ms.detached = true377 ms.detached = true
371- return attachResult{resumeToken: resumeToken, err: detachedResult(d.Reason)}378+ return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)}
372 case ports.MsgPong:379 case ports.MsgPong:
373 // Liveness reply; nothing to do.380 // Liveness reply; nothing to do.
374 default:381 default:
M internal/usecase/client/client_test.go +6 -16
@@ -531,17 +531,6 @@ func TestAttachForwardsResize(t *testing.T) {
531531 }
532532 }
533533
534-type testDialer struct {
535- tr ports.Transport
536- err error
537- calls atomic.Int32
538-}
539-
540-func (d *testDialer) Dial(context.Context) (ports.Transport, error) {
541- d.calls.Add(1)
542- return d.tr, d.err
543-}
544-
545534 func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
546535 var out bytes.Buffer
547536 var restoreCount atomic.Int32
@@ -558,7 +547,8 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
558547 )
559548 defer unblock()
560549 tr.EXPECT().Close().Return(nil).Once()
561- d := &testDialer{tr: tr}
550+ d := portsmocks.NewMockDialer(t)
551+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
562552
563553 err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
564554 require.Error(t, err)
@@ -577,23 +567,23 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
577567 nil,
578568 ).Once()
579569 tr.EXPECT().Close().Return(nil).Once()
580- d := &testDialer{tr: tr}
570+ d := portsmocks.NewMockDialer(t)
571+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
581572
582573 err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main")
583574 require.Error(t, err)
584575 var pe *client.ProtocolError
585576 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
586- require.Equal(t, int32(1), d.calls.Load())
587577 }
588578
589579 func TestRunPhaseASingleAttempt(t *testing.T) {
590580 dialErr := errors.New("dial failed")
591- d := &testDialer{err: dialErr}
581+ d := portsmocks.NewMockDialer(t)
582+ d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
592583 tm := portsmocks.NewMockTerminal(t)
593584
594585 err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
595586 require.ErrorIs(t, err, dialErr)
596- require.Equal(t, int32(1), d.calls.Load(), "Phase A Run must not retry")
597587 }
598588
599589 type sequenceDialer struct {
@@ -531,17 +531,6 @@ func TestAttachForwardsResize(t *testing.T) {
531 }531 }
532 }532 }
533 533
534-type testDialer struct {
535- tr ports.Transport
536- err error
537- calls atomic.Int32
538-}
539-
540-func (d *testDialer) Dial(context.Context) (ports.Transport, error) {
541- d.calls.Add(1)
542- return d.tr, d.err
543-}
544-
545 func TestRunRestoresRawModeAfterAttachError(t *testing.T) {534 func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
546 var out bytes.Buffer535 var out bytes.Buffer
547 var restoreCount atomic.Int32536 var restoreCount atomic.Int32
@@ -558,7 +547,8 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
558 )547 )
559 defer unblock()548 defer unblock()
560 tr.EXPECT().Close().Return(nil).Once()549 tr.EXPECT().Close().Return(nil).Once()
561- d := &testDialer{tr: tr}550+ d := portsmocks.NewMockDialer(t)
551+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
562 552
563 err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")553 err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
564 require.Error(t, err)554 require.Error(t, err)
@@ -577,23 +567,23 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
577 nil,567 nil,
578 ).Once()568 ).Once()
579 tr.EXPECT().Close().Return(nil).Once()569 tr.EXPECT().Close().Return(nil).Once()
580- d := &testDialer{tr: tr}570+ d := portsmocks.NewMockDialer(t)
571+ d.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
581 572
582 err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main")573 err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main")
583 require.Error(t, err)574 require.Error(t, err)
584 var pe *client.ProtocolError575 var pe *client.ProtocolError
585 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)576 require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err)
586- require.Equal(t, int32(1), d.calls.Load())
587 }577 }
588 578
589 func TestRunPhaseASingleAttempt(t *testing.T) {579 func TestRunPhaseASingleAttempt(t *testing.T) {
590 dialErr := errors.New("dial failed")580 dialErr := errors.New("dial failed")
591- d := &testDialer{err: dialErr}581+ d := portsmocks.NewMockDialer(t)
582+ d.EXPECT().Dial(mock.Anything).Return(nil, dialErr).Once()
592 tm := portsmocks.NewMockTerminal(t)583 tm := portsmocks.NewMockTerminal(t)
593 584
594 err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")585 err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "")
595 require.ErrorIs(t, err, dialErr)586 require.ErrorIs(t, err, dialErr)
596- require.Equal(t, int32(1), d.calls.Load(), "Phase A Run must not retry")
597 }587 }
598 588
599 type sequenceDialer struct {589 type sequenceDialer struct {
M internal/usecase/daemon/client.go +61 -18
@@ -49,7 +49,6 @@ type attachedClient struct {
4949 resumeCapable bool
5050 resumeToken uint64
5151 parked bool
52- ackedStateNum uint64
5352 nextStateNum uint64
5453 echoAck atomic.Uint64
5554 bars barCache // only touched while sendMu is held
@@ -192,6 +191,21 @@ func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
192191 return tr.Close()
193192 }
194193
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+
195209 // send serialises a frame onto the client's transport.
196210 func (ac *attachedClient) send(f ports.Frame) error {
197211 ac.sendMu.Lock()
@@ -213,10 +227,26 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
213227 }
214228
215229 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
230+ return d.boundedSendWith(ac, func() error { return ac.send(f) })
231+}
232+
233+func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
234+ return d.boundedSendWith(ac, func() error {
235+ ac.sendMu.Lock()
236+ defer ac.sendMu.Unlock()
237+ tr := ac.transport()
238+ if tr == nil {
239+ return errors.New("client transport is nil")
240+ }
241+ return tr.Send(ac.nextOutputFrameLocked(b))
242+ })
243+}
244+
245+func (d *Daemon) boundedSendWith(ac *attachedClient, send func() error) error {
216246 timer := d.clock.NewTimer(detachNotifyTimeout)
217247 result := make(chan error, 1)
218248 go func() {
219- result <- ac.send(f)
249+ result <- send()
220250 }()
221251 select {
222252 case err := <-result:
@@ -286,7 +316,7 @@ type attachClientOptions struct {
286316 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
287317 resumeToken := uint64(0)
288318 if opts.resumeCapable {
289- resumeToken = newResumeToken()
319+ resumeToken = d.nextResumeTokenLocked()
290320 }
291321 ac := &attachedClient{
292322 tr: tr,
@@ -380,18 +410,24 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
380410 // runConnLoop is the per-connection input router: it pumps client messages
381411 // until detach, EOF, or a transport error.
382412 func (d *Daemon) runConnLoop(ac *attachedClient) {
413+ tr := ac.transport()
414+ if tr == nil {
415+ return
416+ }
383417 for {
384- sess := ac.currentSession()
385- if sess == nil {
418+ if !ac.currentTransportIs(tr) {
386419 return
387420 }
388- tr := ac.transport()
389- if tr == nil {
421+ sess := ac.currentSession()
422+ if sess == nil {
390423 return
391424 }
392425 f, err := tr.Recv()
393426 if err != nil {
394- d.clientGone(sess, ac, false)
427+ d.clientGone(sess, ac, tr, false)
428+ return
429+ }
430+ if !ac.currentTransportIs(tr) {
395431 return
396432 }
397433 sess = ac.currentSession()
@@ -412,12 +448,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
412448 d.applyTheme(sess, ac, th)
413449 }
414450 case ports.MsgDetach:
415- d.clientGone(sess, ac, true)
451+ d.clientGone(sess, ac, tr, true)
416452 return
417453 case ports.MsgAck:
418- if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {
419- ac.ackedStateNum = ack.AckedStateNum
420- }
454+ // Output state ACKs are reserved for the datagram renderer path; the
455+ // reliable transport daemon has no state reader today.
421456 case ports.MsgPing:
422457 _ = ac.send(framePong())
423458 default:
@@ -430,7 +465,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
430465 // clientGone detaches ac if it is still the session's current client. An
431466 // ephemeral session dies with its client; a named one survives headless. When
432467
433-func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
468+func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
469+ if failed != nil && !ac.currentTransportIs(failed) {
470+ return // stale connection loop; a newer transport owns this client
471+ }
434472 if !sess.detachIfCurrent(ac) {
435473 return // already displaced by a newer client; nothing to do
436474 }
@@ -441,7 +479,10 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
441479 if !ephemeral {
442480 d.refreshSessionCwd(sess)
443481 }
444- oldTr := ac.transport()
482+ oldTr := failed
483+ if oldTr == nil {
484+ oldTr = ac.transport()
485+ }
445486 if !explicit && d.parkAttachment(sess, ac) {
446487 _ = ac.closeCapturedTransport(oldTr)
447488 return
@@ -525,17 +566,19 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
525566 // detachOnSendError drops a client whose transport failed. Like every other
526567 // detach path, losing the client kills an ephemeral session (its lifetime is
527568 // its client's); a named session keeps running headless.
528-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {
569+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
570+ if failed != nil && !ac.currentTransportIs(failed) {
571+ return
572+ }
529573 if sess.detachIfCurrent(ac) {
530574 d.unregisterPreview(ac)
531575 d.resetScreenDefaultColors(sess)
532- oldTr := ac.transport()
533576 if d.parkAttachment(sess, ac) {
534- _ = ac.closeCapturedTransport(oldTr)
577+ _ = ac.closeCapturedTransport(failed)
535578 d.log.Warn("parked client after send error", "session", sess.name)
536579 return
537580 }
538- _ = ac.closeCapturedTransport(oldTr)
581+ _ = ac.closeCapturedTransport(failed)
539582 d.log.Warn("detached client after send error", "session", sess.name)
540583 sess.mu.Lock()
541584 ephemeral := sess.ephemeral
@@ -49,7 +49,6 @@ type attachedClient struct {
49 resumeCapable bool49 resumeCapable bool
50 resumeToken uint6450 resumeToken uint64
51 parked bool51 parked bool
52- ackedStateNum uint64
53 nextStateNum uint6452 nextStateNum uint64
54 echoAck atomic.Uint6453 echoAck atomic.Uint64
55 bars barCache // only touched while sendMu is held54 bars barCache // only touched while sendMu is held
@@ -192,6 +191,21 @@ func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error {
192 return tr.Close()191 return tr.Close()
193 }192 }
194 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+
195 // send serialises a frame onto the client's transport.209 // send serialises a frame onto the client's transport.
196 func (ac *attachedClient) send(f ports.Frame) error {210 func (ac *attachedClient) send(f ports.Frame) error {
197 ac.sendMu.Lock()211 ac.sendMu.Lock()
@@ -213,10 +227,26 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
213 }227 }
214 228
215 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {229 func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
230+ return d.boundedSendWith(ac, func() error { return ac.send(f) })
231+}
232+
233+func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
234+ return d.boundedSendWith(ac, func() error {
235+ ac.sendMu.Lock()
236+ defer ac.sendMu.Unlock()
237+ tr := ac.transport()
238+ if tr == nil {
239+ return errors.New("client transport is nil")
240+ }
241+ return tr.Send(ac.nextOutputFrameLocked(b))
242+ })
243+}
244+
245+func (d *Daemon) boundedSendWith(ac *attachedClient, send func() error) error {
216 timer := d.clock.NewTimer(detachNotifyTimeout)246 timer := d.clock.NewTimer(detachNotifyTimeout)
217 result := make(chan error, 1)247 result := make(chan error, 1)
218 go func() {248 go func() {
219- result <- ac.send(f)249+ result <- send()
220 }()250 }()
221 select {251 select {
222 case err := <-result:252 case err := <-result:
@@ -286,7 +316,7 @@ type attachClientOptions struct {
286 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {316 func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) {
287 resumeToken := uint64(0)317 resumeToken := uint64(0)
288 if opts.resumeCapable {318 if opts.resumeCapable {
289- resumeToken = newResumeToken()319+ resumeToken = d.nextResumeTokenLocked()
290 }320 }
291 ac := &attachedClient{321 ac := &attachedClient{
292 tr: tr,322 tr: tr,
@@ -380,18 +410,24 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain
380 // runConnLoop is the per-connection input router: it pumps client messages410 // runConnLoop is the per-connection input router: it pumps client messages
381 // until detach, EOF, or a transport error.411 // until detach, EOF, or a transport error.
382 func (d *Daemon) runConnLoop(ac *attachedClient) {412 func (d *Daemon) runConnLoop(ac *attachedClient) {
413+ tr := ac.transport()
414+ if tr == nil {
415+ return
416+ }
383 for {417 for {
384- sess := ac.currentSession()418+ if !ac.currentTransportIs(tr) {
385- if sess == nil {
386 return419 return
387 }420 }
388- tr := ac.transport()421+ sess := ac.currentSession()
389- if tr == nil {422+ if sess == nil {
390 return423 return
391 }424 }
392 f, err := tr.Recv()425 f, err := tr.Recv()
393 if err != nil {426 if err != nil {
394- d.clientGone(sess, ac, false)427+ d.clientGone(sess, ac, tr, false)
428+ return
429+ }
430+ if !ac.currentTransportIs(tr) {
395 return431 return
396 }432 }
397 sess = ac.currentSession()433 sess = ac.currentSession()
@@ -412,12 +448,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
412 d.applyTheme(sess, ac, th)448 d.applyTheme(sess, ac, th)
413 }449 }
414 case ports.MsgDetach:450 case ports.MsgDetach:
415- d.clientGone(sess, ac, true)451+ d.clientGone(sess, ac, tr, true)
416 return452 return
417 case ports.MsgAck:453 case ports.MsgAck:
418- if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil {454+ // Output state ACKs are reserved for the datagram renderer path; the
419- ac.ackedStateNum = ack.AckedStateNum455+ // reliable transport daemon has no state reader today.
420- }
421 case ports.MsgPing:456 case ports.MsgPing:
422 _ = ac.send(framePong())457 _ = ac.send(framePong())
423 default:458 default:
@@ -430,7 +465,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) {
430 // clientGone detaches ac if it is still the session's current client. An465 // clientGone detaches ac if it is still the session's current client. An
431 // ephemeral session dies with its client; a named one survives headless. When466 // ephemeral session dies with its client; a named one survives headless. When
432 467
433-func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {468+func (d *Daemon) clientGone(sess *session, ac *attachedClient, failed ports.Transport, explicit bool) {
469+ if failed != nil && !ac.currentTransportIs(failed) {
470+ return // stale connection loop; a newer transport owns this client
471+ }
434 if !sess.detachIfCurrent(ac) {472 if !sess.detachIfCurrent(ac) {
435 return // already displaced by a newer client; nothing to do473 return // already displaced by a newer client; nothing to do
436 }474 }
@@ -441,7 +479,10 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) {
441 if !ephemeral {479 if !ephemeral {
442 d.refreshSessionCwd(sess)480 d.refreshSessionCwd(sess)
443 }481 }
444- oldTr := ac.transport()482+ oldTr := failed
483+ if oldTr == nil {
484+ oldTr = ac.transport()
485+ }
445 if !explicit && d.parkAttachment(sess, ac) {486 if !explicit && d.parkAttachment(sess, ac) {
446 _ = ac.closeCapturedTransport(oldTr)487 _ = ac.closeCapturedTransport(oldTr)
447 return488 return
@@ -525,17 +566,19 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
525 // detachOnSendError drops a client whose transport failed. Like every other566 // detachOnSendError drops a client whose transport failed. Like every other
526 // detach path, losing the client kills an ephemeral session (its lifetime is567 // detach path, losing the client kills an ephemeral session (its lifetime is
527 // its client's); a named session keeps running headless.568 // its client's); a named session keeps running headless.
528-func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) {569+func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient, failed ports.Transport) {
570+ if failed != nil && !ac.currentTransportIs(failed) {
571+ return
572+ }
529 if sess.detachIfCurrent(ac) {573 if sess.detachIfCurrent(ac) {
530 d.unregisterPreview(ac)574 d.unregisterPreview(ac)
531 d.resetScreenDefaultColors(sess)575 d.resetScreenDefaultColors(sess)
532- oldTr := ac.transport()
533 if d.parkAttachment(sess, ac) {576 if d.parkAttachment(sess, ac) {
534- _ = ac.closeCapturedTransport(oldTr)577+ _ = ac.closeCapturedTransport(failed)
535 d.log.Warn("parked client after send error", "session", sess.name)578 d.log.Warn("parked client after send error", "session", sess.name)
536 return579 return
537 }580 }
538- _ = ac.closeCapturedTransport(oldTr)581+ _ = ac.closeCapturedTransport(failed)
539 d.log.Warn("detached client after send error", "session", sess.name)582 d.log.Warn("detached client after send error", "session", sess.name)
540 sess.mu.Lock()583 sess.mu.Lock()
541 ephemeral := sess.ephemeral584 ephemeral := sess.ephemeral
M internal/usecase/daemon/copymode.go +3 -2
@@ -252,8 +252,9 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) {
252252 if copyOut && text != "" {
253253 chunks := scopy.OSC52(text)
254254 for _, chunk := range chunks {
255- if err := d.boundedSendErr(ac, frameOutput(chunk)); err != nil {
256- d.detachOnSendError(sess, ac)
255+ failed := ac.transport()
256+ if err := d.boundedSendOutputErr(ac, chunk); err != nil {
257+ d.detachOnSendError(sess, ac, failed)
257258 return
258259 }
259260 }
@@ -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 := ac.transport()
256- d.detachOnSendError(sess, ac)256+ if err := d.boundedSendOutputErr(ac, chunk); err != nil {
257+ d.detachOnSendError(sess, ac, failed)
257 return258 return
258 }259 }
259 }260 }
M internal/usecase/daemon/daemon.go +1 -1
@@ -441,7 +441,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
441441 }
442442
443443 if err := ac.send(frameWelcome(sess, ac)); err != nil {
444- d.clientGone(sess, ac, false)
444+ d.clientGone(sess, ac, tr, false)
445445 return
446446 }
447447 d.firstPaint(sess, ac, h.Size)
@@ -441,7 +441,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) {
441 }441 }
442 442
443 if err := ac.send(frameWelcome(sess, ac)); err != nil {443 if err := ac.send(frameWelcome(sess, ac)); err != nil {
444- d.clientGone(sess, ac, false)444+ d.clientGone(sess, ac, tr, false)
445 return445 return
446 }446 }
447 d.firstPaint(sess, ac, h.Size)447 d.firstPaint(sess, ac, h.Size)
M internal/usecase/daemon/input.go +5 -9
@@ -34,16 +34,12 @@ import (
3434 "github.com/bnema/vev/internal/usecase/mouse"
3535 )
3636
37-func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) {
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.
3842 d.handleInput(sess, ac, data)
39- if seq > 0 {
40- for {
41- cur := ac.echoAck.Load()
42- if seq <= cur || ac.echoAck.CompareAndSwap(cur, seq) {
43- break
44- }
45- }
46- }
4743 }
4844
4945 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
@@ -34,16 +34,12 @@ import (
34 "github.com/bnema/vev/internal/usecase/mouse"34 "github.com/bnema/vev/internal/usecase/mouse"
35 )35 )
36 36
37-func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) {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.
38 d.handleInput(sess, ac, data)42 d.handleInput(sess, ac, data)
39- if seq > 0 {
40- for {
41- cur := ac.echoAck.Load()
42- if seq <= cur || ac.echoAck.CompareAndSwap(cur, seq) {
43- break
44- }
45- }
46- }
47 }43 }
48 44
49 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {45 func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) {
M internal/usecase/daemon/palette.go +1 -1
@@ -217,7 +217,7 @@ func (e paletteExec) PrevTab() error {
217217 }
218218
219219 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)
221221 return nil
222222 }
223223
@@ -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 +6 -5
@@ -31,6 +31,7 @@ import (
3131 "time"
3232
3333 "github.com/bnema/vev/internal/domain"
34+ "github.com/bnema/vev/internal/ports"
3435 scopy "github.com/bnema/vev/internal/usecase/copy"
3536 "github.com/bnema/vev/internal/usecase/layout"
3637 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -379,15 +380,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
379380 tb.mu.Unlock()
380381
381382 var serr error
383+ var sendTr ports.Transport
382384 if err == nil {
383385 data = append(data, cursorTail...)
384386 if len(data) > 0 {
385- tr := ac.transport()
386- if tr == nil {
387+ sendTr = ac.transport()
388+ if sendTr == nil {
387389 serr = errors.New("client transport is nil")
388390 } else {
389- ac.nextStateNum++
390- serr = tr.Send(frameOutputState(data, ac.nextStateNum, ac.echoAck.Load()))
391+ serr = sendTr.Send(ac.nextOutputFrameLocked(data))
391392 }
392393 }
393394 }
@@ -398,7 +399,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
398399 return
399400 }
400401 if serr != nil {
401- d.detachOnSendError(sess, ac)
402+ d.detachOnSendError(sess, ac, sendTr)
402403 }
403404 }
404405
@@ -31,6 +31,7 @@ import (
31 "time"31 "time"
32 32
33 "github.com/bnema/vev/internal/domain"33 "github.com/bnema/vev/internal/domain"
34+ "github.com/bnema/vev/internal/ports"
34 scopy "github.com/bnema/vev/internal/usecase/copy"35 scopy "github.com/bnema/vev/internal/usecase/copy"
35 "github.com/bnema/vev/internal/usecase/layout"36 "github.com/bnema/vev/internal/usecase/layout"
36 themeui "github.com/bnema/vev/internal/usecase/theme"37 themeui "github.com/bnema/vev/internal/usecase/theme"
@@ -379,15 +380,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
379 tb.mu.Unlock()380 tb.mu.Unlock()
380 381
381 var serr error382 var serr error
383+ var sendTr ports.Transport
382 if err == nil {384 if err == nil {
383 data = append(data, cursorTail...)385 data = append(data, cursorTail...)
384 if len(data) > 0 {386 if len(data) > 0 {
385- tr := ac.transport()387+ sendTr = ac.transport()
386- if tr == nil {388+ if sendTr == nil {
387 serr = errors.New("client transport is nil")389 serr = errors.New("client transport is nil")
388 } else {390 } else {
389- ac.nextStateNum++391+ serr = sendTr.Send(ac.nextOutputFrameLocked(data))
390- serr = tr.Send(frameOutputState(data, ac.nextStateNum, ac.echoAck.Load()))
391 }392 }
392 }393 }
393 }394 }
@@ -398,7 +399,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) {
398 return399 return
399 }400 }
400 if serr != nil {401 if serr != nil {
401- d.detachOnSendError(sess, ac)402+ d.detachOnSendError(sess, ac, sendTr)
402 }403 }
403 }404 }
404 405
M internal/usecase/daemon/resume.go +1 -1
@@ -11,7 +11,7 @@ import (
1111 func newResumeToken() uint64 {
1212 var b [8]byte
1313 if _, err := rand.Read(b[:]); err != nil {
14- return 0
14+ panic("crypto/rand failed generating resume token: " + err.Error())
1515 }
1616 return binary.BigEndian.Uint64(b[:])
1717 }
@@ -11,7 +11,7 @@ import (
11 func newResumeToken() uint64 {11 func newResumeToken() uint64 {
12 var b [8]byte12 var b [8]byte
13 if _, err := rand.Read(b[:]); err != nil {13 if _, err := rand.Read(b[:]); err != nil {
14- return 014+ panic("crypto/rand failed generating resume token: " + err.Error())
15 }15 }
16 return binary.BigEndian.Uint64(b[:])16 return binary.BigEndian.Uint64(b[:])
17 }17 }
M internal/usecase/daemon/resume_test.go +71 -10
@@ -15,9 +15,15 @@ import (
1515 type closeTrackingTransport struct {
1616 mu sync.Mutex
1717 closed bool
18+ sends []ports.Frame
1819 }
1920
20-func (t *closeTrackingTransport) Send(ports.Frame) error { return nil }
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+}
2127 func (t *closeTrackingTransport) Recv() (ports.Frame, error) {
2228 return ports.Frame{}, errors.New("closed")
2329 }
@@ -33,6 +39,12 @@ func (t *closeTrackingTransport) Closed() bool {
3339 return t.closed
3440 }
3541
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+
3648 func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
3749 return ports.Hello{
3850 Version: ports.ProtocolVersion,
@@ -56,7 +68,7 @@ func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
5668 token := ac.resumeToken
5769 require.NotZero(t, token)
5870
59- d.clientGone(sess, ac, false)
71+ d.clientGone(sess, ac, ac.transport(), false)
6072 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
6173 require.Nil(t, sess.client)
6274 d.mu.Lock()
@@ -70,7 +82,7 @@ func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
7082 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
7183 es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2)
7284 require.NoError(t, err)
73- d2.clientGone(es, eac, false)
85+ d2.clientGone(es, eac, eac.transport(), false)
7486 require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session")
7587 }
7688
@@ -83,7 +95,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
8395 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
8496 require.NoError(t, err)
8597 oldToken := ac.resumeToken
86- d.clientGone(sess, ac, false)
98+ d.clientGone(sess, ac, ac.transport(), false)
8799
88100 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
89101 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", oldToken), tr2)
@@ -103,7 +115,7 @@ func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
103115 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
104116 require.NoError(t, err)
105117 token := ac.resumeToken
106- d.clientGone(sess, ac, false)
118+ d.clientGone(sess, ac, ac.transport(), false)
107119
108120 wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
109121 wrongClient.ClientID = [16]byte{9, 9, 9, 9}
@@ -158,7 +170,7 @@ func TestExplicitDetachDoesNotPark(t *testing.T) {
158170 tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
159171 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
160172 require.NoError(t, err)
161- d.clientGone(sess, ac, true)
173+ d.clientGone(sess, ac, ac.transport(), true)
162174 d.mu.Lock()
163175 parked := len(d.parked)
164176 d.mu.Unlock()
@@ -174,7 +186,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
174186 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
175187 require.NoError(t, err)
176188 token := ac.resumeToken
177- d.clientGone(sess, ac, false)
189+ d.clientGone(sess, ac, ac.transport(), false)
178190 timer := <-clk.timers
179191 timer.ch <- clk.Now()
180192 require.Eventually(t, func() bool {
@@ -190,7 +202,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
190202 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
191203 sess2, ac2, err := d2.route(helloResumeCapable(ports.IntentNew, "other", 0), tr2)
192204 require.NoError(t, err)
193- d2.clientGone(sess2, ac2, false)
205+ d2.clientGone(sess2, ac2, ac2.transport(), false)
194206 d2.shutdownAll(ports.ReasonServerShutdown)
195207 d2.mu.Lock()
196208 parked := len(d2.parked)
@@ -206,7 +218,7 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
206218 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
207219 require.NoError(t, err)
208220 token := ac.resumeToken
209- d.clientGone(sess, ac, false)
221+ d.clientGone(sess, ac, ac.transport(), false)
210222
211223 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
212224 d.mu.Lock()
@@ -226,7 +238,7 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
226238 sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
227239 require.NoError(t, err)
228240 token := oldAC.resumeToken
229- d.clientGone(sess, oldAC, false)
241+ d.clientGone(sess, oldAC, oldAC.transport(), false)
230242
231243 activeTr := &closeTrackingTransport{}
232244 _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr)
@@ -243,3 +255,52 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
243255 require.Nil(t, resumedAC)
244256 require.Same(t, activeAC, sess.client)
245257 }
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+}
@@ -15,9 +15,15 @@ import (
15 type closeTrackingTransport struct {15 type closeTrackingTransport struct {
16 mu sync.Mutex16 mu sync.Mutex
17 closed bool17 closed bool
18+ sends []ports.Frame
18 }19 }
19 20
20-func (t *closeTrackingTransport) Send(ports.Frame) error { return nil }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+}
21 func (t *closeTrackingTransport) Recv() (ports.Frame, error) {27 func (t *closeTrackingTransport) Recv() (ports.Frame, error) {
22 return ports.Frame{}, errors.New("closed")28 return ports.Frame{}, errors.New("closed")
23 }29 }
@@ -33,6 +39,12 @@ func (t *closeTrackingTransport) Closed() bool {
33 return t.closed39 return t.closed
34 }40 }
35 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+
36 func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {48 func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello {
37 return ports.Hello{49 return ports.Hello{
38 Version: ports.ProtocolVersion,50 Version: ports.ProtocolVersion,
@@ -56,7 +68,7 @@ func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
56 token := ac.resumeToken68 token := ac.resumeToken
57 require.NotZero(t, token)69 require.NotZero(t, token)
58 70
59- d.clientGone(sess, ac, false)71+ d.clientGone(sess, ac, ac.transport(), false)
60 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")72 require.Equal(t, 1, sessionCount(d), "named session survives parked link loss")
61 require.Nil(t, sess.client)73 require.Nil(t, sess.client)
62 d.mu.Lock()74 d.mu.Lock()
@@ -70,7 +82,7 @@ func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) {
70 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))82 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
71 es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2)83 es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2)
72 require.NoError(t, err)84 require.NoError(t, err)
73- d2.clientGone(es, eac, false)85+ d2.clientGone(es, eac, eac.transport(), false)
74 require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session")86 require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session")
75 }87 }
76 88
@@ -83,7 +95,7 @@ func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) {
83 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)95 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
84 require.NoError(t, err)96 require.NoError(t, err)
85 oldToken := ac.resumeToken97 oldToken := ac.resumeToken
86- d.clientGone(sess, ac, false)98+ d.clientGone(sess, ac, ac.transport(), false)
87 99
88 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))100 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
89 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", oldToken), tr2)101 resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", oldToken), tr2)
@@ -103,7 +115,7 @@ func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) {
103 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)115 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
104 require.NoError(t, err)116 require.NoError(t, err)
105 token := ac.resumeToken117 token := ac.resumeToken
106- d.clientGone(sess, ac, false)118+ d.clientGone(sess, ac, ac.transport(), false)
107 119
108 wrongClient := helloResumeCapable(ports.IntentResume, "work", token)120 wrongClient := helloResumeCapable(ports.IntentResume, "work", token)
109 wrongClient.ClientID = [16]byte{9, 9, 9, 9}121 wrongClient.ClientID = [16]byte{9, 9, 9, 9}
@@ -158,7 +170,7 @@ func TestExplicitDetachDoesNotPark(t *testing.T) {
158 tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))170 tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
159 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)171 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
160 require.NoError(t, err)172 require.NoError(t, err)
161- d.clientGone(sess, ac, true)173+ d.clientGone(sess, ac, ac.transport(), true)
162 d.mu.Lock()174 d.mu.Lock()
163 parked := len(d.parked)175 parked := len(d.parked)
164 d.mu.Unlock()176 d.mu.Unlock()
@@ -174,7 +186,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
174 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)186 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
175 require.NoError(t, err)187 require.NoError(t, err)
176 token := ac.resumeToken188 token := ac.resumeToken
177- d.clientGone(sess, ac, false)189+ d.clientGone(sess, ac, ac.transport(), false)
178 timer := <-clk.timers190 timer := <-clk.timers
179 timer.ch <- clk.Now()191 timer.ch <- clk.Now()
180 require.Eventually(t, func() bool {192 require.Eventually(t, func() bool {
@@ -190,7 +202,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) {
190 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))202 tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{}))
191 sess2, ac2, err := d2.route(helloResumeCapable(ports.IntentNew, "other", 0), tr2)203 sess2, ac2, err := d2.route(helloResumeCapable(ports.IntentNew, "other", 0), tr2)
192 require.NoError(t, err)204 require.NoError(t, err)
193- d2.clientGone(sess2, ac2, false)205+ d2.clientGone(sess2, ac2, ac2.transport(), false)
194 d2.shutdownAll(ports.ReasonServerShutdown)206 d2.shutdownAll(ports.ReasonServerShutdown)
195 d2.mu.Lock()207 d2.mu.Lock()
196 parked := len(d2.parked)208 parked := len(d2.parked)
@@ -206,7 +218,7 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) {
206 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)218 sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
207 require.NoError(t, err)219 require.NoError(t, err)
208 token := ac.resumeToken220 token := ac.resumeToken
209- d.clientGone(sess, ac, false)221+ d.clientGone(sess, ac, ac.transport(), false)
210 222
211 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))223 require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true))
212 d.mu.Lock()224 d.mu.Lock()
@@ -226,7 +238,7 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
226 sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)238 sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr)
227 require.NoError(t, err)239 require.NoError(t, err)
228 token := oldAC.resumeToken240 token := oldAC.resumeToken
229- d.clientGone(sess, oldAC, false)241+ d.clientGone(sess, oldAC, oldAC.transport(), false)
230 242
231 activeTr := &closeTrackingTransport{}243 activeTr := &closeTrackingTransport{}
232 _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr)244 _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr)
@@ -243,3 +255,52 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) {
243 require.Nil(t, resumedAC)255 require.Nil(t, resumedAC)
244 require.Same(t, activeAC, sess.client)256 require.Same(t, activeAC, sess.client)
245 }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/status_test.go +2 -2
@@ -127,7 +127,7 @@ func TestClientGoneResetsScreenDefaultColors(t *testing.T) {
127127 tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a"))
128128 require.NotEmpty(t, before)
129129
130- d.clientGone(sess, ac, true)
130+ d.clientGone(sess, ac, ac.transport(), true)
131131
132132 var got []byte
133133 tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) }
@@ -177,7 +177,7 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) {
177177 HasBackground: true, Background: renderer.RGB{R: 10, G: 11, B: 12},
178178 })
179179
180- d.detachOnSendError(sess, ac)
180+ d.detachOnSendError(sess, ac, ac.transport())
181181
182182 tb := sess.activeTab()
183183 var got []byte
@@ -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...) }
@@ -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
M internal/usecase/daemon/wire.go +2 -13
@@ -36,9 +36,6 @@ func frameWelcome(s *session, ac *attachedClient) ports.Frame {
3636 Capabilities: ports.CapabilityResume,
3737 ResumeToken: ac.resumeToken,
3838 }
39- if ac.resumeCapable && ac.resumeToken == 0 {
40- w.ResumeToken = 0
41- }
4239 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
4340 }
4441
@@ -46,16 +43,8 @@ func frameError(code uint16, text string) ports.Frame {
4643 return ports.Frame{Type: ports.MsgError, Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Code: code, Text: text})}
4744 }
4845
49-func frameOutput(b []byte) ports.Frame {
50- return frameOutputState(b, 0)
51-}
52-
53-func frameOutputState(b []byte, state uint64, echoAck ...uint64) ports.Frame {
54- ack := uint64(0)
55- if len(echoAck) > 0 {
56- ack = echoAck[0]
57- }
58- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{NewStateNum: state, EchoAck: ack, Data: b})}
46+func frameOutputState(b []byte, state uint64, echoAck uint64) ports.Frame {
47+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{NewStateNum: state, EchoAck: echoAck, Data: b})}
5948 }
6049
6150 func frameDetached(reason uint8) ports.Frame {
@@ -36,9 +36,6 @@ func frameWelcome(s *session, ac *attachedClient) ports.Frame {
36 Capabilities: ports.CapabilityResume,36 Capabilities: ports.CapabilityResume,
37 ResumeToken: ac.resumeToken,37 ResumeToken: ac.resumeToken,
38 }38 }
39- if ac.resumeCapable && ac.resumeToken == 0 {
40- w.ResumeToken = 0
41- }
42 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}39 return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)}
43 }40 }
44 41
@@ -46,16 +43,8 @@ func frameError(code uint16, text string) ports.Frame {
46 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})}
47 }44 }
48 45
49-func frameOutput(b []byte) ports.Frame {46+func frameOutputState(b []byte, state uint64, echoAck uint64) ports.Frame {
50- return frameOutputState(b, 0)47+ return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{NewStateNum: state, EchoAck: echoAck, Data: b})}
51-}
52-
53-func frameOutputState(b []byte, state uint64, echoAck ...uint64) ports.Frame {
54- ack := uint64(0)
55- if len(echoAck) > 0 {
56- ack = echoAck[0]
57- }
58- return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{NewStateNum: state, EchoAck: ack, Data: b})}
59 }48 }
60 49
61 func frameDetached(reason uint8) ports.Frame {50 func frameDetached(reason uint8) ports.Frame {

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