fix(remote): harden resilient attach transport
- commit
- 14aa864a92a4ae98c4ccfc20a9fec48a02a02ef5
- author
- brice · · 2026-07-04 11:59 UTC
- parent
- 75dd680
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 14 | 14 | ) | |
| 15 | 15 | ||
| 16 | 16 | 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") | |
| 20 | 31 | ) | |
| 21 | 32 | ||
| 22 | 33 | type Transport struct { | |
| @@ -25,19 +36,28 @@ type Transport struct { | |||
| 25 | 36 | sendDir, recvDir uint32 | |
| 26 | 37 | mtu int | |
| 27 | 38 | ||
| 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{} | |
| 34 | 56 | ||
| 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 | |
| 41 | 61 | } | |
| 42 | 62 | ||
| 43 | 63 | type pending struct { | |
| @@ -50,9 +70,11 @@ func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir | |||
| 50 | 70 | if err != nil { | |
| 51 | 71 | 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 | 75 | go t.readLoop() | |
| 55 | 76 | go t.resendLoop() | |
| 77 | + | go t.deliveryLoop() | |
| 56 | 78 | return t, nil | |
| 57 | 79 | } | |
| 58 | 80 | ||
| @@ -61,6 +83,18 @@ func (t *Transport) Send(f ports.Frame) error { | |||
| 61 | 83 | // dropping MsgOutput can permanently desynchronize the client screen. | |
| 62 | 84 | reliable := true | |
| 63 | 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 | 98 | t.seq++ | |
| 65 | 99 | seq := t.seq | |
| 66 | 100 | if reliable { | |
| @@ -75,21 +109,71 @@ func (t *Transport) Recv() (ports.Frame, error) { | |||
| 75 | 109 | case f := <-t.in: | |
| 76 | 110 | return f, nil | |
| 77 | 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 | 118 | return ports.Frame{}, errors.New("dgram: closed") | |
| 79 | 119 | } | |
| 80 | 120 | } | |
| 81 | 121 | 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")) | |
| 88 | 123 | return t.pc.Close() | |
| 89 | 124 | } | |
| 90 | 125 | ||
| 91 | 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 | 177 | func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error { | |
| 94 | 178 | p := encodeData(seq, reliable, f) | |
| 95 | 179 | return t.sendPayload(p) | |
| @@ -100,6 +184,12 @@ func (t *Transport) sendAck(seq uint64) { | |||
| 100 | 184 | binary.BigEndian.PutUint64(b[1:], seq) | |
| 101 | 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 | 193 | func (t *Transport) sendPayload(p []byte) error { | |
| 104 | 194 | frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead()) | |
| 105 | 195 | if err != nil { | |
| @@ -114,8 +204,12 @@ func (t *Transport) sendPayload(p []byte) error { | |||
| 114 | 204 | t.mu.Lock() | |
| 115 | 205 | peer := t.peer | |
| 116 | 206 | closed := t.closed | |
| 207 | + | closeErr := t.closeErr | |
| 117 | 208 | t.mu.Unlock() | |
| 118 | 209 | if closed { | |
| 210 | + | if closeErr != nil { | |
| 211 | + | return closeErr | |
| 212 | + | } | |
| 119 | 213 | return errors.New("dgram: closed") | |
| 120 | 214 | } | |
| 121 | 215 | if peer == nil { | |
| @@ -134,6 +228,7 @@ func (t *Transport) readLoop() { | |||
| 134 | 228 | for { | |
| 135 | 229 | n, addr, err := t.pc.ReadFrom(buf) | |
| 136 | 230 | if err != nil { | |
| 231 | + | t.closeWithError(err) | |
| 137 | 232 | return | |
| 138 | 233 | } | |
| 139 | 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 | 238 | t.mu.Lock() | |
| 144 | 239 | t.peer = addr | |
| 240 | + | t.lastHeard = time.Now() | |
| 145 | 241 | t.mu.Unlock() | |
| 146 | 242 | frag, err := pdgram.UnmarshalFragment(pt) | |
| 147 | 243 | if err != nil { | |
| @@ -168,6 +264,23 @@ func (t *Transport) handleRecord(p []byte) { | |||
| 168 | 264 | t.mu.Lock() | |
| 169 | 265 | delete(t.pending, seq) | |
| 170 | 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 | 284 | case recData: | |
| 172 | 285 | seq, reliable, f, ok := decodeData(p) | |
| 173 | 286 | if !ok { | |
| @@ -175,14 +288,51 @@ func (t *Transport) handleRecord(p []byte) { | |||
| 175 | 288 | } | |
| 176 | 289 | if reliable { | |
| 177 | 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 | 318 | 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 | 336 | func (t *Transport) deliver(f ports.Frame) { | |
| 187 | 337 | select { | |
| 188 | 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 { | |
| 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 | |
| 210 | 358 | } | |
| 211 | 359 | } | |
| 212 | - | return true | |
| 213 | 360 | } | |
| 214 | 361 | ||
| 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 { | |
| 223 | 373 | seq uint64 | |
| 224 | 374 | 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}) | |
| 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 | 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 | 427 | 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 | - | } | |
| @@ -14,9 +14,20 @@ import ( | |||||
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | const ( | 16 | const ( | ||
| 17 | - | recData byte = 1 | 17 | + | recData byte = 1 |
| 18 | - | recAck byte = 2 | 18 | + | recAck byte = 2 |
| 19 | - | defaultResend = 40 * time.Millisecond | 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") | |||
| 20 | ) | 31 | ) | ||
| 21 | 32 | ||||
| 22 | type Transport struct { | 33 | type Transport struct { | ||
| @@ -25,19 +36,28 @@ type Transport struct { | |||||
| 25 | sendDir, recvDir uint32 | 36 | sendDir, recvDir uint32 | ||
| 26 | mtu int | 37 | mtu int | ||
| 27 | 38 | ||||
| 28 | - | mu sync.Mutex | 39 | + | mu sync.Mutex |
| 29 | - | peer net.Addr | 40 | + | peer net.Addr |
| 30 | - | ctr uint64 | 41 | + | ctr uint64 |
| 31 | - | seq uint64 | 42 | + | seq uint64 |
| 32 | - | pending map[uint64]*pending | 43 | + | probeSeq uint64 |
| 33 | - | closed bool | 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{} | |||
| 34 | 56 | ||||
| 35 | - | replay *pdgram.ReplayWindow | 57 | + | deliverMu sync.Mutex |
| 36 | - | reasm *pdgram.Reassembler | 58 | + | deliverCond *sync.Cond |
| 37 | - | delivered map[uint64]struct{} | 59 | + | nextRecvSeq uint64 |
| 38 | - | deliveredMax uint64 | 60 | + | 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, err | 71 | 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, nil | 78 | 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 := true | 84 | 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.seq | 99 | 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, nil | 110 | 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.peer | 205 | peer := t.peer | ||
| 116 | closed := t.closed | 206 | 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 | return | 232 | 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 = addr | 239 | 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 | return | 318 | 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 false | 347 | + | 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 = seq | 351 | + | t.resendPending() |
| 202 | - | } | 352 | + | t.checkSilence() |
| 203 | - | const window = 1024 | 353 | + | case <-heartbeat.C: |
| 204 | - | if t.deliveredMax > window { | 354 | + | _ = t.sendProbe(recProbe, 0) |
| 205 | - | cutoff := t.deliveredMax - window | 355 | + | 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 uint64 | 373 | seq uint64 | ||
| 224 | f ports.Frame | 374 | 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:]...)}, true | 427 | 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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "context" | |
| 5 | 6 | "errors" | |
| 6 | 7 | "net" | |
| 7 | 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 | 322 | func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) { | |
| 211 | 323 | ch := make(chan ports.Frame, 1) | |
| 212 | 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 | 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 | + | } | |
| @@ -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 { | |||
| 130 | 130 | // local shell. OpenSSH sends the remote command as one string for the remote | |
| 131 | 131 | // user's shell to interpret, so every remote argv word is POSIX single-quoted. | |
| 132 | 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 | 140 | if session != "" { | |
| 135 | 141 | remote = append(remote, shellQuote(session)) | |
| 136 | 142 | } | |
| @@ -130,7 +130,13 @@ type CommandSpec struct { | |||||
| 130 | // local shell. OpenSSH sends the remote command as one string for the remote | 130 | // 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) { | |||
| 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 | 59 | func TestTransportRoundTripAndVersionMismatchFrame(t *testing.T) { | |
| 47 | 60 | clientRead, serverWrite := io.Pipe() | |
| 48 | 61 | 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 @@ | |||
| 1 | 1 | package app | |
| 2 | 2 | ||
| 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" | |
| 35 | 4 | ||
| 36 | 5 | func TestParseRemoteAttachTarget(t *testing.T) { | |
| 37 | 6 | tests := []struct { | |
| @@ -1,37 +1,6 @@ | |||||
| 1 | package app | 1 | 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 @@ | |||
| 5 | 5 | package app | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | - | "bufio" | |
| 9 | 8 | "context" | |
| 10 | 9 | "crypto/rand" | |
| 11 | 10 | "encoding/base64" | |
| @@ -17,9 +16,7 @@ import ( | |||
| 17 | 16 | "net/http" | |
| 18 | 17 | _ "net/http/pprof" | |
| 19 | 18 | "os" | |
| 20 | - | "os/exec" | |
| 21 | 19 | "os/signal" | |
| 22 | - | "strings" | |
| 23 | 20 | "syscall" | |
| 24 | 21 | "text/tabwriter" | |
| 25 | 22 | "time" | |
| @@ -281,7 +278,7 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||
| 281 | 278 | func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} } | |
| 282 | 279 | ||
| 283 | 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 | 284 | type runAttachDeps struct { | |
| @@ -381,118 +378,6 @@ func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 381 | 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 | 381 | func createDetachedLocalSession(ctx context.Context, name string) error { | |
| 497 | 382 | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 498 | 383 | if err != nil { | |
| @@ -5,7 +5,6 @@ | |||||
| 5 | package app | 5 | 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) { | |||
| 393 | 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 | 396 | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |
| 430 | 397 | var gotTarget, gotSession, gotDialer string | |
| 431 | 398 | 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 string | 397 | 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 @@ | |||
| 5 | 5 | package portsmocks | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "context" | |
| 8 | 9 | "io" | |
| 9 | 10 | "time" | |
| 10 | 11 | ||
| @@ -957,6 +958,95 @@ func (_c *MockTransport_Send_Call) RunAndReturn(run func(frame ports.Frame) erro | |||
| 957 | 958 | 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 | 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 | 1051 | // The first argument is typically a *testing.T value. | |
| 962 | 1052 | func NewMockListener(t interface { | |
| @@ -5,6 +5,7 @@ | |||||
| 5 | package portsmocks | 5 | 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 _c | 958 | 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() | |||
| 88 | 88 | ||
| 89 | 89 | func newClientID() [16]byte { | |
| 90 | 90 | 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 | 94 | return id | |
| 93 | 95 | } | |
| 94 | 96 | ||
| @@ -106,6 +108,8 @@ type reconnectBackoff struct { | |||
| 106 | 108 | ||
| 107 | 109 | var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second} | |
| 108 | 110 | ||
| 111 | + | var reconnectSleep = sleepReconnect | |
| 112 | + | ||
| 109 | 113 | const ( | |
| 110 | 114 | statusReconnect = "\r\x1b[2Kreconnecting…" | |
| 111 | 115 | statusClear = "\r\x1b[2K" | |
| @@ -175,7 +179,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u | |||
| 175 | 179 | return err | |
| 176 | 180 | } | |
| 177 | 181 | drawStatus() | |
| 178 | - | if !sleepReconnect(ctx, backoff) { | |
| 182 | + | if !reconnectSleep(ctx, backoff) { | |
| 179 | 183 | return ctx.Err() | |
| 180 | 184 | } | |
| 181 | 185 | backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max) | |
| @@ -183,9 +187,11 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u | |||
| 183 | 187 | continue | |
| 184 | 188 | } | |
| 185 | 189 | ms.dialed = true | |
| 186 | - | backoff = defaultReconnectBackoff.initial | |
| 187 | 190 | ||
| 188 | 191 | result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus) | |
| 192 | + | if result.welcomed { | |
| 193 | + | backoff = defaultReconnectBackoff.initial | |
| 194 | + | } | |
| 189 | 195 | _ = transport.Close() | |
| 190 | 196 | if result.resumeToken != 0 { | |
| 191 | 197 | resumeToken = result.resumeToken | |
| @@ -199,7 +205,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u | |||
| 199 | 205 | return result.err | |
| 200 | 206 | } | |
| 201 | 207 | drawStatus() | |
| 202 | - | if !sleepReconnect(ctx, backoff) { | |
| 208 | + | if !reconnectSleep(ctx, backoff) { | |
| 203 | 209 | clearStatus() | |
| 204 | 210 | return ctx.Err() | |
| 205 | 211 | } | |
| @@ -255,6 +261,7 @@ func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 255 | 261 | ||
| 256 | 262 | type attachResult struct { | |
| 257 | 263 | resumeToken uint64 | |
| 264 | + | welcomed bool | |
| 258 | 265 | err error | |
| 259 | 266 | } | |
| 260 | 267 | ||
| @@ -310,7 +317,7 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 310 | 317 | ||
| 311 | 318 | // 3. Enter raw mode after Welcome; Run owns restoration. | |
| 312 | 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 | 322 | clearStatus() | |
| 316 | 323 | ||
| @@ -338,37 +345,37 @@ func attachOnce(ctx context.Context, transport ports.Transport, term ports.Termi | |||
| 338 | 345 | // context was cancelled. A queued sender error takes priority. | |
| 339 | 346 | select { | |
| 340 | 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 | 349 | default: | |
| 343 | - | return attachResult{resumeToken: resumeToken} | |
| 350 | + | return attachResult{resumeToken: resumeToken, welcomed: true} | |
| 344 | 351 | } | |
| 345 | 352 | case r := <-recvCh: | |
| 346 | 353 | if r.err != nil { | |
| 347 | 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 | 359 | switch r.frame.Type { | |
| 353 | 360 | case ports.MsgOutput: | |
| 354 | 361 | o, derr := ports.UnmarshalOutput(r.frame.Payload) | |
| 355 | 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 | 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 | 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 | 371 | ms.firstOutput = true | |
| 365 | 372 | case ports.MsgDetached: | |
| 366 | 373 | d, derr := ports.UnmarshalDetached(r.frame.Payload) | |
| 367 | 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 | 377 | ms.detached = true | |
| 371 | - | return attachResult{resumeToken: resumeToken, err: detachedResult(d.Reason)} | |
| 378 | + | return attachResult{resumeToken: resumeToken, welcomed: true, err: detachedResult(d.Reason)} | |
| 372 | 379 | case ports.MsgPong: | |
| 373 | 380 | // Liveness reply; nothing to do. | |
| 374 | 381 | default: | |
| @@ -88,7 +88,9 @@ var processClientID = newClientID() | |||||
| 88 | 88 | ||||
| 89 | func newClientID() [16]byte { | 89 | func newClientID() [16]byte { | ||
| 90 | var id [16]byte | 90 | 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 id | 94 | 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 err | 179 | 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 | continue | 187 | continue | ||
| 184 | } | 188 | } | ||
| 185 | ms.dialed = true | 189 | 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.resumeToken | 197 | resumeToken = result.resumeToken | ||
| @@ -199,7 +205,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent u | |||||
| 199 | return result.err | 205 | 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 uint64 | 263 | resumeToken uint64 | ||
| 264 | + | welcomed bool | |||
| 258 | err error | 265 | 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 = true | 371 | 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 = true | 377 | 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) { | |||
| 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 | 534 | func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |
| 546 | 535 | var out bytes.Buffer | |
| 547 | 536 | var restoreCount atomic.Int32 | |
| @@ -558,7 +547,8 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||
| 558 | 547 | ) | |
| 559 | 548 | defer unblock() | |
| 560 | 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 | 553 | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | |
| 564 | 554 | require.Error(t, err) | |
| @@ -577,23 +567,23 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||
| 577 | 567 | nil, | |
| 578 | 568 | ).Once() | |
| 579 | 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 | 573 | err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main") | |
| 583 | 574 | require.Error(t, err) | |
| 584 | 575 | var pe *client.ProtocolError | |
| 585 | 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 | 579 | func TestRunPhaseASingleAttempt(t *testing.T) { | |
| 590 | 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 | 583 | tm := portsmocks.NewMockTerminal(t) | |
| 593 | 584 | ||
| 594 | 585 | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | |
| 595 | 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 | 589 | 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.Buffer | 535 | var out bytes.Buffer | ||
| 547 | var restoreCount atomic.Int32 | 536 | 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.ProtocolError | 575 | 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 { | |||
| 49 | 49 | resumeCapable bool | |
| 50 | 50 | resumeToken uint64 | |
| 51 | 51 | parked bool | |
| 52 | - | ackedStateNum uint64 | |
| 53 | 52 | nextStateNum uint64 | |
| 54 | 53 | echoAck atomic.Uint64 | |
| 55 | 54 | bars barCache // only touched while sendMu is held | |
| @@ -192,6 +191,21 @@ func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error { | |||
| 192 | 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 | 209 | // send serialises a frame onto the client's transport. | |
| 196 | 210 | func (ac *attachedClient) send(f ports.Frame) error { | |
| 197 | 211 | ac.sendMu.Lock() | |
| @@ -213,10 +227,26 @@ func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) { | |||
| 213 | 227 | } | |
| 214 | 228 | ||
| 215 | 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 | 246 | timer := d.clock.NewTimer(detachNotifyTimeout) | |
| 217 | 247 | result := make(chan error, 1) | |
| 218 | 248 | go func() { | |
| 219 | - | result <- ac.send(f) | |
| 249 | + | result <- send() | |
| 220 | 250 | }() | |
| 221 | 251 | select { | |
| 222 | 252 | case err := <-result: | |
| @@ -286,7 +316,7 @@ type attachClientOptions struct { | |||
| 286 | 316 | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) { | |
| 287 | 317 | resumeToken := uint64(0) | |
| 288 | 318 | if opts.resumeCapable { | |
| 289 | - | resumeToken = newResumeToken() | |
| 319 | + | resumeToken = d.nextResumeTokenLocked() | |
| 290 | 320 | } | |
| 291 | 321 | ac := &attachedClient{ | |
| 292 | 322 | tr: tr, | |
| @@ -380,18 +410,24 @@ func (d *Daemon) firstPaint(sess *session, ac *attachedClient, clientSize domain | |||
| 380 | 410 | // runConnLoop is the per-connection input router: it pumps client messages | |
| 381 | 411 | // until detach, EOF, or a transport error. | |
| 382 | 412 | func (d *Daemon) runConnLoop(ac *attachedClient) { | |
| 413 | + | tr := ac.transport() | |
| 414 | + | if tr == nil { | |
| 415 | + | return | |
| 416 | + | } | |
| 383 | 417 | for { | |
| 384 | - | sess := ac.currentSession() | |
| 385 | - | if sess == nil { | |
| 418 | + | if !ac.currentTransportIs(tr) { | |
| 386 | 419 | return | |
| 387 | 420 | } | |
| 388 | - | tr := ac.transport() | |
| 389 | - | if tr == nil { | |
| 421 | + | sess := ac.currentSession() | |
| 422 | + | if sess == nil { | |
| 390 | 423 | return | |
| 391 | 424 | } | |
| 392 | 425 | f, err := tr.Recv() | |
| 393 | 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 | 431 | return | |
| 396 | 432 | } | |
| 397 | 433 | sess = ac.currentSession() | |
| @@ -412,12 +448,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 412 | 448 | d.applyTheme(sess, ac, th) | |
| 413 | 449 | } | |
| 414 | 450 | case ports.MsgDetach: | |
| 415 | - | d.clientGone(sess, ac, true) | |
| 451 | + | d.clientGone(sess, ac, tr, true) | |
| 416 | 452 | return | |
| 417 | 453 | 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. | |
| 421 | 456 | case ports.MsgPing: | |
| 422 | 457 | _ = ac.send(framePong()) | |
| 423 | 458 | default: | |
| @@ -430,7 +465,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 430 | 465 | // clientGone detaches ac if it is still the session's current client. An | |
| 431 | 466 | // 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 | 472 | if !sess.detachIfCurrent(ac) { | |
| 435 | 473 | 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 | 479 | if !ephemeral { | |
| 442 | 480 | d.refreshSessionCwd(sess) | |
| 443 | 481 | } | |
| 444 | - | oldTr := ac.transport() | |
| 482 | + | oldTr := failed | |
| 483 | + | if oldTr == nil { | |
| 484 | + | oldTr = ac.transport() | |
| 485 | + | } | |
| 445 | 486 | if !explicit && d.parkAttachment(sess, ac) { | |
| 446 | 487 | _ = ac.closeCapturedTransport(oldTr) | |
| 447 | 488 | return | |
| @@ -525,17 +566,19 @@ func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) { | |||
| 525 | 566 | // detachOnSendError drops a client whose transport failed. Like every other | |
| 526 | 567 | // detach path, losing the client kills an ephemeral session (its lifetime is | |
| 527 | 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 | 573 | if sess.detachIfCurrent(ac) { | |
| 530 | 574 | d.unregisterPreview(ac) | |
| 531 | 575 | d.resetScreenDefaultColors(sess) | |
| 532 | - | oldTr := ac.transport() | |
| 533 | 576 | if d.parkAttachment(sess, ac) { | |
| 534 | - | _ = ac.closeCapturedTransport(oldTr) | |
| 577 | + | _ = ac.closeCapturedTransport(failed) | |
| 535 | 578 | d.log.Warn("parked client after send error", "session", sess.name) | |
| 536 | 579 | return | |
| 537 | 580 | } | |
| 538 | - | _ = ac.closeCapturedTransport(oldTr) | |
| 581 | + | _ = ac.closeCapturedTransport(failed) | |
| 539 | 582 | d.log.Warn("detached client after send error", "session", sess.name) | |
| 540 | 583 | sess.mu.Lock() | |
| 541 | 584 | ephemeral := sess.ephemeral | |
| @@ -49,7 +49,6 @@ type attachedClient struct { | |||||
| 49 | resumeCapable bool | 49 | resumeCapable bool | ||
| 50 | resumeToken uint64 | 50 | resumeToken uint64 | ||
| 51 | parked bool | 51 | parked bool | ||
| 52 | - | ackedStateNum uint64 | |||
| 53 | nextStateNum uint64 | 52 | nextStateNum uint64 | ||
| 54 | echoAck atomic.Uint64 | 53 | echoAck atomic.Uint64 | ||
| 55 | bars barCache // only touched while sendMu is held | 54 | 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 messages | 410 | // 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 | return | 419 | return | ||
| 387 | } | 420 | } | ||
| 388 | - | tr := ac.transport() | 421 | + | sess := ac.currentSession() |
| 389 | - | if tr == nil { | 422 | + | if sess == nil { |
| 390 | return | 423 | 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 | return | 431 | 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 | return | 452 | 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.AckedStateNum | 455 | + | // 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. An | 465 | // 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. When | 466 | // 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 do | 473 | 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 | return | 488 | 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 other | 566 | // detachOnSendError drops a client whose transport failed. Like every other | ||
| 526 | // detach path, losing the client kills an ephemeral session (its lifetime is | 567 | // 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 | return | 579 | 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.ephemeral | 584 | ephemeral := sess.ephemeral | ||
M internal/usecase/daemon/copymode.go +3 -2
| @@ -252,8 +252,9 @@ func (d *Daemon) handleCopyInput(ac *attachedClient, data []byte) { | |||
| 252 | 252 | if copyOut && text != "" { | |
| 253 | 253 | chunks := scopy.OSC52(text) | |
| 254 | 254 | 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) | |
| 257 | 258 | return | |
| 258 | 259 | } | |
| 259 | 260 | } | |
| @@ -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 | return | 258 | 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) { | |||
| 441 | 441 | } | |
| 442 | 442 | ||
| 443 | 443 | if err := ac.send(frameWelcome(sess, ac)); err != nil { | |
| 444 | - | d.clientGone(sess, ac, false) | |
| 444 | + | d.clientGone(sess, ac, tr, false) | |
| 445 | 445 | return | |
| 446 | 446 | } | |
| 447 | 447 | 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 | return | 445 | 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 ( | |||
| 34 | 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 | 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 | 45 | 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 { | |||
| 217 | 217 | } | |
| 218 | 218 | ||
| 219 | 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 | 221 | return nil | |
| 222 | 222 | } | |
| 223 | 223 | ||
| @@ -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 nil | 221 | return nil | ||
| 222 | } | 222 | } | ||
| 223 | 223 | ||||
M internal/usecase/daemon/render.go +6 -5
| @@ -31,6 +31,7 @@ import ( | |||
| 31 | 31 | "time" | |
| 32 | 32 | ||
| 33 | 33 | "github.com/bnema/vev/internal/domain" | |
| 34 | + | "github.com/bnema/vev/internal/ports" | |
| 34 | 35 | scopy "github.com/bnema/vev/internal/usecase/copy" | |
| 35 | 36 | "github.com/bnema/vev/internal/usecase/layout" | |
| 36 | 37 | themeui "github.com/bnema/vev/internal/usecase/theme" | |
| @@ -379,15 +380,15 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 379 | 380 | tb.mu.Unlock() | |
| 380 | 381 | ||
| 381 | 382 | var serr error | |
| 383 | + | var sendTr ports.Transport | |
| 382 | 384 | if err == nil { | |
| 383 | 385 | data = append(data, cursorTail...) | |
| 384 | 386 | if len(data) > 0 { | |
| 385 | - | tr := ac.transport() | |
| 386 | - | if tr == nil { | |
| 387 | + | sendTr = ac.transport() | |
| 388 | + | if sendTr == nil { | |
| 387 | 389 | serr = errors.New("client transport is nil") | |
| 388 | 390 | } else { | |
| 389 | - | ac.nextStateNum++ | |
| 390 | - | serr = tr.Send(frameOutputState(data, ac.nextStateNum, ac.echoAck.Load())) | |
| 391 | + | serr = sendTr.Send(ac.nextOutputFrameLocked(data)) | |
| 391 | 392 | } | |
| 392 | 393 | } | |
| 393 | 394 | } | |
| @@ -398,7 +399,7 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 398 | 399 | return | |
| 399 | 400 | } | |
| 400 | 401 | if serr != nil { | |
| 401 | - | d.detachOnSendError(sess, ac) | |
| 402 | + | d.detachOnSendError(sess, ac, sendTr) | |
| 402 | 403 | } | |
| 403 | 404 | } | |
| 404 | 405 | ||
| @@ -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 error | 382 | 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 | return | 399 | 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 ( | |||
| 11 | 11 | func newResumeToken() uint64 { | |
| 12 | 12 | var b [8]byte | |
| 13 | 13 | if _, err := rand.Read(b[:]); err != nil { | |
| 14 | - | return 0 | |
| 14 | + | panic("crypto/rand failed generating resume token: " + err.Error()) | |
| 15 | 15 | } | |
| 16 | 16 | return binary.BigEndian.Uint64(b[:]) | |
| 17 | 17 | } | |
| @@ -11,7 +11,7 @@ import ( | |||||
| 11 | func newResumeToken() uint64 { | 11 | func newResumeToken() uint64 { | ||
| 12 | var b [8]byte | 12 | var b [8]byte | ||
| 13 | if _, err := rand.Read(b[:]); err != nil { | 13 | if _, err := rand.Read(b[:]); err != nil { | ||
| 14 | - | return 0 | 14 | + | 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 ( | |||
| 15 | 15 | type closeTrackingTransport struct { | |
| 16 | 16 | mu sync.Mutex | |
| 17 | 17 | 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 | 27 | func (t *closeTrackingTransport) Recv() (ports.Frame, error) { | |
| 22 | 28 | return ports.Frame{}, errors.New("closed") | |
| 23 | 29 | } | |
| @@ -33,6 +39,12 @@ func (t *closeTrackingTransport) Closed() bool { | |||
| 33 | 39 | 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 | 48 | func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello { | |
| 37 | 49 | return ports.Hello{ | |
| 38 | 50 | Version: ports.ProtocolVersion, | |
| @@ -56,7 +68,7 @@ func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) { | |||
| 56 | 68 | token := ac.resumeToken | |
| 57 | 69 | require.NotZero(t, token) | |
| 58 | 70 | ||
| 59 | - | d.clientGone(sess, ac, false) | |
| 71 | + | d.clientGone(sess, ac, ac.transport(), false) | |
| 60 | 72 | require.Equal(t, 1, sessionCount(d), "named session survives parked link loss") | |
| 61 | 73 | require.Nil(t, sess.client) | |
| 62 | 74 | d.mu.Lock() | |
| @@ -70,7 +82,7 @@ func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) { | |||
| 70 | 82 | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 71 | 83 | es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2) | |
| 72 | 84 | require.NoError(t, err) | |
| 73 | - | d2.clientGone(es, eac, false) | |
| 85 | + | d2.clientGone(es, eac, eac.transport(), false) | |
| 74 | 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 | 95 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 84 | 96 | require.NoError(t, err) | |
| 85 | 97 | oldToken := ac.resumeToken | |
| 86 | - | d.clientGone(sess, ac, false) | |
| 98 | + | d.clientGone(sess, ac, ac.transport(), false) | |
| 87 | 99 | ||
| 88 | 100 | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 89 | 101 | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", oldToken), tr2) | |
| @@ -103,7 +115,7 @@ func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) { | |||
| 103 | 115 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 104 | 116 | require.NoError(t, err) | |
| 105 | 117 | token := ac.resumeToken | |
| 106 | - | d.clientGone(sess, ac, false) | |
| 118 | + | d.clientGone(sess, ac, ac.transport(), false) | |
| 107 | 119 | ||
| 108 | 120 | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | |
| 109 | 121 | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | |
| @@ -158,7 +170,7 @@ func TestExplicitDetachDoesNotPark(t *testing.T) { | |||
| 158 | 170 | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 159 | 171 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 160 | 172 | require.NoError(t, err) | |
| 161 | - | d.clientGone(sess, ac, true) | |
| 173 | + | d.clientGone(sess, ac, ac.transport(), true) | |
| 162 | 174 | d.mu.Lock() | |
| 163 | 175 | parked := len(d.parked) | |
| 164 | 176 | d.mu.Unlock() | |
| @@ -174,7 +186,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) { | |||
| 174 | 186 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 175 | 187 | require.NoError(t, err) | |
| 176 | 188 | token := ac.resumeToken | |
| 177 | - | d.clientGone(sess, ac, false) | |
| 189 | + | d.clientGone(sess, ac, ac.transport(), false) | |
| 178 | 190 | timer := <-clk.timers | |
| 179 | 191 | timer.ch <- clk.Now() | |
| 180 | 192 | require.Eventually(t, func() bool { | |
| @@ -190,7 +202,7 @@ func TestParkExpiryAndShutdownCleanup(t *testing.T) { | |||
| 190 | 202 | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 191 | 203 | sess2, ac2, err := d2.route(helloResumeCapable(ports.IntentNew, "other", 0), tr2) | |
| 192 | 204 | require.NoError(t, err) | |
| 193 | - | d2.clientGone(sess2, ac2, false) | |
| 205 | + | d2.clientGone(sess2, ac2, ac2.transport(), false) | |
| 194 | 206 | d2.shutdownAll(ports.ReasonServerShutdown) | |
| 195 | 207 | d2.mu.Lock() | |
| 196 | 208 | parked := len(d2.parked) | |
| @@ -206,7 +218,7 @@ func TestKilledSessionPurgesParkedResumeToken(t *testing.T) { | |||
| 206 | 218 | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 207 | 219 | require.NoError(t, err) | |
| 208 | 220 | token := ac.resumeToken | |
| 209 | - | d.clientGone(sess, ac, false) | |
| 221 | + | d.clientGone(sess, ac, ac.transport(), false) | |
| 210 | 222 | ||
| 211 | 223 | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 212 | 224 | d.mu.Lock() | |
| @@ -226,7 +238,7 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) { | |||
| 226 | 238 | sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 227 | 239 | require.NoError(t, err) | |
| 228 | 240 | token := oldAC.resumeToken | |
| 229 | - | d.clientGone(sess, oldAC, false) | |
| 241 | + | d.clientGone(sess, oldAC, oldAC.transport(), false) | |
| 230 | 242 | ||
| 231 | 243 | activeTr := &closeTrackingTransport{} | |
| 232 | 244 | _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr) | |
| @@ -243,3 +255,52 @@ func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) { | |||
| 243 | 255 | require.Nil(t, resumedAC) | |
| 244 | 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 | + | } | |
| @@ -15,9 +15,15 @@ import ( | |||||
| 15 | type closeTrackingTransport struct { | 15 | type closeTrackingTransport struct { | ||
| 16 | mu sync.Mutex | 16 | mu sync.Mutex | ||
| 17 | closed bool | 17 | 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.closed | 39 | 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.resumeToken | 68 | 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.resumeToken | 97 | 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.resumeToken | 117 | 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.resumeToken | 188 | token := ac.resumeToken | ||
| 177 | - | d.clientGone(sess, ac, false) | 189 | + | d.clientGone(sess, ac, ac.transport(), false) |
| 178 | timer := <-clk.timers | 190 | 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.resumeToken | 220 | 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.resumeToken | 240 | 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) { | |||
| 127 | 127 | tb.focusedPane().screen.Write([]byte("\x1b]10;?\a\x1b]11;?\a")) | |
| 128 | 128 | require.NotEmpty(t, before) | |
| 129 | 129 | ||
| 130 | - | d.clientGone(sess, ac, true) | |
| 130 | + | d.clientGone(sess, ac, ac.transport(), true) | |
| 131 | 131 | ||
| 132 | 132 | var got []byte | |
| 133 | 133 | tb.focusedPane().screen.OnResponse = func(b []byte) { got = append(got, b...) } | |
| @@ -177,7 +177,7 @@ func TestDetachOnSendErrorResetsScreenDefaultColors(t *testing.T) { | |||
| 177 | 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 | 182 | tb := sess.activeTab() | |
| 183 | 183 | 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 []byte | 132 | 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 []byte | 183 | var got []byte | ||
M internal/usecase/daemon/wire.go +2 -13
| @@ -36,9 +36,6 @@ func frameWelcome(s *session, ac *attachedClient) ports.Frame { | |||
| 36 | 36 | Capabilities: ports.CapabilityResume, | |
| 37 | 37 | ResumeToken: ac.resumeToken, | |
| 38 | 38 | } | |
| 39 | - | if ac.resumeCapable && ac.resumeToken == 0 { | |
| 40 | - | w.ResumeToken = 0 | |
| 41 | - | } | |
| 42 | 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 | 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 { | |
| 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})} | |
| 59 | 48 | } | |
| 60 | 49 | ||
| 61 | 50 | 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 { | ||