feat(remote): add resilient attach transport
- commit
- 75dd680f4a2745b20baa2eb3ed90e47d24029dd9
- author
- brice · · 2026-07-04 10:52 UTC
- committer
- brice · · 2026-07-04 11:29 UTC
- parent
- e8e16e8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
26 files · +2534 -138
A internal/adapters/dgram/transport.go +274 -0
| @@ -0,0 +1,274 @@ | |||
| 1 | + | // Package dgram adapts authenticated UDP-style packet connections to ports.Transport. | |
| 2 | + | package dgram | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "context" | |
| 6 | + | "encoding/binary" | |
| 7 | + | "errors" | |
| 8 | + | "net" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | const ( | |
| 17 | + | recData byte = 1 | |
| 18 | + | recAck byte = 2 | |
| 19 | + | defaultResend = 40 * time.Millisecond | |
| 20 | + | ) | |
| 21 | + | ||
| 22 | + | type Transport struct { | |
| 23 | + | pc net.PacketConn | |
| 24 | + | codec *pdgram.Codec | |
| 25 | + | sendDir, recvDir uint32 | |
| 26 | + | mtu int | |
| 27 | + | ||
| 28 | + | mu sync.Mutex | |
| 29 | + | peer net.Addr | |
| 30 | + | ctr uint64 | |
| 31 | + | seq uint64 | |
| 32 | + | pending map[uint64]*pending | |
| 33 | + | closed bool | |
| 34 | + | ||
| 35 | + | replay *pdgram.ReplayWindow | |
| 36 | + | reasm *pdgram.Reassembler | |
| 37 | + | delivered map[uint64]struct{} | |
| 38 | + | deliveredMax uint64 | |
| 39 | + | in chan ports.Frame | |
| 40 | + | done chan struct{} | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | type pending struct { | |
| 44 | + | frame ports.Frame | |
| 45 | + | last time.Time | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) { | |
| 49 | + | c, err := pdgram.NewCodec(key) | |
| 50 | + | if err != nil { | |
| 51 | + | return nil, err | |
| 52 | + | } | |
| 53 | + | t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: pdgram.DefaultMTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), delivered: make(map[uint64]struct{}), in: make(chan ports.Frame, 32), done: make(chan struct{})} | |
| 54 | + | go t.readLoop() | |
| 55 | + | go t.resendLoop() | |
| 56 | + | return t, nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (t *Transport) Send(f ports.Frame) error { | |
| 60 | + | // Until full ACK-driven terminal state sync exists, every frame is reliable; | |
| 61 | + | // dropping MsgOutput can permanently desynchronize the client screen. | |
| 62 | + | reliable := true | |
| 63 | + | t.mu.Lock() | |
| 64 | + | t.seq++ | |
| 65 | + | seq := t.seq | |
| 66 | + | if reliable { | |
| 67 | + | t.pending[seq] = &pending{frame: f} | |
| 68 | + | } | |
| 69 | + | t.mu.Unlock() | |
| 70 | + | return t.sendData(seq, reliable, f) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (t *Transport) Recv() (ports.Frame, error) { | |
| 74 | + | select { | |
| 75 | + | case f := <-t.in: | |
| 76 | + | return f, nil | |
| 77 | + | case <-t.done: | |
| 78 | + | return ports.Frame{}, errors.New("dgram: closed") | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | func (t *Transport) Close() error { | |
| 82 | + | t.mu.Lock() | |
| 83 | + | if !t.closed { | |
| 84 | + | t.closed = true | |
| 85 | + | close(t.done) | |
| 86 | + | } | |
| 87 | + | t.mu.Unlock() | |
| 88 | + | return t.pc.Close() | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer } | |
| 92 | + | ||
| 93 | + | func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error { | |
| 94 | + | p := encodeData(seq, reliable, f) | |
| 95 | + | return t.sendPayload(p) | |
| 96 | + | } | |
| 97 | + | func (t *Transport) sendAck(seq uint64) { | |
| 98 | + | var b [9]byte | |
| 99 | + | b[0] = recAck | |
| 100 | + | binary.BigEndian.PutUint64(b[1:], seq) | |
| 101 | + | _ = t.sendPayload(b[:]) | |
| 102 | + | } | |
| 103 | + | func (t *Transport) sendPayload(p []byte) error { | |
| 104 | + | frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead()) | |
| 105 | + | if err != nil { | |
| 106 | + | return err | |
| 107 | + | } | |
| 108 | + | for _, f := range frags { | |
| 109 | + | raw, err := pdgram.MarshalFragment(f) | |
| 110 | + | if err != nil { | |
| 111 | + | return err | |
| 112 | + | } | |
| 113 | + | pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil) | |
| 114 | + | t.mu.Lock() | |
| 115 | + | peer := t.peer | |
| 116 | + | closed := t.closed | |
| 117 | + | t.mu.Unlock() | |
| 118 | + | if closed { | |
| 119 | + | return errors.New("dgram: closed") | |
| 120 | + | } | |
| 121 | + | if peer == nil { | |
| 122 | + | return errors.New("dgram: no peer") | |
| 123 | + | } | |
| 124 | + | if _, err := t.pc.WriteTo(pkt, peer); err != nil { | |
| 125 | + | return err | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr } | |
| 131 | + | ||
| 132 | + | func (t *Transport) readLoop() { | |
| 133 | + | buf := make([]byte, 64*1024) | |
| 134 | + | for { | |
| 135 | + | n, addr, err := t.pc.ReadFrom(buf) | |
| 136 | + | if err != nil { | |
| 137 | + | return | |
| 138 | + | } | |
| 139 | + | _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay) | |
| 140 | + | if err != nil { | |
| 141 | + | continue | |
| 142 | + | } | |
| 143 | + | t.mu.Lock() | |
| 144 | + | t.peer = addr | |
| 145 | + | t.mu.Unlock() | |
| 146 | + | frag, err := pdgram.UnmarshalFragment(pt) | |
| 147 | + | if err != nil { | |
| 148 | + | continue | |
| 149 | + | } | |
| 150 | + | payload, ok, err := t.reasm.Add(frag) | |
| 151 | + | if err != nil || !ok { | |
| 152 | + | continue | |
| 153 | + | } | |
| 154 | + | t.handleRecord(payload) | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | func (t *Transport) handleRecord(p []byte) { | |
| 159 | + | if len(p) < 1 { | |
| 160 | + | return | |
| 161 | + | } | |
| 162 | + | switch p[0] { | |
| 163 | + | case recAck: | |
| 164 | + | if len(p) != 9 { | |
| 165 | + | return | |
| 166 | + | } | |
| 167 | + | seq := binary.BigEndian.Uint64(p[1:]) | |
| 168 | + | t.mu.Lock() | |
| 169 | + | delete(t.pending, seq) | |
| 170 | + | t.mu.Unlock() | |
| 171 | + | case recData: | |
| 172 | + | seq, reliable, f, ok := decodeData(p) | |
| 173 | + | if !ok { | |
| 174 | + | return | |
| 175 | + | } | |
| 176 | + | if reliable { | |
| 177 | + | t.sendAck(seq) | |
| 178 | + | if !t.markReliableDelivered(seq) { | |
| 179 | + | return | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | go t.deliver(f) | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (t *Transport) deliver(f ports.Frame) { | |
| 187 | + | select { | |
| 188 | + | case t.in <- f: | |
| 189 | + | case <-t.done: | |
| 190 | + | } | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (t *Transport) markReliableDelivered(seq uint64) bool { | |
| 194 | + | t.mu.Lock() | |
| 195 | + | defer t.mu.Unlock() | |
| 196 | + | if _, ok := t.delivered[seq]; ok { | |
| 197 | + | return false | |
| 198 | + | } | |
| 199 | + | t.delivered[seq] = struct{}{} | |
| 200 | + | if seq > t.deliveredMax { | |
| 201 | + | t.deliveredMax = seq | |
| 202 | + | } | |
| 203 | + | const window = 1024 | |
| 204 | + | if t.deliveredMax > window { | |
| 205 | + | cutoff := t.deliveredMax - window | |
| 206 | + | for s := range t.delivered { | |
| 207 | + | if s < cutoff { | |
| 208 | + | delete(t.delivered, s) | |
| 209 | + | } | |
| 210 | + | } | |
| 211 | + | } | |
| 212 | + | return true | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func (t *Transport) resendLoop() { | |
| 216 | + | tick := time.NewTicker(defaultResend) | |
| 217 | + | defer tick.Stop() | |
| 218 | + | for { | |
| 219 | + | select { | |
| 220 | + | case <-tick.C: | |
| 221 | + | now := time.Now() | |
| 222 | + | var resend []struct { | |
| 223 | + | seq uint64 | |
| 224 | + | f ports.Frame | |
| 225 | + | } | |
| 226 | + | t.mu.Lock() | |
| 227 | + | for seq, p := range t.pending { | |
| 228 | + | if p.last.IsZero() || now.Sub(p.last) >= defaultResend { | |
| 229 | + | p.last = now | |
| 230 | + | resend = append(resend, struct { | |
| 231 | + | seq uint64 | |
| 232 | + | f ports.Frame | |
| 233 | + | }{seq, p.frame}) | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | t.mu.Unlock() | |
| 237 | + | for _, r := range resend { | |
| 238 | + | _ = t.sendData(r.seq, true, r.f) | |
| 239 | + | } | |
| 240 | + | case <-t.done: | |
| 241 | + | return | |
| 242 | + | } | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 246 | + | func encodeData(seq uint64, reliable bool, f ports.Frame) []byte { | |
| 247 | + | b := make([]byte, 12+len(f.Payload)) | |
| 248 | + | b[0] = recData | |
| 249 | + | binary.BigEndian.PutUint64(b[1:9], seq) | |
| 250 | + | if reliable { | |
| 251 | + | b[9] = 1 | |
| 252 | + | } | |
| 253 | + | b[10] = byte(f.Type) | |
| 254 | + | b[11] = 0 | |
| 255 | + | copy(b[12:], f.Payload) | |
| 256 | + | return b | |
| 257 | + | } | |
| 258 | + | func decodeData(b []byte) (uint64, bool, ports.Frame, bool) { | |
| 259 | + | if len(b) < 12 || b[0] != recData { | |
| 260 | + | return 0, false, ports.Frame{}, false | |
| 261 | + | } | |
| 262 | + | return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | type Dialer struct { | |
| 266 | + | PC net.PacketConn | |
| 267 | + | Peer net.Addr | |
| 268 | + | Key []byte | |
| 269 | + | SendDir, RecvDir uint32 | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func (d Dialer) Dial(ctx context.Context) (ports.Transport, error) { | |
| 273 | + | return NewTransport(d.PC, d.Peer, d.Key, d.SendDir, d.RecvDir) | |
| 274 | + | } | |
| @@ -0,0 +1,274 @@ | |||||
| 1 | + | // Package dgram adapts authenticated UDP-style packet connections to ports.Transport. | |||
| 2 | + | package dgram | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "context" | |||
| 6 | + | "encoding/binary" | |||
| 7 | + | "errors" | |||
| 8 | + | "net" | |||
| 9 | + | "sync" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const ( | |||
| 17 | + | recData byte = 1 | |||
| 18 | + | recAck byte = 2 | |||
| 19 | + | defaultResend = 40 * time.Millisecond | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 22 | + | type Transport struct { | |||
| 23 | + | pc net.PacketConn | |||
| 24 | + | codec *pdgram.Codec | |||
| 25 | + | sendDir, recvDir uint32 | |||
| 26 | + | mtu int | |||
| 27 | + | ||||
| 28 | + | mu sync.Mutex | |||
| 29 | + | peer net.Addr | |||
| 30 | + | ctr uint64 | |||
| 31 | + | seq uint64 | |||
| 32 | + | pending map[uint64]*pending | |||
| 33 | + | closed bool | |||
| 34 | + | ||||
| 35 | + | replay *pdgram.ReplayWindow | |||
| 36 | + | reasm *pdgram.Reassembler | |||
| 37 | + | delivered map[uint64]struct{} | |||
| 38 | + | deliveredMax uint64 | |||
| 39 | + | in chan ports.Frame | |||
| 40 | + | done chan struct{} | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | type pending struct { | |||
| 44 | + | frame ports.Frame | |||
| 45 | + | last time.Time | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func NewTransport(pc net.PacketConn, peer net.Addr, key []byte, sendDir, recvDir uint32) (*Transport, error) { | |||
| 49 | + | c, err := pdgram.NewCodec(key) | |||
| 50 | + | if err != nil { | |||
| 51 | + | return nil, err | |||
| 52 | + | } | |||
| 53 | + | t := &Transport{pc: pc, codec: c, sendDir: sendDir, recvDir: recvDir, mtu: pdgram.DefaultMTU, peer: peer, pending: make(map[uint64]*pending), replay: pdgram.NewReplayWindow(), reasm: pdgram.NewReassembler(), delivered: make(map[uint64]struct{}), in: make(chan ports.Frame, 32), done: make(chan struct{})} | |||
| 54 | + | go t.readLoop() | |||
| 55 | + | go t.resendLoop() | |||
| 56 | + | return t, nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (t *Transport) Send(f ports.Frame) error { | |||
| 60 | + | // Until full ACK-driven terminal state sync exists, every frame is reliable; | |||
| 61 | + | // dropping MsgOutput can permanently desynchronize the client screen. | |||
| 62 | + | reliable := true | |||
| 63 | + | t.mu.Lock() | |||
| 64 | + | t.seq++ | |||
| 65 | + | seq := t.seq | |||
| 66 | + | if reliable { | |||
| 67 | + | t.pending[seq] = &pending{frame: f} | |||
| 68 | + | } | |||
| 69 | + | t.mu.Unlock() | |||
| 70 | + | return t.sendData(seq, reliable, f) | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func (t *Transport) Recv() (ports.Frame, error) { | |||
| 74 | + | select { | |||
| 75 | + | case f := <-t.in: | |||
| 76 | + | return f, nil | |||
| 77 | + | case <-t.done: | |||
| 78 | + | return ports.Frame{}, errors.New("dgram: closed") | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | func (t *Transport) Close() error { | |||
| 82 | + | t.mu.Lock() | |||
| 83 | + | if !t.closed { | |||
| 84 | + | t.closed = true | |||
| 85 | + | close(t.done) | |||
| 86 | + | } | |||
| 87 | + | t.mu.Unlock() | |||
| 88 | + | return t.pc.Close() | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer } | |||
| 92 | + | ||||
| 93 | + | func (t *Transport) sendData(seq uint64, reliable bool, f ports.Frame) error { | |||
| 94 | + | p := encodeData(seq, reliable, f) | |||
| 95 | + | return t.sendPayload(p) | |||
| 96 | + | } | |||
| 97 | + | func (t *Transport) sendAck(seq uint64) { | |||
| 98 | + | var b [9]byte | |||
| 99 | + | b[0] = recAck | |||
| 100 | + | binary.BigEndian.PutUint64(b[1:], seq) | |||
| 101 | + | _ = t.sendPayload(b[:]) | |||
| 102 | + | } | |||
| 103 | + | func (t *Transport) sendPayload(p []byte) error { | |||
| 104 | + | frags, err := pdgram.FragmentPayload(t.nextCounter(), p, t.mtu-pdgram.HeaderSize-t.codec.Overhead()) | |||
| 105 | + | if err != nil { | |||
| 106 | + | return err | |||
| 107 | + | } | |||
| 108 | + | for _, f := range frags { | |||
| 109 | + | raw, err := pdgram.MarshalFragment(f) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 113 | + | pkt := t.codec.Seal(t.sendDir, t.nextCounter(), raw, nil) | |||
| 114 | + | t.mu.Lock() | |||
| 115 | + | peer := t.peer | |||
| 116 | + | closed := t.closed | |||
| 117 | + | t.mu.Unlock() | |||
| 118 | + | if closed { | |||
| 119 | + | return errors.New("dgram: closed") | |||
| 120 | + | } | |||
| 121 | + | if peer == nil { | |||
| 122 | + | return errors.New("dgram: no peer") | |||
| 123 | + | } | |||
| 124 | + | if _, err := t.pc.WriteTo(pkt, peer); err != nil { | |||
| 125 | + | return err | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | return nil | |||
| 129 | + | } | |||
| 130 | + | func (t *Transport) nextCounter() uint64 { t.mu.Lock(); defer t.mu.Unlock(); t.ctr++; return t.ctr } | |||
| 131 | + | ||||
| 132 | + | func (t *Transport) readLoop() { | |||
| 133 | + | buf := make([]byte, 64*1024) | |||
| 134 | + | for { | |||
| 135 | + | n, addr, err := t.pc.ReadFrom(buf) | |||
| 136 | + | if err != nil { | |||
| 137 | + | return | |||
| 138 | + | } | |||
| 139 | + | _, pt, err := t.codec.Open(append([]byte(nil), buf[:n]...), t.recvDir, nil, t.replay) | |||
| 140 | + | if err != nil { | |||
| 141 | + | continue | |||
| 142 | + | } | |||
| 143 | + | t.mu.Lock() | |||
| 144 | + | t.peer = addr | |||
| 145 | + | t.mu.Unlock() | |||
| 146 | + | frag, err := pdgram.UnmarshalFragment(pt) | |||
| 147 | + | if err != nil { | |||
| 148 | + | continue | |||
| 149 | + | } | |||
| 150 | + | payload, ok, err := t.reasm.Add(frag) | |||
| 151 | + | if err != nil || !ok { | |||
| 152 | + | continue | |||
| 153 | + | } | |||
| 154 | + | t.handleRecord(payload) | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func (t *Transport) handleRecord(p []byte) { | |||
| 159 | + | if len(p) < 1 { | |||
| 160 | + | return | |||
| 161 | + | } | |||
| 162 | + | switch p[0] { | |||
| 163 | + | case recAck: | |||
| 164 | + | if len(p) != 9 { | |||
| 165 | + | return | |||
| 166 | + | } | |||
| 167 | + | seq := binary.BigEndian.Uint64(p[1:]) | |||
| 168 | + | t.mu.Lock() | |||
| 169 | + | delete(t.pending, seq) | |||
| 170 | + | t.mu.Unlock() | |||
| 171 | + | case recData: | |||
| 172 | + | seq, reliable, f, ok := decodeData(p) | |||
| 173 | + | if !ok { | |||
| 174 | + | return | |||
| 175 | + | } | |||
| 176 | + | if reliable { | |||
| 177 | + | t.sendAck(seq) | |||
| 178 | + | if !t.markReliableDelivered(seq) { | |||
| 179 | + | return | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | go t.deliver(f) | |||
| 183 | + | } | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (t *Transport) deliver(f ports.Frame) { | |||
| 187 | + | select { | |||
| 188 | + | case t.in <- f: | |||
| 189 | + | case <-t.done: | |||
| 190 | + | } | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func (t *Transport) markReliableDelivered(seq uint64) bool { | |||
| 194 | + | t.mu.Lock() | |||
| 195 | + | defer t.mu.Unlock() | |||
| 196 | + | if _, ok := t.delivered[seq]; ok { | |||
| 197 | + | return false | |||
| 198 | + | } | |||
| 199 | + | t.delivered[seq] = struct{}{} | |||
| 200 | + | if seq > t.deliveredMax { | |||
| 201 | + | t.deliveredMax = seq | |||
| 202 | + | } | |||
| 203 | + | const window = 1024 | |||
| 204 | + | if t.deliveredMax > window { | |||
| 205 | + | cutoff := t.deliveredMax - window | |||
| 206 | + | for s := range t.delivered { | |||
| 207 | + | if s < cutoff { | |||
| 208 | + | delete(t.delivered, s) | |||
| 209 | + | } | |||
| 210 | + | } | |||
| 211 | + | } | |||
| 212 | + | return true | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | func (t *Transport) resendLoop() { | |||
| 216 | + | tick := time.NewTicker(defaultResend) | |||
| 217 | + | defer tick.Stop() | |||
| 218 | + | for { | |||
| 219 | + | select { | |||
| 220 | + | case <-tick.C: | |||
| 221 | + | now := time.Now() | |||
| 222 | + | var resend []struct { | |||
| 223 | + | seq uint64 | |||
| 224 | + | f ports.Frame | |||
| 225 | + | } | |||
| 226 | + | t.mu.Lock() | |||
| 227 | + | for seq, p := range t.pending { | |||
| 228 | + | if p.last.IsZero() || now.Sub(p.last) >= defaultResend { | |||
| 229 | + | p.last = now | |||
| 230 | + | resend = append(resend, struct { | |||
| 231 | + | seq uint64 | |||
| 232 | + | f ports.Frame | |||
| 233 | + | }{seq, p.frame}) | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | t.mu.Unlock() | |||
| 237 | + | for _, r := range resend { | |||
| 238 | + | _ = t.sendData(r.seq, true, r.f) | |||
| 239 | + | } | |||
| 240 | + | case <-t.done: | |||
| 241 | + | return | |||
| 242 | + | } | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 246 | + | func encodeData(seq uint64, reliable bool, f ports.Frame) []byte { | |||
| 247 | + | b := make([]byte, 12+len(f.Payload)) | |||
| 248 | + | b[0] = recData | |||
| 249 | + | binary.BigEndian.PutUint64(b[1:9], seq) | |||
| 250 | + | if reliable { | |||
| 251 | + | b[9] = 1 | |||
| 252 | + | } | |||
| 253 | + | b[10] = byte(f.Type) | |||
| 254 | + | b[11] = 0 | |||
| 255 | + | copy(b[12:], f.Payload) | |||
| 256 | + | return b | |||
| 257 | + | } | |||
| 258 | + | func decodeData(b []byte) (uint64, bool, ports.Frame, bool) { | |||
| 259 | + | if len(b) < 12 || b[0] != recData { | |||
| 260 | + | return 0, false, ports.Frame{}, false | |||
| 261 | + | } | |||
| 262 | + | return binary.BigEndian.Uint64(b[1:9]), b[9] == 1, ports.Frame{Type: ports.MsgType(b[10]), Payload: append([]byte(nil), b[12:]...)}, true | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | type Dialer struct { | |||
| 266 | + | PC net.PacketConn | |||
| 267 | + | Peer net.Addr | |||
| 268 | + | Key []byte | |||
| 269 | + | SendDir, RecvDir uint32 | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func (d Dialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 273 | + | return NewTransport(d.PC, d.Peer, d.Key, d.SendDir, d.RecvDir) | |||
| 274 | + | } | |||
A internal/adapters/dgram/transport_test.go +232 -0
| @@ -0,0 +1,232 @@ | |||
| 1 | + | package dgram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "errors" | |
| 6 | + | "net" | |
| 7 | + | "sync" | |
| 8 | + | "sync/atomic" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type testAddr string | |
| 17 | + | ||
| 18 | + | func (a testAddr) Network() string { return "mem" } | |
| 19 | + | func (a testAddr) String() string { return string(a) } | |
| 20 | + | ||
| 21 | + | type packet struct { | |
| 22 | + | b []byte | |
| 23 | + | addr net.Addr | |
| 24 | + | } | |
| 25 | + | type fakePC struct { | |
| 26 | + | addr net.Addr | |
| 27 | + | mu sync.Mutex | |
| 28 | + | closed bool | |
| 29 | + | in chan packet | |
| 30 | + | peers map[string]*fakePC | |
| 31 | + | drop func([]byte, net.Addr) bool | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func newPair() (*fakePC, *fakePC) { | |
| 35 | + | a := &fakePC{addr: testAddr("a"), in: make(chan packet, 100), peers: map[string]*fakePC{}} | |
| 36 | + | b := &fakePC{addr: testAddr("b"), in: make(chan packet, 100), peers: map[string]*fakePC{}} | |
| 37 | + | a.peers["b"] = b | |
| 38 | + | b.peers["a"] = a | |
| 39 | + | return a, b | |
| 40 | + | } | |
| 41 | + | func (p *fakePC) ReadFrom(b []byte) (int, net.Addr, error) { | |
| 42 | + | q, ok := <-p.in | |
| 43 | + | if !ok { | |
| 44 | + | return 0, nil, errors.New("closed") | |
| 45 | + | } | |
| 46 | + | copy(b, q.b) | |
| 47 | + | return len(q.b), q.addr, nil | |
| 48 | + | } | |
| 49 | + | func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) { | |
| 50 | + | p.mu.Lock() | |
| 51 | + | closed := p.closed | |
| 52 | + | p.mu.Unlock() | |
| 53 | + | if closed { | |
| 54 | + | return 0, errors.New("closed") | |
| 55 | + | } | |
| 56 | + | if p.drop != nil && p.drop(b, addr) { | |
| 57 | + | return len(b), nil | |
| 58 | + | } | |
| 59 | + | peer := p.peers[addr.String()] | |
| 60 | + | peer.mu.Lock() | |
| 61 | + | peerClosed := peer.closed | |
| 62 | + | peer.mu.Unlock() | |
| 63 | + | if peerClosed { | |
| 64 | + | return 0, errors.New("peer closed") | |
| 65 | + | } | |
| 66 | + | peer.in <- packet{append([]byte(nil), b...), p.addr} | |
| 67 | + | return len(b), nil | |
| 68 | + | } | |
| 69 | + | func (p *fakePC) Close() error { | |
| 70 | + | p.mu.Lock() | |
| 71 | + | defer p.mu.Unlock() | |
| 72 | + | if !p.closed { | |
| 73 | + | p.closed = true | |
| 74 | + | close(p.in) | |
| 75 | + | } | |
| 76 | + | return nil | |
| 77 | + | } | |
| 78 | + | func (p *fakePC) LocalAddr() net.Addr { return p.addr } | |
| 79 | + | func (p *fakePC) SetDeadline(time.Time) error { return nil } | |
| 80 | + | func (p *fakePC) SetReadDeadline(time.Time) error { return nil } | |
| 81 | + | func (p *fakePC) SetWriteDeadline(time.Time) error { return nil } | |
| 82 | + | ||
| 83 | + | func key() []byte { return bytes.Repeat([]byte{1}, pdgram.KeySize) } | |
| 84 | + | ||
| 85 | + | func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) { | |
| 86 | + | aPC, bPC := newPair() | |
| 87 | + | var droppedAck atomic.Bool | |
| 88 | + | bPC.drop = func(_ []byte, addr net.Addr) bool { | |
| 89 | + | return addr.String() == "a" && droppedAck.CompareAndSwap(false, true) | |
| 90 | + | } | |
| 91 | + | a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |
| 92 | + | if err != nil { | |
| 93 | + | t.Fatal(err) | |
| 94 | + | } | |
| 95 | + | b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |
| 96 | + | if err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | defer func() { | |
| 100 | + | if err := a.Close(); err != nil { | |
| 101 | + | t.Errorf("close a: %v", err) | |
| 102 | + | } | |
| 103 | + | }() | |
| 104 | + | defer func() { | |
| 105 | + | if err := b.Close(); err != nil { | |
| 106 | + | t.Errorf("close b: %v", err) | |
| 107 | + | } | |
| 108 | + | }() | |
| 109 | + | if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | got := recvWithin(t, b, time.Second) | |
| 113 | + | if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !droppedAck.Load() { | |
| 114 | + | t.Fatalf("got=%+v droppedAck=%v", got, droppedAck.Load()) | |
| 115 | + | } | |
| 116 | + | if got, ok := recvMaybe(b, 3*defaultResend); ok { | |
| 117 | + | t.Fatalf("duplicate reliable frame delivered: %+v", got) | |
| 118 | + | } | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func TestReliableInputRetransmitsUntilAck(t *testing.T) { | |
| 122 | + | aPC, bPC := newPair() | |
| 123 | + | var dropped atomic.Bool | |
| 124 | + | aPC.drop = func(_ []byte, addr net.Addr) bool { | |
| 125 | + | return addr.String() == "b" && dropped.CompareAndSwap(false, true) | |
| 126 | + | } | |
| 127 | + | a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |
| 128 | + | if err != nil { | |
| 129 | + | t.Fatal(err) | |
| 130 | + | } | |
| 131 | + | b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |
| 132 | + | if err != nil { | |
| 133 | + | t.Fatal(err) | |
| 134 | + | } | |
| 135 | + | defer func() { | |
| 136 | + | if err := a.Close(); err != nil { | |
| 137 | + | t.Errorf("close a: %v", err) | |
| 138 | + | } | |
| 139 | + | }() | |
| 140 | + | defer func() { | |
| 141 | + | if err := b.Close(); err != nil { | |
| 142 | + | t.Errorf("close b: %v", err) | |
| 143 | + | } | |
| 144 | + | }() | |
| 145 | + | if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil { | |
| 146 | + | t.Fatal(err) | |
| 147 | + | } | |
| 148 | + | got := recvWithin(t, b, time.Second) | |
| 149 | + | if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() { | |
| 150 | + | t.Fatalf("got=%+v dropped=%v", got, dropped.Load()) | |
| 151 | + | } | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestAuthenticatedOnlyRehome(t *testing.T) { | |
| 155 | + | aPC, bPC := newPair() | |
| 156 | + | a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |
| 157 | + | if err != nil { | |
| 158 | + | t.Fatal(err) | |
| 159 | + | } | |
| 160 | + | defer func() { | |
| 161 | + | if err := a.Close(); err != nil { | |
| 162 | + | t.Errorf("close a: %v", err) | |
| 163 | + | } | |
| 164 | + | }() | |
| 165 | + | _ = bPC | |
| 166 | + | old := a.Peer().String() | |
| 167 | + | aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")} | |
| 168 | + | time.Sleep(30 * time.Millisecond) | |
| 169 | + | if a.Peer().String() != old { | |
| 170 | + | t.Fatalf("unauthenticated packet changed peer to %v", a.Peer()) | |
| 171 | + | } | |
| 172 | + | c, _ := pdgram.NewCodec(key()) | |
| 173 | + | rec := encodeData(7, false, ports.Frame{Type: ports.MsgPing}) | |
| 174 | + | frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU) | |
| 175 | + | raw, _ := pdgram.MarshalFragment(frags[0]) | |
| 176 | + | aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")} | |
| 177 | + | time.Sleep(30 * time.Millisecond) | |
| 178 | + | if a.Peer().String() != "evil" { | |
| 179 | + | t.Fatalf("authenticated packet did not rehome: %v", a.Peer()) | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func TestOutputRetransmitsUntilAck(t *testing.T) { | |
| 184 | + | aPC, bPC := newPair() | |
| 185 | + | var dropped atomic.Bool | |
| 186 | + | aPC.drop = func(_ []byte, addr net.Addr) bool { | |
| 187 | + | return addr.String() == "b" && dropped.CompareAndSwap(false, true) | |
| 188 | + | } | |
| 189 | + | a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |
| 190 | + | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |
| 191 | + | defer func() { | |
| 192 | + | if err := a.Close(); err != nil { | |
| 193 | + | t.Errorf("close a: %v", err) | |
| 194 | + | } | |
| 195 | + | }() | |
| 196 | + | defer func() { | |
| 197 | + | if err := b.Close(); err != nil { | |
| 198 | + | t.Errorf("close b: %v", err) | |
| 199 | + | } | |
| 200 | + | }() | |
| 201 | + | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil { | |
| 202 | + | t.Fatal(err) | |
| 203 | + | } | |
| 204 | + | got := recvWithin(t, b, time.Second) | |
| 205 | + | if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() { | |
| 206 | + | t.Fatalf("got=%+v dropped=%v", got, dropped.Load()) | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) { | |
| 211 | + | ch := make(chan ports.Frame, 1) | |
| 212 | + | go func() { f, _ := tr.Recv(); ch <- f }() | |
| 213 | + | select { | |
| 214 | + | case f := <-ch: | |
| 215 | + | return f, true | |
| 216 | + | case <-time.After(d): | |
| 217 | + | return ports.Frame{}, false | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame { | |
| 222 | + | t.Helper() | |
| 223 | + | ch := make(chan ports.Frame, 1) | |
| 224 | + | go func() { f, _ := tr.Recv(); ch <- f }() | |
| 225 | + | select { | |
| 226 | + | case f := <-ch: | |
| 227 | + | return f | |
| 228 | + | case <-time.After(d): | |
| 229 | + | t.Fatal("timeout") | |
| 230 | + | return ports.Frame{} | |
| 231 | + | } | |
| 232 | + | } | |
| @@ -0,0 +1,232 @@ | |||||
| 1 | + | package dgram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "errors" | |||
| 6 | + | "net" | |||
| 7 | + | "sync" | |||
| 8 | + | "sync/atomic" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | pdgram "github.com/bnema/vev/pkg/dgram" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type testAddr string | |||
| 17 | + | ||||
| 18 | + | func (a testAddr) Network() string { return "mem" } | |||
| 19 | + | func (a testAddr) String() string { return string(a) } | |||
| 20 | + | ||||
| 21 | + | type packet struct { | |||
| 22 | + | b []byte | |||
| 23 | + | addr net.Addr | |||
| 24 | + | } | |||
| 25 | + | type fakePC struct { | |||
| 26 | + | addr net.Addr | |||
| 27 | + | mu sync.Mutex | |||
| 28 | + | closed bool | |||
| 29 | + | in chan packet | |||
| 30 | + | peers map[string]*fakePC | |||
| 31 | + | drop func([]byte, net.Addr) bool | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func newPair() (*fakePC, *fakePC) { | |||
| 35 | + | a := &fakePC{addr: testAddr("a"), in: make(chan packet, 100), peers: map[string]*fakePC{}} | |||
| 36 | + | b := &fakePC{addr: testAddr("b"), in: make(chan packet, 100), peers: map[string]*fakePC{}} | |||
| 37 | + | a.peers["b"] = b | |||
| 38 | + | b.peers["a"] = a | |||
| 39 | + | return a, b | |||
| 40 | + | } | |||
| 41 | + | func (p *fakePC) ReadFrom(b []byte) (int, net.Addr, error) { | |||
| 42 | + | q, ok := <-p.in | |||
| 43 | + | if !ok { | |||
| 44 | + | return 0, nil, errors.New("closed") | |||
| 45 | + | } | |||
| 46 | + | copy(b, q.b) | |||
| 47 | + | return len(q.b), q.addr, nil | |||
| 48 | + | } | |||
| 49 | + | func (p *fakePC) WriteTo(b []byte, addr net.Addr) (int, error) { | |||
| 50 | + | p.mu.Lock() | |||
| 51 | + | closed := p.closed | |||
| 52 | + | p.mu.Unlock() | |||
| 53 | + | if closed { | |||
| 54 | + | return 0, errors.New("closed") | |||
| 55 | + | } | |||
| 56 | + | if p.drop != nil && p.drop(b, addr) { | |||
| 57 | + | return len(b), nil | |||
| 58 | + | } | |||
| 59 | + | peer := p.peers[addr.String()] | |||
| 60 | + | peer.mu.Lock() | |||
| 61 | + | peerClosed := peer.closed | |||
| 62 | + | peer.mu.Unlock() | |||
| 63 | + | if peerClosed { | |||
| 64 | + | return 0, errors.New("peer closed") | |||
| 65 | + | } | |||
| 66 | + | peer.in <- packet{append([]byte(nil), b...), p.addr} | |||
| 67 | + | return len(b), nil | |||
| 68 | + | } | |||
| 69 | + | func (p *fakePC) Close() error { | |||
| 70 | + | p.mu.Lock() | |||
| 71 | + | defer p.mu.Unlock() | |||
| 72 | + | if !p.closed { | |||
| 73 | + | p.closed = true | |||
| 74 | + | close(p.in) | |||
| 75 | + | } | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | func (p *fakePC) LocalAddr() net.Addr { return p.addr } | |||
| 79 | + | func (p *fakePC) SetDeadline(time.Time) error { return nil } | |||
| 80 | + | func (p *fakePC) SetReadDeadline(time.Time) error { return nil } | |||
| 81 | + | func (p *fakePC) SetWriteDeadline(time.Time) error { return nil } | |||
| 82 | + | ||||
| 83 | + | func key() []byte { return bytes.Repeat([]byte{1}, pdgram.KeySize) } | |||
| 84 | + | ||||
| 85 | + | func TestReliableDuplicateAckedButNotDeliveredTwice(t *testing.T) { | |||
| 86 | + | aPC, bPC := newPair() | |||
| 87 | + | var droppedAck atomic.Bool | |||
| 88 | + | bPC.drop = func(_ []byte, addr net.Addr) bool { | |||
| 89 | + | return addr.String() == "a" && droppedAck.CompareAndSwap(false, true) | |||
| 90 | + | } | |||
| 91 | + | a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |||
| 92 | + | if err != nil { | |||
| 93 | + | t.Fatal(err) | |||
| 94 | + | } | |||
| 95 | + | b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |||
| 96 | + | if err != nil { | |||
| 97 | + | t.Fatal(err) | |||
| 98 | + | } | |||
| 99 | + | defer func() { | |||
| 100 | + | if err := a.Close(); err != nil { | |||
| 101 | + | t.Errorf("close a: %v", err) | |||
| 102 | + | } | |||
| 103 | + | }() | |||
| 104 | + | defer func() { | |||
| 105 | + | if err := b.Close(); err != nil { | |||
| 106 | + | t.Errorf("close b: %v", err) | |||
| 107 | + | } | |||
| 108 | + | }() | |||
| 109 | + | if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | got := recvWithin(t, b, time.Second) | |||
| 113 | + | if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !droppedAck.Load() { | |||
| 114 | + | t.Fatalf("got=%+v droppedAck=%v", got, droppedAck.Load()) | |||
| 115 | + | } | |||
| 116 | + | if got, ok := recvMaybe(b, 3*defaultResend); ok { | |||
| 117 | + | t.Fatalf("duplicate reliable frame delivered: %+v", got) | |||
| 118 | + | } | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func TestReliableInputRetransmitsUntilAck(t *testing.T) { | |||
| 122 | + | aPC, bPC := newPair() | |||
| 123 | + | var dropped atomic.Bool | |||
| 124 | + | aPC.drop = func(_ []byte, addr net.Addr) bool { | |||
| 125 | + | return addr.String() == "b" && dropped.CompareAndSwap(false, true) | |||
| 126 | + | } | |||
| 127 | + | a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |||
| 128 | + | if err != nil { | |||
| 129 | + | t.Fatal(err) | |||
| 130 | + | } | |||
| 131 | + | b, err := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |||
| 132 | + | if err != nil { | |||
| 133 | + | t.Fatal(err) | |||
| 134 | + | } | |||
| 135 | + | defer func() { | |||
| 136 | + | if err := a.Close(); err != nil { | |||
| 137 | + | t.Errorf("close a: %v", err) | |||
| 138 | + | } | |||
| 139 | + | }() | |||
| 140 | + | defer func() { | |||
| 141 | + | if err := b.Close(); err != nil { | |||
| 142 | + | t.Errorf("close b: %v", err) | |||
| 143 | + | } | |||
| 144 | + | }() | |||
| 145 | + | if err := a.Send(ports.Frame{Type: ports.MsgInput, Payload: []byte("typed")}); err != nil { | |||
| 146 | + | t.Fatal(err) | |||
| 147 | + | } | |||
| 148 | + | got := recvWithin(t, b, time.Second) | |||
| 149 | + | if got.Type != ports.MsgInput || string(got.Payload) != "typed" || !dropped.Load() { | |||
| 150 | + | t.Fatalf("got=%+v dropped=%v", got, dropped.Load()) | |||
| 151 | + | } | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestAuthenticatedOnlyRehome(t *testing.T) { | |||
| 155 | + | aPC, bPC := newPair() | |||
| 156 | + | a, err := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |||
| 157 | + | if err != nil { | |||
| 158 | + | t.Fatal(err) | |||
| 159 | + | } | |||
| 160 | + | defer func() { | |||
| 161 | + | if err := a.Close(); err != nil { | |||
| 162 | + | t.Errorf("close a: %v", err) | |||
| 163 | + | } | |||
| 164 | + | }() | |||
| 165 | + | _ = bPC | |||
| 166 | + | old := a.Peer().String() | |||
| 167 | + | aPC.in <- packet{[]byte("not authenticated"), testAddr("evil")} | |||
| 168 | + | time.Sleep(30 * time.Millisecond) | |||
| 169 | + | if a.Peer().String() != old { | |||
| 170 | + | t.Fatalf("unauthenticated packet changed peer to %v", a.Peer()) | |||
| 171 | + | } | |||
| 172 | + | c, _ := pdgram.NewCodec(key()) | |||
| 173 | + | rec := encodeData(7, false, ports.Frame{Type: ports.MsgPing}) | |||
| 174 | + | frags, _ := pdgram.FragmentPayload(9, rec, pdgram.DefaultMTU) | |||
| 175 | + | raw, _ := pdgram.MarshalFragment(frags[0]) | |||
| 176 | + | aPC.in <- packet{c.Seal(2, 99, raw, nil), testAddr("evil")} | |||
| 177 | + | time.Sleep(30 * time.Millisecond) | |||
| 178 | + | if a.Peer().String() != "evil" { | |||
| 179 | + | t.Fatalf("authenticated packet did not rehome: %v", a.Peer()) | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | func TestOutputRetransmitsUntilAck(t *testing.T) { | |||
| 184 | + | aPC, bPC := newPair() | |||
| 185 | + | var dropped atomic.Bool | |||
| 186 | + | aPC.drop = func(_ []byte, addr net.Addr) bool { | |||
| 187 | + | return addr.String() == "b" && dropped.CompareAndSwap(false, true) | |||
| 188 | + | } | |||
| 189 | + | a, _ := NewTransport(aPC, testAddr("b"), key(), 1, 2) | |||
| 190 | + | b, _ := NewTransport(bPC, testAddr("a"), key(), 2, 1) | |||
| 191 | + | defer func() { | |||
| 192 | + | if err := a.Close(); err != nil { | |||
| 193 | + | t.Errorf("close a: %v", err) | |||
| 194 | + | } | |||
| 195 | + | }() | |||
| 196 | + | defer func() { | |||
| 197 | + | if err := b.Close(); err != nil { | |||
| 198 | + | t.Errorf("close b: %v", err) | |||
| 199 | + | } | |||
| 200 | + | }() | |||
| 201 | + | if err := a.Send(ports.Frame{Type: ports.MsgOutput, Payload: []byte("state1")}); err != nil { | |||
| 202 | + | t.Fatal(err) | |||
| 203 | + | } | |||
| 204 | + | got := recvWithin(t, b, time.Second) | |||
| 205 | + | if got.Type != ports.MsgOutput || string(got.Payload) != "state1" || !dropped.Load() { | |||
| 206 | + | t.Fatalf("got=%+v dropped=%v", got, dropped.Load()) | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func recvMaybe(tr *Transport, d time.Duration) (ports.Frame, bool) { | |||
| 211 | + | ch := make(chan ports.Frame, 1) | |||
| 212 | + | go func() { f, _ := tr.Recv(); ch <- f }() | |||
| 213 | + | select { | |||
| 214 | + | case f := <-ch: | |||
| 215 | + | return f, true | |||
| 216 | + | case <-time.After(d): | |||
| 217 | + | return ports.Frame{}, false | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func recvWithin(t *testing.T, tr *Transport, d time.Duration) ports.Frame { | |||
| 222 | + | t.Helper() | |||
| 223 | + | ch := make(chan ports.Frame, 1) | |||
| 224 | + | go func() { f, _ := tr.Recv(); ch <- f }() | |||
| 225 | + | select { | |||
| 226 | + | case f := <-ch: | |||
| 227 | + | return f | |||
| 228 | + | case <-time.After(d): | |||
| 229 | + | t.Fatal("timeout") | |||
| 230 | + | return ports.Frame{} | |||
| 231 | + | } | |||
| 232 | + | } | |||
M internal/app/remote_test.go +32 -1
| @@ -1,6 +1,37 @@ | |||
| 1 | 1 | package app | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestLimitedBufferCapsBootstrapStderr(t *testing.T) { | |
| 10 | + | var b limitedBuffer | |
| 11 | + | chunk := strings.Repeat("x", maxBootstrapStderr+1024) | |
| 12 | + | n, err := b.Write([]byte(chunk)) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | if n != len(chunk) { | |
| 17 | + | t.Fatalf("n=%d, want %d", n, len(chunk)) | |
| 18 | + | } | |
| 19 | + | if got := len(b.String()); got != maxBootstrapStderr { | |
| 20 | + | t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestRemoteDatagramDialerReportsUDPAndStdioFailures(t *testing.T) { | |
| 25 | + | t.Setenv("PATH", t.TempDir()) | |
| 26 | + | _, err := (remoteDatagramDialer{target: "example.invalid"}).Dial(context.Background()) | |
| 27 | + | if err == nil { | |
| 28 | + | t.Fatal("expected dial error") | |
| 29 | + | } | |
| 30 | + | msg := err.Error() | |
| 31 | + | if !strings.Contains(msg, "datagram dial failed") || !strings.Contains(msg, "stdio fallback failed") { | |
| 32 | + | t.Fatalf("error %q does not include both failures", msg) | |
| 33 | + | } | |
| 34 | + | } | |
| 4 | 35 | ||
| 5 | 36 | func TestParseRemoteAttachTarget(t *testing.T) { | |
| 6 | 37 | tests := []struct { | |
| @@ -1,6 +1,37 @@ | |||||
| 1 | package app | 1 | package app | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "context" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestLimitedBufferCapsBootstrapStderr(t *testing.T) { | |||
| 10 | + | var b limitedBuffer | |||
| 11 | + | chunk := strings.Repeat("x", maxBootstrapStderr+1024) | |||
| 12 | + | n, err := b.Write([]byte(chunk)) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | if n != len(chunk) { | |||
| 17 | + | t.Fatalf("n=%d, want %d", n, len(chunk)) | |||
| 18 | + | } | |||
| 19 | + | if got := len(b.String()); got != maxBootstrapStderr { | |||
| 20 | + | t.Fatalf("captured=%d, want %d", got, maxBootstrapStderr) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestRemoteDatagramDialerReportsUDPAndStdioFailures(t *testing.T) { | |||
| 25 | + | t.Setenv("PATH", t.TempDir()) | |||
| 26 | + | _, err := (remoteDatagramDialer{target: "example.invalid"}).Dial(context.Background()) | |||
| 27 | + | if err == nil { | |||
| 28 | + | t.Fatal("expected dial error") | |||
| 29 | + | } | |||
| 30 | + | msg := err.Error() | |||
| 31 | + | if !strings.Contains(msg, "datagram dial failed") || !strings.Contains(msg, "stdio fallback failed") { | |||
| 32 | + | t.Fatalf("error %q does not include both failures", msg) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 4 | 35 | ||||
| 5 | func TestParseRemoteAttachTarget(t *testing.T) { | 36 | func TestParseRemoteAttachTarget(t *testing.T) { | ||
| 6 | tests := []struct { | 37 | tests := []struct { | ||
M internal/app/run.go +232 -13
| @@ -5,20 +5,27 @@ | |||
| 5 | 5 | package app | |
| 6 | 6 | ||
| 7 | 7 | import ( | |
| 8 | + | "bufio" | |
| 8 | 9 | "context" | |
| 10 | + | "crypto/rand" | |
| 11 | + | "encoding/base64" | |
| 9 | 12 | "errors" | |
| 10 | 13 | "fmt" | |
| 11 | 14 | "io" | |
| 12 | 15 | "log/slog" | |
| 16 | + | "net" | |
| 13 | 17 | "net/http" | |
| 14 | 18 | _ "net/http/pprof" | |
| 15 | 19 | "os" | |
| 20 | + | "os/exec" | |
| 16 | 21 | "os/signal" | |
| 22 | + | "strings" | |
| 17 | 23 | "syscall" | |
| 18 | 24 | "text/tabwriter" | |
| 19 | 25 | "time" | |
| 20 | 26 | ||
| 21 | 27 | "github.com/bnema/vev/internal/adapters/clock" | |
| 28 | + | "github.com/bnema/vev/internal/adapters/dgram" | |
| 22 | 29 | "github.com/bnema/vev/internal/adapters/ipc" | |
| 23 | 30 | "github.com/bnema/vev/internal/adapters/pty" | |
| 24 | 31 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| @@ -42,6 +49,7 @@ const ( | |||
| 42 | 49 | kindKill | |
| 43 | 50 | kindDaemon | |
| 44 | 51 | kindStdio | |
| 52 | + | kindUDPBootstrap | |
| 45 | 53 | kindHelp | |
| 46 | 54 | kindVersion | |
| 47 | 55 | ) | |
| @@ -116,6 +124,15 @@ func parseArgs(args []string) (command, error) { | |||
| 116 | 124 | cmd.name = args[1] | |
| 117 | 125 | } | |
| 118 | 126 | return cmd, nil | |
| 127 | + | case "_udp-bootstrap": | |
| 128 | + | if len(args) > 2 { | |
| 129 | + | return command{}, usagef("`_udp-bootstrap` accepts at most one session name") | |
| 130 | + | } | |
| 131 | + | cmd := command{kind: kindUDPBootstrap} | |
| 132 | + | if len(args) == 2 { | |
| 133 | + | cmd.name = args[1] | |
| 134 | + | } | |
| 135 | + | return cmd, nil | |
| 119 | 136 | case "new": | |
| 120 | 137 | if len(args) < 2 || args[1] == "" { | |
| 121 | 138 | return command{}, usagef("`new` requires a session name") | |
| @@ -185,6 +202,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||
| 185 | 202 | return runDaemon() | |
| 186 | 203 | case kindStdio: | |
| 187 | 204 | return runStdio(ctx) | |
| 205 | + | case kindUDPBootstrap: | |
| 206 | + | return runUDPBootstrap(ctx, cmd.name) | |
| 188 | 207 | case kindList: | |
| 189 | 208 | return runList(ctx) | |
| 190 | 209 | case kindKill: | |
| @@ -252,13 +271,24 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||
| 252 | 271 | defer func() { _ = logFile.Close() }() | |
| 253 | 272 | ||
| 254 | 273 | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{ | |
| 255 | - | attachLocal: attachLocalDaemon, | |
| 274 | + | localDialer: defaultLocalDialer, | |
| 275 | + | remoteDialer: defaultRemoteDialer, | |
| 276 | + | runClient: client.Run, | |
| 256 | 277 | createDetached: createDetachedLocalSession, | |
| 257 | 278 | }) | |
| 258 | 279 | } | |
| 259 | 280 | ||
| 281 | + | func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} } | |
| 282 | + | ||
| 283 | + | func defaultRemoteDialer(target, session string) ports.Dialer { | |
| 284 | + | return remoteDatagramDialer{target: target, session: session} | |
| 285 | + | } | |
| 286 | + | ||
| 260 | 287 | type runAttachDeps struct { | |
| 261 | - | attachLocal func(context.Context, uint8, string) error | |
| 288 | + | attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests | |
| 289 | + | localDialer func() ports.Dialer | |
| 290 | + | remoteDialer func(target, session string) ports.Dialer | |
| 291 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string) error | |
| 262 | 292 | createDetached func(context.Context, string) error | |
| 263 | 293 | } | |
| 264 | 294 | ||
| @@ -270,18 +300,31 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||
| 270 | 300 | return errors.New("vev: sessions should be nested with care; unset VEV to force") | |
| 271 | 301 | } | |
| 272 | 302 | ||
| 303 | + | runClient := deps.runClient | |
| 304 | + | if runClient == nil { | |
| 305 | + | runClient = client.Run | |
| 306 | + | } | |
| 273 | 307 | if remoteTarget != "" { | |
| 274 | - | transport, err := sshstdio.Dial(remoteTarget, name) | |
| 275 | - | if err != nil { | |
| 276 | - | return err | |
| 308 | + | remoteDialer := deps.remoteDialer | |
| 309 | + | if remoteDialer == nil { | |
| 310 | + | remoteDialer = defaultRemoteDialer | |
| 277 | 311 | } | |
| 278 | - | // client.Attach owns and closes transport. | |
| 279 | - | return client.Attach(ctx, transport, term.New(), intent, name) | |
| 312 | + | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), intent, name) | |
| 280 | 313 | } | |
| 281 | 314 | ||
| 315 | + | attachLocal := deps.attachLocal | |
| 316 | + | if attachLocal == nil { | |
| 317 | + | localDialer := deps.localDialer | |
| 318 | + | if localDialer == nil { | |
| 319 | + | localDialer = defaultLocalDialer | |
| 320 | + | } | |
| 321 | + | attachLocal = func(ctx context.Context, intent uint8, name string) error { | |
| 322 | + | return runClient(ctx, localDialer(), term.New(), intent, name) | |
| 323 | + | } | |
| 324 | + | } | |
| 282 | 325 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | |
| 283 | 326 | confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr), | |
| 284 | - | attach: deps.attachLocal, | |
| 327 | + | attach: attachLocal, | |
| 285 | 328 | killDaemon: requestDaemonStop, | |
| 286 | 329 | settleAfterKill: waitForDaemonStop, | |
| 287 | 330 | }) | |
| @@ -332,13 +375,122 @@ func isDaemonVersionDrift(err error) bool { | |||
| 332 | 375 | return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal | |
| 333 | 376 | } | |
| 334 | 377 | ||
| 335 | - | func attachLocalDaemon(ctx context.Context, intent uint8, name string) error { | |
| 336 | - | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 378 | + | type localDaemonDialer struct{ dir string } | |
| 379 | + | ||
| 380 | + | func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) { | |
| 381 | + | return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff) | |
| 382 | + | } | |
| 383 | + | ||
| 384 | + | const maxBootstrapStderr = 64 * 1024 | |
| 385 | + | ||
| 386 | + | type limitedBuffer struct { | |
| 387 | + | buf []byte | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func (b *limitedBuffer) Write(p []byte) (int, error) { | |
| 391 | + | if len(b.buf) < maxBootstrapStderr { | |
| 392 | + | keep := min(len(p), maxBootstrapStderr-len(b.buf)) | |
| 393 | + | b.buf = append(b.buf, p[:keep]...) | |
| 394 | + | } | |
| 395 | + | return len(p), nil | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func (b *limitedBuffer) String() string { return string(b.buf) } | |
| 399 | + | ||
| 400 | + | type remoteDatagramDialer struct { | |
| 401 | + | target string | |
| 402 | + | session string | |
| 403 | + | } | |
| 404 | + | ||
| 405 | + | func (d remoteDatagramDialer) Dial(ctx context.Context) (ports.Transport, error) { | |
| 406 | + | tr, udpErr := d.dialDatagram(ctx) | |
| 407 | + | if udpErr == nil { | |
| 408 | + | return tr, nil | |
| 409 | + | } | |
| 410 | + | tr, stdioErr := sshstdio.Dial(d.target, d.session) | |
| 411 | + | if stdioErr != nil { | |
| 412 | + | return nil, fmt.Errorf("datagram dial failed: %w; stdio fallback failed: %w", udpErr, stdioErr) | |
| 413 | + | } | |
| 414 | + | return tr, nil | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func (d remoteDatagramDialer) dialDatagram(ctx context.Context) (ports.Transport, error) { | |
| 418 | + | remote := []string{shellQuote("vev"), shellQuote("_udp-bootstrap")} | |
| 419 | + | if d.session != "" { | |
| 420 | + | remote = append(remote, shellQuote(d.session)) | |
| 421 | + | } | |
| 422 | + | cmd := exec.CommandContext(ctx, "ssh", "--", d.target, strings.Join(remote, " ")) | |
| 423 | + | stdout, err := cmd.StdoutPipe() | |
| 337 | 424 | if err != nil { | |
| 338 | - | return err | |
| 425 | + | return nil, err | |
| 426 | + | } | |
| 427 | + | var stderr limitedBuffer | |
| 428 | + | cmd.Stderr = &stderr | |
| 429 | + | if err := cmd.Start(); err != nil { | |
| 430 | + | return nil, err | |
| 431 | + | } | |
| 432 | + | started := true | |
| 433 | + | defer func() { | |
| 434 | + | if started { | |
| 435 | + | _ = cmd.Process.Kill() | |
| 436 | + | _ = cmd.Wait() | |
| 437 | + | } | |
| 438 | + | }() | |
| 439 | + | ||
| 440 | + | line, err := bufio.NewReader(stdout).ReadString('\n') | |
| 441 | + | if err != nil { | |
| 442 | + | return nil, fmt.Errorf("udp bootstrap: %w: %s", err, strings.TrimSpace(stderr.String())) | |
| 443 | + | } | |
| 444 | + | fields := strings.Fields(strings.TrimSpace(line)) | |
| 445 | + | if len(fields) != 3 || fields[0] != "VEV-UDP" { | |
| 446 | + | return nil, fmt.Errorf("udp bootstrap: unexpected reply %q", strings.TrimSpace(line)) | |
| 447 | + | } | |
| 448 | + | key, err := base64.StdEncoding.DecodeString(fields[2]) | |
| 449 | + | if err != nil { | |
| 450 | + | return nil, err | |
| 451 | + | } | |
| 452 | + | host := sshTargetHost(d.target) | |
| 453 | + | peer, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, fields[1])) | |
| 454 | + | if err != nil { | |
| 455 | + | return nil, err | |
| 456 | + | } | |
| 457 | + | var lc net.ListenConfig | |
| 458 | + | pc, err := lc.ListenPacket(ctx, "udp", ":0") | |
| 459 | + | if err != nil { | |
| 460 | + | return nil, err | |
| 339 | 461 | } | |
| 340 | - | // client.Attach owns and closes transport. | |
| 341 | - | return client.Attach(ctx, transport, term.New(), intent, name) | |
| 462 | + | tr, err := dgram.NewTransport(pc, peer, key, 1, 2) | |
| 463 | + | if err != nil { | |
| 464 | + | _ = pc.Close() | |
| 465 | + | return nil, err | |
| 466 | + | } | |
| 467 | + | started = false | |
| 468 | + | return closeBothTransport{Transport: tr, close: func() error { | |
| 469 | + | _ = tr.Close() | |
| 470 | + | _ = cmd.Process.Kill() | |
| 471 | + | return cmd.Wait() | |
| 472 | + | }}, nil | |
| 473 | + | } | |
| 474 | + | ||
| 475 | + | func sshTargetHost(target string) string { | |
| 476 | + | if at := strings.LastIndexByte(target, '@'); at >= 0 { | |
| 477 | + | target = target[at+1:] | |
| 478 | + | } | |
| 479 | + | if h, _, err := net.SplitHostPort(target); err == nil { | |
| 480 | + | return h | |
| 481 | + | } | |
| 482 | + | return target | |
| 483 | + | } | |
| 484 | + | ||
| 485 | + | type closeBothTransport struct { | |
| 486 | + | ports.Transport | |
| 487 | + | close func() error | |
| 488 | + | } | |
| 489 | + | ||
| 490 | + | func (t closeBothTransport) Close() error { return t.close() } | |
| 491 | + | ||
| 492 | + | func shellQuote(s string) string { | |
| 493 | + | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | |
| 342 | 494 | } | |
| 343 | 495 | ||
| 344 | 496 | func createDetachedLocalSession(ctx context.Context, name string) error { | |
| @@ -462,6 +614,73 @@ func runStdio(ctx context.Context) error { | |||
| 462 | 614 | return proxyTransports(ctx, stdio, transport) | |
| 463 | 615 | } | |
| 464 | 616 | ||
| 617 | + | // runUDPBootstrap is the hidden remote-side bootstrap used before switching to | |
| 618 | + | // authenticated datagrams. It deliberately prints only an ephemeral UDP port and | |
| 619 | + | // random session key to stdout so callers can fall back to _stdio if setup fails. | |
| 620 | + | func runUDPBootstrap(ctx context.Context, session string) error { | |
| 621 | + | select { | |
| 622 | + | case <-ctx.Done(): | |
| 623 | + | return ctx.Err() | |
| 624 | + | default: | |
| 625 | + | } | |
| 626 | + | var lc net.ListenConfig | |
| 627 | + | conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0") | |
| 628 | + | if err != nil { | |
| 629 | + | return err | |
| 630 | + | } | |
| 631 | + | defer func() { _ = conn.Close() }() | |
| 632 | + | key := make([]byte, 32) | |
| 633 | + | if _, err := rand.Read(key); err != nil { | |
| 634 | + | return err | |
| 635 | + | } | |
| 636 | + | daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |
| 637 | + | if err != nil { | |
| 638 | + | return err | |
| 639 | + | } | |
| 640 | + | defer func() { _ = daemonTr.Close() }() | |
| 641 | + | dg, err := dgram.NewTransport(conn, nil, key, 2, 1) | |
| 642 | + | if err != nil { | |
| 643 | + | return err | |
| 644 | + | } | |
| 645 | + | defer func() { _ = dg.Close() }() | |
| 646 | + | ||
| 647 | + | addr, ok := conn.LocalAddr().(*net.UDPAddr) | |
| 648 | + | if !ok { | |
| 649 | + | return errors.New("vev: udp bootstrap did not get a UDP address") | |
| 650 | + | } | |
| 651 | + | if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { | |
| 652 | + | return err | |
| 653 | + | } | |
| 654 | + | _ = os.Stdout.Sync() | |
| 655 | + | ||
| 656 | + | var dgramTr ports.Transport = dg | |
| 657 | + | if session != "" { | |
| 658 | + | dgramTr = preHelloNameTransport{Transport: dg, session: session} | |
| 659 | + | } | |
| 660 | + | return proxyTransports(ctx, dgramTr, daemonTr) | |
| 661 | + | } | |
| 662 | + | ||
| 663 | + | type preHelloNameTransport struct { | |
| 664 | + | ports.Transport | |
| 665 | + | session string | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | func (t preHelloNameTransport) Recv() (ports.Frame, error) { | |
| 669 | + | f, err := t.Transport.Recv() | |
| 670 | + | if err != nil || f.Type != ports.MsgHello || t.session == "" { | |
| 671 | + | return f, err | |
| 672 | + | } | |
| 673 | + | h, err := ports.UnmarshalHello(f.Payload) | |
| 674 | + | if err != nil { | |
| 675 | + | return f, nil | |
| 676 | + | } | |
| 677 | + | if h.Name == "" { | |
| 678 | + | h.Name = t.session | |
| 679 | + | f.Payload = ports.MarshalHello(h) | |
| 680 | + | } | |
| 681 | + | return f, nil | |
| 682 | + | } | |
| 683 | + | ||
| 465 | 684 | func proxyTransports(ctx context.Context, a, b ports.Transport) error { | |
| 466 | 685 | errCh := make(chan error, 2) | |
| 467 | 686 | copyFrames := func(dst, src ports.Transport) { | |
| @@ -5,20 +5,27 @@ | |||||
| 5 | package app | 5 | package app | ||
| 6 | 6 | ||||
| 7 | import ( | 7 | import ( | ||
| 8 | + | "bufio" | |||
| 8 | "context" | 9 | "context" | ||
| 10 | + | "crypto/rand" | |||
| 11 | + | "encoding/base64" | |||
| 9 | "errors" | 12 | "errors" | ||
| 10 | "fmt" | 13 | "fmt" | ||
| 11 | "io" | 14 | "io" | ||
| 12 | "log/slog" | 15 | "log/slog" | ||
| 16 | + | "net" | |||
| 13 | "net/http" | 17 | "net/http" | ||
| 14 | _ "net/http/pprof" | 18 | _ "net/http/pprof" | ||
| 15 | "os" | 19 | "os" | ||
| 20 | + | "os/exec" | |||
| 16 | "os/signal" | 21 | "os/signal" | ||
| 22 | + | "strings" | |||
| 17 | "syscall" | 23 | "syscall" | ||
| 18 | "text/tabwriter" | 24 | "text/tabwriter" | ||
| 19 | "time" | 25 | "time" | ||
| 20 | 26 | ||||
| 21 | "github.com/bnema/vev/internal/adapters/clock" | 27 | "github.com/bnema/vev/internal/adapters/clock" | ||
| 28 | + | "github.com/bnema/vev/internal/adapters/dgram" | |||
| 22 | "github.com/bnema/vev/internal/adapters/ipc" | 29 | "github.com/bnema/vev/internal/adapters/ipc" | ||
| 23 | "github.com/bnema/vev/internal/adapters/pty" | 30 | "github.com/bnema/vev/internal/adapters/pty" | ||
| 24 | "github.com/bnema/vev/internal/adapters/sshstdio" | 31 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| @@ -42,6 +49,7 @@ const ( | |||||
| 42 | kindKill | 49 | kindKill | ||
| 43 | kindDaemon | 50 | kindDaemon | ||
| 44 | kindStdio | 51 | kindStdio | ||
| 52 | + | kindUDPBootstrap | |||
| 45 | kindHelp | 53 | kindHelp | ||
| 46 | kindVersion | 54 | kindVersion | ||
| 47 | ) | 55 | ) | ||
| @@ -116,6 +124,15 @@ func parseArgs(args []string) (command, error) { | |||||
| 116 | cmd.name = args[1] | 124 | cmd.name = args[1] | ||
| 117 | } | 125 | } | ||
| 118 | return cmd, nil | 126 | return cmd, nil | ||
| 127 | + | case "_udp-bootstrap": | |||
| 128 | + | if len(args) > 2 { | |||
| 129 | + | return command{}, usagef("`_udp-bootstrap` accepts at most one session name") | |||
| 130 | + | } | |||
| 131 | + | cmd := command{kind: kindUDPBootstrap} | |||
| 132 | + | if len(args) == 2 { | |||
| 133 | + | cmd.name = args[1] | |||
| 134 | + | } | |||
| 135 | + | return cmd, nil | |||
| 119 | case "new": | 136 | case "new": | ||
| 120 | if len(args) < 2 || args[1] == "" { | 137 | if len(args) < 2 || args[1] == "" { | ||
| 121 | return command{}, usagef("`new` requires a session name") | 138 | return command{}, usagef("`new` requires a session name") | ||
| @@ -185,6 +202,8 @@ func dispatch(ctx context.Context, cmd command) error { | |||||
| 185 | return runDaemon() | 202 | return runDaemon() | ||
| 186 | case kindStdio: | 203 | case kindStdio: | ||
| 187 | return runStdio(ctx) | 204 | return runStdio(ctx) | ||
| 205 | + | case kindUDPBootstrap: | |||
| 206 | + | return runUDPBootstrap(ctx, cmd.name) | |||
| 188 | case kindList: | 207 | case kindList: | ||
| 189 | return runList(ctx) | 208 | return runList(ctx) | ||
| 190 | case kindKill: | 209 | case kindKill: | ||
| @@ -252,13 +271,24 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err | |||||
| 252 | defer func() { _ = logFile.Close() }() | 271 | defer func() { _ = logFile.Close() }() | ||
| 253 | 272 | ||||
| 254 | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{ | 273 | return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), runAttachDeps{ | ||
| 255 | - | attachLocal: attachLocalDaemon, | 274 | + | localDialer: defaultLocalDialer, |
| 275 | + | remoteDialer: defaultRemoteDialer, | |||
| 276 | + | runClient: client.Run, | |||
| 256 | createDetached: createDetachedLocalSession, | 277 | createDetached: createDetachedLocalSession, | ||
| 257 | }) | 278 | }) | ||
| 258 | } | 279 | } | ||
| 259 | 280 | ||||
| 281 | + | func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} } | |||
| 282 | + | ||||
| 283 | + | func defaultRemoteDialer(target, session string) ports.Dialer { | |||
| 284 | + | return remoteDatagramDialer{target: target, session: session} | |||
| 285 | + | } | |||
| 286 | + | ||||
| 260 | type runAttachDeps struct { | 287 | type runAttachDeps struct { | ||
| 261 | - | attachLocal func(context.Context, uint8, string) error | 288 | + | attachLocal func(context.Context, uint8, string) error // compatibility hook for focused tests |
| 289 | + | localDialer func() ports.Dialer | |||
| 290 | + | remoteDialer func(target, session string) ports.Dialer | |||
| 291 | + | runClient func(context.Context, ports.Dialer, ports.Terminal, uint8, string) error | |||
| 262 | createDetached func(context.Context, string) error | 292 | createDetached func(context.Context, string) error | ||
| 263 | } | 293 | } | ||
| 264 | 294 | ||||
| @@ -270,18 +300,31 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac | |||||
| 270 | return errors.New("vev: sessions should be nested with care; unset VEV to force") | 300 | return errors.New("vev: sessions should be nested with care; unset VEV to force") | ||
| 271 | } | 301 | } | ||
| 272 | 302 | ||||
| 303 | + | runClient := deps.runClient | |||
| 304 | + | if runClient == nil { | |||
| 305 | + | runClient = client.Run | |||
| 306 | + | } | |||
| 273 | if remoteTarget != "" { | 307 | if remoteTarget != "" { | ||
| 274 | - | transport, err := sshstdio.Dial(remoteTarget, name) | 308 | + | remoteDialer := deps.remoteDialer |
| 275 | - | if err != nil { | 309 | + | if remoteDialer == nil { |
| 276 | - | return err | 310 | + | remoteDialer = defaultRemoteDialer |
| 277 | } | 311 | } | ||
| 278 | - | // client.Attach owns and closes transport. | 312 | + | return runClient(ctx, remoteDialer(remoteTarget, name), term.New(), intent, name) |
| 279 | - | return client.Attach(ctx, transport, term.New(), intent, name) | |||
| 280 | } | 313 | } | ||
| 281 | 314 | ||||
| 315 | + | attachLocal := deps.attachLocal | |||
| 316 | + | if attachLocal == nil { | |||
| 317 | + | localDialer := deps.localDialer | |||
| 318 | + | if localDialer == nil { | |||
| 319 | + | localDialer = defaultLocalDialer | |||
| 320 | + | } | |||
| 321 | + | attachLocal = func(ctx context.Context, intent uint8, name string) error { | |||
| 322 | + | return runClient(ctx, localDialer(), term.New(), intent, name) | |||
| 323 | + | } | |||
| 324 | + | } | |||
| 282 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | 325 | return runLocalAttachWithRecovery(ctx, intent, name, attachRecoveryDeps{ | ||
| 283 | confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr), | 326 | confirmer: confirm.NewConfirmer(os.Stdin, os.Stderr), | ||
| 284 | - | attach: deps.attachLocal, | 327 | + | attach: attachLocal, |
| 285 | killDaemon: requestDaemonStop, | 328 | killDaemon: requestDaemonStop, | ||
| 286 | settleAfterKill: waitForDaemonStop, | 329 | settleAfterKill: waitForDaemonStop, | ||
| 287 | }) | 330 | }) | ||
| @@ -332,13 +375,122 @@ func isDaemonVersionDrift(err error) bool { | |||||
| 332 | return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal | 375 | return protocolErr.Code == ports.ErrVersionMismatch || protocolErr.Text == legacyMalformedHelloSignal | ||
| 333 | } | 376 | } | ||
| 334 | 377 | ||||
| 335 | - | func attachLocalDaemon(ctx context.Context, intent uint8, name string) error { | 378 | + | type localDaemonDialer struct{ dir string } |
| 336 | - | transport, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | 379 | + | |
| 380 | + | func (d localDaemonDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 381 | + | return ensureDaemon(ctx, d.dir, realDial, realSpawn, defaultBackoff) | |||
| 382 | + | } | |||
| 383 | + | ||||
| 384 | + | const maxBootstrapStderr = 64 * 1024 | |||
| 385 | + | ||||
| 386 | + | type limitedBuffer struct { | |||
| 387 | + | buf []byte | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func (b *limitedBuffer) Write(p []byte) (int, error) { | |||
| 391 | + | if len(b.buf) < maxBootstrapStderr { | |||
| 392 | + | keep := min(len(p), maxBootstrapStderr-len(b.buf)) | |||
| 393 | + | b.buf = append(b.buf, p[:keep]...) | |||
| 394 | + | } | |||
| 395 | + | return len(p), nil | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func (b *limitedBuffer) String() string { return string(b.buf) } | |||
| 399 | + | ||||
| 400 | + | type remoteDatagramDialer struct { | |||
| 401 | + | target string | |||
| 402 | + | session string | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | func (d remoteDatagramDialer) Dial(ctx context.Context) (ports.Transport, error) { | |||
| 406 | + | tr, udpErr := d.dialDatagram(ctx) | |||
| 407 | + | if udpErr == nil { | |||
| 408 | + | return tr, nil | |||
| 409 | + | } | |||
| 410 | + | tr, stdioErr := sshstdio.Dial(d.target, d.session) | |||
| 411 | + | if stdioErr != nil { | |||
| 412 | + | return nil, fmt.Errorf("datagram dial failed: %w; stdio fallback failed: %w", udpErr, stdioErr) | |||
| 413 | + | } | |||
| 414 | + | return tr, nil | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func (d remoteDatagramDialer) dialDatagram(ctx context.Context) (ports.Transport, error) { | |||
| 418 | + | remote := []string{shellQuote("vev"), shellQuote("_udp-bootstrap")} | |||
| 419 | + | if d.session != "" { | |||
| 420 | + | remote = append(remote, shellQuote(d.session)) | |||
| 421 | + | } | |||
| 422 | + | cmd := exec.CommandContext(ctx, "ssh", "--", d.target, strings.Join(remote, " ")) | |||
| 423 | + | stdout, err := cmd.StdoutPipe() | |||
| 337 | if err != nil { | 424 | if err != nil { | ||
| 338 | - | return err | 425 | + | return nil, err |
| 426 | + | } | |||
| 427 | + | var stderr limitedBuffer | |||
| 428 | + | cmd.Stderr = &stderr | |||
| 429 | + | if err := cmd.Start(); err != nil { | |||
| 430 | + | return nil, err | |||
| 431 | + | } | |||
| 432 | + | started := true | |||
| 433 | + | defer func() { | |||
| 434 | + | if started { | |||
| 435 | + | _ = cmd.Process.Kill() | |||
| 436 | + | _ = cmd.Wait() | |||
| 437 | + | } | |||
| 438 | + | }() | |||
| 439 | + | ||||
| 440 | + | line, err := bufio.NewReader(stdout).ReadString('\n') | |||
| 441 | + | if err != nil { | |||
| 442 | + | return nil, fmt.Errorf("udp bootstrap: %w: %s", err, strings.TrimSpace(stderr.String())) | |||
| 443 | + | } | |||
| 444 | + | fields := strings.Fields(strings.TrimSpace(line)) | |||
| 445 | + | if len(fields) != 3 || fields[0] != "VEV-UDP" { | |||
| 446 | + | return nil, fmt.Errorf("udp bootstrap: unexpected reply %q", strings.TrimSpace(line)) | |||
| 447 | + | } | |||
| 448 | + | key, err := base64.StdEncoding.DecodeString(fields[2]) | |||
| 449 | + | if err != nil { | |||
| 450 | + | return nil, err | |||
| 451 | + | } | |||
| 452 | + | host := sshTargetHost(d.target) | |||
| 453 | + | peer, err := net.ResolveUDPAddr("udp", net.JoinHostPort(host, fields[1])) | |||
| 454 | + | if err != nil { | |||
| 455 | + | return nil, err | |||
| 456 | + | } | |||
| 457 | + | var lc net.ListenConfig | |||
| 458 | + | pc, err := lc.ListenPacket(ctx, "udp", ":0") | |||
| 459 | + | if err != nil { | |||
| 460 | + | return nil, err | |||
| 339 | } | 461 | } | ||
| 340 | - | // client.Attach owns and closes transport. | 462 | + | tr, err := dgram.NewTransport(pc, peer, key, 1, 2) |
| 341 | - | return client.Attach(ctx, transport, term.New(), intent, name) | 463 | + | if err != nil { |
| 464 | + | _ = pc.Close() | |||
| 465 | + | return nil, err | |||
| 466 | + | } | |||
| 467 | + | started = false | |||
| 468 | + | return closeBothTransport{Transport: tr, close: func() error { | |||
| 469 | + | _ = tr.Close() | |||
| 470 | + | _ = cmd.Process.Kill() | |||
| 471 | + | return cmd.Wait() | |||
| 472 | + | }}, nil | |||
| 473 | + | } | |||
| 474 | + | ||||
| 475 | + | func sshTargetHost(target string) string { | |||
| 476 | + | if at := strings.LastIndexByte(target, '@'); at >= 0 { | |||
| 477 | + | target = target[at+1:] | |||
| 478 | + | } | |||
| 479 | + | if h, _, err := net.SplitHostPort(target); err == nil { | |||
| 480 | + | return h | |||
| 481 | + | } | |||
| 482 | + | return target | |||
| 483 | + | } | |||
| 484 | + | ||||
| 485 | + | type closeBothTransport struct { | |||
| 486 | + | ports.Transport | |||
| 487 | + | close func() error | |||
| 488 | + | } | |||
| 489 | + | ||||
| 490 | + | func (t closeBothTransport) Close() error { return t.close() } | |||
| 491 | + | ||||
| 492 | + | func shellQuote(s string) string { | |||
| 493 | + | return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" | |||
| 342 | } | 494 | } | ||
| 343 | 495 | ||||
| 344 | func createDetachedLocalSession(ctx context.Context, name string) error { | 496 | func createDetachedLocalSession(ctx context.Context, name string) error { | ||
| @@ -462,6 +614,73 @@ func runStdio(ctx context.Context) error { | |||||
| 462 | return proxyTransports(ctx, stdio, transport) | 614 | return proxyTransports(ctx, stdio, transport) | ||
| 463 | } | 615 | } | ||
| 464 | 616 | ||||
| 617 | + | // runUDPBootstrap is the hidden remote-side bootstrap used before switching to | |||
| 618 | + | // authenticated datagrams. It deliberately prints only an ephemeral UDP port and | |||
| 619 | + | // random session key to stdout so callers can fall back to _stdio if setup fails. | |||
| 620 | + | func runUDPBootstrap(ctx context.Context, session string) error { | |||
| 621 | + | select { | |||
| 622 | + | case <-ctx.Done(): | |||
| 623 | + | return ctx.Err() | |||
| 624 | + | default: | |||
| 625 | + | } | |||
| 626 | + | var lc net.ListenConfig | |||
| 627 | + | conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0") | |||
| 628 | + | if err != nil { | |||
| 629 | + | return err | |||
| 630 | + | } | |||
| 631 | + | defer func() { _ = conn.Close() }() | |||
| 632 | + | key := make([]byte, 32) | |||
| 633 | + | if _, err := rand.Read(key); err != nil { | |||
| 634 | + | return err | |||
| 635 | + | } | |||
| 636 | + | daemonTr, err := ensureDaemon(ctx, ipc.SocketDir(), realDial, realSpawn, defaultBackoff) | |||
| 637 | + | if err != nil { | |||
| 638 | + | return err | |||
| 639 | + | } | |||
| 640 | + | defer func() { _ = daemonTr.Close() }() | |||
| 641 | + | dg, err := dgram.NewTransport(conn, nil, key, 2, 1) | |||
| 642 | + | if err != nil { | |||
| 643 | + | return err | |||
| 644 | + | } | |||
| 645 | + | defer func() { _ = dg.Close() }() | |||
| 646 | + | ||||
| 647 | + | addr, ok := conn.LocalAddr().(*net.UDPAddr) | |||
| 648 | + | if !ok { | |||
| 649 | + | return errors.New("vev: udp bootstrap did not get a UDP address") | |||
| 650 | + | } | |||
| 651 | + | if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil { | |||
| 652 | + | return err | |||
| 653 | + | } | |||
| 654 | + | _ = os.Stdout.Sync() | |||
| 655 | + | ||||
| 656 | + | var dgramTr ports.Transport = dg | |||
| 657 | + | if session != "" { | |||
| 658 | + | dgramTr = preHelloNameTransport{Transport: dg, session: session} | |||
| 659 | + | } | |||
| 660 | + | return proxyTransports(ctx, dgramTr, daemonTr) | |||
| 661 | + | } | |||
| 662 | + | ||||
| 663 | + | type preHelloNameTransport struct { | |||
| 664 | + | ports.Transport | |||
| 665 | + | session string | |||
| 666 | + | } | |||
| 667 | + | ||||
| 668 | + | func (t preHelloNameTransport) Recv() (ports.Frame, error) { | |||
| 669 | + | f, err := t.Transport.Recv() | |||
| 670 | + | if err != nil || f.Type != ports.MsgHello || t.session == "" { | |||
| 671 | + | return f, err | |||
| 672 | + | } | |||
| 673 | + | h, err := ports.UnmarshalHello(f.Payload) | |||
| 674 | + | if err != nil { | |||
| 675 | + | return f, nil | |||
| 676 | + | } | |||
| 677 | + | if h.Name == "" { | |||
| 678 | + | h.Name = t.session | |||
| 679 | + | f.Payload = ports.MarshalHello(h) | |||
| 680 | + | } | |||
| 681 | + | return f, nil | |||
| 682 | + | } | |||
| 683 | + | ||||
| 465 | func proxyTransports(ctx context.Context, a, b ports.Transport) error { | 684 | func proxyTransports(ctx context.Context, a, b ports.Transport) error { | ||
| 466 | errCh := make(chan error, 2) | 685 | errCh := make(chan error, 2) | ||
| 467 | copyFrames := func(dst, src ports.Transport) { | 686 | copyFrames := func(dst, src ports.Transport) { | ||
M internal/app/run_test.go +82 -0
| @@ -386,3 +386,85 @@ func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) { | |||
| 386 | 386 | t.Fatalf("detached name = %q, want scratch", gotName) | |
| 387 | 387 | } | |
| 388 | 388 | } | |
| 389 | + | ||
| 390 | + | type namedDialer struct{ name string } | |
| 391 | + | ||
| 392 | + | func (d namedDialer) Dial(context.Context) (ports.Transport, error) { | |
| 393 | + | return nil, errors.New("not used") | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func TestSSHTargetHost(t *testing.T) { | |
| 397 | + | tests := []struct { | |
| 398 | + | target string | |
| 399 | + | want string | |
| 400 | + | }{ | |
| 401 | + | {target: "example.com", want: "example.com"}, | |
| 402 | + | {target: "user@example.com", want: "example.com"}, | |
| 403 | + | {target: "example.com:2222", want: "example.com"}, | |
| 404 | + | {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"}, | |
| 405 | + | } | |
| 406 | + | for _, tt := range tests { | |
| 407 | + | t.Run(tt.target, func(t *testing.T) { | |
| 408 | + | if got := sshTargetHost(tt.target); got != tt.want { | |
| 409 | + | t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want) | |
| 410 | + | } | |
| 411 | + | }) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | ||
| 415 | + | func TestShellQuote(t *testing.T) { | |
| 416 | + | tests := map[string]string{ | |
| 417 | + | "vev": "'vev'", | |
| 418 | + | "has space": "'has space'", | |
| 419 | + | "can't stop": "'can'\\''t stop'", | |
| 420 | + | "": "''", | |
| 421 | + | } | |
| 422 | + | for in, want := range tests { | |
| 423 | + | if got := shellQuote(in); got != want { | |
| 424 | + | t.Fatalf("shellQuote(%q) = %q, want %q", in, got, want) | |
| 425 | + | } | |
| 426 | + | } | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |
| 430 | + | var gotTarget, gotSession, gotDialer string | |
| 431 | + | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{ | |
| 432 | + | remoteDialer: func(target, session string) ports.Dialer { | |
| 433 | + | gotTarget, gotSession = target, session | |
| 434 | + | return namedDialer{name: "remote"} | |
| 435 | + | }, | |
| 436 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error { | |
| 437 | + | gotDialer = d.(namedDialer).name | |
| 438 | + | if intent != ports.IntentAttach || name != "work" { | |
| 439 | + | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | |
| 440 | + | } | |
| 441 | + | return nil | |
| 442 | + | }, | |
| 443 | + | }) | |
| 444 | + | if err != nil { | |
| 445 | + | t.Fatalf("runAttachWithDeps returned error: %v", err) | |
| 446 | + | } | |
| 447 | + | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | |
| 448 | + | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | |
| 449 | + | } | |
| 450 | + | } | |
| 451 | + | ||
| 452 | + | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |
| 453 | + | var gotDialer string | |
| 454 | + | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{ | |
| 455 | + | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | |
| 456 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error { | |
| 457 | + | gotDialer = d.(namedDialer).name | |
| 458 | + | if intent != ports.IntentEphemeral || name != "" { | |
| 459 | + | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | |
| 460 | + | } | |
| 461 | + | return nil | |
| 462 | + | }, | |
| 463 | + | }) | |
| 464 | + | if err != nil { | |
| 465 | + | t.Fatalf("runAttachWithDeps returned error: %v", err) | |
| 466 | + | } | |
| 467 | + | if gotDialer != "local" { | |
| 468 | + | t.Fatalf("local dialer = %q, want local", gotDialer) | |
| 469 | + | } | |
| 470 | + | } | |
| @@ -386,3 +386,85 @@ func TestRunAttachNestedNewCreatesDetachedSession(t *testing.T) { | |||||
| 386 | t.Fatalf("detached name = %q, want scratch", gotName) | 386 | t.Fatalf("detached name = %q, want scratch", gotName) | ||
| 387 | } | 387 | } | ||
| 388 | } | 388 | } | ||
| 389 | + | ||||
| 390 | + | type namedDialer struct{ name string } | |||
| 391 | + | ||||
| 392 | + | func (d namedDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 393 | + | return nil, errors.New("not used") | |||
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func TestSSHTargetHost(t *testing.T) { | |||
| 397 | + | tests := []struct { | |||
| 398 | + | target string | |||
| 399 | + | want string | |||
| 400 | + | }{ | |||
| 401 | + | {target: "example.com", want: "example.com"}, | |||
| 402 | + | {target: "user@example.com", want: "example.com"}, | |||
| 403 | + | {target: "example.com:2222", want: "example.com"}, | |||
| 404 | + | {target: "user@[2001:db8::1]:2222", want: "2001:db8::1"}, | |||
| 405 | + | } | |||
| 406 | + | for _, tt := range tests { | |||
| 407 | + | t.Run(tt.target, func(t *testing.T) { | |||
| 408 | + | if got := sshTargetHost(tt.target); got != tt.want { | |||
| 409 | + | t.Fatalf("sshTargetHost(%q) = %q, want %q", tt.target, got, tt.want) | |||
| 410 | + | } | |||
| 411 | + | }) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | ||||
| 415 | + | func TestShellQuote(t *testing.T) { | |||
| 416 | + | tests := map[string]string{ | |||
| 417 | + | "vev": "'vev'", | |||
| 418 | + | "has space": "'has space'", | |||
| 419 | + | "can't stop": "'can'\\''t stop'", | |||
| 420 | + | "": "''", | |||
| 421 | + | } | |||
| 422 | + | for in, want := range tests { | |||
| 423 | + | if got := shellQuote(in); got != want { | |||
| 424 | + | t.Fatalf("shellQuote(%q) = %q, want %q", in, got, want) | |||
| 425 | + | } | |||
| 426 | + | } | |||
| 427 | + | } | |||
| 428 | + | ||||
| 429 | + | func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) { | |||
| 430 | + | var gotTarget, gotSession, gotDialer string | |||
| 431 | + | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", runAttachDeps{ | |||
| 432 | + | remoteDialer: func(target, session string) ports.Dialer { | |||
| 433 | + | gotTarget, gotSession = target, session | |||
| 434 | + | return namedDialer{name: "remote"} | |||
| 435 | + | }, | |||
| 436 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error { | |||
| 437 | + | gotDialer = d.(namedDialer).name | |||
| 438 | + | if intent != ports.IntentAttach || name != "work" { | |||
| 439 | + | t.Fatalf("intent/name = %d/%q, want attach/work", intent, name) | |||
| 440 | + | } | |||
| 441 | + | return nil | |||
| 442 | + | }, | |||
| 443 | + | }) | |||
| 444 | + | if err != nil { | |||
| 445 | + | t.Fatalf("runAttachWithDeps returned error: %v", err) | |||
| 446 | + | } | |||
| 447 | + | if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" { | |||
| 448 | + | t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer) | |||
| 449 | + | } | |||
| 450 | + | } | |||
| 451 | + | ||||
| 452 | + | func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) { | |||
| 453 | + | var gotDialer string | |||
| 454 | + | err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", runAttachDeps{ | |||
| 455 | + | localDialer: func() ports.Dialer { return namedDialer{name: "local"} }, | |||
| 456 | + | runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, intent uint8, name string) error { | |||
| 457 | + | gotDialer = d.(namedDialer).name | |||
| 458 | + | if intent != ports.IntentEphemeral || name != "" { | |||
| 459 | + | t.Fatalf("intent/name = %d/%q, want ephemeral/empty", intent, name) | |||
| 460 | + | } | |||
| 461 | + | return nil | |||
| 462 | + | }, | |||
| 463 | + | }) | |||
| 464 | + | if err != nil { | |||
| 465 | + | t.Fatalf("runAttachWithDeps returned error: %v", err) | |||
| 466 | + | } | |||
| 467 | + | if gotDialer != "local" { | |||
| 468 | + | t.Fatalf("local dialer = %q, want local", gotDialer) | |||
| 469 | + | } | |||
| 470 | + | } | |||
M internal/ports/frame.go +2 -1
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | |
| 4 | - | const ProtocolVersion uint16 = 4 | |
| 4 | + | const ProtocolVersion uint16 = 5 | |
| 5 | 5 | ||
| 6 | 6 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 7 | type MsgType uint8 | |
| @@ -18,6 +18,7 @@ const ( | |||
| 18 | 18 | MsgList MsgType = 6 | |
| 19 | 19 | MsgKill MsgType = 7 | |
| 20 | 20 | MsgTheme MsgType = 8 | |
| 21 | + | MsgAck MsgType = 9 | |
| 21 | 22 | ||
| 22 | 23 | MsgWelcome MsgType = 16 | |
| 23 | 24 | MsgError MsgType = 17 | |
| @@ -1,7 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | // ProtocolVersion is the current vev IPC wire protocol version. | 3 | // ProtocolVersion is the current vev IPC wire protocol version. | ||
| 4 | - | const ProtocolVersion uint16 = 4 | 4 | + | const ProtocolVersion uint16 = 5 |
| 5 | 5 | ||||
| 6 | // MsgType identifies the kind of payload carried by a Frame. | 6 | // MsgType identifies the kind of payload carried by a Frame. | ||
| 7 | type MsgType uint8 | 7 | type MsgType uint8 | ||
| @@ -18,6 +18,7 @@ const ( | |||||
| 18 | MsgList MsgType = 6 | 18 | MsgList MsgType = 6 | ||
| 19 | MsgKill MsgType = 7 | 19 | MsgKill MsgType = 7 | ||
| 20 | MsgTheme MsgType = 8 | 20 | MsgTheme MsgType = 8 | ||
| 21 | + | MsgAck MsgType = 9 | |||
| 21 | 22 | ||||
| 22 | MsgWelcome MsgType = 16 | 23 | MsgWelcome MsgType = 16 | ||
| 23 | MsgError MsgType = 17 | 24 | MsgError MsgType = 17 | ||
M internal/ports/frame_test.go +3 -2
| @@ -3,8 +3,8 @@ package ports | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | func TestProtocolVersion(t *testing.T) { | |
| 6 | - | if ProtocolVersion != 4 { | |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion) | |
| 6 | + | if ProtocolVersion != 5 { | |
| 7 | + | t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion) | |
| 8 | 8 | } | |
| 9 | 9 | } | |
| 10 | 10 | ||
| @@ -21,6 +21,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 21 | 21 | {"MsgList", MsgList}, | |
| 22 | 22 | {"MsgKill", MsgKill}, | |
| 23 | 23 | {"MsgTheme", MsgTheme}, | |
| 24 | + | {"MsgAck", MsgAck}, | |
| 24 | 25 | {"MsgWelcome", MsgWelcome}, | |
| 25 | 26 | {"MsgError", MsgError}, | |
| 26 | 27 | {"MsgOutput", MsgOutput}, | |
| @@ -3,8 +3,8 @@ package ports | |||||
| 3 | import "testing" | 3 | import "testing" | ||
| 4 | 4 | ||||
| 5 | func TestProtocolVersion(t *testing.T) { | 5 | func TestProtocolVersion(t *testing.T) { | ||
| 6 | - | if ProtocolVersion != 4 { | 6 | + | if ProtocolVersion != 5 { |
| 7 | - | t.Fatalf("ProtocolVersion = %d, want 4", ProtocolVersion) | 7 | + | t.Fatalf("ProtocolVersion = %d, want 5", ProtocolVersion) |
| 8 | } | 8 | } | ||
| 9 | } | 9 | } | ||
| 10 | 10 | ||||
| @@ -21,6 +21,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||||
| 21 | {"MsgList", MsgList}, | 21 | {"MsgList", MsgList}, | ||
| 22 | {"MsgKill", MsgKill}, | 22 | {"MsgKill", MsgKill}, | ||
| 23 | {"MsgTheme", MsgTheme}, | 23 | {"MsgTheme", MsgTheme}, | ||
| 24 | + | {"MsgAck", MsgAck}, | |||
| 24 | {"MsgWelcome", MsgWelcome}, | 25 | {"MsgWelcome", MsgWelcome}, | ||
| 25 | {"MsgError", MsgError}, | 26 | {"MsgError", MsgError}, | ||
| 26 | {"MsgOutput", MsgOutput}, | 27 | {"MsgOutput", MsgOutput}, | ||
M internal/ports/ports.go +6 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "io" | |
| 5 | 6 | "time" | |
| 6 | 7 | ||
| @@ -37,6 +38,11 @@ type Transport interface { | |||
| 37 | 38 | Close() error | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 41 | + | // Dialer establishes outbound Transport connections. | |
| 42 | + | type Dialer interface { | |
| 43 | + | Dial(ctx context.Context) (Transport, error) | |
| 44 | + | } | |
| 45 | + | ||
| 40 | 46 | // Listener accepts incoming Transport connections. | |
| 41 | 47 | type Listener interface { | |
| 42 | 48 | Accept() (Transport, error) | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package ports | 1 | package ports | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "io" | 5 | "io" | ||
| 5 | "time" | 6 | "time" | ||
| 6 | 7 | ||||
| @@ -37,6 +38,11 @@ type Transport interface { | |||||
| 37 | Close() error | 38 | Close() error | ||
| 38 | } | 39 | } | ||
| 39 | 40 | ||||
| 41 | + | // Dialer establishes outbound Transport connections. | |||
| 42 | + | type Dialer interface { | |||
| 43 | + | Dial(ctx context.Context) (Transport, error) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 40 | // Listener accepts incoming Transport connections. | 46 | // Listener accepts incoming Transport connections. | ||
| 41 | type Listener interface { | 47 | type Listener interface { | ||
| 42 | Accept() (Transport, error) | 48 | Accept() (Transport, error) | ||
M internal/ports/wire.go +145 -19
| @@ -28,6 +28,14 @@ const ( | |||
| 28 | 28 | IntentEphemeral uint8 = 0 | |
| 29 | 29 | IntentNew uint8 = 1 | |
| 30 | 30 | IntentAttach uint8 = 2 | |
| 31 | + | IntentResume uint8 = 3 | |
| 32 | + | ) | |
| 33 | + | ||
| 34 | + | // Capability bits advertised in Welcome. | |
| 35 | + | const ( | |
| 36 | + | CapabilityResume uint32 = 1 << 0 | |
| 37 | + | CapabilityUDP uint32 = 1 << 1 | |
| 38 | + | CapabilityPredict uint32 = 1 << 2 | |
| 31 | 39 | ) | |
| 32 | 40 | ||
| 33 | 41 | // ErrorMsg codes. | |
| @@ -48,17 +56,20 @@ const ( | |||
| 48 | 56 | ||
| 49 | 57 | // Hello is sent by the client immediately after connecting. | |
| 50 | 58 | type Hello struct { | |
| 51 | - | Version uint16 | |
| 52 | - | Intent uint8 | |
| 53 | - | Name string | |
| 54 | - | Size domain.Size | |
| 55 | - | TermEnv string | |
| 56 | - | Cwd string | |
| 59 | + | Version uint16 | |
| 60 | + | Intent uint8 | |
| 61 | + | ClientID [16]byte | |
| 62 | + | ResumeToken uint64 | |
| 63 | + | Name string | |
| 64 | + | Size domain.Size | |
| 65 | + | TermEnv string | |
| 66 | + | Cwd string | |
| 57 | 67 | } | |
| 58 | 68 | ||
| 59 | 69 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | |
| 60 | 70 | type Input struct { | |
| 61 | - | Data []byte | |
| 71 | + | InputSeq uint64 | |
| 72 | + | Data []byte | |
| 62 | 73 | } | |
| 63 | 74 | ||
| 64 | 75 | // Resize notifies the daemon of a client-side terminal size change. | |
| @@ -86,11 +97,18 @@ type Ping struct{} | |||
| 86 | 97 | // Pong answers a Ping. | |
| 87 | 98 | type Pong struct{} | |
| 88 | 99 | ||
| 100 | + | // Ack acknowledges receipt/application of an output state number. | |
| 101 | + | type Ack struct { | |
| 102 | + | AckedStateNum uint64 | |
| 103 | + | } | |
| 104 | + | ||
| 89 | 105 | // Welcome is the daemon's reply to a successful Hello. | |
| 90 | 106 | type Welcome struct { | |
| 91 | - | SessionID string | |
| 92 | - | SessionName string | |
| 93 | - | Ephemeral bool | |
| 107 | + | SessionID string | |
| 108 | + | SessionName string | |
| 109 | + | Ephemeral bool | |
| 110 | + | ResumeToken uint64 | |
| 111 | + | Capabilities uint32 | |
| 94 | 112 | } | |
| 95 | 113 | ||
| 96 | 114 | // ErrorMsg reports a protocol- or session-level failure to the client. | |
| @@ -101,7 +119,10 @@ type ErrorMsg struct { | |||
| 101 | 119 | ||
| 102 | 120 | // Output carries raw PTY output, destined for the client's terminal. | |
| 103 | 121 | type Output struct { | |
| 104 | - | Data []byte | |
| 122 | + | BaseStateNum uint64 | |
| 123 | + | NewStateNum uint64 | |
| 124 | + | EchoAck uint64 | |
| 125 | + | Data []byte | |
| 105 | 126 | } | |
| 106 | 127 | ||
| 107 | 128 | // Detached tells a client it has been disconnected from its session and why. | |
| @@ -149,6 +170,22 @@ func (w *payloadWriter) putUint16(v uint16) { | |||
| 149 | 170 | w.b = append(w.b, tmp[:]...) | |
| 150 | 171 | } | |
| 151 | 172 | ||
| 173 | + | func (w *payloadWriter) putUint32(v uint32) { | |
| 174 | + | var tmp [4]byte | |
| 175 | + | binary.BigEndian.PutUint32(tmp[:], v) | |
| 176 | + | w.b = append(w.b, tmp[:]...) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func (w *payloadWriter) putUint64(v uint64) { | |
| 180 | + | var tmp [8]byte | |
| 181 | + | binary.BigEndian.PutUint64(tmp[:], v) | |
| 182 | + | w.b = append(w.b, tmp[:]...) | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | func (w *payloadWriter) putBytes(b []byte) { | |
| 186 | + | w.b = append(w.b, b...) | |
| 187 | + | } | |
| 188 | + | ||
| 152 | 189 | func (w *payloadWriter) putString(s string) { | |
| 153 | 190 | w.putUint16(uint16(len(s))) | |
| 154 | 191 | w.b = append(w.b, s...) | |
| @@ -178,6 +215,33 @@ func (r *payloadReader) getUint16() (uint16, error) { | |||
| 178 | 215 | return v, nil | |
| 179 | 216 | } | |
| 180 | 217 | ||
| 218 | + | func (r *payloadReader) getUint32() (uint32, error) { | |
| 219 | + | if len(r.b) < 4 { | |
| 220 | + | return 0, errShortPayload | |
| 221 | + | } | |
| 222 | + | v := binary.BigEndian.Uint32(r.b) | |
| 223 | + | r.b = r.b[4:] | |
| 224 | + | return v, nil | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (r *payloadReader) getUint64() (uint64, error) { | |
| 228 | + | if len(r.b) < 8 { | |
| 229 | + | return 0, errShortPayload | |
| 230 | + | } | |
| 231 | + | v := binary.BigEndian.Uint64(r.b) | |
| 232 | + | r.b = r.b[8:] | |
| 233 | + | return v, nil | |
| 234 | + | } | |
| 235 | + | ||
| 236 | + | func (r *payloadReader) getBytes(n int) ([]byte, error) { | |
| 237 | + | if len(r.b) < n { | |
| 238 | + | return nil, errShortPayload | |
| 239 | + | } | |
| 240 | + | b := append([]byte(nil), r.b[:n]...) | |
| 241 | + | r.b = r.b[n:] | |
| 242 | + | return b, nil | |
| 243 | + | } | |
| 244 | + | ||
| 181 | 245 | func (r *payloadReader) getString() (string, error) { | |
| 182 | 246 | n, err := r.getUint16() | |
| 183 | 247 | if err != nil { | |
| @@ -220,6 +284,8 @@ func MarshalHello(h Hello) []byte { | |||
| 220 | 284 | w := payloadWriter{} | |
| 221 | 285 | w.putUint16(h.Version) | |
| 222 | 286 | w.putUint8(h.Intent) | |
| 287 | + | w.putBytes(h.ClientID[:]) | |
| 288 | + | w.putUint64(h.ResumeToken) | |
| 223 | 289 | w.putString(h.Name) | |
| 224 | 290 | w.putUint16(uint16(h.Size.Cols)) | |
| 225 | 291 | w.putUint16(uint16(h.Size.Rows)) | |
| @@ -240,6 +306,14 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 240 | 306 | if h.Intent, err = r.getUint8(); err != nil { | |
| 241 | 307 | return Hello{}, err | |
| 242 | 308 | } | |
| 309 | + | clientID, err := r.getBytes(len(h.ClientID)) | |
| 310 | + | if err != nil { | |
| 311 | + | return Hello{}, err | |
| 312 | + | } | |
| 313 | + | copy(h.ClientID[:], clientID) | |
| 314 | + | if h.ResumeToken, err = r.getUint64(); err != nil { | |
| 315 | + | return Hello{}, err | |
| 316 | + | } | |
| 243 | 317 | if h.Name, err = r.getString(); err != nil { | |
| 244 | 318 | return Hello{}, err | |
| 245 | 319 | } | |
| @@ -266,14 +340,21 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||
| 266 | 340 | ||
| 267 | 341 | // MarshalInput encodes m into an Input message payload. | |
| 268 | 342 | func MarshalInput(m Input) []byte { | |
| 269 | - | return append([]byte(nil), m.Data...) | |
| 343 | + | w := payloadWriter{} | |
| 344 | + | w.putUint64(m.InputSeq) | |
| 345 | + | w.putBytes(m.Data) | |
| 346 | + | return w.b | |
| 270 | 347 | } | |
| 271 | 348 | ||
| 272 | - | // UnmarshalInput decodes an Input message payload. The whole payload is the | |
| 273 | - | // data; there is no separate length prefix. | |
| 349 | + | // UnmarshalInput decodes an Input message payload. After the fixed input | |
| 350 | + | // sequence, the rest of the payload is data; there is no length prefix. | |
| 274 | 351 | func UnmarshalInput(b []byte) (Input, error) { | |
| 275 | 352 | r := payloadReader{b: b} | |
| 276 | - | return Input{Data: r.rest()}, nil | |
| 353 | + | seq, err := r.getUint64() | |
| 354 | + | if err != nil { | |
| 355 | + | return Input{}, err | |
| 356 | + | } | |
| 357 | + | return Input{InputSeq: seq, Data: r.rest()}, nil | |
| 277 | 358 | } | |
| 278 | 359 | ||
| 279 | 360 | // MarshalResize encodes m into a Resize message payload. | |
| @@ -405,6 +486,26 @@ func UnmarshalPong(b []byte) (Pong, error) { | |||
| 405 | 486 | return Pong{}, nil | |
| 406 | 487 | } | |
| 407 | 488 | ||
| 489 | + | // MarshalAck encodes m into an Ack message payload. | |
| 490 | + | func MarshalAck(m Ack) []byte { | |
| 491 | + | w := payloadWriter{} | |
| 492 | + | w.putUint64(m.AckedStateNum) | |
| 493 | + | return w.b | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | // UnmarshalAck decodes an Ack message payload. | |
| 497 | + | func UnmarshalAck(b []byte) (Ack, error) { | |
| 498 | + | r := payloadReader{b: b} | |
| 499 | + | acked, err := r.getUint64() | |
| 500 | + | if err != nil { | |
| 501 | + | return Ack{}, err | |
| 502 | + | } | |
| 503 | + | if err := r.done(); err != nil { | |
| 504 | + | return Ack{}, err | |
| 505 | + | } | |
| 506 | + | return Ack{AckedStateNum: acked}, nil | |
| 507 | + | } | |
| 508 | + | ||
| 408 | 509 | // MarshalWelcome encodes m into a Welcome message payload. | |
| 409 | 510 | func MarshalWelcome(m Welcome) []byte { | |
| 410 | 511 | w := payloadWriter{} | |
| @@ -415,6 +516,8 @@ func MarshalWelcome(m Welcome) []byte { | |||
| 415 | 516 | } else { | |
| 416 | 517 | w.putUint8(0) | |
| 417 | 518 | } | |
| 519 | + | w.putUint64(m.ResumeToken) | |
| 520 | + | w.putUint32(m.Capabilities) | |
| 418 | 521 | return w.b | |
| 419 | 522 | } | |
| 420 | 523 | ||
| @@ -435,6 +538,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) { | |||
| 435 | 538 | return Welcome{}, err | |
| 436 | 539 | } | |
| 437 | 540 | m.Ephemeral = eph != 0 | |
| 541 | + | if m.ResumeToken, err = r.getUint64(); err != nil { | |
| 542 | + | return Welcome{}, err | |
| 543 | + | } | |
| 544 | + | if m.Capabilities, err = r.getUint32(); err != nil { | |
| 545 | + | return Welcome{}, err | |
| 546 | + | } | |
| 438 | 547 | if err := r.done(); err != nil { | |
| 439 | 548 | return Welcome{}, err | |
| 440 | 549 | } | |
| @@ -469,14 +578,31 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) { | |||
| 469 | 578 | ||
| 470 | 579 | // MarshalOutput encodes m into an Output message payload. | |
| 471 | 580 | func MarshalOutput(m Output) []byte { | |
| 472 | - | return append([]byte(nil), m.Data...) | |
| 581 | + | w := payloadWriter{} | |
| 582 | + | w.putUint64(m.BaseStateNum) | |
| 583 | + | w.putUint64(m.NewStateNum) | |
| 584 | + | w.putUint64(m.EchoAck) | |
| 585 | + | w.putBytes(m.Data) | |
| 586 | + | return w.b | |
| 473 | 587 | } | |
| 474 | 588 | ||
| 475 | - | // UnmarshalOutput decodes an Output message payload. The whole payload is | |
| 476 | - | // the data; there is no separate length prefix. | |
| 589 | + | // UnmarshalOutput decodes an Output message payload. After fixed state | |
| 590 | + | // numbers, the rest of the payload is data; there is no length prefix. | |
| 477 | 591 | func UnmarshalOutput(b []byte) (Output, error) { | |
| 478 | 592 | r := payloadReader{b: b} | |
| 479 | - | return Output{Data: r.rest()}, nil | |
| 593 | + | base, err := r.getUint64() | |
| 594 | + | if err != nil { | |
| 595 | + | return Output{}, err | |
| 596 | + | } | |
| 597 | + | newState, err := r.getUint64() | |
| 598 | + | if err != nil { | |
| 599 | + | return Output{}, err | |
| 600 | + | } | |
| 601 | + | echoAck, err := r.getUint64() | |
| 602 | + | if err != nil { | |
| 603 | + | return Output{}, err | |
| 604 | + | } | |
| 605 | + | return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil | |
| 480 | 606 | } | |
| 481 | 607 | ||
| 482 | 608 | // MarshalDetached encodes m into a Detached message payload. | |
| @@ -28,6 +28,14 @@ const ( | |||||
| 28 | IntentEphemeral uint8 = 0 | 28 | IntentEphemeral uint8 = 0 | ||
| 29 | IntentNew uint8 = 1 | 29 | IntentNew uint8 = 1 | ||
| 30 | IntentAttach uint8 = 2 | 30 | IntentAttach uint8 = 2 | ||
| 31 | + | IntentResume uint8 = 3 | |||
| 32 | + | ) | |||
| 33 | + | ||||
| 34 | + | // Capability bits advertised in Welcome. | |||
| 35 | + | const ( | |||
| 36 | + | CapabilityResume uint32 = 1 << 0 | |||
| 37 | + | CapabilityUDP uint32 = 1 << 1 | |||
| 38 | + | CapabilityPredict uint32 = 1 << 2 | |||
| 31 | ) | 39 | ) | ||
| 32 | 40 | ||||
| 33 | // ErrorMsg codes. | 41 | // ErrorMsg codes. | ||
| @@ -48,17 +56,20 @@ const ( | |||||
| 48 | 56 | ||||
| 49 | // Hello is sent by the client immediately after connecting. | 57 | // Hello is sent by the client immediately after connecting. | ||
| 50 | type Hello struct { | 58 | type Hello struct { | ||
| 51 | - | Version uint16 | 59 | + | Version uint16 |
| 52 | - | Intent uint8 | 60 | + | Intent uint8 |
| 53 | - | Name string | 61 | + | ClientID [16]byte |
| 54 | - | Size domain.Size | 62 | + | ResumeToken uint64 |
| 55 | - | TermEnv string | 63 | + | Name string |
| 56 | - | Cwd string | 64 | + | Size domain.Size |
| 65 | + | TermEnv string | |||
| 66 | + | Cwd string | |||
| 57 | } | 67 | } | ||
| 58 | 68 | ||||
| 59 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | 69 | // Input carries raw bytes typed/pasted by the client, destined for the PTY. | ||
| 60 | type Input struct { | 70 | type Input struct { | ||
| 61 | - | Data []byte | 71 | + | InputSeq uint64 |
| 72 | + | Data []byte | |||
| 62 | } | 73 | } | ||
| 63 | 74 | ||||
| 64 | // Resize notifies the daemon of a client-side terminal size change. | 75 | // Resize notifies the daemon of a client-side terminal size change. | ||
| @@ -86,11 +97,18 @@ type Ping struct{} | |||||
| 86 | // Pong answers a Ping. | 97 | // Pong answers a Ping. | ||
| 87 | type Pong struct{} | 98 | type Pong struct{} | ||
| 88 | 99 | ||||
| 100 | + | // Ack acknowledges receipt/application of an output state number. | |||
| 101 | + | type Ack struct { | |||
| 102 | + | AckedStateNum uint64 | |||
| 103 | + | } | |||
| 104 | + | ||||
| 89 | // Welcome is the daemon's reply to a successful Hello. | 105 | // Welcome is the daemon's reply to a successful Hello. | ||
| 90 | type Welcome struct { | 106 | type Welcome struct { | ||
| 91 | - | SessionID string | 107 | + | SessionID string |
| 92 | - | SessionName string | 108 | + | SessionName string |
| 93 | - | Ephemeral bool | 109 | + | Ephemeral bool |
| 110 | + | ResumeToken uint64 | |||
| 111 | + | Capabilities uint32 | |||
| 94 | } | 112 | } | ||
| 95 | 113 | ||||
| 96 | // ErrorMsg reports a protocol- or session-level failure to the client. | 114 | // ErrorMsg reports a protocol- or session-level failure to the client. | ||
| @@ -101,7 +119,10 @@ type ErrorMsg struct { | |||||
| 101 | 119 | ||||
| 102 | // Output carries raw PTY output, destined for the client's terminal. | 120 | // Output carries raw PTY output, destined for the client's terminal. | ||
| 103 | type Output struct { | 121 | type Output struct { | ||
| 104 | - | Data []byte | 122 | + | BaseStateNum uint64 |
| 123 | + | NewStateNum uint64 | |||
| 124 | + | EchoAck uint64 | |||
| 125 | + | Data []byte | |||
| 105 | } | 126 | } | ||
| 106 | 127 | ||||
| 107 | // Detached tells a client it has been disconnected from its session and why. | 128 | // Detached tells a client it has been disconnected from its session and why. | ||
| @@ -149,6 +170,22 @@ func (w *payloadWriter) putUint16(v uint16) { | |||||
| 149 | w.b = append(w.b, tmp[:]...) | 170 | w.b = append(w.b, tmp[:]...) | ||
| 150 | } | 171 | } | ||
| 151 | 172 | ||||
| 173 | + | func (w *payloadWriter) putUint32(v uint32) { | |||
| 174 | + | var tmp [4]byte | |||
| 175 | + | binary.BigEndian.PutUint32(tmp[:], v) | |||
| 176 | + | w.b = append(w.b, tmp[:]...) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | func (w *payloadWriter) putUint64(v uint64) { | |||
| 180 | + | var tmp [8]byte | |||
| 181 | + | binary.BigEndian.PutUint64(tmp[:], v) | |||
| 182 | + | w.b = append(w.b, tmp[:]...) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func (w *payloadWriter) putBytes(b []byte) { | |||
| 186 | + | w.b = append(w.b, b...) | |||
| 187 | + | } | |||
| 188 | + | ||||
| 152 | func (w *payloadWriter) putString(s string) { | 189 | func (w *payloadWriter) putString(s string) { | ||
| 153 | w.putUint16(uint16(len(s))) | 190 | w.putUint16(uint16(len(s))) | ||
| 154 | w.b = append(w.b, s...) | 191 | w.b = append(w.b, s...) | ||
| @@ -178,6 +215,33 @@ func (r *payloadReader) getUint16() (uint16, error) { | |||||
| 178 | return v, nil | 215 | return v, nil | ||
| 179 | } | 216 | } | ||
| 180 | 217 | ||||
| 218 | + | func (r *payloadReader) getUint32() (uint32, error) { | |||
| 219 | + | if len(r.b) < 4 { | |||
| 220 | + | return 0, errShortPayload | |||
| 221 | + | } | |||
| 222 | + | v := binary.BigEndian.Uint32(r.b) | |||
| 223 | + | r.b = r.b[4:] | |||
| 224 | + | return v, nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (r *payloadReader) getUint64() (uint64, error) { | |||
| 228 | + | if len(r.b) < 8 { | |||
| 229 | + | return 0, errShortPayload | |||
| 230 | + | } | |||
| 231 | + | v := binary.BigEndian.Uint64(r.b) | |||
| 232 | + | r.b = r.b[8:] | |||
| 233 | + | return v, nil | |||
| 234 | + | } | |||
| 235 | + | ||||
| 236 | + | func (r *payloadReader) getBytes(n int) ([]byte, error) { | |||
| 237 | + | if len(r.b) < n { | |||
| 238 | + | return nil, errShortPayload | |||
| 239 | + | } | |||
| 240 | + | b := append([]byte(nil), r.b[:n]...) | |||
| 241 | + | r.b = r.b[n:] | |||
| 242 | + | return b, nil | |||
| 243 | + | } | |||
| 244 | + | ||||
| 181 | func (r *payloadReader) getString() (string, error) { | 245 | func (r *payloadReader) getString() (string, error) { | ||
| 182 | n, err := r.getUint16() | 246 | n, err := r.getUint16() | ||
| 183 | if err != nil { | 247 | if err != nil { | ||
| @@ -220,6 +284,8 @@ func MarshalHello(h Hello) []byte { | |||||
| 220 | w := payloadWriter{} | 284 | w := payloadWriter{} | ||
| 221 | w.putUint16(h.Version) | 285 | w.putUint16(h.Version) | ||
| 222 | w.putUint8(h.Intent) | 286 | w.putUint8(h.Intent) | ||
| 287 | + | w.putBytes(h.ClientID[:]) | |||
| 288 | + | w.putUint64(h.ResumeToken) | |||
| 223 | w.putString(h.Name) | 289 | w.putString(h.Name) | ||
| 224 | w.putUint16(uint16(h.Size.Cols)) | 290 | w.putUint16(uint16(h.Size.Cols)) | ||
| 225 | w.putUint16(uint16(h.Size.Rows)) | 291 | w.putUint16(uint16(h.Size.Rows)) | ||
| @@ -240,6 +306,14 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 240 | if h.Intent, err = r.getUint8(); err != nil { | 306 | if h.Intent, err = r.getUint8(); err != nil { | ||
| 241 | return Hello{}, err | 307 | return Hello{}, err | ||
| 242 | } | 308 | } | ||
| 309 | + | clientID, err := r.getBytes(len(h.ClientID)) | |||
| 310 | + | if err != nil { | |||
| 311 | + | return Hello{}, err | |||
| 312 | + | } | |||
| 313 | + | copy(h.ClientID[:], clientID) | |||
| 314 | + | if h.ResumeToken, err = r.getUint64(); err != nil { | |||
| 315 | + | return Hello{}, err | |||
| 316 | + | } | |||
| 243 | if h.Name, err = r.getString(); err != nil { | 317 | if h.Name, err = r.getString(); err != nil { | ||
| 244 | return Hello{}, err | 318 | return Hello{}, err | ||
| 245 | } | 319 | } | ||
| @@ -266,14 +340,21 @@ func UnmarshalHello(b []byte) (Hello, error) { | |||||
| 266 | 340 | ||||
| 267 | // MarshalInput encodes m into an Input message payload. | 341 | // MarshalInput encodes m into an Input message payload. | ||
| 268 | func MarshalInput(m Input) []byte { | 342 | func MarshalInput(m Input) []byte { | ||
| 269 | - | return append([]byte(nil), m.Data...) | 343 | + | w := payloadWriter{} |
| 344 | + | w.putUint64(m.InputSeq) | |||
| 345 | + | w.putBytes(m.Data) | |||
| 346 | + | return w.b | |||
| 270 | } | 347 | } | ||
| 271 | 348 | ||||
| 272 | - | // UnmarshalInput decodes an Input message payload. The whole payload is the | 349 | + | // UnmarshalInput decodes an Input message payload. After the fixed input |
| 273 | - | // data; there is no separate length prefix. | 350 | + | // sequence, the rest of the payload is data; there is no length prefix. |
| 274 | func UnmarshalInput(b []byte) (Input, error) { | 351 | func UnmarshalInput(b []byte) (Input, error) { | ||
| 275 | r := payloadReader{b: b} | 352 | r := payloadReader{b: b} | ||
| 276 | - | return Input{Data: r.rest()}, nil | 353 | + | seq, err := r.getUint64() |
| 354 | + | if err != nil { | |||
| 355 | + | return Input{}, err | |||
| 356 | + | } | |||
| 357 | + | return Input{InputSeq: seq, Data: r.rest()}, nil | |||
| 277 | } | 358 | } | ||
| 278 | 359 | ||||
| 279 | // MarshalResize encodes m into a Resize message payload. | 360 | // MarshalResize encodes m into a Resize message payload. | ||
| @@ -405,6 +486,26 @@ func UnmarshalPong(b []byte) (Pong, error) { | |||||
| 405 | return Pong{}, nil | 486 | return Pong{}, nil | ||
| 406 | } | 487 | } | ||
| 407 | 488 | ||||
| 489 | + | // MarshalAck encodes m into an Ack message payload. | |||
| 490 | + | func MarshalAck(m Ack) []byte { | |||
| 491 | + | w := payloadWriter{} | |||
| 492 | + | w.putUint64(m.AckedStateNum) | |||
| 493 | + | return w.b | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | // UnmarshalAck decodes an Ack message payload. | |||
| 497 | + | func UnmarshalAck(b []byte) (Ack, error) { | |||
| 498 | + | r := payloadReader{b: b} | |||
| 499 | + | acked, err := r.getUint64() | |||
| 500 | + | if err != nil { | |||
| 501 | + | return Ack{}, err | |||
| 502 | + | } | |||
| 503 | + | if err := r.done(); err != nil { | |||
| 504 | + | return Ack{}, err | |||
| 505 | + | } | |||
| 506 | + | return Ack{AckedStateNum: acked}, nil | |||
| 507 | + | } | |||
| 508 | + | ||||
| 408 | // MarshalWelcome encodes m into a Welcome message payload. | 509 | // MarshalWelcome encodes m into a Welcome message payload. | ||
| 409 | func MarshalWelcome(m Welcome) []byte { | 510 | func MarshalWelcome(m Welcome) []byte { | ||
| 410 | w := payloadWriter{} | 511 | w := payloadWriter{} | ||
| @@ -415,6 +516,8 @@ func MarshalWelcome(m Welcome) []byte { | |||||
| 415 | } else { | 516 | } else { | ||
| 416 | w.putUint8(0) | 517 | w.putUint8(0) | ||
| 417 | } | 518 | } | ||
| 519 | + | w.putUint64(m.ResumeToken) | |||
| 520 | + | w.putUint32(m.Capabilities) | |||
| 418 | return w.b | 521 | return w.b | ||
| 419 | } | 522 | } | ||
| 420 | 523 | ||||
| @@ -435,6 +538,12 @@ func UnmarshalWelcome(b []byte) (Welcome, error) { | |||||
| 435 | return Welcome{}, err | 538 | return Welcome{}, err | ||
| 436 | } | 539 | } | ||
| 437 | m.Ephemeral = eph != 0 | 540 | m.Ephemeral = eph != 0 | ||
| 541 | + | if m.ResumeToken, err = r.getUint64(); err != nil { | |||
| 542 | + | return Welcome{}, err | |||
| 543 | + | } | |||
| 544 | + | if m.Capabilities, err = r.getUint32(); err != nil { | |||
| 545 | + | return Welcome{}, err | |||
| 546 | + | } | |||
| 438 | if err := r.done(); err != nil { | 547 | if err := r.done(); err != nil { | ||
| 439 | return Welcome{}, err | 548 | return Welcome{}, err | ||
| 440 | } | 549 | } | ||
| @@ -469,14 +578,31 @@ func UnmarshalErrorMsg(b []byte) (ErrorMsg, error) { | |||||
| 469 | 578 | ||||
| 470 | // MarshalOutput encodes m into an Output message payload. | 579 | // MarshalOutput encodes m into an Output message payload. | ||
| 471 | func MarshalOutput(m Output) []byte { | 580 | func MarshalOutput(m Output) []byte { | ||
| 472 | - | return append([]byte(nil), m.Data...) | 581 | + | w := payloadWriter{} |
| 582 | + | w.putUint64(m.BaseStateNum) | |||
| 583 | + | w.putUint64(m.NewStateNum) | |||
| 584 | + | w.putUint64(m.EchoAck) | |||
| 585 | + | w.putBytes(m.Data) | |||
| 586 | + | return w.b | |||
| 473 | } | 587 | } | ||
| 474 | 588 | ||||
| 475 | - | // UnmarshalOutput decodes an Output message payload. The whole payload is | 589 | + | // UnmarshalOutput decodes an Output message payload. After fixed state |
| 476 | - | // the data; there is no separate length prefix. | 590 | + | // numbers, the rest of the payload is data; there is no length prefix. |
| 477 | func UnmarshalOutput(b []byte) (Output, error) { | 591 | func UnmarshalOutput(b []byte) (Output, error) { | ||
| 478 | r := payloadReader{b: b} | 592 | r := payloadReader{b: b} | ||
| 479 | - | return Output{Data: r.rest()}, nil | 593 | + | base, err := r.getUint64() |
| 594 | + | if err != nil { | |||
| 595 | + | return Output{}, err | |||
| 596 | + | } | |||
| 597 | + | newState, err := r.getUint64() | |||
| 598 | + | if err != nil { | |||
| 599 | + | return Output{}, err | |||
| 600 | + | } | |||
| 601 | + | echoAck, err := r.getUint64() | |||
| 602 | + | if err != nil { | |||
| 603 | + | return Output{}, err | |||
| 604 | + | } | |||
| 605 | + | return Output{BaseStateNum: base, NewStateNum: newState, EchoAck: echoAck, Data: r.rest()}, nil | |||
| 480 | } | 606 | } | ||
| 481 | 607 | ||||
| 482 | // MarshalDetached encodes m into a Detached message payload. | 608 | // MarshalDetached encodes m into a Detached message payload. | ||
M internal/ports/wire_test.go +48 -25
| @@ -60,14 +60,16 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 60 | 60 | { | |
| 61 | 61 | name: "typical", | |
| 62 | 62 | msg: Hello{ | |
| 63 | - | Version: 1, | |
| 64 | - | Intent: IntentNew, | |
| 65 | - | Name: "w0", | |
| 66 | - | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 67 | - | TermEnv: "xterm-256color", | |
| 68 | - | Cwd: "/tmp/project", | |
| 63 | + | Version: 1, | |
| 64 | + | Intent: IntentNew, | |
| 65 | + | ClientID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}, | |
| 66 | + | ResumeToken: 0x0102030405060708, | |
| 67 | + | Name: "w0", | |
| 68 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 69 | + | TermEnv: "xterm-256color", | |
| 70 | + | Cwd: "/tmp/project", | |
| 69 | 71 | }, | |
| 70 | - | want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74}, | |
| 72 | + | want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74}, | |
| 71 | 73 | }, | |
| 72 | 74 | { | |
| 73 | 75 | name: "empty strings", | |
| @@ -79,7 +81,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 79 | 81 | TermEnv: "", | |
| 80 | 82 | Cwd: "", | |
| 81 | 83 | }, | |
| 82 | - | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 84 | + | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 83 | 85 | }, | |
| 84 | 86 | } | |
| 85 | 87 | ||
| @@ -96,6 +98,10 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||
| 96 | 98 | if !reflect.DeepEqual(back, tt.msg) { | |
| 97 | 99 | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | |
| 98 | 100 | } | |
| 101 | + | peek, ok := PeekHelloVersion(got) | |
| 102 | + | if !ok || peek != tt.msg.Version { | |
| 103 | + | t.Fatalf("PeekHelloVersion() = %d, %v; want %d, true", peek, ok, tt.msg.Version) | |
| 104 | + | } | |
| 99 | 105 | }) | |
| 100 | 106 | } | |
| 101 | 107 | ||
| @@ -110,8 +116,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||
| 110 | 116 | msg Input | |
| 111 | 117 | want []byte | |
| 112 | 118 | }{ | |
| 113 | - | {name: "data", msg: Input{Data: []byte("hi")}, want: []byte{0x68, 0x69}}, | |
| 114 | - | {name: "empty", msg: Input{Data: nil}, want: []byte{}}, | |
| 119 | + | {name: "data", msg: Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}}, | |
| 120 | + | {name: "empty", msg: Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, | |
| 115 | 121 | } | |
| 116 | 122 | ||
| 117 | 123 | for _, tt := range tests { | |
| @@ -130,14 +136,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||
| 130 | 136 | }) | |
| 131 | 137 | } | |
| 132 | 138 | ||
| 133 | - | // Input is rest-of-payload: any byte sequence, including a truncated | |
| 134 | - | // one, is a valid (if empty or partial) Data value. No error path | |
| 135 | - | // exists to exercise beyond "never panics". | |
| 136 | - | mustNotPanic(t, func() { | |
| 137 | - | if _, err := UnmarshalInput([]byte{0x01}); err != nil { | |
| 138 | - | t.Fatalf("UnmarshalInput() unexpected error = %v", err) | |
| 139 | - | } | |
| 140 | - | }) | |
| 139 | + | full := MarshalInput(tests[0].msg) | |
| 140 | + | assertAllPrefixesFail(t, full[:8], UnmarshalInput) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | 143 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -267,13 +267,13 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) { | |||
| 267 | 267 | }{ | |
| 268 | 268 | { | |
| 269 | 269 | name: "ephemeral", | |
| 270 | - | msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true}, | |
| 271 | - | want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01}, | |
| 270 | + | msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict}, | |
| 271 | + | want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05}, | |
| 272 | 272 | }, | |
| 273 | 273 | { | |
| 274 | 274 | name: "non-ephemeral empty name", | |
| 275 | 275 | msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false}, | |
| 276 | - | want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00}, | |
| 276 | + | want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | |
| 277 | 277 | }, | |
| 278 | 278 | } | |
| 279 | 279 | ||
| @@ -319,8 +319,8 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | |||
| 319 | 319 | } | |
| 320 | 320 | ||
| 321 | 321 | func TestOutputGoldenAndRoundTrip(t *testing.T) { | |
| 322 | - | msg := Output{Data: []byte("hello\n")} | |
| 323 | - | want := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a} | |
| 322 | + | msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")} | |
| 323 | + | want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a} | |
| 324 | 324 | ||
| 325 | 325 | got := MarshalOutput(msg) | |
| 326 | 326 | if !bytes.Equal(got, want) { | |
| @@ -333,6 +333,28 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) { | |||
| 333 | 333 | if !reflect.DeepEqual(back, msg) { | |
| 334 | 334 | t.Fatalf("round trip = %#v, want %#v", back, msg) | |
| 335 | 335 | } | |
| 336 | + | ||
| 337 | + | assertAllPrefixesFail(t, got[:24], UnmarshalOutput) | |
| 338 | + | } | |
| 339 | + | ||
| 340 | + | func TestAckGoldenAndRoundTrip(t *testing.T) { | |
| 341 | + | msg := Ack{AckedStateNum: 0x0102030405060708} | |
| 342 | + | want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} | |
| 343 | + | ||
| 344 | + | got := MarshalAck(msg) | |
| 345 | + | if !bytes.Equal(got, want) { | |
| 346 | + | t.Fatalf("MarshalAck() = %#v, want %#v", got, want) | |
| 347 | + | } | |
| 348 | + | back, err := UnmarshalAck(got) | |
| 349 | + | if err != nil { | |
| 350 | + | t.Fatalf("UnmarshalAck() error = %v", err) | |
| 351 | + | } | |
| 352 | + | if back != msg { | |
| 353 | + | t.Fatalf("round trip = %#v, want %#v", back, msg) | |
| 354 | + | } | |
| 355 | + | ||
| 356 | + | assertAllPrefixesFail(t, got, UnmarshalAck) | |
| 357 | + | assertTrailingGarbageFails(t, got, UnmarshalAck) | |
| 336 | 358 | } | |
| 337 | 359 | ||
| 338 | 360 | func TestDetachedGoldenAndRoundTrip(t *testing.T) { | |
| @@ -469,9 +491,10 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||
| 469 | 491 | IntentEphemeral: "IntentEphemeral", | |
| 470 | 492 | IntentNew: "IntentNew", | |
| 471 | 493 | IntentAttach: "IntentAttach", | |
| 494 | + | IntentResume: "IntentResume", | |
| 472 | 495 | } | |
| 473 | - | if len(vals) != 3 { | |
| 474 | - | t.Fatalf("expected 3 distinct intent values, got %d", len(vals)) | |
| 496 | + | if len(vals) != 4 { | |
| 497 | + | t.Fatalf("expected 4 distinct intent values, got %d", len(vals)) | |
| 475 | 498 | } | |
| 476 | 499 | }) | |
| 477 | 500 | ||
| @@ -60,14 +60,16 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 60 | { | 60 | { | ||
| 61 | name: "typical", | 61 | name: "typical", | ||
| 62 | msg: Hello{ | 62 | msg: Hello{ | ||
| 63 | - | Version: 1, | 63 | + | Version: 1, |
| 64 | - | Intent: IntentNew, | 64 | + | Intent: IntentNew, |
| 65 | - | Name: "w0", | 65 | + | ClientID: [16]byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10}, |
| 66 | - | Size: domain.Size{Cols: 80, Rows: 24}, | 66 | + | ResumeToken: 0x0102030405060708, |
| 67 | - | TermEnv: "xterm-256color", | 67 | + | Name: "w0", |
| 68 | - | Cwd: "/tmp/project", | 68 | + | Size: domain.Size{Cols: 80, Rows: 24}, |
| 69 | + | TermEnv: "xterm-256color", | |||
| 70 | + | Cwd: "/tmp/project", | |||
| 69 | }, | 71 | }, | ||
| 70 | - | want: []byte{0x00, 0x01, 0x01, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74}, | 72 | + | want: []byte{0x00, 0x01, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x02, 0x77, 0x30, 0x00, 0x50, 0x00, 0x18, 0x00, 0x0e, 0x78, 0x74, 0x65, 0x72, 0x6d, 0x2d, 0x32, 0x35, 0x36, 0x63, 0x6f, 0x6c, 0x6f, 0x72, 0x00, 0x0c, 0x2f, 0x74, 0x6d, 0x70, 0x2f, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74}, |
| 71 | }, | 73 | }, | ||
| 72 | { | 74 | { | ||
| 73 | name: "empty strings", | 75 | name: "empty strings", | ||
| @@ -79,7 +81,7 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 79 | TermEnv: "", | 81 | TermEnv: "", | ||
| 80 | Cwd: "", | 82 | Cwd: "", | ||
| 81 | }, | 83 | }, | ||
| 82 | - | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, | 84 | + | want: []byte{0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, |
| 83 | }, | 85 | }, | ||
| 84 | } | 86 | } | ||
| 85 | 87 | ||||
| @@ -96,6 +98,10 @@ func TestHelloGoldenAndRoundTrip(t *testing.T) { | |||||
| 96 | if !reflect.DeepEqual(back, tt.msg) { | 98 | if !reflect.DeepEqual(back, tt.msg) { | ||
| 97 | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | 99 | t.Fatalf("round trip = %#v, want %#v", back, tt.msg) | ||
| 98 | } | 100 | } | ||
| 101 | + | peek, ok := PeekHelloVersion(got) | |||
| 102 | + | if !ok || peek != tt.msg.Version { | |||
| 103 | + | t.Fatalf("PeekHelloVersion() = %d, %v; want %d, true", peek, ok, tt.msg.Version) | |||
| 104 | + | } | |||
| 99 | }) | 105 | }) | ||
| 100 | } | 106 | } | ||
| 101 | 107 | ||||
| @@ -110,8 +116,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||||
| 110 | msg Input | 116 | msg Input | ||
| 111 | want []byte | 117 | want []byte | ||
| 112 | }{ | 118 | }{ | ||
| 113 | - | {name: "data", msg: Input{Data: []byte("hi")}, want: []byte{0x68, 0x69}}, | 119 | + | {name: "data", msg: Input{InputSeq: 0x0102030405060708, Data: []byte("hi")}, want: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x68, 0x69}}, |
| 114 | - | {name: "empty", msg: Input{Data: nil}, want: []byte{}}, | 120 | + | {name: "empty", msg: Input{InputSeq: 0, Data: nil}, want: []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}}, |
| 115 | } | 121 | } | ||
| 116 | 122 | ||||
| 117 | for _, tt := range tests { | 123 | for _, tt := range tests { | ||
| @@ -130,14 +136,8 @@ func TestInputGoldenAndRoundTrip(t *testing.T) { | |||||
| 130 | }) | 136 | }) | ||
| 131 | } | 137 | } | ||
| 132 | 138 | ||||
| 133 | - | // Input is rest-of-payload: any byte sequence, including a truncated | 139 | + | full := MarshalInput(tests[0].msg) |
| 134 | - | // one, is a valid (if empty or partial) Data value. No error path | 140 | + | assertAllPrefixesFail(t, full[:8], UnmarshalInput) |
| 135 | - | // exists to exercise beyond "never panics". | |||
| 136 | - | mustNotPanic(t, func() { | |||
| 137 | - | if _, err := UnmarshalInput([]byte{0x01}); err != nil { | |||
| 138 | - | t.Fatalf("UnmarshalInput() unexpected error = %v", err) | |||
| 139 | - | } | |||
| 140 | - | }) | |||
| 141 | } | 141 | } | ||
| 142 | 142 | ||||
| 143 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | 143 | func TestThemeGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -267,13 +267,13 @@ func TestWelcomeGoldenAndRoundTrip(t *testing.T) { | |||||
| 267 | }{ | 267 | }{ | ||
| 268 | { | 268 | { | ||
| 269 | name: "ephemeral", | 269 | name: "ephemeral", | ||
| 270 | - | msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true}, | 270 | + | msg: Welcome{SessionID: "sess-1", SessionName: "main", Ephemeral: true, ResumeToken: 0x0102030405060708, Capabilities: CapabilityResume | CapabilityPredict}, |
| 271 | - | want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01}, | 271 | + | want: []byte{0x00, 0x06, 0x73, 0x65, 0x73, 0x73, 0x2d, 0x31, 0x00, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x01, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x00, 0x00, 0x00, 0x05}, |
| 272 | }, | 272 | }, | ||
| 273 | { | 273 | { | ||
| 274 | name: "non-ephemeral empty name", | 274 | name: "non-ephemeral empty name", | ||
| 275 | msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false}, | 275 | msg: Welcome{SessionID: "abc", SessionName: "", Ephemeral: false}, | ||
| 276 | - | want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00}, | 276 | + | want: []byte{0x00, 0x03, 0x61, 0x62, 0x63, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}, |
| 277 | }, | 277 | }, | ||
| 278 | } | 278 | } | ||
| 279 | 279 | ||||
| @@ -319,8 +319,8 @@ func TestErrorMsgGoldenAndRoundTrip(t *testing.T) { | |||||
| 319 | } | 319 | } | ||
| 320 | 320 | ||||
| 321 | func TestOutputGoldenAndRoundTrip(t *testing.T) { | 321 | func TestOutputGoldenAndRoundTrip(t *testing.T) { | ||
| 322 | - | msg := Output{Data: []byte("hello\n")} | 322 | + | msg := Output{BaseStateNum: 1, NewStateNum: 2, EchoAck: 3, Data: []byte("hello\n")} |
| 323 | - | want := []byte{0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a} | 323 | + | want := []byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0x65, 0x6c, 0x6c, 0x6f, 0x0a} |
| 324 | 324 | ||||
| 325 | got := MarshalOutput(msg) | 325 | got := MarshalOutput(msg) | ||
| 326 | if !bytes.Equal(got, want) { | 326 | if !bytes.Equal(got, want) { | ||
| @@ -333,6 +333,28 @@ func TestOutputGoldenAndRoundTrip(t *testing.T) { | |||||
| 333 | if !reflect.DeepEqual(back, msg) { | 333 | if !reflect.DeepEqual(back, msg) { | ||
| 334 | t.Fatalf("round trip = %#v, want %#v", back, msg) | 334 | t.Fatalf("round trip = %#v, want %#v", back, msg) | ||
| 335 | } | 335 | } | ||
| 336 | + | ||||
| 337 | + | assertAllPrefixesFail(t, got[:24], UnmarshalOutput) | |||
| 338 | + | } | |||
| 339 | + | ||||
| 340 | + | func TestAckGoldenAndRoundTrip(t *testing.T) { | |||
| 341 | + | msg := Ack{AckedStateNum: 0x0102030405060708} | |||
| 342 | + | want := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08} | |||
| 343 | + | ||||
| 344 | + | got := MarshalAck(msg) | |||
| 345 | + | if !bytes.Equal(got, want) { | |||
| 346 | + | t.Fatalf("MarshalAck() = %#v, want %#v", got, want) | |||
| 347 | + | } | |||
| 348 | + | back, err := UnmarshalAck(got) | |||
| 349 | + | if err != nil { | |||
| 350 | + | t.Fatalf("UnmarshalAck() error = %v", err) | |||
| 351 | + | } | |||
| 352 | + | if back != msg { | |||
| 353 | + | t.Fatalf("round trip = %#v, want %#v", back, msg) | |||
| 354 | + | } | |||
| 355 | + | ||||
| 356 | + | assertAllPrefixesFail(t, got, UnmarshalAck) | |||
| 357 | + | assertTrailingGarbageFails(t, got, UnmarshalAck) | |||
| 336 | } | 358 | } | ||
| 337 | 359 | ||||
| 338 | func TestDetachedGoldenAndRoundTrip(t *testing.T) { | 360 | func TestDetachedGoldenAndRoundTrip(t *testing.T) { | ||
| @@ -469,9 +491,10 @@ func TestMsgTypeConstantsDistinct(t *testing.T) { | |||||
| 469 | IntentEphemeral: "IntentEphemeral", | 491 | IntentEphemeral: "IntentEphemeral", | ||
| 470 | IntentNew: "IntentNew", | 492 | IntentNew: "IntentNew", | ||
| 471 | IntentAttach: "IntentAttach", | 493 | IntentAttach: "IntentAttach", | ||
| 494 | + | IntentResume: "IntentResume", | |||
| 472 | } | 495 | } | ||
| 473 | - | if len(vals) != 3 { | 496 | + | if len(vals) != 4 { |
| 474 | - | t.Fatalf("expected 3 distinct intent values, got %d", len(vals)) | 497 | + | t.Fatalf("expected 4 distinct intent values, got %d", len(vals)) |
| 475 | } | 498 | } | ||
| 476 | }) | 499 | }) | ||
| 477 | 500 | ||||
M internal/usecase/client/client.go +198 -45
| @@ -9,12 +9,14 @@ package client | |||
| 9 | 9 | ||
| 10 | 10 | import ( | |
| 11 | 11 | "context" | |
| 12 | + | "crypto/rand" | |
| 12 | 13 | "errors" | |
| 13 | 14 | "fmt" | |
| 14 | 15 | "io" | |
| 15 | 16 | "log/slog" | |
| 16 | 17 | "os" | |
| 17 | 18 | "strings" | |
| 19 | + | "time" | |
| 18 | 20 | ||
| 19 | 21 | "github.com/bnema/vev/internal/domain" | |
| 20 | 22 | "github.com/bnema/vev/internal/ports" | |
| @@ -80,6 +82,16 @@ func (m milestones) missing() []string { | |||
| 80 | 82 | return out | |
| 81 | 83 | } | |
| 82 | 84 | ||
| 85 | + | // processClientID identifies this client process across links. It is kept in | |
| 86 | + | // memory only; a fresh process gets a fresh ID. | |
| 87 | + | var processClientID = newClientID() | |
| 88 | + | ||
| 89 | + | func newClientID() [16]byte { | |
| 90 | + | var id [16]byte | |
| 91 | + | _, _ = rand.Read(id[:]) | |
| 92 | + | return id | |
| 93 | + | } | |
| 94 | + | ||
| 83 | 95 | // stdinBufSize is the read buffer for the terminal input pump. | |
| 84 | 96 | const stdinBufSize = 4096 | |
| 85 | 97 | ||
| @@ -87,32 +99,170 @@ const stdinBufSize = 4096 | |||
| 87 | 99 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | |
| 88 | 100 | const sendQueueDepth = 64 | |
| 89 | 101 | ||
| 90 | - | // Attach connects an already-dialed transport to the controlling terminal | |
| 91 | - | // and runs the attach loop until the session detaches, the daemon | |
| 92 | - | // disappears, or the context is cancelled. | |
| 93 | - | // | |
| 94 | - | // It owns transport for its lifetime and closes it before returning. The | |
| 95 | - | // terminal is put into raw mode after a successful Welcome and restored on | |
| 96 | - | // every exit path — normal return, error, or panic. | |
| 97 | - | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) { | |
| 102 | + | type reconnectBackoff struct { | |
| 103 | + | initial time.Duration | |
| 104 | + | max time.Duration | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second} | |
| 108 | + | ||
| 109 | + | const ( | |
| 110 | + | statusReconnect = "\r\x1b[2Kreconnecting…" | |
| 111 | + | statusClear = "\r\x1b[2K" | |
| 112 | + | ) | |
| 113 | + | ||
| 114 | + | // Run connects via dialer and runs the attach client. It owns the terminal | |
| 115 | + | // lifecycle above attach attempts so raw mode remains active while a live | |
| 116 | + | // client process redials a lost link. | |
| 117 | + | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent uint8, name string) (retErr error) { | |
| 98 | 118 | log := slog.Default().With("component", "client") | |
| 99 | - | ms := milestones{dialed: true} // transport arrives already connected | |
| 119 | + | ms := milestones{} | |
| 100 | 120 | ||
| 101 | - | // Runs last: a single structured diagnostic on failure, to the log file | |
| 102 | - | // (never the terminal). Console output while raw is forbidden. | |
| 103 | 121 | defer func() { | |
| 104 | 122 | if retErr != nil { | |
| 105 | 123 | log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing()) | |
| 106 | 124 | } | |
| 107 | 125 | }() | |
| 108 | 126 | ||
| 109 | - | // Attach owns the transport: close it on every exit path. | |
| 110 | - | defer func() { _ = transport.Close() }() | |
| 127 | + | var restore func() error | |
| 128 | + | rawEntered := false | |
| 129 | + | defer func() { | |
| 130 | + | if rawEntered { | |
| 131 | + | if rerr := restore(); rerr != nil && retErr == nil { | |
| 132 | + | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | }() | |
| 136 | + | enterRaw := func() error { | |
| 137 | + | if rawEntered { | |
| 138 | + | return nil | |
| 139 | + | } | |
| 140 | + | var err error | |
| 141 | + | restore, err = term.EnterRaw() | |
| 142 | + | if err != nil { | |
| 143 | + | return fmt.Errorf("vev: entering raw mode: %w", err) | |
| 144 | + | } | |
| 145 | + | rawEntered = true | |
| 146 | + | ms.rawEntered = true | |
| 147 | + | return nil | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | resumeToken := uint64(0) | |
| 151 | + | attemptIntent := intent | |
| 152 | + | backoff := defaultReconnectBackoff.initial | |
| 153 | + | showingStatus := false | |
| 154 | + | drawStatus := func() { | |
| 155 | + | if !rawEntered || showingStatus { | |
| 156 | + | return | |
| 157 | + | } | |
| 158 | + | _, _ = term.Out().Write([]byte(statusReconnect)) | |
| 159 | + | _ = term.Flush() | |
| 160 | + | showingStatus = true | |
| 161 | + | } | |
| 162 | + | clearStatus := func() { | |
| 163 | + | if !showingStatus { | |
| 164 | + | return | |
| 165 | + | } | |
| 166 | + | _, _ = term.Out().Write([]byte(statusClear)) | |
| 167 | + | _ = term.Flush() | |
| 168 | + | showingStatus = false | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | for { | |
| 172 | + | transport, err := dialer.Dial(ctx) | |
| 173 | + | if err != nil { | |
| 174 | + | if resumeToken == 0 || ctx.Err() != nil { | |
| 175 | + | return err | |
| 176 | + | } | |
| 177 | + | drawStatus() | |
| 178 | + | if !sleepReconnect(ctx, backoff) { | |
| 179 | + | return ctx.Err() | |
| 180 | + | } | |
| 181 | + | backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max) | |
| 182 | + | attemptIntent = ports.IntentResume | |
| 183 | + | continue | |
| 184 | + | } | |
| 185 | + | ms.dialed = true | |
| 186 | + | backoff = defaultReconnectBackoff.initial | |
| 187 | + | ||
| 188 | + | result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus) | |
| 189 | + | _ = transport.Close() | |
| 190 | + | if result.resumeToken != 0 { | |
| 191 | + | resumeToken = result.resumeToken | |
| 192 | + | } | |
| 193 | + | if result.err == nil { | |
| 194 | + | clearStatus() | |
| 195 | + | return nil | |
| 196 | + | } | |
| 197 | + | if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil { | |
| 198 | + | clearStatus() | |
| 199 | + | return result.err | |
| 200 | + | } | |
| 201 | + | drawStatus() | |
| 202 | + | if !sleepReconnect(ctx, backoff) { | |
| 203 | + | clearStatus() | |
| 204 | + | return ctx.Err() | |
| 205 | + | } | |
| 206 | + | backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max) | |
| 207 | + | attemptIntent = ports.IntentResume | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func sleepReconnect(ctx context.Context, d time.Duration) bool { | |
| 212 | + | t := time.NewTimer(d) | |
| 213 | + | defer t.Stop() | |
| 214 | + | select { | |
| 215 | + | case <-t.C: | |
| 216 | + | return true | |
| 217 | + | case <-ctx.Done(): | |
| 218 | + | return false | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func nextReconnectBackoff(cur, limit time.Duration) time.Duration { | |
| 223 | + | cur *= 2 | |
| 224 | + | if cur > limit { | |
| 225 | + | return limit | |
| 226 | + | } | |
| 227 | + | return cur | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func shouldReconnect(err error) bool { | |
| 231 | + | if err == nil { | |
| 232 | + | return false | |
| 233 | + | } | |
| 234 | + | if _, ok := errors.AsType[*ProtocolError](err); ok { | |
| 235 | + | return false | |
| 236 | + | } | |
| 237 | + | _, ok := errors.AsType[*DetachedError](err) | |
| 238 | + | return !ok | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // Attach connects an already-dialed transport to the controlling terminal | |
| 242 | + | // and runs the attach loop until the session detaches, the daemon | |
| 243 | + | // disappears, or the context is cancelled. | |
| 244 | + | // | |
| 245 | + | // It is kept as a compatibility wrapper for callers that still own dialing. | |
| 246 | + | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error { | |
| 247 | + | return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | type singleTransportDialer struct{ transport ports.Transport } | |
| 251 | + | ||
| 252 | + | func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) { | |
| 253 | + | return d.transport, nil | |
| 254 | + | } | |
| 111 | 255 | ||
| 256 | + | type attachResult struct { | |
| 257 | + | resumeToken uint64 | |
| 258 | + | err error | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult { | |
| 112 | 262 | // 1. Handshake: send Hello with our size and TERM. | |
| 113 | 263 | size, err := term.Size() | |
| 114 | 264 | if err != nil { | |
| 115 | - | return fmt.Errorf("vev: reading terminal size: %w", err) | |
| 265 | + | return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)} | |
| 116 | 266 | } | |
| 117 | 267 | cwd, err := os.Getwd() | |
| 118 | 268 | if err != nil { | |
| @@ -121,47 +271,48 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||
| 121 | 271 | colorTerm := strings.ToLower(os.Getenv("COLORTERM")) | |
| 122 | 272 | trueColor := colorTerm == "truecolor" || colorTerm == "24bit" | |
| 123 | 273 | hello := ports.Hello{ | |
| 124 | - | Version: ports.ProtocolVersion, | |
| 125 | - | Intent: intent, | |
| 126 | - | Name: name, | |
| 127 | - | Size: size, | |
| 128 | - | TermEnv: os.Getenv("TERM"), | |
| 129 | - | Cwd: cwd, | |
| 274 | + | Version: ports.ProtocolVersion, | |
| 275 | + | Intent: intent, | |
| 276 | + | ClientID: clientID, | |
| 277 | + | ResumeToken: resumeToken, | |
| 278 | + | Name: name, | |
| 279 | + | Size: size, | |
| 280 | + | TermEnv: os.Getenv("TERM"), | |
| 281 | + | Cwd: cwd, | |
| 130 | 282 | } | |
| 131 | 283 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | |
| 132 | - | return fmt.Errorf("vev: sending hello: %w", err) | |
| 284 | + | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} | |
| 133 | 285 | } | |
| 134 | 286 | ms.helloSent = true | |
| 135 | 287 | ||
| 136 | 288 | // 2. Await Welcome or a typed rejection. | |
| 137 | 289 | reply, err := transport.Recv() | |
| 138 | 290 | if err != nil { | |
| 139 | - | return fmt.Errorf("vev: awaiting welcome: %w", err) | |
| 291 | + | return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)} | |
| 140 | 292 | } | |
| 141 | 293 | switch reply.Type { | |
| 142 | 294 | case ports.MsgWelcome: | |
| 295 | + | welcome, derr := ports.UnmarshalWelcome(reply.Payload) | |
| 296 | + | if derr != nil { | |
| 297 | + | return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)} | |
| 298 | + | } | |
| 299 | + | resumeToken = welcome.ResumeToken | |
| 143 | 300 | ms.welcomed = true | |
| 144 | 301 | case ports.MsgError: | |
| 145 | 302 | em, derr := ports.UnmarshalErrorMsg(reply.Payload) | |
| 146 | 303 | if derr != nil { | |
| 147 | - | return fmt.Errorf("vev: decoding error reply: %w", derr) | |
| 304 | + | return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)} | |
| 148 | 305 | } | |
| 149 | - | return &ProtocolError{Code: em.Code, Text: em.Text} | |
| 306 | + | return attachResult{resumeToken: resumeToken, err: &ProtocolError{Code: em.Code, Text: em.Text}} | |
| 150 | 307 | default: | |
| 151 | - | return fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type) | |
| 308 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)} | |
| 152 | 309 | } | |
| 153 | 310 | ||
| 154 | - | // 3. Enter raw mode; restore on every subsequent exit path. | |
| 155 | - | restore, err := term.EnterRaw() | |
| 156 | - | if err != nil { | |
| 157 | - | return fmt.Errorf("vev: entering raw mode: %w", err) | |
| 311 | + | // 3. Enter raw mode after Welcome; Run owns restoration. | |
| 312 | + | if err := enterRaw(); err != nil { | |
| 313 | + | return attachResult{resumeToken: resumeToken, err: err} | |
| 158 | 314 | } | |
| 159 | - | ms.rawEntered = true | |
| 160 | - | defer func() { | |
| 161 | - | if rerr := restore(); rerr != nil && retErr == nil { | |
| 162 | - | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | |
| 163 | - | } | |
| 164 | - | }() | |
| 315 | + | clearStatus() | |
| 165 | 316 | ||
| 166 | 317 | // 4. Derive a cancellable context so the pumps always unwind when the | |
| 167 | 318 | // loop returns. cancel runs first (LIFO): it signals the pumps; the | |
| @@ -187,37 +338,37 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||
| 187 | 338 | // context was cancelled. A queued sender error takes priority. | |
| 188 | 339 | select { | |
| 189 | 340 | case serr := <-sendErrCh: | |
| 190 | - | return fmt.Errorf("vev: sending to daemon: %w", serr) | |
| 341 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: sending to daemon: %w", serr)} | |
| 191 | 342 | default: | |
| 192 | - | return nil | |
| 343 | + | return attachResult{resumeToken: resumeToken} | |
| 193 | 344 | } | |
| 194 | 345 | case r := <-recvCh: | |
| 195 | 346 | if r.err != nil { | |
| 196 | 347 | if errors.Is(r.err, io.EOF) { | |
| 197 | - | return fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err) | |
| 348 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)} | |
| 198 | 349 | } | |
| 199 | - | return fmt.Errorf("vev: receiving from daemon: %w", r.err) | |
| 350 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)} | |
| 200 | 351 | } | |
| 201 | 352 | switch r.frame.Type { | |
| 202 | 353 | case ports.MsgOutput: | |
| 203 | 354 | o, derr := ports.UnmarshalOutput(r.frame.Payload) | |
| 204 | 355 | if derr != nil { | |
| 205 | - | return fmt.Errorf("vev: decoding output: %w", derr) | |
| 356 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding output: %w", derr)} | |
| 206 | 357 | } | |
| 207 | 358 | if _, werr := term.Out().Write(o.Data); werr != nil { | |
| 208 | - | return fmt.Errorf("vev: writing terminal output: %w", werr) | |
| 359 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: writing terminal output: %w", werr)} | |
| 209 | 360 | } | |
| 210 | 361 | if ferr := term.Flush(); ferr != nil { | |
| 211 | - | return fmt.Errorf("vev: flushing terminal: %w", ferr) | |
| 362 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: flushing terminal: %w", ferr)} | |
| 212 | 363 | } | |
| 213 | 364 | ms.firstOutput = true | |
| 214 | 365 | case ports.MsgDetached: | |
| 215 | 366 | d, derr := ports.UnmarshalDetached(r.frame.Payload) | |
| 216 | 367 | if derr != nil { | |
| 217 | - | return fmt.Errorf("vev: decoding detached: %w", derr) | |
| 368 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding detached: %w", derr)} | |
| 218 | 369 | } | |
| 219 | 370 | ms.detached = true | |
| 220 | - | return detachedResult(d.Reason) | |
| 371 | + | return attachResult{resumeToken: resumeToken, err: detachedResult(d.Reason)} | |
| 221 | 372 | case ports.MsgPong: | |
| 222 | 373 | // Liveness reply; nothing to do. | |
| 223 | 374 | default: | |
| @@ -290,6 +441,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 290 | 441 | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) { | |
| 291 | 442 | buf := make([]byte, stdinBufSize) | |
| 292 | 443 | var scanner theme.Scanner | |
| 444 | + | var inputSeq uint64 | |
| 293 | 445 | current := ports.Theme{TrueColor: trueColor} | |
| 294 | 446 | send := func(frame ports.Frame) bool { | |
| 295 | 447 | select { | |
| @@ -320,7 +472,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||
| 320 | 472 | return | |
| 321 | 473 | } | |
| 322 | 474 | copyData := append([]byte(nil), data...) | |
| 323 | - | ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})}) | |
| 475 | + | inputSeq++ | |
| 476 | + | ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | |
| 324 | 477 | }) | |
| 325 | 478 | if !ok { | |
| 326 | 479 | return | |
| @@ -9,12 +9,14 @@ package client | |||||
| 9 | 9 | ||||
| 10 | import ( | 10 | import ( | ||
| 11 | "context" | 11 | "context" | ||
| 12 | + | "crypto/rand" | |||
| 12 | "errors" | 13 | "errors" | ||
| 13 | "fmt" | 14 | "fmt" | ||
| 14 | "io" | 15 | "io" | ||
| 15 | "log/slog" | 16 | "log/slog" | ||
| 16 | "os" | 17 | "os" | ||
| 17 | "strings" | 18 | "strings" | ||
| 19 | + | "time" | |||
| 18 | 20 | ||||
| 19 | "github.com/bnema/vev/internal/domain" | 21 | "github.com/bnema/vev/internal/domain" | ||
| 20 | "github.com/bnema/vev/internal/ports" | 22 | "github.com/bnema/vev/internal/ports" | ||
| @@ -80,6 +82,16 @@ func (m milestones) missing() []string { | |||||
| 80 | return out | 82 | return out | ||
| 81 | } | 83 | } | ||
| 82 | 84 | ||||
| 85 | + | // processClientID identifies this client process across links. It is kept in | |||
| 86 | + | // memory only; a fresh process gets a fresh ID. | |||
| 87 | + | var processClientID = newClientID() | |||
| 88 | + | ||||
| 89 | + | func newClientID() [16]byte { | |||
| 90 | + | var id [16]byte | |||
| 91 | + | _, _ = rand.Read(id[:]) | |||
| 92 | + | return id | |||
| 93 | + | } | |||
| 94 | + | ||||
| 83 | // stdinBufSize is the read buffer for the terminal input pump. | 95 | // stdinBufSize is the read buffer for the terminal input pump. | ||
| 84 | const stdinBufSize = 4096 | 96 | const stdinBufSize = 4096 | ||
| 85 | 97 | ||||
| @@ -87,32 +99,170 @@ const stdinBufSize = 4096 | |||||
| 87 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | 99 | // goroutine, decoupling the input/resize pumps from transport back-pressure. | ||
| 88 | const sendQueueDepth = 64 | 100 | const sendQueueDepth = 64 | ||
| 89 | 101 | ||||
| 90 | - | // Attach connects an already-dialed transport to the controlling terminal | 102 | + | type reconnectBackoff struct { |
| 91 | - | // and runs the attach loop until the session detaches, the daemon | 103 | + | initial time.Duration |
| 92 | - | // disappears, or the context is cancelled. | 104 | + | max time.Duration |
| 93 | - | // | 105 | + | } |
| 94 | - | // It owns transport for its lifetime and closes it before returning. The | 106 | + | |
| 95 | - | // terminal is put into raw mode after a successful Welcome and restored on | 107 | + | var defaultReconnectBackoff = reconnectBackoff{initial: 100 * time.Millisecond, max: 2 * time.Second} |
| 96 | - | // every exit path — normal return, error, or panic. | 108 | + | |
| 97 | - | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) (retErr error) { | 109 | + | const ( |
| 110 | + | statusReconnect = "\r\x1b[2Kreconnecting…" | |||
| 111 | + | statusClear = "\r\x1b[2K" | |||
| 112 | + | ) | |||
| 113 | + | ||||
| 114 | + | // Run connects via dialer and runs the attach client. It owns the terminal | |||
| 115 | + | // lifecycle above attach attempts so raw mode remains active while a live | |||
| 116 | + | // client process redials a lost link. | |||
| 117 | + | func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, intent uint8, name string) (retErr error) { | |||
| 98 | log := slog.Default().With("component", "client") | 118 | log := slog.Default().With("component", "client") | ||
| 99 | - | ms := milestones{dialed: true} // transport arrives already connected | 119 | + | ms := milestones{} |
| 100 | 120 | ||||
| 101 | - | // Runs last: a single structured diagnostic on failure, to the log file | |||
| 102 | - | // (never the terminal). Console output while raw is forbidden. | |||
| 103 | defer func() { | 121 | defer func() { | ||
| 104 | if retErr != nil { | 122 | if retErr != nil { | ||
| 105 | log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing()) | 123 | log.Error("attach ended with error", "err", retErr, "missing_milestones", ms.missing()) | ||
| 106 | } | 124 | } | ||
| 107 | }() | 125 | }() | ||
| 108 | 126 | ||||
| 109 | - | // Attach owns the transport: close it on every exit path. | 127 | + | var restore func() error |
| 110 | - | defer func() { _ = transport.Close() }() | 128 | + | rawEntered := false |
| 129 | + | defer func() { | |||
| 130 | + | if rawEntered { | |||
| 131 | + | if rerr := restore(); rerr != nil && retErr == nil { | |||
| 132 | + | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | }() | |||
| 136 | + | enterRaw := func() error { | |||
| 137 | + | if rawEntered { | |||
| 138 | + | return nil | |||
| 139 | + | } | |||
| 140 | + | var err error | |||
| 141 | + | restore, err = term.EnterRaw() | |||
| 142 | + | if err != nil { | |||
| 143 | + | return fmt.Errorf("vev: entering raw mode: %w", err) | |||
| 144 | + | } | |||
| 145 | + | rawEntered = true | |||
| 146 | + | ms.rawEntered = true | |||
| 147 | + | return nil | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | resumeToken := uint64(0) | |||
| 151 | + | attemptIntent := intent | |||
| 152 | + | backoff := defaultReconnectBackoff.initial | |||
| 153 | + | showingStatus := false | |||
| 154 | + | drawStatus := func() { | |||
| 155 | + | if !rawEntered || showingStatus { | |||
| 156 | + | return | |||
| 157 | + | } | |||
| 158 | + | _, _ = term.Out().Write([]byte(statusReconnect)) | |||
| 159 | + | _ = term.Flush() | |||
| 160 | + | showingStatus = true | |||
| 161 | + | } | |||
| 162 | + | clearStatus := func() { | |||
| 163 | + | if !showingStatus { | |||
| 164 | + | return | |||
| 165 | + | } | |||
| 166 | + | _, _ = term.Out().Write([]byte(statusClear)) | |||
| 167 | + | _ = term.Flush() | |||
| 168 | + | showingStatus = false | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | for { | |||
| 172 | + | transport, err := dialer.Dial(ctx) | |||
| 173 | + | if err != nil { | |||
| 174 | + | if resumeToken == 0 || ctx.Err() != nil { | |||
| 175 | + | return err | |||
| 176 | + | } | |||
| 177 | + | drawStatus() | |||
| 178 | + | if !sleepReconnect(ctx, backoff) { | |||
| 179 | + | return ctx.Err() | |||
| 180 | + | } | |||
| 181 | + | backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max) | |||
| 182 | + | attemptIntent = ports.IntentResume | |||
| 183 | + | continue | |||
| 184 | + | } | |||
| 185 | + | ms.dialed = true | |||
| 186 | + | backoff = defaultReconnectBackoff.initial | |||
| 187 | + | ||||
| 188 | + | result := attachOnce(ctx, transport, term, attemptIntent, name, resumeToken, processClientID, &ms, enterRaw, clearStatus) | |||
| 189 | + | _ = transport.Close() | |||
| 190 | + | if result.resumeToken != 0 { | |||
| 191 | + | resumeToken = result.resumeToken | |||
| 192 | + | } | |||
| 193 | + | if result.err == nil { | |||
| 194 | + | clearStatus() | |||
| 195 | + | return nil | |||
| 196 | + | } | |||
| 197 | + | if !shouldReconnect(result.err) || resumeToken == 0 || ctx.Err() != nil { | |||
| 198 | + | clearStatus() | |||
| 199 | + | return result.err | |||
| 200 | + | } | |||
| 201 | + | drawStatus() | |||
| 202 | + | if !sleepReconnect(ctx, backoff) { | |||
| 203 | + | clearStatus() | |||
| 204 | + | return ctx.Err() | |||
| 205 | + | } | |||
| 206 | + | backoff = nextReconnectBackoff(backoff, defaultReconnectBackoff.max) | |||
| 207 | + | attemptIntent = ports.IntentResume | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func sleepReconnect(ctx context.Context, d time.Duration) bool { | |||
| 212 | + | t := time.NewTimer(d) | |||
| 213 | + | defer t.Stop() | |||
| 214 | + | select { | |||
| 215 | + | case <-t.C: | |||
| 216 | + | return true | |||
| 217 | + | case <-ctx.Done(): | |||
| 218 | + | return false | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | ||||
| 222 | + | func nextReconnectBackoff(cur, limit time.Duration) time.Duration { | |||
| 223 | + | cur *= 2 | |||
| 224 | + | if cur > limit { | |||
| 225 | + | return limit | |||
| 226 | + | } | |||
| 227 | + | return cur | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | func shouldReconnect(err error) bool { | |||
| 231 | + | if err == nil { | |||
| 232 | + | return false | |||
| 233 | + | } | |||
| 234 | + | if _, ok := errors.AsType[*ProtocolError](err); ok { | |||
| 235 | + | return false | |||
| 236 | + | } | |||
| 237 | + | _, ok := errors.AsType[*DetachedError](err) | |||
| 238 | + | return !ok | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | // Attach connects an already-dialed transport to the controlling terminal | |||
| 242 | + | // and runs the attach loop until the session detaches, the daemon | |||
| 243 | + | // disappears, or the context is cancelled. | |||
| 244 | + | // | |||
| 245 | + | // It is kept as a compatibility wrapper for callers that still own dialing. | |||
| 246 | + | func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string) error { | |||
| 247 | + | return Run(ctx, singleTransportDialer{transport: transport}, term, intent, name) | |||
| 248 | + | } | |||
| 249 | + | ||||
| 250 | + | type singleTransportDialer struct{ transport ports.Transport } | |||
| 251 | + | ||||
| 252 | + | func (d singleTransportDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 253 | + | return d.transport, nil | |||
| 254 | + | } | |||
| 111 | 255 | ||||
| 256 | + | type attachResult struct { | |||
| 257 | + | resumeToken uint64 | |||
| 258 | + | err error | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func attachOnce(ctx context.Context, transport ports.Transport, term ports.Terminal, intent uint8, name string, resumeToken uint64, clientID [16]byte, ms *milestones, enterRaw func() error, clearStatus func()) attachResult { | |||
| 112 | // 1. Handshake: send Hello with our size and TERM. | 262 | // 1. Handshake: send Hello with our size and TERM. | ||
| 113 | size, err := term.Size() | 263 | size, err := term.Size() | ||
| 114 | if err != nil { | 264 | if err != nil { | ||
| 115 | - | return fmt.Errorf("vev: reading terminal size: %w", err) | 265 | + | return attachResult{err: fmt.Errorf("vev: reading terminal size: %w", err)} |
| 116 | } | 266 | } | ||
| 117 | cwd, err := os.Getwd() | 267 | cwd, err := os.Getwd() | ||
| 118 | if err != nil { | 268 | if err != nil { | ||
| @@ -121,47 +271,48 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||||
| 121 | colorTerm := strings.ToLower(os.Getenv("COLORTERM")) | 271 | colorTerm := strings.ToLower(os.Getenv("COLORTERM")) | ||
| 122 | trueColor := colorTerm == "truecolor" || colorTerm == "24bit" | 272 | trueColor := colorTerm == "truecolor" || colorTerm == "24bit" | ||
| 123 | hello := ports.Hello{ | 273 | hello := ports.Hello{ | ||
| 124 | - | Version: ports.ProtocolVersion, | 274 | + | Version: ports.ProtocolVersion, |
| 125 | - | Intent: intent, | 275 | + | Intent: intent, |
| 126 | - | Name: name, | 276 | + | ClientID: clientID, |
| 127 | - | Size: size, | 277 | + | ResumeToken: resumeToken, |
| 128 | - | TermEnv: os.Getenv("TERM"), | 278 | + | Name: name, |
| 129 | - | Cwd: cwd, | 279 | + | Size: size, |
| 280 | + | TermEnv: os.Getenv("TERM"), | |||
| 281 | + | Cwd: cwd, | |||
| 130 | } | 282 | } | ||
| 131 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | 283 | if err := transport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(hello)}); err != nil { | ||
| 132 | - | return fmt.Errorf("vev: sending hello: %w", err) | 284 | + | return attachResult{err: fmt.Errorf("vev: sending hello: %w", err)} |
| 133 | } | 285 | } | ||
| 134 | ms.helloSent = true | 286 | ms.helloSent = true | ||
| 135 | 287 | ||||
| 136 | // 2. Await Welcome or a typed rejection. | 288 | // 2. Await Welcome or a typed rejection. | ||
| 137 | reply, err := transport.Recv() | 289 | reply, err := transport.Recv() | ||
| 138 | if err != nil { | 290 | if err != nil { | ||
| 139 | - | return fmt.Errorf("vev: awaiting welcome: %w", err) | 291 | + | return attachResult{err: fmt.Errorf("vev: awaiting welcome: %w", err)} |
| 140 | } | 292 | } | ||
| 141 | switch reply.Type { | 293 | switch reply.Type { | ||
| 142 | case ports.MsgWelcome: | 294 | case ports.MsgWelcome: | ||
| 295 | + | welcome, derr := ports.UnmarshalWelcome(reply.Payload) | |||
| 296 | + | if derr != nil { | |||
| 297 | + | return attachResult{err: fmt.Errorf("vev: decoding welcome: %w", derr)} | |||
| 298 | + | } | |||
| 299 | + | resumeToken = welcome.ResumeToken | |||
| 143 | ms.welcomed = true | 300 | ms.welcomed = true | ||
| 144 | case ports.MsgError: | 301 | case ports.MsgError: | ||
| 145 | em, derr := ports.UnmarshalErrorMsg(reply.Payload) | 302 | em, derr := ports.UnmarshalErrorMsg(reply.Payload) | ||
| 146 | if derr != nil { | 303 | if derr != nil { | ||
| 147 | - | return fmt.Errorf("vev: decoding error reply: %w", derr) | 304 | + | return attachResult{err: fmt.Errorf("vev: decoding error reply: %w", derr)} |
| 148 | } | 305 | } | ||
| 149 | - | return &ProtocolError{Code: em.Code, Text: em.Text} | 306 | + | return attachResult{resumeToken: resumeToken, err: &ProtocolError{Code: em.Code, Text: em.Text}} |
| 150 | default: | 307 | default: | ||
| 151 | - | return fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type) | 308 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: unexpected reply type %d before welcome", reply.Type)} |
| 152 | } | 309 | } | ||
| 153 | 310 | ||||
| 154 | - | // 3. Enter raw mode; restore on every subsequent exit path. | 311 | + | // 3. Enter raw mode after Welcome; Run owns restoration. |
| 155 | - | restore, err := term.EnterRaw() | 312 | + | if err := enterRaw(); err != nil { |
| 156 | - | if err != nil { | 313 | + | return attachResult{resumeToken: resumeToken, err: err} |
| 157 | - | return fmt.Errorf("vev: entering raw mode: %w", err) | |||
| 158 | } | 314 | } | ||
| 159 | - | ms.rawEntered = true | 315 | + | clearStatus() |
| 160 | - | defer func() { | |||
| 161 | - | if rerr := restore(); rerr != nil && retErr == nil { | |||
| 162 | - | retErr = fmt.Errorf("vev: restoring terminal: %w", rerr) | |||
| 163 | - | } | |||
| 164 | - | }() | |||
| 165 | 316 | ||||
| 166 | // 4. Derive a cancellable context so the pumps always unwind when the | 317 | // 4. Derive a cancellable context so the pumps always unwind when the | ||
| 167 | // loop returns. cancel runs first (LIFO): it signals the pumps; the | 318 | // loop returns. cancel runs first (LIFO): it signals the pumps; the | ||
| @@ -187,37 +338,37 @@ func Attach(ctx context.Context, transport ports.Transport, term ports.Terminal, | |||||
| 187 | // context was cancelled. A queued sender error takes priority. | 338 | // context was cancelled. A queued sender error takes priority. | ||
| 188 | select { | 339 | select { | ||
| 189 | case serr := <-sendErrCh: | 340 | case serr := <-sendErrCh: | ||
| 190 | - | return fmt.Errorf("vev: sending to daemon: %w", serr) | 341 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: sending to daemon: %w", serr)} |
| 191 | default: | 342 | default: | ||
| 192 | - | return nil | 343 | + | return attachResult{resumeToken: resumeToken} |
| 193 | } | 344 | } | ||
| 194 | case r := <-recvCh: | 345 | case r := <-recvCh: | ||
| 195 | if r.err != nil { | 346 | if r.err != nil { | ||
| 196 | if errors.Is(r.err, io.EOF) { | 347 | if errors.Is(r.err, io.EOF) { | ||
| 197 | - | return fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err) | 348 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: daemon vanished (missing: %v): %w", ms.missing(), r.err)} |
| 198 | } | 349 | } | ||
| 199 | - | return fmt.Errorf("vev: receiving from daemon: %w", r.err) | 350 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: receiving from daemon: %w", r.err)} |
| 200 | } | 351 | } | ||
| 201 | switch r.frame.Type { | 352 | switch r.frame.Type { | ||
| 202 | case ports.MsgOutput: | 353 | case ports.MsgOutput: | ||
| 203 | o, derr := ports.UnmarshalOutput(r.frame.Payload) | 354 | o, derr := ports.UnmarshalOutput(r.frame.Payload) | ||
| 204 | if derr != nil { | 355 | if derr != nil { | ||
| 205 | - | return fmt.Errorf("vev: decoding output: %w", derr) | 356 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding output: %w", derr)} |
| 206 | } | 357 | } | ||
| 207 | if _, werr := term.Out().Write(o.Data); werr != nil { | 358 | if _, werr := term.Out().Write(o.Data); werr != nil { | ||
| 208 | - | return fmt.Errorf("vev: writing terminal output: %w", werr) | 359 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: writing terminal output: %w", werr)} |
| 209 | } | 360 | } | ||
| 210 | if ferr := term.Flush(); ferr != nil { | 361 | if ferr := term.Flush(); ferr != nil { | ||
| 211 | - | return fmt.Errorf("vev: flushing terminal: %w", ferr) | 362 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: flushing terminal: %w", ferr)} |
| 212 | } | 363 | } | ||
| 213 | ms.firstOutput = true | 364 | ms.firstOutput = true | ||
| 214 | case ports.MsgDetached: | 365 | case ports.MsgDetached: | ||
| 215 | d, derr := ports.UnmarshalDetached(r.frame.Payload) | 366 | d, derr := ports.UnmarshalDetached(r.frame.Payload) | ||
| 216 | if derr != nil { | 367 | if derr != nil { | ||
| 217 | - | return fmt.Errorf("vev: decoding detached: %w", derr) | 368 | + | return attachResult{resumeToken: resumeToken, err: fmt.Errorf("vev: decoding detached: %w", derr)} |
| 218 | } | 369 | } | ||
| 219 | ms.detached = true | 370 | ms.detached = true | ||
| 220 | - | return detachedResult(d.Reason) | 371 | + | return attachResult{resumeToken: resumeToken, err: detachedResult(d.Reason)} |
| 221 | case ports.MsgPong: | 372 | case ports.MsgPong: | ||
| 222 | // Liveness reply; nothing to do. | 373 | // Liveness reply; nothing to do. | ||
| 223 | default: | 374 | default: | ||
| @@ -290,6 +441,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||||
| 290 | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) { | 441 | func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out chan<- ports.Frame, trueColor bool) { | ||
| 291 | buf := make([]byte, stdinBufSize) | 442 | buf := make([]byte, stdinBufSize) | ||
| 292 | var scanner theme.Scanner | 443 | var scanner theme.Scanner | ||
| 444 | + | var inputSeq uint64 | |||
| 293 | current := ports.Theme{TrueColor: trueColor} | 445 | current := ports.Theme{TrueColor: trueColor} | ||
| 294 | send := func(frame ports.Frame) bool { | 446 | send := func(frame ports.Frame) bool { | ||
| 295 | select { | 447 | select { | ||
| @@ -320,7 +472,8 @@ func runStdin(ctx context.Context, cancel context.CancelFunc, in io.Reader, out | |||||
| 320 | return | 472 | return | ||
| 321 | } | 473 | } | ||
| 322 | copyData := append([]byte(nil), data...) | 474 | copyData := append([]byte(nil), data...) | ||
| 323 | - | ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{Data: copyData})}) | 475 | + | inputSeq++ |
| 476 | + | ok = send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})}) | |||
| 324 | }) | 477 | }) | ||
| 325 | if !ok { | 478 | if !ok { | ||
| 326 | return | 479 | return | ||
M internal/usecase/client/client_test.go +185 -0
| @@ -530,3 +530,188 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 530 | 530 | t.Fatal("resize was not forwarded") | |
| 531 | 531 | } | |
| 532 | 532 | } | |
| 533 | + | ||
| 534 | + | type testDialer struct { | |
| 535 | + | tr ports.Transport | |
| 536 | + | err error | |
| 537 | + | calls atomic.Int32 | |
| 538 | + | } | |
| 539 | + | ||
| 540 | + | func (d *testDialer) Dial(context.Context) (ports.Transport, error) { | |
| 541 | + | d.calls.Add(1) | |
| 542 | + | return d.tr, d.err | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |
| 546 | + | var out bytes.Buffer | |
| 547 | + | var restoreCount atomic.Int32 | |
| 548 | + | resizeCh := make(chan domain.Size) | |
| 549 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |
| 550 | + | defer in.unblock() | |
| 551 | + | ||
| 552 | + | tr := portsmocks.NewMockTransport(t) | |
| 553 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 554 | + | boom := errors.New("connection reset") | |
| 555 | + | unblock := scriptRecv(tr, | |
| 556 | + | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |
| 557 | + | recvItem{err: boom}, | |
| 558 | + | ) | |
| 559 | + | defer unblock() | |
| 560 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 561 | + | d := &testDialer{tr: tr} | |
| 562 | + | ||
| 563 | + | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | |
| 564 | + | require.Error(t, err) | |
| 565 | + | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | |
| 566 | + | } | |
| 567 | + | ||
| 568 | + | func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |
| 569 | + | tm := portsmocks.NewMockTerminal(t) | |
| 570 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |
| 571 | + | // EnterRaw must NOT be called when the daemon rejects Hello before Welcome. | |
| 572 | + | ||
| 573 | + | tr := portsmocks.NewMockTransport(t) | |
| 574 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 575 | + | tr.EXPECT().Recv().Return( | |
| 576 | + | frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})), | |
| 577 | + | nil, | |
| 578 | + | ).Once() | |
| 579 | + | tr.EXPECT().Close().Return(nil).Once() | |
| 580 | + | d := &testDialer{tr: tr} | |
| 581 | + | ||
| 582 | + | err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main") | |
| 583 | + | require.Error(t, err) | |
| 584 | + | var pe *client.ProtocolError | |
| 585 | + | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |
| 586 | + | require.Equal(t, int32(1), d.calls.Load()) | |
| 587 | + | } | |
| 588 | + | ||
| 589 | + | func TestRunPhaseASingleAttempt(t *testing.T) { | |
| 590 | + | dialErr := errors.New("dial failed") | |
| 591 | + | d := &testDialer{err: dialErr} | |
| 592 | + | tm := portsmocks.NewMockTerminal(t) | |
| 593 | + | ||
| 594 | + | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | |
| 595 | + | require.ErrorIs(t, err, dialErr) | |
| 596 | + | require.Equal(t, int32(1), d.calls.Load(), "Phase A Run must not retry") | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | type sequenceDialer struct { | |
| 600 | + | trs []ports.Transport | |
| 601 | + | errs []error | |
| 602 | + | calls atomic.Int32 | |
| 603 | + | } | |
| 604 | + | ||
| 605 | + | func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) { | |
| 606 | + | i := int(d.calls.Add(1)) - 1 | |
| 607 | + | if i < len(d.errs) && d.errs[i] != nil { | |
| 608 | + | return nil, d.errs[i] | |
| 609 | + | } | |
| 610 | + | if i >= len(d.trs) { | |
| 611 | + | return nil, io.EOF | |
| 612 | + | } | |
| 613 | + | return d.trs[i], nil | |
| 614 | + | } | |
| 615 | + | ||
| 616 | + | type recordingTransport struct { | |
| 617 | + | recvs []recvItem | |
| 618 | + | sends []ports.Frame | |
| 619 | + | closed atomic.Int32 | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | func (t *recordingTransport) Send(f ports.Frame) error { | |
| 623 | + | t.sends = append(t.sends, f) | |
| 624 | + | return nil | |
| 625 | + | } | |
| 626 | + | ||
| 627 | + | func (t *recordingTransport) Recv() (ports.Frame, error) { | |
| 628 | + | if len(t.recvs) == 0 { | |
| 629 | + | return ports.Frame{}, io.EOF | |
| 630 | + | } | |
| 631 | + | it := t.recvs[0] | |
| 632 | + | t.recvs = t.recvs[1:] | |
| 633 | + | return it.f, it.err | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | func (t *recordingTransport) Close() error { | |
| 637 | + | t.closed.Add(1) | |
| 638 | + | return nil | |
| 639 | + | } | |
| 640 | + | ||
| 641 | + | type runTerminal struct { | |
| 642 | + | in *blockingReader | |
| 643 | + | out bytes.Buffer | |
| 644 | + | rawCount atomic.Int32 | |
| 645 | + | restoreCount atomic.Int32 | |
| 646 | + | resizeCh chan domain.Size | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | func newRunTerminal() *runTerminal { | |
| 650 | + | return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)} | |
| 651 | + | } | |
| 652 | + | ||
| 653 | + | func (t *runTerminal) EnterRaw() (func() error, error) { | |
| 654 | + | t.rawCount.Add(1) | |
| 655 | + | return func() error { t.restoreCount.Add(1); return nil }, nil | |
| 656 | + | } | |
| 657 | + | func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil } | |
| 658 | + | func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh } | |
| 659 | + | func (t *runTerminal) In() io.Reader { return t.in } | |
| 660 | + | func (t *runTerminal) Out() io.Writer { return &t.out } | |
| 661 | + | func (t *runTerminal) Flush() error { return nil } | |
| 662 | + | ||
| 663 | + | func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello { | |
| 664 | + | t.Helper() | |
| 665 | + | require.NotEmpty(t, tr.sends) | |
| 666 | + | require.Equal(t, ports.MsgHello, tr.sends[0].Type) | |
| 667 | + | h, err := ports.UnmarshalHello(tr.sends[0].Payload) | |
| 668 | + | require.NoError(t, err) | |
| 669 | + | return h | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | func welcomeFrame(token uint64) ports.Frame { | |
| 673 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume})) | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |
| 677 | + | term := newRunTerminal() | |
| 678 | + | defer term.in.unblock() | |
| 679 | + | tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}} | |
| 680 | + | tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}} | |
| 681 | + | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | |
| 682 | + | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | |
| 683 | + | ||
| 684 | + | err := client.Run(context.Background(), d, term, ports.IntentAttach, "main") | |
| 685 | + | require.NoError(t, err) | |
| 686 | + | require.Equal(t, int32(3), d.calls.Load()) | |
| 687 | + | require.Equal(t, int32(1), term.rawCount.Load()) | |
| 688 | + | require.Equal(t, int32(1), term.restoreCount.Load()) | |
| 689 | + | ||
| 690 | + | h1 := helloFromSend(t, tr1) | |
| 691 | + | h2 := helloFromSend(t, tr2) | |
| 692 | + | h3 := helloFromSend(t, tr3) | |
| 693 | + | require.Equal(t, ports.IntentAttach, h1.Intent) | |
| 694 | + | require.Zero(t, h1.ResumeToken) | |
| 695 | + | require.Equal(t, ports.IntentResume, h2.Intent) | |
| 696 | + | require.Equal(t, uint64(11), h2.ResumeToken) | |
| 697 | + | require.Equal(t, ports.IntentResume, h3.Intent) | |
| 698 | + | require.Equal(t, uint64(22), h3.ResumeToken) | |
| 699 | + | require.Equal(t, h1.ClientID, h2.ClientID) | |
| 700 | + | require.Equal(t, h1.ClientID, h3.ClientID) | |
| 701 | + | require.Contains(t, term.out.String(), "reconnecting…") | |
| 702 | + | require.Contains(t, term.out.String(), "\x1b[2K") | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |
| 706 | + | term := newRunTerminal() | |
| 707 | + | defer term.in.unblock() | |
| 708 | + | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | |
| 709 | + | d := &sequenceDialer{trs: []ports.Transport{tr}} | |
| 710 | + | ||
| 711 | + | err := client.Run(context.Background(), d, term, ports.IntentAttach, "main") | |
| 712 | + | require.Error(t, err) | |
| 713 | + | var de *client.DetachedError | |
| 714 | + | require.True(t, errors.As(err, &de)) | |
| 715 | + | require.Equal(t, int32(1), d.calls.Load()) | |
| 716 | + | require.Equal(t, int32(1), term.restoreCount.Load()) | |
| 717 | + | } | |
| @@ -530,3 +530,188 @@ func TestAttachForwardsResize(t *testing.T) { | |||||
| 530 | t.Fatal("resize was not forwarded") | 530 | t.Fatal("resize was not forwarded") | ||
| 531 | } | 531 | } | ||
| 532 | } | 532 | } | ||
| 533 | + | ||||
| 534 | + | type testDialer struct { | |||
| 535 | + | tr ports.Transport | |||
| 536 | + | err error | |||
| 537 | + | calls atomic.Int32 | |||
| 538 | + | } | |||
| 539 | + | ||||
| 540 | + | func (d *testDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 541 | + | d.calls.Add(1) | |||
| 542 | + | return d.tr, d.err | |||
| 543 | + | } | |||
| 544 | + | ||||
| 545 | + | func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||
| 546 | + | var out bytes.Buffer | |||
| 547 | + | var restoreCount atomic.Int32 | |||
| 548 | + | resizeCh := make(chan domain.Size) | |||
| 549 | + | tm, in := newHappyTerminal(t, &out, &restoreCount, resizeCh) | |||
| 550 | + | defer in.unblock() | |||
| 551 | + | ||||
| 552 | + | tr := portsmocks.NewMockTransport(t) | |||
| 553 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 554 | + | boom := errors.New("connection reset") | |||
| 555 | + | unblock := scriptRecv(tr, | |||
| 556 | + | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |||
| 557 | + | recvItem{err: boom}, | |||
| 558 | + | ) | |||
| 559 | + | defer unblock() | |||
| 560 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 561 | + | d := &testDialer{tr: tr} | |||
| 562 | + | ||||
| 563 | + | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | |||
| 564 | + | require.Error(t, err) | |||
| 565 | + | require.Equal(t, int32(1), restoreCount.Load(), "Run must restore raw mode after attachOnce errors") | |||
| 566 | + | } | |||
| 567 | + | ||||
| 568 | + | func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||
| 569 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 570 | + | tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once() | |||
| 571 | + | // EnterRaw must NOT be called when the daemon rejects Hello before Welcome. | |||
| 572 | + | ||||
| 573 | + | tr := portsmocks.NewMockTransport(t) | |||
| 574 | + | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |||
| 575 | + | tr.EXPECT().Recv().Return( | |||
| 576 | + | frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})), | |||
| 577 | + | nil, | |||
| 578 | + | ).Once() | |||
| 579 | + | tr.EXPECT().Close().Return(nil).Once() | |||
| 580 | + | d := &testDialer{tr: tr} | |||
| 581 | + | ||||
| 582 | + | err := client.Run(context.Background(), d, tm, ports.IntentAttach, "main") | |||
| 583 | + | require.Error(t, err) | |||
| 584 | + | var pe *client.ProtocolError | |||
| 585 | + | require.True(t, errors.As(err, &pe), "want *client.ProtocolError, got %T", err) | |||
| 586 | + | require.Equal(t, int32(1), d.calls.Load()) | |||
| 587 | + | } | |||
| 588 | + | ||||
| 589 | + | func TestRunPhaseASingleAttempt(t *testing.T) { | |||
| 590 | + | dialErr := errors.New("dial failed") | |||
| 591 | + | d := &testDialer{err: dialErr} | |||
| 592 | + | tm := portsmocks.NewMockTerminal(t) | |||
| 593 | + | ||||
| 594 | + | err := client.Run(context.Background(), d, tm, ports.IntentEphemeral, "") | |||
| 595 | + | require.ErrorIs(t, err, dialErr) | |||
| 596 | + | require.Equal(t, int32(1), d.calls.Load(), "Phase A Run must not retry") | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | type sequenceDialer struct { | |||
| 600 | + | trs []ports.Transport | |||
| 601 | + | errs []error | |||
| 602 | + | calls atomic.Int32 | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | func (d *sequenceDialer) Dial(context.Context) (ports.Transport, error) { | |||
| 606 | + | i := int(d.calls.Add(1)) - 1 | |||
| 607 | + | if i < len(d.errs) && d.errs[i] != nil { | |||
| 608 | + | return nil, d.errs[i] | |||
| 609 | + | } | |||
| 610 | + | if i >= len(d.trs) { | |||
| 611 | + | return nil, io.EOF | |||
| 612 | + | } | |||
| 613 | + | return d.trs[i], nil | |||
| 614 | + | } | |||
| 615 | + | ||||
| 616 | + | type recordingTransport struct { | |||
| 617 | + | recvs []recvItem | |||
| 618 | + | sends []ports.Frame | |||
| 619 | + | closed atomic.Int32 | |||
| 620 | + | } | |||
| 621 | + | ||||
| 622 | + | func (t *recordingTransport) Send(f ports.Frame) error { | |||
| 623 | + | t.sends = append(t.sends, f) | |||
| 624 | + | return nil | |||
| 625 | + | } | |||
| 626 | + | ||||
| 627 | + | func (t *recordingTransport) Recv() (ports.Frame, error) { | |||
| 628 | + | if len(t.recvs) == 0 { | |||
| 629 | + | return ports.Frame{}, io.EOF | |||
| 630 | + | } | |||
| 631 | + | it := t.recvs[0] | |||
| 632 | + | t.recvs = t.recvs[1:] | |||
| 633 | + | return it.f, it.err | |||
| 634 | + | } | |||
| 635 | + | ||||
| 636 | + | func (t *recordingTransport) Close() error { | |||
| 637 | + | t.closed.Add(1) | |||
| 638 | + | return nil | |||
| 639 | + | } | |||
| 640 | + | ||||
| 641 | + | type runTerminal struct { | |||
| 642 | + | in *blockingReader | |||
| 643 | + | out bytes.Buffer | |||
| 644 | + | rawCount atomic.Int32 | |||
| 645 | + | restoreCount atomic.Int32 | |||
| 646 | + | resizeCh chan domain.Size | |||
| 647 | + | } | |||
| 648 | + | ||||
| 649 | + | func newRunTerminal() *runTerminal { | |||
| 650 | + | return &runTerminal{in: newBlockingReader(), resizeCh: make(chan domain.Size)} | |||
| 651 | + | } | |||
| 652 | + | ||||
| 653 | + | func (t *runTerminal) EnterRaw() (func() error, error) { | |||
| 654 | + | t.rawCount.Add(1) | |||
| 655 | + | return func() error { t.restoreCount.Add(1); return nil }, nil | |||
| 656 | + | } | |||
| 657 | + | func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil } | |||
| 658 | + | func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh } | |||
| 659 | + | func (t *runTerminal) In() io.Reader { return t.in } | |||
| 660 | + | func (t *runTerminal) Out() io.Writer { return &t.out } | |||
| 661 | + | func (t *runTerminal) Flush() error { return nil } | |||
| 662 | + | ||||
| 663 | + | func helloFromSend(t *testing.T, tr *recordingTransport) ports.Hello { | |||
| 664 | + | t.Helper() | |||
| 665 | + | require.NotEmpty(t, tr.sends) | |||
| 666 | + | require.Equal(t, ports.MsgHello, tr.sends[0].Type) | |||
| 667 | + | h, err := ports.UnmarshalHello(tr.sends[0].Payload) | |||
| 668 | + | require.NoError(t, err) | |||
| 669 | + | return h | |||
| 670 | + | } | |||
| 671 | + | ||||
| 672 | + | func welcomeFrame(token uint64) ports.Frame { | |||
| 673 | + | return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main", ResumeToken: token, Capabilities: ports.CapabilityResume})) | |||
| 674 | + | } | |||
| 675 | + | ||||
| 676 | + | func TestRunReconnectsWithRotatedTokenAndSameClientID(t *testing.T) { | |||
| 677 | + | term := newRunTerminal() | |||
| 678 | + | defer term.in.unblock() | |||
| 679 | + | tr1 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {err: io.EOF}}} | |||
| 680 | + | tr2 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(22)}, {err: io.EOF}}} | |||
| 681 | + | tr3 := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(33)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))}}} | |||
| 682 | + | d := &sequenceDialer{trs: []ports.Transport{tr1, tr2, tr3}} | |||
| 683 | + | ||||
| 684 | + | err := client.Run(context.Background(), d, term, ports.IntentAttach, "main") | |||
| 685 | + | require.NoError(t, err) | |||
| 686 | + | require.Equal(t, int32(3), d.calls.Load()) | |||
| 687 | + | require.Equal(t, int32(1), term.rawCount.Load()) | |||
| 688 | + | require.Equal(t, int32(1), term.restoreCount.Load()) | |||
| 689 | + | ||||
| 690 | + | h1 := helloFromSend(t, tr1) | |||
| 691 | + | h2 := helloFromSend(t, tr2) | |||
| 692 | + | h3 := helloFromSend(t, tr3) | |||
| 693 | + | require.Equal(t, ports.IntentAttach, h1.Intent) | |||
| 694 | + | require.Zero(t, h1.ResumeToken) | |||
| 695 | + | require.Equal(t, ports.IntentResume, h2.Intent) | |||
| 696 | + | require.Equal(t, uint64(11), h2.ResumeToken) | |||
| 697 | + | require.Equal(t, ports.IntentResume, h3.Intent) | |||
| 698 | + | require.Equal(t, uint64(22), h3.ResumeToken) | |||
| 699 | + | require.Equal(t, h1.ClientID, h2.ClientID) | |||
| 700 | + | require.Equal(t, h1.ClientID, h3.ClientID) | |||
| 701 | + | require.Contains(t, term.out.String(), "reconnecting…") | |||
| 702 | + | require.Contains(t, term.out.String(), "\x1b[2K") | |||
| 703 | + | } | |||
| 704 | + | ||||
| 705 | + | func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 706 | + | term := newRunTerminal() | |||
| 707 | + | defer term.in.unblock() | |||
| 708 | + | tr := &recordingTransport{recvs: []recvItem{{f: welcomeFrame(11)}, {f: frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonSessionKilled}))}}} | |||
| 709 | + | d := &sequenceDialer{trs: []ports.Transport{tr}} | |||
| 710 | + | ||||
| 711 | + | err := client.Run(context.Background(), d, term, ports.IntentAttach, "main") | |||
| 712 | + | require.Error(t, err) | |||
| 713 | + | var de *client.DetachedError | |||
| 714 | + | require.True(t, errors.As(err, &de)) | |||
| 715 | + | require.Equal(t, int32(1), d.calls.Load()) | |||
| 716 | + | require.Equal(t, int32(1), term.restoreCount.Load()) | |||
| 717 | + | } | |||
M internal/usecase/daemon/client.go +82 -11
| @@ -27,6 +27,7 @@ package daemon | |||
| 27 | 27 | import ( | |
| 28 | 28 | "errors" | |
| 29 | 29 | "sync" | |
| 30 | + | "sync/atomic" | |
| 30 | 31 | "time" | |
| 31 | 32 | ||
| 32 | 33 | "github.com/bnema/vev/internal/domain" | |
| @@ -44,6 +45,13 @@ import ( | |||
| 44 | 45 | type attachedClient struct { | |
| 45 | 46 | tr ports.Transport | |
| 46 | 47 | rend *renderer.Renderer | |
| 48 | + | clientID [16]byte | |
| 49 | + | resumeCapable bool | |
| 50 | + | resumeToken uint64 | |
| 51 | + | parked bool | |
| 52 | + | ackedStateNum uint64 | |
| 53 | + | nextStateNum uint64 | |
| 54 | + | echoAck atomic.Uint64 | |
| 47 | 55 | bars barCache // only touched while sendMu is held | |
| 48 | 56 | size domain.Size | |
| 49 | 57 | keys *keys.Router | |
| @@ -75,6 +83,7 @@ type attachedClient struct { | |||
| 75 | 83 | themeMu sync.Mutex | |
| 76 | 84 | theme themeui.Theme | |
| 77 | 85 | lastCursor cursorOut | |
| 86 | + | linkMu sync.Mutex | |
| 78 | 87 | sendMu sync.Mutex | |
| 79 | 88 | } | |
| 80 | 89 | ||
| @@ -156,11 +165,42 @@ func (ac *attachedClient) promptActive() bool { | |||
| 156 | 165 | return ac.prompt != nil | |
| 157 | 166 | } | |
| 158 | 167 | ||
| 168 | + | func (ac *attachedClient) transport() ports.Transport { | |
| 169 | + | ac.linkMu.Lock() | |
| 170 | + | defer ac.linkMu.Unlock() | |
| 171 | + | return ac.tr | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func (ac *attachedClient) replaceTransport(tr ports.Transport) { | |
| 175 | + | ac.linkMu.Lock() | |
| 176 | + | ac.tr = tr | |
| 177 | + | ac.linkMu.Unlock() | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | func (ac *attachedClient) closeTransport() error { | |
| 181 | + | tr := ac.transport() | |
| 182 | + | if tr == nil { | |
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | return tr.Close() | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error { | |
| 189 | + | if tr == nil { | |
| 190 | + | return nil | |
| 191 | + | } | |
| 192 | + | return tr.Close() | |
| 193 | + | } | |
| 194 | + | ||
| 159 | 195 | // send serialises a frame onto the client's transport. | |
| 160 | 196 | func (ac *attachedClient) send(f ports.Frame) error { | |
| 161 | 197 | ac.sendMu.Lock() | |
| 162 | 198 | defer ac.sendMu.Unlock() | |
| 163 | - | return ac.tr.Send(f) | |
| 199 | + | tr := ac.transport() | |
| 200 | + | if tr == nil { | |
| 201 | + | return errors.New("client transport is nil") | |
| 202 | + | } | |
| 203 | + | return tr.Send(f) | |
| 164 | 204 | } | |
| 165 | 205 | ||
| 166 | 206 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | |
| @@ -188,7 +228,7 @@ func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error { | |||
| 188 | 228 | return err | |
| 189 | 229 | default: | |
| 190 | 230 | } | |
| 191 | - | _ = ac.tr.Close() | |
| 231 | + | _ = ac.closeTransport() | |
| 192 | 232 | return errors.New("send timed out") | |
| 193 | 233 | } | |
| 194 | 234 | } | |
| @@ -217,7 +257,7 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) { | |||
| 217 | 257 | go func() { | |
| 218 | 258 | defer close(done) | |
| 219 | 259 | d.boundedSend(ac, frameDetached(reason)) | |
| 220 | - | _ = ac.tr.Close() | |
| 260 | + | _ = ac.closeTransport() | |
| 221 | 261 | }() | |
| 222 | 262 | } | |
| 223 | 263 | ||
| @@ -238,11 +278,23 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} { | |||
| 238 | 278 | return snapshot | |
| 239 | 279 | } | |
| 240 | 280 | ||
| 241 | - | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) { | |
| 281 | + | type attachClientOptions struct { | |
| 282 | + | clientID [16]byte | |
| 283 | + | resumeCapable bool | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) { | |
| 287 | + | resumeToken := uint64(0) | |
| 288 | + | if opts.resumeCapable { | |
| 289 | + | resumeToken = newResumeToken() | |
| 290 | + | } | |
| 242 | 291 | ac := &attachedClient{ | |
| 243 | - | tr: tr, | |
| 244 | - | rend: renderer.New(renderer.Capabilities{}), | |
| 245 | - | size: sz, | |
| 292 | + | tr: tr, | |
| 293 | + | rend: renderer.New(renderer.Capabilities{}), | |
| 294 | + | size: sz, | |
| 295 | + | clientID: opts.clientID, | |
| 296 | + | resumeCapable: opts.resumeCapable, | |
| 297 | + | resumeToken: resumeToken, | |
| 246 | 298 | } | |
| 247 | 299 | ac.setSession(sess) | |
| 248 | 300 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | |
| @@ -333,7 +385,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 333 | 385 | if sess == nil { | |
| 334 | 386 | return | |
| 335 | 387 | } | |
| 336 | - | f, err := ac.tr.Recv() | |
| 388 | + | tr := ac.transport() | |
| 389 | + | if tr == nil { | |
| 390 | + | return | |
| 391 | + | } | |
| 392 | + | f, err := tr.Recv() | |
| 337 | 393 | if err != nil { | |
| 338 | 394 | d.clientGone(sess, ac, false) | |
| 339 | 395 | return | |
| @@ -345,7 +401,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 345 | 401 | switch f.Type { | |
| 346 | 402 | case ports.MsgInput: | |
| 347 | 403 | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | |
| 348 | - | d.handleInput(sess, ac, in.Data) | |
| 404 | + | d.handleSequencedInput(sess, ac, in.InputSeq, in.Data) | |
| 349 | 405 | } | |
| 350 | 406 | case ports.MsgResize: | |
| 351 | 407 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil { | |
| @@ -358,6 +414,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||
| 358 | 414 | case ports.MsgDetach: | |
| 359 | 415 | d.clientGone(sess, ac, true) | |
| 360 | 416 | return | |
| 417 | + | case ports.MsgAck: | |
| 418 | + | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |
| 419 | + | ac.ackedStateNum = ack.AckedStateNum | |
| 420 | + | } | |
| 361 | 421 | case ports.MsgPing: | |
| 362 | 422 | _ = ac.send(framePong()) | |
| 363 | 423 | default: | |
| @@ -381,6 +441,11 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||
| 381 | 441 | if !ephemeral { | |
| 382 | 442 | d.refreshSessionCwd(sess) | |
| 383 | 443 | } | |
| 444 | + | oldTr := ac.transport() | |
| 445 | + | if !explicit && d.parkAttachment(sess, ac) { | |
| 446 | + | _ = ac.closeCapturedTransport(oldTr) | |
| 447 | + | return | |
| 448 | + | } | |
| 384 | 449 | d.resetScreenDefaultColors(sess) | |
| 385 | 450 | if explicit { | |
| 386 | 451 | // Synchronous so the ack is delivered before the transport closes | |
| @@ -389,7 +454,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||
| 389 | 454 | // connWg.Wait. | |
| 390 | 455 | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | |
| 391 | 456 | } | |
| 392 | - | _ = ac.tr.Close() | |
| 457 | + | _ = ac.closeCapturedTransport(oldTr) | |
| 393 | 458 | if ephemeral { | |
| 394 | 459 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | |
| 395 | 460 | } | |
| @@ -464,7 +529,13 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | |||
| 464 | 529 | if sess.detachIfCurrent(ac) { | |
| 465 | 530 | d.unregisterPreview(ac) | |
| 466 | 531 | d.resetScreenDefaultColors(sess) | |
| 467 | - | _ = ac.tr.Close() | |
| 532 | + | oldTr := ac.transport() | |
| 533 | + | if d.parkAttachment(sess, ac) { | |
| 534 | + | _ = ac.closeCapturedTransport(oldTr) | |
| 535 | + | d.log.Warn("parked client after send error", "session", sess.name) | |
| 536 | + | return | |
| 537 | + | } | |
| 538 | + | _ = ac.closeCapturedTransport(oldTr) | |
| 468 | 539 | d.log.Warn("detached client after send error", "session", sess.name) | |
| 469 | 540 | sess.mu.Lock() | |
| 470 | 541 | ephemeral := sess.ephemeral | |
| @@ -27,6 +27,7 @@ package daemon | |||||
| 27 | import ( | 27 | import ( | ||
| 28 | "errors" | 28 | "errors" | ||
| 29 | "sync" | 29 | "sync" | ||
| 30 | + | "sync/atomic" | |||
| 30 | "time" | 31 | "time" | ||
| 31 | 32 | ||||
| 32 | "github.com/bnema/vev/internal/domain" | 33 | "github.com/bnema/vev/internal/domain" | ||
| @@ -44,6 +45,13 @@ import ( | |||||
| 44 | type attachedClient struct { | 45 | type attachedClient struct { | ||
| 45 | tr ports.Transport | 46 | tr ports.Transport | ||
| 46 | rend *renderer.Renderer | 47 | rend *renderer.Renderer | ||
| 48 | + | clientID [16]byte | |||
| 49 | + | resumeCapable bool | |||
| 50 | + | resumeToken uint64 | |||
| 51 | + | parked bool | |||
| 52 | + | ackedStateNum uint64 | |||
| 53 | + | nextStateNum uint64 | |||
| 54 | + | echoAck atomic.Uint64 | |||
| 47 | bars barCache // only touched while sendMu is held | 55 | bars barCache // only touched while sendMu is held | ||
| 48 | size domain.Size | 56 | size domain.Size | ||
| 49 | keys *keys.Router | 57 | keys *keys.Router | ||
| @@ -75,6 +83,7 @@ type attachedClient struct { | |||||
| 75 | themeMu sync.Mutex | 83 | themeMu sync.Mutex | ||
| 76 | theme themeui.Theme | 84 | theme themeui.Theme | ||
| 77 | lastCursor cursorOut | 85 | lastCursor cursorOut | ||
| 86 | + | linkMu sync.Mutex | |||
| 78 | sendMu sync.Mutex | 87 | sendMu sync.Mutex | ||
| 79 | } | 88 | } | ||
| 80 | 89 | ||||
| @@ -156,11 +165,42 @@ func (ac *attachedClient) promptActive() bool { | |||||
| 156 | return ac.prompt != nil | 165 | return ac.prompt != nil | ||
| 157 | } | 166 | } | ||
| 158 | 167 | ||||
| 168 | + | func (ac *attachedClient) transport() ports.Transport { | |||
| 169 | + | ac.linkMu.Lock() | |||
| 170 | + | defer ac.linkMu.Unlock() | |||
| 171 | + | return ac.tr | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func (ac *attachedClient) replaceTransport(tr ports.Transport) { | |||
| 175 | + | ac.linkMu.Lock() | |||
| 176 | + | ac.tr = tr | |||
| 177 | + | ac.linkMu.Unlock() | |||
| 178 | + | } | |||
| 179 | + | ||||
| 180 | + | func (ac *attachedClient) closeTransport() error { | |||
| 181 | + | tr := ac.transport() | |||
| 182 | + | if tr == nil { | |||
| 183 | + | return nil | |||
| 184 | + | } | |||
| 185 | + | return tr.Close() | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func (ac *attachedClient) closeCapturedTransport(tr ports.Transport) error { | |||
| 189 | + | if tr == nil { | |||
| 190 | + | return nil | |||
| 191 | + | } | |||
| 192 | + | return tr.Close() | |||
| 193 | + | } | |||
| 194 | + | ||||
| 159 | // send serialises a frame onto the client's transport. | 195 | // send serialises a frame onto the client's transport. | ||
| 160 | func (ac *attachedClient) send(f ports.Frame) error { | 196 | func (ac *attachedClient) send(f ports.Frame) error { | ||
| 161 | ac.sendMu.Lock() | 197 | ac.sendMu.Lock() | ||
| 162 | defer ac.sendMu.Unlock() | 198 | defer ac.sendMu.Unlock() | ||
| 163 | - | return ac.tr.Send(f) | 199 | + | tr := ac.transport() |
| 200 | + | if tr == nil { | |||
| 201 | + | return errors.New("client transport is nil") | |||
| 202 | + | } | |||
| 203 | + | return tr.Send(f) | |||
| 164 | } | 204 | } | ||
| 165 | 205 | ||||
| 166 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | 206 | // boundedSend sends f to ac with a deadline watchdog: if the send (including | ||
| @@ -188,7 +228,7 @@ func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error { | |||||
| 188 | return err | 228 | return err | ||
| 189 | default: | 229 | default: | ||
| 190 | } | 230 | } | ||
| 191 | - | _ = ac.tr.Close() | 231 | + | _ = ac.closeTransport() |
| 192 | return errors.New("send timed out") | 232 | return errors.New("send timed out") | ||
| 193 | } | 233 | } | ||
| 194 | } | 234 | } | ||
| @@ -217,7 +257,7 @@ func (d *Daemon) notifyDetachedAsync(ac *attachedClient, reason uint8) { | |||||
| 217 | go func() { | 257 | go func() { | ||
| 218 | defer close(done) | 258 | defer close(done) | ||
| 219 | d.boundedSend(ac, frameDetached(reason)) | 259 | d.boundedSend(ac, frameDetached(reason)) | ||
| 220 | - | _ = ac.tr.Close() | 260 | + | _ = ac.closeTransport() |
| 221 | }() | 261 | }() | ||
| 222 | } | 262 | } | ||
| 223 | 263 | ||||
| @@ -238,11 +278,23 @@ func (d *Daemon) notifiesSnapshot() []chan struct{} { | |||||
| 238 | return snapshot | 278 | return snapshot | ||
| 239 | } | 279 | } | ||
| 240 | 280 | ||||
| 241 | - | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size) (*attachedClient, *attachedClient) { | 281 | + | type attachClientOptions struct { |
| 282 | + | clientID [16]byte | |||
| 283 | + | resumeCapable bool | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, *attachedClient) { | |||
| 287 | + | resumeToken := uint64(0) | |||
| 288 | + | if opts.resumeCapable { | |||
| 289 | + | resumeToken = newResumeToken() | |||
| 290 | + | } | |||
| 242 | ac := &attachedClient{ | 291 | ac := &attachedClient{ | ||
| 243 | - | tr: tr, | 292 | + | tr: tr, |
| 244 | - | rend: renderer.New(renderer.Capabilities{}), | 293 | + | rend: renderer.New(renderer.Capabilities{}), |
| 245 | - | size: sz, | 294 | + | size: sz, |
| 295 | + | clientID: opts.clientID, | |||
| 296 | + | resumeCapable: opts.resumeCapable, | |||
| 297 | + | resumeToken: resumeToken, | |||
| 246 | } | 298 | } | ||
| 247 | ac.setSession(sess) | 299 | ac.setSession(sess) | ||
| 248 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | 300 | ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}) | ||
| @@ -333,7 +385,11 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 333 | if sess == nil { | 385 | if sess == nil { | ||
| 334 | return | 386 | return | ||
| 335 | } | 387 | } | ||
| 336 | - | f, err := ac.tr.Recv() | 388 | + | tr := ac.transport() |
| 389 | + | if tr == nil { | |||
| 390 | + | return | |||
| 391 | + | } | |||
| 392 | + | f, err := tr.Recv() | |||
| 337 | if err != nil { | 393 | if err != nil { | ||
| 338 | d.clientGone(sess, ac, false) | 394 | d.clientGone(sess, ac, false) | ||
| 339 | return | 395 | return | ||
| @@ -345,7 +401,7 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 345 | switch f.Type { | 401 | switch f.Type { | ||
| 346 | case ports.MsgInput: | 402 | case ports.MsgInput: | ||
| 347 | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | 403 | if in, derr := ports.UnmarshalInput(f.Payload); derr == nil { | ||
| 348 | - | d.handleInput(sess, ac, in.Data) | 404 | + | d.handleSequencedInput(sess, ac, in.InputSeq, in.Data) |
| 349 | } | 405 | } | ||
| 350 | case ports.MsgResize: | 406 | case ports.MsgResize: | ||
| 351 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil { | 407 | if rz, derr := ports.UnmarshalResize(f.Payload); derr == nil { | ||
| @@ -358,6 +414,10 @@ func (d *Daemon) runConnLoop(ac *attachedClient) { | |||||
| 358 | case ports.MsgDetach: | 414 | case ports.MsgDetach: | ||
| 359 | d.clientGone(sess, ac, true) | 415 | d.clientGone(sess, ac, true) | ||
| 360 | return | 416 | return | ||
| 417 | + | case ports.MsgAck: | |||
| 418 | + | if ack, derr := ports.UnmarshalAck(f.Payload); derr == nil { | |||
| 419 | + | ac.ackedStateNum = ack.AckedStateNum | |||
| 420 | + | } | |||
| 361 | case ports.MsgPing: | 421 | case ports.MsgPing: | ||
| 362 | _ = ac.send(framePong()) | 422 | _ = ac.send(framePong()) | ||
| 363 | default: | 423 | default: | ||
| @@ -381,6 +441,11 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||||
| 381 | if !ephemeral { | 441 | if !ephemeral { | ||
| 382 | d.refreshSessionCwd(sess) | 442 | d.refreshSessionCwd(sess) | ||
| 383 | } | 443 | } | ||
| 444 | + | oldTr := ac.transport() | |||
| 445 | + | if !explicit && d.parkAttachment(sess, ac) { | |||
| 446 | + | _ = ac.closeCapturedTransport(oldTr) | |||
| 447 | + | return | |||
| 448 | + | } | |||
| 384 | d.resetScreenDefaultColors(sess) | 449 | d.resetScreenDefaultColors(sess) | ||
| 385 | if explicit { | 450 | if explicit { | ||
| 386 | // Synchronous so the ack is delivered before the transport closes | 451 | // Synchronous so the ack is delivered before the transport closes | ||
| @@ -389,7 +454,7 @@ func (d *Daemon) clientGone(sess *session, ac *attachedClient, explicit bool) { | |||||
| 389 | // connWg.Wait. | 454 | // connWg.Wait. | ||
| 390 | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | 455 | d.boundedSend(ac, frameDetached(ports.ReasonDetach)) | ||
| 391 | } | 456 | } | ||
| 392 | - | _ = ac.tr.Close() | 457 | + | _ = ac.closeCapturedTransport(oldTr) |
| 393 | if ephemeral { | 458 | if ephemeral { | ||
| 394 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | 459 | _ = d.killSession(sess, ports.ReasonSessionKilled, false) | ||
| 395 | } | 460 | } | ||
| @@ -464,7 +529,13 @@ func (d *Daemon) detachOnSendError(sess *session, ac *attachedClient) { | |||||
| 464 | if sess.detachIfCurrent(ac) { | 529 | if sess.detachIfCurrent(ac) { | ||
| 465 | d.unregisterPreview(ac) | 530 | d.unregisterPreview(ac) | ||
| 466 | d.resetScreenDefaultColors(sess) | 531 | d.resetScreenDefaultColors(sess) | ||
| 467 | - | _ = ac.tr.Close() | 532 | + | oldTr := ac.transport() |
| 533 | + | if d.parkAttachment(sess, ac) { | |||
| 534 | + | _ = ac.closeCapturedTransport(oldTr) | |||
| 535 | + | d.log.Warn("parked client after send error", "session", sess.name) | |||
| 536 | + | return | |||
| 537 | + | } | |||
| 538 | + | _ = ac.closeCapturedTransport(oldTr) | |||
| 468 | d.log.Warn("detached client after send error", "session", sess.name) | 539 | d.log.Warn("detached client after send error", "session", sess.name) | ||
| 469 | sess.mu.Lock() | 540 | sess.mu.Lock() | ||
| 470 | ephemeral := sess.ephemeral | 541 | ephemeral := sess.ephemeral | ||
M internal/usecase/daemon/daemon.go +49 -4
| @@ -62,6 +62,8 @@ const defaultScrollbackRows = 10_000 | |||
| 62 | 62 | // the in-flight send. Teardown is never gated on a client draining its socket. | |
| 63 | 63 | const detachNotifyTimeout = time.Second | |
| 64 | 64 | ||
| 65 | + | const resumeParkGrace = 60 * time.Second | |
| 66 | + | ||
| 65 | 67 | // defaultSize is used when a client's Hello carries no valid dimensions. | |
| 66 | 68 | var defaultSize = domain.Size{Cols: 80, Rows: 24} | |
| 67 | 69 | ||
| @@ -82,6 +84,7 @@ type Daemon struct { | |||
| 82 | 84 | // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent | |
| 83 | 85 | // with Wait. | |
| 84 | 86 | notifies []chan struct{} | |
| 87 | + | parked map[uint64]*parkedAttachment | |
| 85 | 88 | ||
| 86 | 89 | attnMu sync.Mutex | |
| 87 | 90 | animFrame int | |
| @@ -117,6 +120,14 @@ type Daemon struct { | |||
| 117 | 120 | connWg sync.WaitGroup // per-connection handler goroutines | |
| 118 | 121 | } | |
| 119 | 122 | ||
| 123 | + | type parkedAttachment struct { | |
| 124 | + | sess *session | |
| 125 | + | ac *attachedClient | |
| 126 | + | timer ports.Timer | |
| 127 | + | done chan struct{} | |
| 128 | + | doneOnce sync.Once | |
| 129 | + | } | |
| 130 | + | ||
| 120 | 131 | // session is a single multiplexed session. It owns one or more full-screen | |
| 121 | 132 | ||
| 122 | 133 | type stoppedSession struct { | |
| @@ -169,6 +180,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 169 | 180 | d := &Daemon{ | |
| 170 | 181 | sessions: make(map[domain.SessionID]*session), | |
| 171 | 182 | stopped: make(map[string]stoppedSession), | |
| 183 | + | parked: make(map[uint64]*parkedAttachment), | |
| 172 | 184 | ptys: ptys, | |
| 173 | 185 | clock: clock, | |
| 174 | 186 | log: log, | |
| @@ -270,6 +282,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||
| 270 | 282 | func (d *Daemon) shutdownAll(reason uint8) { | |
| 271 | 283 | d.mu.Lock() | |
| 272 | 284 | d.closing = true | |
| 285 | + | for token, parked := range d.parked { | |
| 286 | + | d.removeParkedLocked(token, parked) | |
| 287 | + | } | |
| 273 | 288 | snapshot := d.sessionsSnapshotLocked() | |
| 274 | 289 | empty := len(snapshot) == 0 | |
| 275 | 290 | d.mu.Unlock() | |
| @@ -425,7 +440,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||
| 425 | 440 | return | |
| 426 | 441 | } | |
| 427 | 442 | ||
| 428 | - | if err := ac.send(frameWelcome(sess)); err != nil { | |
| 443 | + | if err := ac.send(frameWelcome(sess, ac)); err != nil { | |
| 429 | 444 | d.clientGone(sess, ac, false) | |
| 430 | 445 | return | |
| 431 | 446 | } | |
| @@ -460,6 +475,33 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 460 | 475 | return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | |
| 461 | 476 | } | |
| 462 | 477 | switch h.Intent { | |
| 478 | + | case ports.IntentResume: | |
| 479 | + | if sess, ac, ok, err := d.resumeParkedLocked(h, tr, sz); ok || err != nil { | |
| 480 | + | d.mu.Unlock() | |
| 481 | + | return sess, ac, err | |
| 482 | + | } | |
| 483 | + | // Resume miss/expiry falls back to normal attach semantics. | |
| 484 | + | sess := d.findByNameLocked(h.Name) | |
| 485 | + | if sess == nil { | |
| 486 | + | stopped, ok := d.stopped[h.Name] | |
| 487 | + | if !ok || stopped.purging { | |
| 488 | + | d.mu.Unlock() | |
| 489 | + | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |
| 490 | + | } | |
| 491 | + | cwd := platform.DirOrHome(stopped.cwd) | |
| 492 | + | var err error | |
| 493 | + | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | |
| 494 | + | if err != nil { | |
| 495 | + | d.mu.Unlock() | |
| 496 | + | return nil, nil, err | |
| 497 | + | } | |
| 498 | + | } | |
| 499 | + | d.purgeParkedForSessionLocked(sess) | |
| 500 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |
| 501 | + | d.mu.Unlock() | |
| 502 | + | d.detachReplacedClient(old) | |
| 503 | + | return sess, ac, nil | |
| 504 | + | ||
| 463 | 505 | case ports.IntentEphemeral: | |
| 464 | 506 | name := d.allocEphemeralNameLocked() | |
| 465 | 507 | sess, err := d.createSessionLocked(name, true, h.Cwd, sz) | |
| @@ -467,7 +509,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 467 | 509 | d.mu.Unlock() | |
| 468 | 510 | return nil, nil, err | |
| 469 | 511 | } | |
| 470 | - | ac, old := d.attachClient(sess, tr, sz) | |
| 512 | + | d.purgeParkedForSessionLocked(sess) | |
| 513 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID}) | |
| 471 | 514 | d.mu.Unlock() | |
| 472 | 515 | d.detachReplacedClient(old) | |
| 473 | 516 | return sess, ac, nil | |
| @@ -486,7 +529,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 486 | 529 | d.mu.Unlock() | |
| 487 | 530 | return nil, nil, err | |
| 488 | 531 | } | |
| 489 | - | ac, old := d.attachClient(sess, tr, sz) | |
| 532 | + | d.purgeParkedForSessionLocked(sess) | |
| 533 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |
| 490 | 534 | d.mu.Unlock() | |
| 491 | 535 | d.detachReplacedClient(old) | |
| 492 | 536 | return sess, ac, nil | |
| @@ -507,7 +551,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||
| 507 | 551 | return nil, nil, err | |
| 508 | 552 | } | |
| 509 | 553 | } | |
| 510 | - | ac, old := d.attachClient(sess, tr, sz) | |
| 554 | + | d.purgeParkedForSessionLocked(sess) | |
| 555 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |
| 511 | 556 | d.mu.Unlock() | |
| 512 | 557 | d.detachReplacedClient(old) | |
| 513 | 558 | return sess, ac, nil | |
| @@ -62,6 +62,8 @@ const defaultScrollbackRows = 10_000 | |||||
| 62 | // the in-flight send. Teardown is never gated on a client draining its socket. | 62 | // the in-flight send. Teardown is never gated on a client draining its socket. | ||
| 63 | const detachNotifyTimeout = time.Second | 63 | const detachNotifyTimeout = time.Second | ||
| 64 | 64 | ||||
| 65 | + | const resumeParkGrace = 60 * time.Second | |||
| 66 | + | ||||
| 65 | // defaultSize is used when a client's Hello carries no valid dimensions. | 67 | // defaultSize is used when a client's Hello carries no valid dimensions. | ||
| 66 | var defaultSize = domain.Size{Cols: 80, Rows: 24} | 68 | var defaultSize = domain.Size{Cols: 80, Rows: 24} | ||
| 67 | 69 | ||||
| @@ -82,6 +84,7 @@ type Daemon struct { | |||||
| 82 | // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent | 84 | // Serve may be waiting, and WaitGroup forbids Add-from-zero concurrent | ||
| 83 | // with Wait. | 85 | // with Wait. | ||
| 84 | notifies []chan struct{} | 86 | notifies []chan struct{} | ||
| 87 | + | parked map[uint64]*parkedAttachment | |||
| 85 | 88 | ||||
| 86 | attnMu sync.Mutex | 89 | attnMu sync.Mutex | ||
| 87 | animFrame int | 90 | animFrame int | ||
| @@ -117,6 +120,14 @@ type Daemon struct { | |||||
| 117 | connWg sync.WaitGroup // per-connection handler goroutines | 120 | connWg sync.WaitGroup // per-connection handler goroutines | ||
| 118 | } | 121 | } | ||
| 119 | 122 | ||||
| 123 | + | type parkedAttachment struct { | |||
| 124 | + | sess *session | |||
| 125 | + | ac *attachedClient | |||
| 126 | + | timer ports.Timer | |||
| 127 | + | done chan struct{} | |||
| 128 | + | doneOnce sync.Once | |||
| 129 | + | } | |||
| 130 | + | ||||
| 120 | // session is a single multiplexed session. It owns one or more full-screen | 131 | // session is a single multiplexed session. It owns one or more full-screen | ||
| 121 | 132 | ||||
| 122 | type stoppedSession struct { | 133 | type stoppedSession struct { | ||
| @@ -169,6 +180,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 169 | d := &Daemon{ | 180 | d := &Daemon{ | ||
| 170 | sessions: make(map[domain.SessionID]*session), | 181 | sessions: make(map[domain.SessionID]*session), | ||
| 171 | stopped: make(map[string]stoppedSession), | 182 | stopped: make(map[string]stoppedSession), | ||
| 183 | + | parked: make(map[uint64]*parkedAttachment), | |||
| 172 | ptys: ptys, | 184 | ptys: ptys, | ||
| 173 | clock: clock, | 185 | clock: clock, | ||
| 174 | log: log, | 186 | log: log, | ||
| @@ -270,6 +282,9 @@ func (d *Daemon) Serve(ctx context.Context, l ports.Listener) error { | |||||
| 270 | func (d *Daemon) shutdownAll(reason uint8) { | 282 | func (d *Daemon) shutdownAll(reason uint8) { | ||
| 271 | d.mu.Lock() | 283 | d.mu.Lock() | ||
| 272 | d.closing = true | 284 | d.closing = true | ||
| 285 | + | for token, parked := range d.parked { | |||
| 286 | + | d.removeParkedLocked(token, parked) | |||
| 287 | + | } | |||
| 273 | snapshot := d.sessionsSnapshotLocked() | 288 | snapshot := d.sessionsSnapshotLocked() | ||
| 274 | empty := len(snapshot) == 0 | 289 | empty := len(snapshot) == 0 | ||
| 275 | d.mu.Unlock() | 290 | d.mu.Unlock() | ||
| @@ -425,7 +440,7 @@ func (d *Daemon) handleHello(tr ports.Transport, f ports.Frame) { | |||||
| 425 | return | 440 | return | ||
| 426 | } | 441 | } | ||
| 427 | 442 | ||||
| 428 | - | if err := ac.send(frameWelcome(sess)); err != nil { | 443 | + | if err := ac.send(frameWelcome(sess, ac)); err != nil { |
| 429 | d.clientGone(sess, ac, false) | 444 | d.clientGone(sess, ac, false) | ||
| 430 | return | 445 | return | ||
| 431 | } | 446 | } | ||
| @@ -460,6 +475,33 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 460 | return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | 475 | return nil, nil, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"} | ||
| 461 | } | 476 | } | ||
| 462 | switch h.Intent { | 477 | switch h.Intent { | ||
| 478 | + | case ports.IntentResume: | |||
| 479 | + | if sess, ac, ok, err := d.resumeParkedLocked(h, tr, sz); ok || err != nil { | |||
| 480 | + | d.mu.Unlock() | |||
| 481 | + | return sess, ac, err | |||
| 482 | + | } | |||
| 483 | + | // Resume miss/expiry falls back to normal attach semantics. | |||
| 484 | + | sess := d.findByNameLocked(h.Name) | |||
| 485 | + | if sess == nil { | |||
| 486 | + | stopped, ok := d.stopped[h.Name] | |||
| 487 | + | if !ok || stopped.purging { | |||
| 488 | + | d.mu.Unlock() | |||
| 489 | + | return nil, nil, &protoErr{ports.ErrNoSuchSession, "no such session: " + h.Name} | |||
| 490 | + | } | |||
| 491 | + | cwd := platform.DirOrHome(stopped.cwd) | |||
| 492 | + | var err error | |||
| 493 | + | sess, err = d.createSessionLocked(h.Name, false, cwd, sz) | |||
| 494 | + | if err != nil { | |||
| 495 | + | d.mu.Unlock() | |||
| 496 | + | return nil, nil, err | |||
| 497 | + | } | |||
| 498 | + | } | |||
| 499 | + | d.purgeParkedForSessionLocked(sess) | |||
| 500 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |||
| 501 | + | d.mu.Unlock() | |||
| 502 | + | d.detachReplacedClient(old) | |||
| 503 | + | return sess, ac, nil | |||
| 504 | + | ||||
| 463 | case ports.IntentEphemeral: | 505 | case ports.IntentEphemeral: | ||
| 464 | name := d.allocEphemeralNameLocked() | 506 | name := d.allocEphemeralNameLocked() | ||
| 465 | sess, err := d.createSessionLocked(name, true, h.Cwd, sz) | 507 | sess, err := d.createSessionLocked(name, true, h.Cwd, sz) | ||
| @@ -467,7 +509,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 467 | d.mu.Unlock() | 509 | d.mu.Unlock() | ||
| 468 | return nil, nil, err | 510 | return nil, nil, err | ||
| 469 | } | 511 | } | ||
| 470 | - | ac, old := d.attachClient(sess, tr, sz) | 512 | + | d.purgeParkedForSessionLocked(sess) |
| 513 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID}) | |||
| 471 | d.mu.Unlock() | 514 | d.mu.Unlock() | ||
| 472 | d.detachReplacedClient(old) | 515 | d.detachReplacedClient(old) | ||
| 473 | return sess, ac, nil | 516 | return sess, ac, nil | ||
| @@ -486,7 +529,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 486 | d.mu.Unlock() | 529 | d.mu.Unlock() | ||
| 487 | return nil, nil, err | 530 | return nil, nil, err | ||
| 488 | } | 531 | } | ||
| 489 | - | ac, old := d.attachClient(sess, tr, sz) | 532 | + | d.purgeParkedForSessionLocked(sess) |
| 533 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |||
| 490 | d.mu.Unlock() | 534 | d.mu.Unlock() | ||
| 491 | d.detachReplacedClient(old) | 535 | d.detachReplacedClient(old) | ||
| 492 | return sess, ac, nil | 536 | return sess, ac, nil | ||
| @@ -507,7 +551,8 @@ func (d *Daemon) route(h ports.Hello, tr ports.Transport) (*session, *attachedCl | |||||
| 507 | return nil, nil, err | 551 | return nil, nil, err | ||
| 508 | } | 552 | } | ||
| 509 | } | 553 | } | ||
| 510 | - | ac, old := d.attachClient(sess, tr, sz) | 554 | + | d.purgeParkedForSessionLocked(sess) |
| 555 | + | ac, old := d.attachClient(sess, tr, sz, attachClientOptions{clientID: h.ClientID, resumeCapable: true}) | |||
| 511 | d.mu.Unlock() | 556 | d.mu.Unlock() | ||
| 512 | d.detachReplacedClient(old) | 557 | d.detachReplacedClient(old) | ||
| 513 | return sess, ac, nil | 558 | return sess, ac, nil | ||
M internal/usecase/daemon/input.go +12 -0
| @@ -34,6 +34,18 @@ 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) { | |
| 38 | + | d.handleInput(sess, ac, data) | |
| 39 | + | if seq > 0 { | |
| 40 | + | for { | |
| 41 | + | cur := ac.echoAck.Load() | |
| 42 | + | if seq <= cur || ac.echoAck.CompareAndSwap(cur, seq) { | |
| 43 | + | break | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 37 | 49 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | |
| 38 | 50 | ac.mouseScan.Scan(data, | |
| 39 | 51 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | |
| @@ -34,6 +34,18 @@ import ( | |||||
| 34 | "github.com/bnema/vev/internal/usecase/mouse" | 34 | "github.com/bnema/vev/internal/usecase/mouse" | ||
| 35 | ) | 35 | ) | ||
| 36 | 36 | ||||
| 37 | + | func (d *Daemon) handleSequencedInput(sess *session, ac *attachedClient, seq uint64, data []byte) { | |||
| 38 | + | d.handleInput(sess, ac, data) | |||
| 39 | + | if seq > 0 { | |||
| 40 | + | for { | |||
| 41 | + | cur := ac.echoAck.Load() | |||
| 42 | + | if seq <= cur || ac.echoAck.CompareAndSwap(cur, seq) { | |||
| 43 | + | break | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 37 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | 49 | func (d *Daemon) handleInput(_ *session, ac *attachedClient, data []byte) { | ||
| 38 | ac.mouseScan.Scan(data, | 50 | ac.mouseScan.Scan(data, | ||
| 39 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | 51 | func(ev mouse.Event) { d.handleMouse(ac, ev) }, | ||
M internal/usecase/daemon/render.go +8 -1
| @@ -26,6 +26,7 @@ package daemon | |||
| 26 | 26 | ||
| 27 | 27 | import ( | |
| 28 | 28 | "bytes" | |
| 29 | + | "errors" | |
| 29 | 30 | "strconv" | |
| 30 | 31 | "time" | |
| 31 | 32 | ||
| @@ -381,7 +382,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||
| 381 | 382 | if err == nil { | |
| 382 | 383 | data = append(data, cursorTail...) | |
| 383 | 384 | if len(data) > 0 { | |
| 384 | - | serr = ac.tr.Send(frameOutput(data)) | |
| 385 | + | tr := ac.transport() | |
| 386 | + | if tr == nil { | |
| 387 | + | serr = errors.New("client transport is nil") | |
| 388 | + | } else { | |
| 389 | + | ac.nextStateNum++ | |
| 390 | + | serr = tr.Send(frameOutputState(data, ac.nextStateNum, ac.echoAck.Load())) | |
| 391 | + | } | |
| 385 | 392 | } | |
| 386 | 393 | } | |
| 387 | 394 | ac.sendMu.Unlock() | |
| @@ -26,6 +26,7 @@ package daemon | |||||
| 26 | 26 | ||||
| 27 | import ( | 27 | import ( | ||
| 28 | "bytes" | 28 | "bytes" | ||
| 29 | + | "errors" | |||
| 29 | "strconv" | 30 | "strconv" | ||
| 30 | "time" | 31 | "time" | ||
| 31 | 32 | ||||
| @@ -381,7 +382,13 @@ func (d *Daemon) paint(sess *session, ac *attachedClient, reset bool) { | |||||
| 381 | if err == nil { | 382 | if err == nil { | ||
| 382 | data = append(data, cursorTail...) | 383 | data = append(data, cursorTail...) | ||
| 383 | if len(data) > 0 { | 384 | if len(data) > 0 { | ||
| 384 | - | serr = ac.tr.Send(frameOutput(data)) | 385 | + | tr := ac.transport() |
| 386 | + | if tr == nil { | |||
| 387 | + | serr = errors.New("client transport is nil") | |||
| 388 | + | } else { | |||
| 389 | + | ac.nextStateNum++ | |||
| 390 | + | serr = tr.Send(frameOutputState(data, ac.nextStateNum, ac.echoAck.Load())) | |||
| 391 | + | } | |||
| 385 | } | 392 | } | ||
| 386 | } | 393 | } | ||
| 387 | ac.sendMu.Unlock() | 394 | ac.sendMu.Unlock() | ||
M internal/usecase/daemon/render_test.go +14 -0
| @@ -384,6 +384,20 @@ func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) { | |||
| 384 | 384 | require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained") | |
| 385 | 385 | } | |
| 386 | 386 | ||
| 387 | + | func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) { | |
| 388 | + | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |
| 389 | + | sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0} | |
| 390 | + | ac := &attachedClient{rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 10, Rows: 4}} | |
| 391 | + | sess.client = ac | |
| 392 | + | ac.setSession(sess) | |
| 393 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 394 | + | ||
| 395 | + | tb.focusedPane().screen.Write([]byte("x")) | |
| 396 | + | d.render(sess, tb, tb.focusedPane()) | |
| 397 | + | ||
| 398 | + | require.Zero(t, ac.nextStateNum) | |
| 399 | + | } | |
| 400 | + | ||
| 387 | 401 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | |
| 388 | 402 | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | |
| 389 | 403 | left := win.focusedPane() | |
| @@ -384,6 +384,20 @@ func TestRenderClearsDamageOnEmittingPaneWhenDetached(t *testing.T) { | |||||
| 384 | require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained") | 384 | require.Empty(t, emitting.screen.Damage(), "emitting pane damage should be drained") | ||
| 385 | } | 385 | } | ||
| 386 | 386 | ||||
| 387 | + | func TestRenderWithNilTransportDoesNotAdvanceStateCounter(t *testing.T) { | |||
| 388 | + | tb := newTab(nil, domain.Size{Cols: 10, Rows: 3}) | |||
| 389 | + | sess := &session{id: "s", name: "work", tabs: []*tab{tb}, active: 0} | |||
| 390 | + | ac := &attachedClient{rend: renderer.New(renderer.Capabilities{}), size: domain.Size{Cols: 10, Rows: 4}} | |||
| 391 | + | sess.client = ac | |||
| 392 | + | ac.setSession(sess) | |||
| 393 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 394 | + | ||||
| 395 | + | tb.focusedPane().screen.Write([]byte("x")) | |||
| 396 | + | d.render(sess, tb, tb.focusedPane()) | |||
| 397 | + | ||||
| 398 | + | require.Zero(t, ac.nextStateNum) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 387 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | 401 | func TestComposeTabFrameTwoPaneSplitBlitsDividersDimsAndTranslatesDamage(t *testing.T) { | ||
| 388 | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | 402 | win := newTab(nil, domain.Size{Cols: 41, Rows: 4}) | ||
| 389 | left := win.focusedPane() | 403 | left := win.focusedPane() | ||
A internal/usecase/daemon/resume.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "encoding/binary" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/vev/internal/domain" | |
| 8 | + | "github.com/bnema/vev/internal/ports" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func newResumeToken() uint64 { | |
| 12 | + | var b [8]byte | |
| 13 | + | if _, err := rand.Read(b[:]); err != nil { | |
| 14 | + | return 0 | |
| 15 | + | } | |
| 16 | + | return binary.BigEndian.Uint64(b[:]) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (d *Daemon) nextResumeTokenLocked() uint64 { | |
| 20 | + | for { | |
| 21 | + | tok := newResumeToken() | |
| 22 | + | if tok == 0 { | |
| 23 | + | continue | |
| 24 | + | } | |
| 25 | + | if _, exists := d.parked[tok]; !exists { | |
| 26 | + | return tok | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool { | |
| 32 | + | sess.mu.Lock() | |
| 33 | + | ephemeral := sess.ephemeral | |
| 34 | + | sess.mu.Unlock() | |
| 35 | + | if ephemeral || !ac.resumeCapable { | |
| 36 | + | return false | |
| 37 | + | } | |
| 38 | + | if ac.resumeToken == 0 { | |
| 39 | + | d.mu.Lock() | |
| 40 | + | if d.closing { | |
| 41 | + | d.mu.Unlock() | |
| 42 | + | return false | |
| 43 | + | } | |
| 44 | + | ac.resumeToken = d.nextResumeTokenLocked() | |
| 45 | + | d.mu.Unlock() | |
| 46 | + | } | |
| 47 | + | return true | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |
| 51 | + | if !d.prepareParkAttachment(sess, ac) { | |
| 52 | + | return false | |
| 53 | + | } | |
| 54 | + | d.mu.Lock() | |
| 55 | + | if d.closing { | |
| 56 | + | d.mu.Unlock() | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | token := ac.resumeToken | |
| 60 | + | if old := d.parked[token]; old != nil { | |
| 61 | + | old.timer.Stop() | |
| 62 | + | old.closeDone() | |
| 63 | + | } | |
| 64 | + | timer := d.clock.NewTimer(resumeParkGrace) | |
| 65 | + | parked := &parkedAttachment{sess: sess, ac: ac, timer: timer, done: make(chan struct{})} | |
| 66 | + | ac.parked = true | |
| 67 | + | d.parked[token] = parked | |
| 68 | + | d.mu.Unlock() | |
| 69 | + | ||
| 70 | + | go func(token uint64, parked *parkedAttachment) { | |
| 71 | + | select { | |
| 72 | + | case <-timer.C(): | |
| 73 | + | d.expireParked(token, parked) | |
| 74 | + | case <-parked.done: | |
| 75 | + | } | |
| 76 | + | }(token, parked) | |
| 77 | + | return true | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |
| 81 | + | d.mu.Lock() | |
| 82 | + | if d.parked[token] == parked { | |
| 83 | + | d.removeParkedLocked(token, parked) | |
| 84 | + | } | |
| 85 | + | d.mu.Unlock() | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // removeParkedLocked invalidates one parked attachment. Caller holds d.mu and | |
| 89 | + | // has verified d.parked[token] still points at parked when that matters. | |
| 90 | + | func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) { | |
| 91 | + | delete(d.parked, token) | |
| 92 | + | parked.ac.resumeToken = 0 | |
| 93 | + | parked.ac.parked = false | |
| 94 | + | if parked.timer != nil { | |
| 95 | + | parked.timer.Stop() | |
| 96 | + | } | |
| 97 | + | parked.closeDone() | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | func (p *parkedAttachment) closeDone() { | |
| 101 | + | if p.done == nil { | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | p.doneOnce.Do(func() { close(p.done) }) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // purgeParkedForSessionLocked invalidates every parked token for sess. Caller | |
| 108 | + | // holds d.mu. Use before killing or normally attaching to a session so stale | |
| 109 | + | // resume tokens cannot resurrect or replace an active attachment. | |
| 110 | + | func (d *Daemon) purgeParkedForSessionLocked(sess *session) { | |
| 111 | + | for token, parked := range d.parked { | |
| 112 | + | if parked.sess == sess { | |
| 113 | + | d.removeParkedLocked(token, parked) | |
| 114 | + | } | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) { | |
| 119 | + | parked := d.parked[h.ResumeToken] | |
| 120 | + | if parked == nil || h.ResumeToken == 0 { | |
| 121 | + | return nil, nil, false, nil | |
| 122 | + | } | |
| 123 | + | sess := parked.sess | |
| 124 | + | registered := d.sessions[sess.id] == sess | |
| 125 | + | sess.mu.Lock() | |
| 126 | + | active := sess.client != nil | |
| 127 | + | sess.mu.Unlock() | |
| 128 | + | if !registered || active { | |
| 129 | + | d.removeParkedLocked(h.ResumeToken, parked) | |
| 130 | + | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 131 | + | } | |
| 132 | + | ac := parked.ac | |
| 133 | + | if ac.clientID != h.ClientID { | |
| 134 | + | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |
| 135 | + | } | |
| 136 | + | delete(d.parked, h.ResumeToken) | |
| 137 | + | parked.timer.Stop() | |
| 138 | + | parked.closeDone() | |
| 139 | + | ac.replaceTransport(tr) | |
| 140 | + | ac.size = sz | |
| 141 | + | ac.resumeToken = d.nextResumeTokenLocked() | |
| 142 | + | ac.parked = false | |
| 143 | + | ac.setSession(sess) | |
| 144 | + | sess.mu.Lock() | |
| 145 | + | sess.client = ac | |
| 146 | + | sess.mu.Unlock() | |
| 147 | + | return sess, ac, true, nil | |
| 148 | + | } | |
| @@ -0,0 +1,148 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/rand" | |||
| 5 | + | "encoding/binary" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/vev/internal/domain" | |||
| 8 | + | "github.com/bnema/vev/internal/ports" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func newResumeToken() uint64 { | |||
| 12 | + | var b [8]byte | |||
| 13 | + | if _, err := rand.Read(b[:]); err != nil { | |||
| 14 | + | return 0 | |||
| 15 | + | } | |||
| 16 | + | return binary.BigEndian.Uint64(b[:]) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (d *Daemon) nextResumeTokenLocked() uint64 { | |||
| 20 | + | for { | |||
| 21 | + | tok := newResumeToken() | |||
| 22 | + | if tok == 0 { | |||
| 23 | + | continue | |||
| 24 | + | } | |||
| 25 | + | if _, exists := d.parked[tok]; !exists { | |||
| 26 | + | return tok | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool { | |||
| 32 | + | sess.mu.Lock() | |||
| 33 | + | ephemeral := sess.ephemeral | |||
| 34 | + | sess.mu.Unlock() | |||
| 35 | + | if ephemeral || !ac.resumeCapable { | |||
| 36 | + | return false | |||
| 37 | + | } | |||
| 38 | + | if ac.resumeToken == 0 { | |||
| 39 | + | d.mu.Lock() | |||
| 40 | + | if d.closing { | |||
| 41 | + | d.mu.Unlock() | |||
| 42 | + | return false | |||
| 43 | + | } | |||
| 44 | + | ac.resumeToken = d.nextResumeTokenLocked() | |||
| 45 | + | d.mu.Unlock() | |||
| 46 | + | } | |||
| 47 | + | return true | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool { | |||
| 51 | + | if !d.prepareParkAttachment(sess, ac) { | |||
| 52 | + | return false | |||
| 53 | + | } | |||
| 54 | + | d.mu.Lock() | |||
| 55 | + | if d.closing { | |||
| 56 | + | d.mu.Unlock() | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | token := ac.resumeToken | |||
| 60 | + | if old := d.parked[token]; old != nil { | |||
| 61 | + | old.timer.Stop() | |||
| 62 | + | old.closeDone() | |||
| 63 | + | } | |||
| 64 | + | timer := d.clock.NewTimer(resumeParkGrace) | |||
| 65 | + | parked := &parkedAttachment{sess: sess, ac: ac, timer: timer, done: make(chan struct{})} | |||
| 66 | + | ac.parked = true | |||
| 67 | + | d.parked[token] = parked | |||
| 68 | + | d.mu.Unlock() | |||
| 69 | + | ||||
| 70 | + | go func(token uint64, parked *parkedAttachment) { | |||
| 71 | + | select { | |||
| 72 | + | case <-timer.C(): | |||
| 73 | + | d.expireParked(token, parked) | |||
| 74 | + | case <-parked.done: | |||
| 75 | + | } | |||
| 76 | + | }(token, parked) | |||
| 77 | + | return true | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) { | |||
| 81 | + | d.mu.Lock() | |||
| 82 | + | if d.parked[token] == parked { | |||
| 83 | + | d.removeParkedLocked(token, parked) | |||
| 84 | + | } | |||
| 85 | + | d.mu.Unlock() | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // removeParkedLocked invalidates one parked attachment. Caller holds d.mu and | |||
| 89 | + | // has verified d.parked[token] still points at parked when that matters. | |||
| 90 | + | func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) { | |||
| 91 | + | delete(d.parked, token) | |||
| 92 | + | parked.ac.resumeToken = 0 | |||
| 93 | + | parked.ac.parked = false | |||
| 94 | + | if parked.timer != nil { | |||
| 95 | + | parked.timer.Stop() | |||
| 96 | + | } | |||
| 97 | + | parked.closeDone() | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | func (p *parkedAttachment) closeDone() { | |||
| 101 | + | if p.done == nil { | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | p.doneOnce.Do(func() { close(p.done) }) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // purgeParkedForSessionLocked invalidates every parked token for sess. Caller | |||
| 108 | + | // holds d.mu. Use before killing or normally attaching to a session so stale | |||
| 109 | + | // resume tokens cannot resurrect or replace an active attachment. | |||
| 110 | + | func (d *Daemon) purgeParkedForSessionLocked(sess *session) { | |||
| 111 | + | for token, parked := range d.parked { | |||
| 112 | + | if parked.sess == sess { | |||
| 113 | + | d.removeParkedLocked(token, parked) | |||
| 114 | + | } | |||
| 115 | + | } | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) { | |||
| 119 | + | parked := d.parked[h.ResumeToken] | |||
| 120 | + | if parked == nil || h.ResumeToken == 0 { | |||
| 121 | + | return nil, nil, false, nil | |||
| 122 | + | } | |||
| 123 | + | sess := parked.sess | |||
| 124 | + | registered := d.sessions[sess.id] == sess | |||
| 125 | + | sess.mu.Lock() | |||
| 126 | + | active := sess.client != nil | |||
| 127 | + | sess.mu.Unlock() | |||
| 128 | + | if !registered || active { | |||
| 129 | + | d.removeParkedLocked(h.ResumeToken, parked) | |||
| 130 | + | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |||
| 131 | + | } | |||
| 132 | + | ac := parked.ac | |||
| 133 | + | if ac.clientID != h.ClientID { | |||
| 134 | + | return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"} | |||
| 135 | + | } | |||
| 136 | + | delete(d.parked, h.ResumeToken) | |||
| 137 | + | parked.timer.Stop() | |||
| 138 | + | parked.closeDone() | |||
| 139 | + | ac.replaceTransport(tr) | |||
| 140 | + | ac.size = sz | |||
| 141 | + | ac.resumeToken = d.nextResumeTokenLocked() | |||
| 142 | + | ac.parked = false | |||
| 143 | + | ac.setSession(sess) | |||
| 144 | + | sess.mu.Lock() | |||
| 145 | + | sess.client = ac | |||
| 146 | + | sess.mu.Unlock() | |||
| 147 | + | return sess, ac, true, nil | |||
| 148 | + | } | |||
A internal/usecase/daemon/resume_test.go +245 -0
| @@ -0,0 +1,245 @@ | |||
| 1 | + | package daemon | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type closeTrackingTransport struct { | |
| 16 | + | mu sync.Mutex | |
| 17 | + | closed bool | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func (t *closeTrackingTransport) Send(ports.Frame) error { return nil } | |
| 21 | + | func (t *closeTrackingTransport) Recv() (ports.Frame, error) { | |
| 22 | + | return ports.Frame{}, errors.New("closed") | |
| 23 | + | } | |
| 24 | + | func (t *closeTrackingTransport) Close() error { | |
| 25 | + | t.mu.Lock() | |
| 26 | + | t.closed = true | |
| 27 | + | t.mu.Unlock() | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | func (t *closeTrackingTransport) Closed() bool { | |
| 31 | + | t.mu.Lock() | |
| 32 | + | defer t.mu.Unlock() | |
| 33 | + | return t.closed | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello { | |
| 37 | + | return ports.Hello{ | |
| 38 | + | Version: ports.ProtocolVersion, | |
| 39 | + | Intent: intent, | |
| 40 | + | ClientID: [16]byte{1, 2, 3, 4}, | |
| 41 | + | ResumeToken: token, | |
| 42 | + | Name: name, | |
| 43 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |
| 44 | + | TermEnv: "xterm-256color", | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) { | |
| 49 | + | pty, release := newBlockingPTY(t) | |
| 50 | + | defer release() | |
| 51 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 52 | + | ||
| 53 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 54 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 55 | + | require.NoError(t, err) | |
| 56 | + | token := ac.resumeToken | |
| 57 | + | require.NotZero(t, token) | |
| 58 | + | ||
| 59 | + | d.clientGone(sess, ac, false) | |
| 60 | + | require.Equal(t, 1, sessionCount(d), "named session survives parked link loss") | |
| 61 | + | require.Nil(t, sess.client) | |
| 62 | + | d.mu.Lock() | |
| 63 | + | _, parked := d.parked[token] | |
| 64 | + | d.mu.Unlock() | |
| 65 | + | require.True(t, parked, "named resume-capable link loss is parked") | |
| 66 | + | ||
| 67 | + | pty2, release2 := newBlockingPTY(t) | |
| 68 | + | defer release2() | |
| 69 | + | d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{}) | |
| 70 | + | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 71 | + | es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2) | |
| 72 | + | require.NoError(t, err) | |
| 73 | + | d2.clientGone(es, eac, false) | |
| 74 | + | require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |
| 78 | + | pty, release := newBlockingPTY(t) | |
| 79 | + | defer release() | |
| 80 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 81 | + | ||
| 82 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 83 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | oldToken := ac.resumeToken | |
| 86 | + | d.clientGone(sess, ac, false) | |
| 87 | + | ||
| 88 | + | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 89 | + | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", oldToken), tr2) | |
| 90 | + | require.NoError(t, err) | |
| 91 | + | require.Same(t, sess, resumedSess) | |
| 92 | + | require.Same(t, ac, resumedAC) | |
| 93 | + | require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token") | |
| 94 | + | require.Same(t, resumedAC, sess.client) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) { | |
| 98 | + | pty, release := newBlockingPTY(t) | |
| 99 | + | defer release() | |
| 100 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 101 | + | ||
| 102 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 103 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 104 | + | require.NoError(t, err) | |
| 105 | + | token := ac.resumeToken | |
| 106 | + | d.clientGone(sess, ac, false) | |
| 107 | + | ||
| 108 | + | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | |
| 109 | + | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | |
| 110 | + | d.mu.Lock() | |
| 111 | + | _, _, ok, err := d.resumeParkedLocked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 112 | + | _, stillParked := d.parked[token] | |
| 113 | + | d.mu.Unlock() | |
| 114 | + | require.Error(t, err) | |
| 115 | + | require.False(t, ok) | |
| 116 | + | require.True(t, stillParked, "mismatched client must not consume parked token") | |
| 117 | + | require.Equal(t, token, ac.resumeToken) | |
| 118 | + | require.True(t, ac.parked) | |
| 119 | + | ||
| 120 | + | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 121 | + | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2) | |
| 122 | + | require.NoError(t, err) | |
| 123 | + | require.Same(t, sess, resumedSess) | |
| 124 | + | require.Same(t, ac, resumedAC) | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.T) { | |
| 128 | + | pty, release := newBlockingPTY(t) | |
| 129 | + | defer release() | |
| 130 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |
| 131 | + | ||
| 132 | + | oldTr := &closeTrackingTransport{} | |
| 133 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |
| 134 | + | require.NoError(t, err) | |
| 135 | + | token := ac.resumeToken | |
| 136 | + | require.True(t, sess.detachIfCurrent(ac)) | |
| 137 | + | require.True(t, d.parkAttachment(sess, ac)) | |
| 138 | + | ||
| 139 | + | newTr := &closeTrackingTransport{} | |
| 140 | + | d.mu.Lock() | |
| 141 | + | resumedSess, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24}) | |
| 142 | + | d.mu.Unlock() | |
| 143 | + | require.NoError(t, err) | |
| 144 | + | require.True(t, ok) | |
| 145 | + | require.Same(t, sess, resumedSess) | |
| 146 | + | require.Same(t, ac, resumedAC) | |
| 147 | + | ||
| 148 | + | _ = ac.closeCapturedTransport(oldTr) | |
| 149 | + | require.True(t, oldTr.Closed(), "old transport is closed") | |
| 150 | + | require.False(t, newTr.Closed(), "newly rebound transport is not closed by old cleanup") | |
| 151 | + | require.Same(t, newTr, ac.transport()) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func TestExplicitDetachDoesNotPark(t *testing.T) { | |
| 155 | + | pty, release := newBlockingPTY(t) | |
| 156 | + | defer release() | |
| 157 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 158 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 159 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 160 | + | require.NoError(t, err) | |
| 161 | + | d.clientGone(sess, ac, true) | |
| 162 | + | d.mu.Lock() | |
| 163 | + | parked := len(d.parked) | |
| 164 | + | d.mu.Unlock() | |
| 165 | + | require.Zero(t, parked) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | func TestParkExpiryAndShutdownCleanup(t *testing.T) { | |
| 169 | + | clk := &signalClock{timers: make(chan *signalTimer, 8)} | |
| 170 | + | pty, release := newBlockingPTY(t) | |
| 171 | + | defer release() | |
| 172 | + | d := newTestDaemon(t, newFactory(t, pty), clk) | |
| 173 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 174 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 175 | + | require.NoError(t, err) | |
| 176 | + | token := ac.resumeToken | |
| 177 | + | d.clientGone(sess, ac, false) | |
| 178 | + | timer := <-clk.timers | |
| 179 | + | timer.ch <- clk.Now() | |
| 180 | + | require.Eventually(t, func() bool { | |
| 181 | + | d.mu.Lock() | |
| 182 | + | defer d.mu.Unlock() | |
| 183 | + | _, ok := d.parked[token] | |
| 184 | + | return !ok | |
| 185 | + | }, 2*time.Second, 10*time.Millisecond) | |
| 186 | + | ||
| 187 | + | pty2, release2 := newBlockingPTY(t) | |
| 188 | + | defer release2() | |
| 189 | + | d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{}) | |
| 190 | + | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 191 | + | sess2, ac2, err := d2.route(helloResumeCapable(ports.IntentNew, "other", 0), tr2) | |
| 192 | + | require.NoError(t, err) | |
| 193 | + | d2.clientGone(sess2, ac2, false) | |
| 194 | + | d2.shutdownAll(ports.ReasonServerShutdown) | |
| 195 | + | d2.mu.Lock() | |
| 196 | + | parked := len(d2.parked) | |
| 197 | + | d2.mu.Unlock() | |
| 198 | + | require.Zero(t, parked) | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func TestKilledSessionPurgesParkedResumeToken(t *testing.T) { | |
| 202 | + | pty, release := newBlockingPTY(t) | |
| 203 | + | defer release() | |
| 204 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 205 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 206 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 207 | + | require.NoError(t, err) | |
| 208 | + | token := ac.resumeToken | |
| 209 | + | d.clientGone(sess, ac, false) | |
| 210 | + | ||
| 211 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |
| 212 | + | d.mu.Lock() | |
| 213 | + | _, parked := d.parked[token] | |
| 214 | + | _, _, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 215 | + | d.mu.Unlock() | |
| 216 | + | require.False(t, parked, "killSession purges parked token") | |
| 217 | + | require.NoError(t, err) | |
| 218 | + | require.False(t, ok, "killed session cannot be resumed") | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) { | |
| 222 | + | pty, release := newBlockingPTY(t) | |
| 223 | + | defer release() | |
| 224 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |
| 225 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |
| 226 | + | sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |
| 227 | + | require.NoError(t, err) | |
| 228 | + | token := oldAC.resumeToken | |
| 229 | + | d.clientGone(sess, oldAC, false) | |
| 230 | + | ||
| 231 | + | activeTr := &closeTrackingTransport{} | |
| 232 | + | _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr) | |
| 233 | + | require.NoError(t, err) | |
| 234 | + | require.NotSame(t, oldAC, activeAC) | |
| 235 | + | ||
| 236 | + | d.mu.Lock() | |
| 237 | + | _, parked := d.parked[token] | |
| 238 | + | _, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |
| 239 | + | d.mu.Unlock() | |
| 240 | + | require.False(t, parked, "normal attach invalidates stale parked token") | |
| 241 | + | require.NoError(t, err) | |
| 242 | + | require.False(t, ok) | |
| 243 | + | require.Nil(t, resumedAC) | |
| 244 | + | require.Same(t, activeAC, sess.client) | |
| 245 | + | } | |
| @@ -0,0 +1,245 @@ | |||||
| 1 | + | package daemon | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type closeTrackingTransport struct { | |||
| 16 | + | mu sync.Mutex | |||
| 17 | + | closed bool | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func (t *closeTrackingTransport) Send(ports.Frame) error { return nil } | |||
| 21 | + | func (t *closeTrackingTransport) Recv() (ports.Frame, error) { | |||
| 22 | + | return ports.Frame{}, errors.New("closed") | |||
| 23 | + | } | |||
| 24 | + | func (t *closeTrackingTransport) Close() error { | |||
| 25 | + | t.mu.Lock() | |||
| 26 | + | t.closed = true | |||
| 27 | + | t.mu.Unlock() | |||
| 28 | + | return nil | |||
| 29 | + | } | |||
| 30 | + | func (t *closeTrackingTransport) Closed() bool { | |||
| 31 | + | t.mu.Lock() | |||
| 32 | + | defer t.mu.Unlock() | |||
| 33 | + | return t.closed | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func helloResumeCapable(intent uint8, name string, token uint64) ports.Hello { | |||
| 37 | + | return ports.Hello{ | |||
| 38 | + | Version: ports.ProtocolVersion, | |||
| 39 | + | Intent: intent, | |||
| 40 | + | ClientID: [16]byte{1, 2, 3, 4}, | |||
| 41 | + | ResumeToken: token, | |||
| 42 | + | Name: name, | |||
| 43 | + | Size: domain.Size{Cols: 80, Rows: 24}, | |||
| 44 | + | TermEnv: "xterm-256color", | |||
| 45 | + | } | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestNamedLinkLossParksButEphemeralLinkLossKills(t *testing.T) { | |||
| 49 | + | pty, release := newBlockingPTY(t) | |||
| 50 | + | defer release() | |||
| 51 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 52 | + | ||||
| 53 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 54 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |||
| 55 | + | require.NoError(t, err) | |||
| 56 | + | token := ac.resumeToken | |||
| 57 | + | require.NotZero(t, token) | |||
| 58 | + | ||||
| 59 | + | d.clientGone(sess, ac, false) | |||
| 60 | + | require.Equal(t, 1, sessionCount(d), "named session survives parked link loss") | |||
| 61 | + | require.Nil(t, sess.client) | |||
| 62 | + | d.mu.Lock() | |||
| 63 | + | _, parked := d.parked[token] | |||
| 64 | + | d.mu.Unlock() | |||
| 65 | + | require.True(t, parked, "named resume-capable link loss is parked") | |||
| 66 | + | ||||
| 67 | + | pty2, release2 := newBlockingPTY(t) | |||
| 68 | + | defer release2() | |||
| 69 | + | d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{}) | |||
| 70 | + | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 71 | + | es, eac, err := d2.route(helloResumeCapable(ports.IntentEphemeral, "", 0), tr2) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | d2.clientGone(es, eac, false) | |||
| 74 | + | require.Equal(t, 0, sessionCount(d2), "ephemeral link loss still destroys session") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestResumeRebindsRotatesAndDoesNotOpenPTY(t *testing.T) { | |||
| 78 | + | pty, release := newBlockingPTY(t) | |||
| 79 | + | defer release() | |||
| 80 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 81 | + | ||||
| 82 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 83 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | oldToken := ac.resumeToken | |||
| 86 | + | d.clientGone(sess, ac, false) | |||
| 87 | + | ||||
| 88 | + | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 89 | + | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", oldToken), tr2) | |||
| 90 | + | require.NoError(t, err) | |||
| 91 | + | require.Same(t, sess, resumedSess) | |||
| 92 | + | require.Same(t, ac, resumedAC) | |||
| 93 | + | require.NotEqual(t, oldToken, resumedAC.resumeToken, "resume rotates token") | |||
| 94 | + | require.Same(t, resumedAC, sess.client) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestResumeClientIDMismatchDoesNotConsumeParkedToken(t *testing.T) { | |||
| 98 | + | pty, release := newBlockingPTY(t) | |||
| 99 | + | defer release() | |||
| 100 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 101 | + | ||||
| 102 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 103 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |||
| 104 | + | require.NoError(t, err) | |||
| 105 | + | token := ac.resumeToken | |||
| 106 | + | d.clientGone(sess, ac, false) | |||
| 107 | + | ||||
| 108 | + | wrongClient := helloResumeCapable(ports.IntentResume, "work", token) | |||
| 109 | + | wrongClient.ClientID = [16]byte{9, 9, 9, 9} | |||
| 110 | + | d.mu.Lock() | |||
| 111 | + | _, _, ok, err := d.resumeParkedLocked(wrongClient, &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 112 | + | _, stillParked := d.parked[token] | |||
| 113 | + | d.mu.Unlock() | |||
| 114 | + | require.Error(t, err) | |||
| 115 | + | require.False(t, ok) | |||
| 116 | + | require.True(t, stillParked, "mismatched client must not consume parked token") | |||
| 117 | + | require.Equal(t, token, ac.resumeToken) | |||
| 118 | + | require.True(t, ac.parked) | |||
| 119 | + | ||||
| 120 | + | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 121 | + | resumedSess, resumedAC, err := d.route(helloResumeCapable(ports.IntentResume, "work", token), tr2) | |||
| 122 | + | require.NoError(t, err) | |||
| 123 | + | require.Same(t, sess, resumedSess) | |||
| 124 | + | require.Same(t, ac, resumedAC) | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | func TestResumeCloseCapturedOldTransportDoesNotCloseReboundTransport(t *testing.T) { | |||
| 128 | + | pty, release := newBlockingPTY(t) | |||
| 129 | + | defer release() | |||
| 130 | + | d := newTestDaemon(t, newFactorySeq(t, pty), stubClock{}) | |||
| 131 | + | ||||
| 132 | + | oldTr := &closeTrackingTransport{} | |||
| 133 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), oldTr) | |||
| 134 | + | require.NoError(t, err) | |||
| 135 | + | token := ac.resumeToken | |||
| 136 | + | require.True(t, sess.detachIfCurrent(ac)) | |||
| 137 | + | require.True(t, d.parkAttachment(sess, ac)) | |||
| 138 | + | ||||
| 139 | + | newTr := &closeTrackingTransport{} | |||
| 140 | + | d.mu.Lock() | |||
| 141 | + | resumedSess, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), newTr, domain.Size{Cols: 80, Rows: 24}) | |||
| 142 | + | d.mu.Unlock() | |||
| 143 | + | require.NoError(t, err) | |||
| 144 | + | require.True(t, ok) | |||
| 145 | + | require.Same(t, sess, resumedSess) | |||
| 146 | + | require.Same(t, ac, resumedAC) | |||
| 147 | + | ||||
| 148 | + | _ = ac.closeCapturedTransport(oldTr) | |||
| 149 | + | require.True(t, oldTr.Closed(), "old transport is closed") | |||
| 150 | + | require.False(t, newTr.Closed(), "newly rebound transport is not closed by old cleanup") | |||
| 151 | + | require.Same(t, newTr, ac.transport()) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 154 | + | func TestExplicitDetachDoesNotPark(t *testing.T) { | |||
| 155 | + | pty, release := newBlockingPTY(t) | |||
| 156 | + | defer release() | |||
| 157 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 158 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 159 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |||
| 160 | + | require.NoError(t, err) | |||
| 161 | + | d.clientGone(sess, ac, true) | |||
| 162 | + | d.mu.Lock() | |||
| 163 | + | parked := len(d.parked) | |||
| 164 | + | d.mu.Unlock() | |||
| 165 | + | require.Zero(t, parked) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | func TestParkExpiryAndShutdownCleanup(t *testing.T) { | |||
| 169 | + | clk := &signalClock{timers: make(chan *signalTimer, 8)} | |||
| 170 | + | pty, release := newBlockingPTY(t) | |||
| 171 | + | defer release() | |||
| 172 | + | d := newTestDaemon(t, newFactory(t, pty), clk) | |||
| 173 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 174 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |||
| 175 | + | require.NoError(t, err) | |||
| 176 | + | token := ac.resumeToken | |||
| 177 | + | d.clientGone(sess, ac, false) | |||
| 178 | + | timer := <-clk.timers | |||
| 179 | + | timer.ch <- clk.Now() | |||
| 180 | + | require.Eventually(t, func() bool { | |||
| 181 | + | d.mu.Lock() | |||
| 182 | + | defer d.mu.Unlock() | |||
| 183 | + | _, ok := d.parked[token] | |||
| 184 | + | return !ok | |||
| 185 | + | }, 2*time.Second, 10*time.Millisecond) | |||
| 186 | + | ||||
| 187 | + | pty2, release2 := newBlockingPTY(t) | |||
| 188 | + | defer release2() | |||
| 189 | + | d2 := newTestDaemon(t, newFactory(t, pty2), stubClock{}) | |||
| 190 | + | tr2, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 191 | + | sess2, ac2, err := d2.route(helloResumeCapable(ports.IntentNew, "other", 0), tr2) | |||
| 192 | + | require.NoError(t, err) | |||
| 193 | + | d2.clientGone(sess2, ac2, false) | |||
| 194 | + | d2.shutdownAll(ports.ReasonServerShutdown) | |||
| 195 | + | d2.mu.Lock() | |||
| 196 | + | parked := len(d2.parked) | |||
| 197 | + | d2.mu.Unlock() | |||
| 198 | + | require.Zero(t, parked) | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func TestKilledSessionPurgesParkedResumeToken(t *testing.T) { | |||
| 202 | + | pty, release := newBlockingPTY(t) | |||
| 203 | + | defer release() | |||
| 204 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 205 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 206 | + | sess, ac, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |||
| 207 | + | require.NoError(t, err) | |||
| 208 | + | token := ac.resumeToken | |||
| 209 | + | d.clientGone(sess, ac, false) | |||
| 210 | + | ||||
| 211 | + | require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, true)) | |||
| 212 | + | d.mu.Lock() | |||
| 213 | + | _, parked := d.parked[token] | |||
| 214 | + | _, _, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 215 | + | d.mu.Unlock() | |||
| 216 | + | require.False(t, parked, "killSession purges parked token") | |||
| 217 | + | require.NoError(t, err) | |||
| 218 | + | require.False(t, ok, "killed session cannot be resumed") | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func TestStaleParkedTokenCannotStealActiveAttachment(t *testing.T) { | |||
| 222 | + | pty, release := newBlockingPTY(t) | |||
| 223 | + | defer release() | |||
| 224 | + | d := newTestDaemon(t, newFactory(t, pty), stubClock{}) | |||
| 225 | + | tr, _, _ := newConn(t, mustHello(ports.IntentAttach, "unused", domain.Size{})) | |||
| 226 | + | sess, oldAC, err := d.route(helloResumeCapable(ports.IntentNew, "work", 0), tr) | |||
| 227 | + | require.NoError(t, err) | |||
| 228 | + | token := oldAC.resumeToken | |||
| 229 | + | d.clientGone(sess, oldAC, false) | |||
| 230 | + | ||||
| 231 | + | activeTr := &closeTrackingTransport{} | |||
| 232 | + | _, activeAC, err := d.route(helloResumeCapable(ports.IntentAttach, "work", 0), activeTr) | |||
| 233 | + | require.NoError(t, err) | |||
| 234 | + | require.NotSame(t, oldAC, activeAC) | |||
| 235 | + | ||||
| 236 | + | d.mu.Lock() | |||
| 237 | + | _, parked := d.parked[token] | |||
| 238 | + | _, resumedAC, ok, err := d.resumeParkedLocked(helloResumeCapable(ports.IntentResume, "work", token), &closeTrackingTransport{}, domain.Size{Cols: 80, Rows: 24}) | |||
| 239 | + | d.mu.Unlock() | |||
| 240 | + | require.False(t, parked, "normal attach invalidates stale parked token") | |||
| 241 | + | require.NoError(t, err) | |||
| 242 | + | require.False(t, ok) | |||
| 243 | + | require.Nil(t, resumedAC) | |||
| 244 | + | require.Same(t, activeAC, sess.client) | |||
| 245 | + | } | |||
M internal/usecase/daemon/session.go +1 -0
| @@ -415,6 +415,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||
| 415 | 415 | return nil | |
| 416 | 416 | } | |
| 417 | 417 | delete(d.sessions, sess.id) | |
| 418 | + | d.purgeParkedForSessionLocked(sess) | |
| 418 | 419 | sess.mu.Lock() | |
| 419 | 420 | stoppedName := sess.name | |
| 420 | 421 | stoppedCwd := sess.cwd | |
| @@ -415,6 +415,7 @@ func (d *Daemon) killSession(sess *session, reason uint8, purge bool) error { | |||||
| 415 | return nil | 415 | return nil | ||
| 416 | } | 416 | } | ||
| 417 | delete(d.sessions, sess.id) | 417 | delete(d.sessions, sess.id) | ||
| 418 | + | d.purgeParkedForSessionLocked(sess) | |||
| 418 | sess.mu.Lock() | 419 | sess.mu.Lock() | ||
| 419 | stoppedName := sess.name | 420 | stoppedName := sess.name | ||
| 420 | stoppedCwd := sess.cwd | 421 | stoppedCwd := sess.cwd | ||
M internal/usecase/daemon/status_test.go +3 -3
| @@ -102,7 +102,7 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | |||
| 102 | 102 | }) | |
| 103 | 103 | tr, _ := newCapturingTransport(t) | |
| 104 | 104 | ||
| 105 | - | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | |
| 105 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 106 | 106 | ||
| 107 | 107 | var got []byte | |
| 108 | 108 | tb := sess.activeTab() | |
| @@ -151,7 +151,7 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) { | |||
| 151 | 151 | require.True(t, sess.detachIfCurrent(ac)) | |
| 152 | 152 | ||
| 153 | 153 | tr, _ := newCapturingTransport(t) | |
| 154 | - | newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | |
| 154 | + | newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 155 | 155 | d.applyTheme(sess, newAC, ports.Theme{ | |
| 156 | 156 | HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22}, | |
| 157 | 157 | HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25}, | |
| @@ -191,7 +191,7 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) { | |||
| 191 | 191 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | |
| 192 | 192 | defer release() | |
| 193 | 193 | tr, _ := newCapturingTransport(t) | |
| 194 | - | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | |
| 194 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) | |
| 195 | 195 | ||
| 196 | 196 | d.applyTheme(sess, old, ports.Theme{ | |
| 197 | 197 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | |
| @@ -102,7 +102,7 @@ func TestAttachClientClearsStaleScreenDefaultColors(t *testing.T) { | |||||
| 102 | }) | 102 | }) | ||
| 103 | tr, _ := newCapturingTransport(t) | 103 | tr, _ := newCapturingTransport(t) | ||
| 104 | 104 | ||||
| 105 | - | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | 105 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) |
| 106 | 106 | ||||
| 107 | var got []byte | 107 | var got []byte | ||
| 108 | tb := sess.activeTab() | 108 | tb := sess.activeTab() | ||
| @@ -151,7 +151,7 @@ func TestClientGoneResetDoesNotClobberNewlyAttachedClient(t *testing.T) { | |||||
| 151 | require.True(t, sess.detachIfCurrent(ac)) | 151 | require.True(t, sess.detachIfCurrent(ac)) | ||
| 152 | 152 | ||||
| 153 | tr, _ := newCapturingTransport(t) | 153 | tr, _ := newCapturingTransport(t) | ||
| 154 | - | newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | 154 | + | newAC, _ := d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) |
| 155 | d.applyTheme(sess, newAC, ports.Theme{ | 155 | d.applyTheme(sess, newAC, ports.Theme{ | ||
| 156 | HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22}, | 156 | HasForeground: true, Foreground: renderer.RGB{R: 20, G: 21, B: 22}, | ||
| 157 | HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25}, | 157 | HasBackground: true, Background: renderer.RGB{R: 23, G: 24, B: 25}, | ||
| @@ -191,7 +191,7 @@ func TestApplyThemeIgnoresReplacedClient(t *testing.T) { | |||||
| 191 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | 191 | d, sess, old, _ := newManualSessionWithPTYs(t, p) | ||
| 192 | defer release() | 192 | defer release() | ||
| 193 | tr, _ := newCapturingTransport(t) | 193 | tr, _ := newCapturingTransport(t) | ||
| 194 | - | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}) | 194 | + | d.attachClient(sess, tr, domain.Size{Cols: 80, Rows: 24}, attachClientOptions{}) |
| 195 | 195 | ||||
| 196 | d.applyTheme(sess, old, ports.Theme{ | 196 | d.applyTheme(sess, old, ports.Theme{ | ||
| 197 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | 197 | HasForeground: true, Foreground: renderer.RGB{R: 1, G: 2, B: 3}, | ||
M internal/usecase/daemon/wire.go +21 -7
| @@ -28,12 +28,18 @@ import ( | |||
| 28 | 28 | "github.com/bnema/vev/internal/ports" | |
| 29 | 29 | ) | |
| 30 | 30 | ||
| 31 | - | func frameWelcome(s *session) ports.Frame { | |
| 32 | - | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{ | |
| 33 | - | SessionID: string(s.id), | |
| 34 | - | SessionName: s.name, | |
| 35 | - | Ephemeral: s.ephemeral, | |
| 36 | - | })} | |
| 31 | + | func frameWelcome(s *session, ac *attachedClient) ports.Frame { | |
| 32 | + | w := ports.Welcome{ | |
| 33 | + | SessionID: string(s.id), | |
| 34 | + | SessionName: s.name, | |
| 35 | + | Ephemeral: s.ephemeral, | |
| 36 | + | Capabilities: ports.CapabilityResume, | |
| 37 | + | ResumeToken: ac.resumeToken, | |
| 38 | + | } | |
| 39 | + | if ac.resumeCapable && ac.resumeToken == 0 { | |
| 40 | + | w.ResumeToken = 0 | |
| 41 | + | } | |
| 42 | + | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)} | |
| 37 | 43 | } | |
| 38 | 44 | ||
| 39 | 45 | func frameError(code uint16, text string) ports.Frame { | |
| @@ -41,7 +47,15 @@ func frameError(code uint16, text string) ports.Frame { | |||
| 41 | 47 | } | |
| 42 | 48 | ||
| 43 | 49 | func frameOutput(b []byte) ports.Frame { | |
| 44 | - | return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})} | |
| 50 | + | return frameOutputState(b, 0) | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func frameOutputState(b []byte, state uint64, echoAck ...uint64) ports.Frame { | |
| 54 | + | ack := uint64(0) | |
| 55 | + | if len(echoAck) > 0 { | |
| 56 | + | ack = echoAck[0] | |
| 57 | + | } | |
| 58 | + | return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{NewStateNum: state, EchoAck: ack, Data: b})} | |
| 45 | 59 | } | |
| 46 | 60 | ||
| 47 | 61 | func frameDetached(reason uint8) ports.Frame { | |
| @@ -28,12 +28,18 @@ import ( | |||||
| 28 | "github.com/bnema/vev/internal/ports" | 28 | "github.com/bnema/vev/internal/ports" | ||
| 29 | ) | 29 | ) | ||
| 30 | 30 | ||||
| 31 | - | func frameWelcome(s *session) ports.Frame { | 31 | + | func frameWelcome(s *session, ac *attachedClient) ports.Frame { |
| 32 | - | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{ | 32 | + | w := ports.Welcome{ |
| 33 | - | SessionID: string(s.id), | 33 | + | SessionID: string(s.id), |
| 34 | - | SessionName: s.name, | 34 | + | SessionName: s.name, |
| 35 | - | Ephemeral: s.ephemeral, | 35 | + | Ephemeral: s.ephemeral, |
| 36 | - | })} | 36 | + | Capabilities: ports.CapabilityResume, |
| 37 | + | ResumeToken: ac.resumeToken, | |||
| 38 | + | } | |||
| 39 | + | if ac.resumeCapable && ac.resumeToken == 0 { | |||
| 40 | + | w.ResumeToken = 0 | |||
| 41 | + | } | |||
| 42 | + | return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(w)} | |||
| 37 | } | 43 | } | ||
| 38 | 44 | ||||
| 39 | func frameError(code uint16, text string) ports.Frame { | 45 | func frameError(code uint16, text string) ports.Frame { | ||
| @@ -41,7 +47,15 @@ func frameError(code uint16, text string) ports.Frame { | |||||
| 41 | } | 47 | } | ||
| 42 | 48 | ||||
| 43 | func frameOutput(b []byte) ports.Frame { | 49 | func frameOutput(b []byte) ports.Frame { | ||
| 44 | - | return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{Data: b})} | 50 | + | return frameOutputState(b, 0) |
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | func frameOutputState(b []byte, state uint64, echoAck ...uint64) ports.Frame { | |||
| 54 | + | ack := uint64(0) | |||
| 55 | + | if len(echoAck) > 0 { | |||
| 56 | + | ack = echoAck[0] | |||
| 57 | + | } | |||
| 58 | + | return ports.Frame{Type: ports.MsgOutput, Payload: ports.MarshalOutput(ports.Output{NewStateNum: state, EchoAck: ack, Data: b})} | |||
| 45 | } | 59 | } | ||
| 46 | 60 | ||||
| 47 | func frameDetached(reason uint8) ports.Frame { | 61 | func frameDetached(reason uint8) ports.Frame { | ||
A pkg/dgram/dgram.go +247 -0
| @@ -0,0 +1,247 @@ | |||
| 1 | + | // Package dgram provides stdlib-only helpers for authenticated datagram | |
| 2 | + | // framing, replay protection, and MTU-sized fragmentation. | |
| 3 | + | package dgram | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "crypto/aes" | |
| 7 | + | "crypto/cipher" | |
| 8 | + | "encoding/binary" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const ( | |
| 14 | + | KeySize = 32 | |
| 15 | + | NonceSize = 12 | |
| 16 | + | HeaderSize = 4 + 8 | |
| 17 | + | DefaultMTU = 1200 | |
| 18 | + | fragmentHdr = 8 + 2 + 2 + 2 + 2 // seq, idx, count, total, reserved | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | var ( | |
| 22 | + | ErrReplay = errors.New("dgram: replayed packet") | |
| 23 | + | ErrDirection = errors.New("dgram: unexpected direction") | |
| 24 | + | ErrFragment = errors.New("dgram: invalid fragment") | |
| 25 | + | ) | |
| 26 | + | ||
| 27 | + | type Codec struct { | |
| 28 | + | gcm cipher.AEAD | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func NewCodec(key []byte) (*Codec, error) { | |
| 32 | + | if len(key) != KeySize { | |
| 33 | + | return nil, fmt.Errorf("dgram: key must be %d bytes", KeySize) | |
| 34 | + | } | |
| 35 | + | block, err := aes.NewCipher(key) | |
| 36 | + | if err != nil { | |
| 37 | + | return nil, err | |
| 38 | + | } | |
| 39 | + | gcm, err := cipher.NewGCM(block) | |
| 40 | + | if err != nil { | |
| 41 | + | return nil, err | |
| 42 | + | } | |
| 43 | + | return &Codec{gcm: gcm}, nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func (c *Codec) Overhead() int { return c.gcm.Overhead() } | |
| 47 | + | ||
| 48 | + | func (c *Codec) Seal(direction uint32, counter uint64, plaintext, aad []byte) []byte { | |
| 49 | + | var nonce [NonceSize]byte | |
| 50 | + | binary.BigEndian.PutUint32(nonce[0:4], direction) | |
| 51 | + | binary.BigEndian.PutUint64(nonce[4:12], counter) | |
| 52 | + | out := make([]byte, HeaderSize, HeaderSize+len(plaintext)+c.gcm.Overhead()) | |
| 53 | + | binary.BigEndian.PutUint32(out[0:4], direction) | |
| 54 | + | binary.BigEndian.PutUint64(out[4:12], counter) | |
| 55 | + | return c.gcm.Seal(out, nonce[:], plaintext, aad) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (c *Codec) Open(packet []byte, wantDirection uint32, aad []byte, replay *ReplayWindow) (uint64, []byte, error) { | |
| 59 | + | if len(packet) < HeaderSize+c.gcm.Overhead() { | |
| 60 | + | return 0, nil, errors.New("dgram: packet too short") | |
| 61 | + | } | |
| 62 | + | dir := binary.BigEndian.Uint32(packet[0:4]) | |
| 63 | + | if dir != wantDirection { | |
| 64 | + | return 0, nil, ErrDirection | |
| 65 | + | } | |
| 66 | + | ctr := binary.BigEndian.Uint64(packet[4:12]) | |
| 67 | + | if replay != nil && !replay.Check(ctr) { | |
| 68 | + | return 0, nil, ErrReplay | |
| 69 | + | } | |
| 70 | + | var nonce [NonceSize]byte | |
| 71 | + | copy(nonce[:], packet[:HeaderSize]) | |
| 72 | + | pt, err := c.gcm.Open(nil, nonce[:], packet[HeaderSize:], aad) | |
| 73 | + | if err != nil { | |
| 74 | + | return 0, nil, err | |
| 75 | + | } | |
| 76 | + | if replay != nil { | |
| 77 | + | replay.Accept(ctr) | |
| 78 | + | } | |
| 79 | + | return ctr, pt, nil | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | type ReplayWindow struct { | |
| 83 | + | max uint64 | |
| 84 | + | seen uint64 | |
| 85 | + | init bool | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | func NewReplayWindow() *ReplayWindow { return &ReplayWindow{} } | |
| 89 | + | ||
| 90 | + | func (w *ReplayWindow) Check(counter uint64) bool { | |
| 91 | + | if !w.init { | |
| 92 | + | return true | |
| 93 | + | } | |
| 94 | + | if counter > w.max { | |
| 95 | + | return true | |
| 96 | + | } | |
| 97 | + | delta := w.max - counter | |
| 98 | + | if delta >= 64 { | |
| 99 | + | return false | |
| 100 | + | } | |
| 101 | + | return (w.seen & (uint64(1) << delta)) == 0 | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func (w *ReplayWindow) Accept(counter uint64) { | |
| 105 | + | if !w.init { | |
| 106 | + | w.max, w.seen, w.init = counter, 1, true | |
| 107 | + | return | |
| 108 | + | } | |
| 109 | + | if counter > w.max { | |
| 110 | + | shift := counter - w.max | |
| 111 | + | if shift >= 64 { | |
| 112 | + | w.seen = 1 | |
| 113 | + | } else { | |
| 114 | + | w.seen = (w.seen << shift) | 1 | |
| 115 | + | } | |
| 116 | + | w.max = counter | |
| 117 | + | return | |
| 118 | + | } | |
| 119 | + | delta := w.max - counter | |
| 120 | + | if delta < 64 { | |
| 121 | + | w.seen |= uint64(1) << delta | |
| 122 | + | } | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | type Fragment struct { | |
| 126 | + | Seq uint64 | |
| 127 | + | Index uint16 | |
| 128 | + | Count uint16 | |
| 129 | + | Data []byte | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | func FragmentPayload(seq uint64, payload []byte, mtu int) ([]Fragment, error) { | |
| 133 | + | if mtu <= fragmentHdr { | |
| 134 | + | return nil, ErrFragment | |
| 135 | + | } | |
| 136 | + | max := mtu - fragmentHdr | |
| 137 | + | count := (len(payload) + max - 1) / max | |
| 138 | + | if count == 0 { | |
| 139 | + | count = 1 | |
| 140 | + | } | |
| 141 | + | if count > 0xffff { | |
| 142 | + | return nil, ErrFragment | |
| 143 | + | } | |
| 144 | + | out := make([]Fragment, count) | |
| 145 | + | for i := 0; i < count; i++ { | |
| 146 | + | start := i * max | |
| 147 | + | end := min(len(payload), start+max) | |
| 148 | + | out[i] = Fragment{Seq: seq, Index: uint16(i), Count: uint16(count), Data: append([]byte(nil), payload[start:end]...)} | |
| 149 | + | } | |
| 150 | + | return out, nil | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | func MarshalFragment(f Fragment) ([]byte, error) { | |
| 154 | + | if f.Count == 0 || f.Index >= f.Count { | |
| 155 | + | return nil, ErrFragment | |
| 156 | + | } | |
| 157 | + | b := make([]byte, fragmentHdr+len(f.Data)) | |
| 158 | + | binary.BigEndian.PutUint64(b[0:8], f.Seq) | |
| 159 | + | binary.BigEndian.PutUint16(b[8:10], f.Index) | |
| 160 | + | binary.BigEndian.PutUint16(b[10:12], f.Count) | |
| 161 | + | binary.BigEndian.PutUint16(b[12:14], uint16(len(f.Data))) | |
| 162 | + | copy(b[fragmentHdr:], f.Data) | |
| 163 | + | return b, nil | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func UnmarshalFragment(b []byte) (Fragment, error) { | |
| 167 | + | if len(b) < fragmentHdr { | |
| 168 | + | return Fragment{}, ErrFragment | |
| 169 | + | } | |
| 170 | + | f := Fragment{Seq: binary.BigEndian.Uint64(b[0:8]), Index: binary.BigEndian.Uint16(b[8:10]), Count: binary.BigEndian.Uint16(b[10:12])} | |
| 171 | + | n := int(binary.BigEndian.Uint16(b[12:14])) | |
| 172 | + | if f.Count == 0 || f.Index >= f.Count || len(b[fragmentHdr:]) != n { | |
| 173 | + | return Fragment{}, ErrFragment | |
| 174 | + | } | |
| 175 | + | f.Data = append([]byte(nil), b[fragmentHdr:]...) | |
| 176 | + | return f, nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | const maxReassemblyInflight = 1024 | |
| 180 | + | ||
| 181 | + | type Reassembler struct { | |
| 182 | + | inflight map[uint64]*assembly | |
| 183 | + | order []uint64 | |
| 184 | + | } | |
| 185 | + | type assembly struct { | |
| 186 | + | count uint16 | |
| 187 | + | got int | |
| 188 | + | parts [][]byte | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func NewReassembler() *Reassembler { return &Reassembler{inflight: make(map[uint64]*assembly)} } | |
| 192 | + | func (r *Reassembler) Add(f Fragment) ([]byte, bool, error) { | |
| 193 | + | if f.Count == 0 || f.Index >= f.Count { | |
| 194 | + | return nil, false, ErrFragment | |
| 195 | + | } | |
| 196 | + | a := r.inflight[f.Seq] | |
| 197 | + | if a == nil { | |
| 198 | + | r.evictOldest() | |
| 199 | + | a = &assembly{count: f.Count, parts: make([][]byte, f.Count)} | |
| 200 | + | r.inflight[f.Seq] = a | |
| 201 | + | r.order = append(r.order, f.Seq) | |
| 202 | + | } | |
| 203 | + | if a.count != f.Count { | |
| 204 | + | r.remove(f.Seq) | |
| 205 | + | return nil, false, ErrFragment | |
| 206 | + | } | |
| 207 | + | if a.parts[f.Index] != nil { | |
| 208 | + | return nil, false, nil | |
| 209 | + | } | |
| 210 | + | a.parts[f.Index] = append([]byte(nil), f.Data...) | |
| 211 | + | a.got++ | |
| 212 | + | if a.got != int(a.count) { | |
| 213 | + | return nil, false, nil | |
| 214 | + | } | |
| 215 | + | var total int | |
| 216 | + | for _, p := range a.parts { | |
| 217 | + | total += len(p) | |
| 218 | + | } | |
| 219 | + | out := make([]byte, 0, total) | |
| 220 | + | for _, p := range a.parts { | |
| 221 | + | out = append(out, p...) | |
| 222 | + | } | |
| 223 | + | r.remove(f.Seq) | |
| 224 | + | return out, true, nil | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | func (r *Reassembler) evictOldest() { | |
| 228 | + | for len(r.order) >= maxReassemblyInflight { | |
| 229 | + | seq := r.order[0] | |
| 230 | + | r.order = r.order[1:] | |
| 231 | + | if _, ok := r.inflight[seq]; ok { | |
| 232 | + | delete(r.inflight, seq) | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | } | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (r *Reassembler) remove(seq uint64) { | |
| 239 | + | delete(r.inflight, seq) | |
| 240 | + | for i, s := range r.order { | |
| 241 | + | if s == seq { | |
| 242 | + | copy(r.order[i:], r.order[i+1:]) | |
| 243 | + | r.order = r.order[:len(r.order)-1] | |
| 244 | + | return | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | } | |
| @@ -0,0 +1,247 @@ | |||||
| 1 | + | // Package dgram provides stdlib-only helpers for authenticated datagram | |||
| 2 | + | // framing, replay protection, and MTU-sized fragmentation. | |||
| 3 | + | package dgram | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "crypto/aes" | |||
| 7 | + | "crypto/cipher" | |||
| 8 | + | "encoding/binary" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | const ( | |||
| 14 | + | KeySize = 32 | |||
| 15 | + | NonceSize = 12 | |||
| 16 | + | HeaderSize = 4 + 8 | |||
| 17 | + | DefaultMTU = 1200 | |||
| 18 | + | fragmentHdr = 8 + 2 + 2 + 2 + 2 // seq, idx, count, total, reserved | |||
| 19 | + | ) | |||
| 20 | + | ||||
| 21 | + | var ( | |||
| 22 | + | ErrReplay = errors.New("dgram: replayed packet") | |||
| 23 | + | ErrDirection = errors.New("dgram: unexpected direction") | |||
| 24 | + | ErrFragment = errors.New("dgram: invalid fragment") | |||
| 25 | + | ) | |||
| 26 | + | ||||
| 27 | + | type Codec struct { | |||
| 28 | + | gcm cipher.AEAD | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func NewCodec(key []byte) (*Codec, error) { | |||
| 32 | + | if len(key) != KeySize { | |||
| 33 | + | return nil, fmt.Errorf("dgram: key must be %d bytes", KeySize) | |||
| 34 | + | } | |||
| 35 | + | block, err := aes.NewCipher(key) | |||
| 36 | + | if err != nil { | |||
| 37 | + | return nil, err | |||
| 38 | + | } | |||
| 39 | + | gcm, err := cipher.NewGCM(block) | |||
| 40 | + | if err != nil { | |||
| 41 | + | return nil, err | |||
| 42 | + | } | |||
| 43 | + | return &Codec{gcm: gcm}, nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func (c *Codec) Overhead() int { return c.gcm.Overhead() } | |||
| 47 | + | ||||
| 48 | + | func (c *Codec) Seal(direction uint32, counter uint64, plaintext, aad []byte) []byte { | |||
| 49 | + | var nonce [NonceSize]byte | |||
| 50 | + | binary.BigEndian.PutUint32(nonce[0:4], direction) | |||
| 51 | + | binary.BigEndian.PutUint64(nonce[4:12], counter) | |||
| 52 | + | out := make([]byte, HeaderSize, HeaderSize+len(plaintext)+c.gcm.Overhead()) | |||
| 53 | + | binary.BigEndian.PutUint32(out[0:4], direction) | |||
| 54 | + | binary.BigEndian.PutUint64(out[4:12], counter) | |||
| 55 | + | return c.gcm.Seal(out, nonce[:], plaintext, aad) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (c *Codec) Open(packet []byte, wantDirection uint32, aad []byte, replay *ReplayWindow) (uint64, []byte, error) { | |||
| 59 | + | if len(packet) < HeaderSize+c.gcm.Overhead() { | |||
| 60 | + | return 0, nil, errors.New("dgram: packet too short") | |||
| 61 | + | } | |||
| 62 | + | dir := binary.BigEndian.Uint32(packet[0:4]) | |||
| 63 | + | if dir != wantDirection { | |||
| 64 | + | return 0, nil, ErrDirection | |||
| 65 | + | } | |||
| 66 | + | ctr := binary.BigEndian.Uint64(packet[4:12]) | |||
| 67 | + | if replay != nil && !replay.Check(ctr) { | |||
| 68 | + | return 0, nil, ErrReplay | |||
| 69 | + | } | |||
| 70 | + | var nonce [NonceSize]byte | |||
| 71 | + | copy(nonce[:], packet[:HeaderSize]) | |||
| 72 | + | pt, err := c.gcm.Open(nil, nonce[:], packet[HeaderSize:], aad) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return 0, nil, err | |||
| 75 | + | } | |||
| 76 | + | if replay != nil { | |||
| 77 | + | replay.Accept(ctr) | |||
| 78 | + | } | |||
| 79 | + | return ctr, pt, nil | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | type ReplayWindow struct { | |||
| 83 | + | max uint64 | |||
| 84 | + | seen uint64 | |||
| 85 | + | init bool | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | func NewReplayWindow() *ReplayWindow { return &ReplayWindow{} } | |||
| 89 | + | ||||
| 90 | + | func (w *ReplayWindow) Check(counter uint64) bool { | |||
| 91 | + | if !w.init { | |||
| 92 | + | return true | |||
| 93 | + | } | |||
| 94 | + | if counter > w.max { | |||
| 95 | + | return true | |||
| 96 | + | } | |||
| 97 | + | delta := w.max - counter | |||
| 98 | + | if delta >= 64 { | |||
| 99 | + | return false | |||
| 100 | + | } | |||
| 101 | + | return (w.seen & (uint64(1) << delta)) == 0 | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func (w *ReplayWindow) Accept(counter uint64) { | |||
| 105 | + | if !w.init { | |||
| 106 | + | w.max, w.seen, w.init = counter, 1, true | |||
| 107 | + | return | |||
| 108 | + | } | |||
| 109 | + | if counter > w.max { | |||
| 110 | + | shift := counter - w.max | |||
| 111 | + | if shift >= 64 { | |||
| 112 | + | w.seen = 1 | |||
| 113 | + | } else { | |||
| 114 | + | w.seen = (w.seen << shift) | 1 | |||
| 115 | + | } | |||
| 116 | + | w.max = counter | |||
| 117 | + | return | |||
| 118 | + | } | |||
| 119 | + | delta := w.max - counter | |||
| 120 | + | if delta < 64 { | |||
| 121 | + | w.seen |= uint64(1) << delta | |||
| 122 | + | } | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | type Fragment struct { | |||
| 126 | + | Seq uint64 | |||
| 127 | + | Index uint16 | |||
| 128 | + | Count uint16 | |||
| 129 | + | Data []byte | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | func FragmentPayload(seq uint64, payload []byte, mtu int) ([]Fragment, error) { | |||
| 133 | + | if mtu <= fragmentHdr { | |||
| 134 | + | return nil, ErrFragment | |||
| 135 | + | } | |||
| 136 | + | max := mtu - fragmentHdr | |||
| 137 | + | count := (len(payload) + max - 1) / max | |||
| 138 | + | if count == 0 { | |||
| 139 | + | count = 1 | |||
| 140 | + | } | |||
| 141 | + | if count > 0xffff { | |||
| 142 | + | return nil, ErrFragment | |||
| 143 | + | } | |||
| 144 | + | out := make([]Fragment, count) | |||
| 145 | + | for i := 0; i < count; i++ { | |||
| 146 | + | start := i * max | |||
| 147 | + | end := min(len(payload), start+max) | |||
| 148 | + | out[i] = Fragment{Seq: seq, Index: uint16(i), Count: uint16(count), Data: append([]byte(nil), payload[start:end]...)} | |||
| 149 | + | } | |||
| 150 | + | return out, nil | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | func MarshalFragment(f Fragment) ([]byte, error) { | |||
| 154 | + | if f.Count == 0 || f.Index >= f.Count { | |||
| 155 | + | return nil, ErrFragment | |||
| 156 | + | } | |||
| 157 | + | b := make([]byte, fragmentHdr+len(f.Data)) | |||
| 158 | + | binary.BigEndian.PutUint64(b[0:8], f.Seq) | |||
| 159 | + | binary.BigEndian.PutUint16(b[8:10], f.Index) | |||
| 160 | + | binary.BigEndian.PutUint16(b[10:12], f.Count) | |||
| 161 | + | binary.BigEndian.PutUint16(b[12:14], uint16(len(f.Data))) | |||
| 162 | + | copy(b[fragmentHdr:], f.Data) | |||
| 163 | + | return b, nil | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func UnmarshalFragment(b []byte) (Fragment, error) { | |||
| 167 | + | if len(b) < fragmentHdr { | |||
| 168 | + | return Fragment{}, ErrFragment | |||
| 169 | + | } | |||
| 170 | + | f := Fragment{Seq: binary.BigEndian.Uint64(b[0:8]), Index: binary.BigEndian.Uint16(b[8:10]), Count: binary.BigEndian.Uint16(b[10:12])} | |||
| 171 | + | n := int(binary.BigEndian.Uint16(b[12:14])) | |||
| 172 | + | if f.Count == 0 || f.Index >= f.Count || len(b[fragmentHdr:]) != n { | |||
| 173 | + | return Fragment{}, ErrFragment | |||
| 174 | + | } | |||
| 175 | + | f.Data = append([]byte(nil), b[fragmentHdr:]...) | |||
| 176 | + | return f, nil | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | const maxReassemblyInflight = 1024 | |||
| 180 | + | ||||
| 181 | + | type Reassembler struct { | |||
| 182 | + | inflight map[uint64]*assembly | |||
| 183 | + | order []uint64 | |||
| 184 | + | } | |||
| 185 | + | type assembly struct { | |||
| 186 | + | count uint16 | |||
| 187 | + | got int | |||
| 188 | + | parts [][]byte | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func NewReassembler() *Reassembler { return &Reassembler{inflight: make(map[uint64]*assembly)} } | |||
| 192 | + | func (r *Reassembler) Add(f Fragment) ([]byte, bool, error) { | |||
| 193 | + | if f.Count == 0 || f.Index >= f.Count { | |||
| 194 | + | return nil, false, ErrFragment | |||
| 195 | + | } | |||
| 196 | + | a := r.inflight[f.Seq] | |||
| 197 | + | if a == nil { | |||
| 198 | + | r.evictOldest() | |||
| 199 | + | a = &assembly{count: f.Count, parts: make([][]byte, f.Count)} | |||
| 200 | + | r.inflight[f.Seq] = a | |||
| 201 | + | r.order = append(r.order, f.Seq) | |||
| 202 | + | } | |||
| 203 | + | if a.count != f.Count { | |||
| 204 | + | r.remove(f.Seq) | |||
| 205 | + | return nil, false, ErrFragment | |||
| 206 | + | } | |||
| 207 | + | if a.parts[f.Index] != nil { | |||
| 208 | + | return nil, false, nil | |||
| 209 | + | } | |||
| 210 | + | a.parts[f.Index] = append([]byte(nil), f.Data...) | |||
| 211 | + | a.got++ | |||
| 212 | + | if a.got != int(a.count) { | |||
| 213 | + | return nil, false, nil | |||
| 214 | + | } | |||
| 215 | + | var total int | |||
| 216 | + | for _, p := range a.parts { | |||
| 217 | + | total += len(p) | |||
| 218 | + | } | |||
| 219 | + | out := make([]byte, 0, total) | |||
| 220 | + | for _, p := range a.parts { | |||
| 221 | + | out = append(out, p...) | |||
| 222 | + | } | |||
| 223 | + | r.remove(f.Seq) | |||
| 224 | + | return out, true, nil | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | func (r *Reassembler) evictOldest() { | |||
| 228 | + | for len(r.order) >= maxReassemblyInflight { | |||
| 229 | + | seq := r.order[0] | |||
| 230 | + | r.order = r.order[1:] | |||
| 231 | + | if _, ok := r.inflight[seq]; ok { | |||
| 232 | + | delete(r.inflight, seq) | |||
| 233 | + | return | |||
| 234 | + | } | |||
| 235 | + | } | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func (r *Reassembler) remove(seq uint64) { | |||
| 239 | + | delete(r.inflight, seq) | |||
| 240 | + | for i, s := range r.order { | |||
| 241 | + | if s == seq { | |||
| 242 | + | copy(r.order[i:], r.order[i+1:]) | |||
| 243 | + | r.order = r.order[:len(r.order)-1] | |||
| 244 | + | return | |||
| 245 | + | } | |||
| 246 | + | } | |||
| 247 | + | } | |||
A pkg/dgram/dgram_test.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package dgram | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "errors" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func testCodec(t *testing.T) *Codec { | |
| 10 | + | t.Helper() | |
| 11 | + | key := bytes.Repeat([]byte{7}, KeySize) | |
| 12 | + | c, err := NewCodec(key) | |
| 13 | + | if err != nil { | |
| 14 | + | t.Fatal(err) | |
| 15 | + | } | |
| 16 | + | return c | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func TestCodecOverheadMatchesSealedPacketGrowth(t *testing.T) { | |
| 20 | + | c := testCodec(t) | |
| 21 | + | plaintext := []byte("hello") | |
| 22 | + | pkt := c.Seal(1, 1, plaintext, nil) | |
| 23 | + | if got, want := len(pkt), HeaderSize+len(plaintext)+c.Overhead(); got != want { | |
| 24 | + | t.Fatalf("sealed packet length = %d, want %d", got, want) | |
| 25 | + | } | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestSealOpen(t *testing.T) { | |
| 29 | + | c := testCodec(t) | |
| 30 | + | pkt := c.Seal(1, 42, []byte("hello"), []byte("aad")) | |
| 31 | + | ctr, pt, err := c.Open(pkt, 1, []byte("aad"), NewReplayWindow()) | |
| 32 | + | if err != nil { | |
| 33 | + | t.Fatal(err) | |
| 34 | + | } | |
| 35 | + | if ctr != 42 || string(pt) != "hello" { | |
| 36 | + | t.Fatalf("ctr=%d pt=%q", ctr, pt) | |
| 37 | + | } | |
| 38 | + | if _, _, err := c.Open(pkt, 2, []byte("aad"), nil); !errors.Is(err, ErrDirection) { | |
| 39 | + | t.Fatalf("wrong direction err=%v", err) | |
| 40 | + | } | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func TestReplayReject(t *testing.T) { | |
| 44 | + | c := testCodec(t) | |
| 45 | + | rw := NewReplayWindow() | |
| 46 | + | pkt := c.Seal(1, 1, []byte("x"), nil) | |
| 47 | + | if _, _, err := c.Open(pkt, 1, nil, rw); err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | if _, _, err := c.Open(pkt, 1, nil, rw); !errors.Is(err, ErrReplay) { | |
| 51 | + | t.Fatalf("err=%v", err) | |
| 52 | + | } | |
| 53 | + | old := c.Seal(1, 0, []byte("old"), nil) | |
| 54 | + | if _, _, err := c.Open(old, 1, nil, rw); err != nil { | |
| 55 | + | t.Fatalf("within window should pass: %v", err) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func TestFragmentation(t *testing.T) { | |
| 60 | + | payload := bytes.Repeat([]byte("abcdef"), 400) | |
| 61 | + | frags, err := FragmentPayload(9, payload, 100) | |
| 62 | + | if err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | if len(frags) < 2 { | |
| 66 | + | t.Fatalf("got %d fragments", len(frags)) | |
| 67 | + | } | |
| 68 | + | for _, f := range frags { | |
| 69 | + | b, err := MarshalFragment(f) | |
| 70 | + | if err != nil { | |
| 71 | + | t.Fatal(err) | |
| 72 | + | } | |
| 73 | + | g, err := UnmarshalFragment(b) | |
| 74 | + | if err != nil { | |
| 75 | + | t.Fatal(err) | |
| 76 | + | } | |
| 77 | + | if g.Seq != f.Seq || g.Index != f.Index || !bytes.Equal(g.Data, f.Data) { | |
| 78 | + | t.Fatalf("bad roundtrip") | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func TestReassemblyDisorderDuplicates(t *testing.T) { | |
| 84 | + | payload := []byte("abcdefghijklmnopqrstuvwxyz") | |
| 85 | + | frags, err := FragmentPayload(3, payload, 30) | |
| 86 | + | if err != nil { | |
| 87 | + | t.Fatal(err) | |
| 88 | + | } | |
| 89 | + | r := NewReassembler() | |
| 90 | + | order := []int{1, 0, 1} | |
| 91 | + | var got []byte | |
| 92 | + | var done bool | |
| 93 | + | for _, idx := range order { | |
| 94 | + | out, ok, err := r.Add(frags[idx]) | |
| 95 | + | if err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | if ok { | |
| 99 | + | got, done = out, true | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | if !done || !bytes.Equal(got, payload) { | |
| 103 | + | t.Fatalf("done=%v got=%q", done, got) | |
| 104 | + | } | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | func TestReassemblyCountMismatchRemovesStaleAssembly(t *testing.T) { | |
| 108 | + | r := NewReassembler() | |
| 109 | + | if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 2, Data: []byte("stale")}); err != nil { | |
| 110 | + | t.Fatal(err) | |
| 111 | + | } | |
| 112 | + | if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 3, Data: []byte("a")}); !errors.Is(err, ErrFragment) { | |
| 113 | + | t.Fatalf("err=%v, want ErrFragment", err) | |
| 114 | + | } | |
| 115 | + | for _, f := range []Fragment{ | |
| 116 | + | {Seq: 7, Index: 0, Count: 3, Data: []byte("a")}, | |
| 117 | + | {Seq: 7, Index: 1, Count: 3, Data: []byte("b")}, | |
| 118 | + | {Seq: 7, Index: 2, Count: 3, Data: []byte("c")}, | |
| 119 | + | } { | |
| 120 | + | out, ok, err := r.Add(f) | |
| 121 | + | if err != nil { | |
| 122 | + | t.Fatal(err) | |
| 123 | + | } | |
| 124 | + | if ok && string(out) != "abc" { | |
| 125 | + | t.Fatalf("out=%q", out) | |
| 126 | + | } | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestReassemblyInflightBounded(t *testing.T) { | |
| 131 | + | r := NewReassembler() | |
| 132 | + | for i := range uint64(maxReassemblyInflight + 10) { | |
| 133 | + | if _, _, err := r.Add(Fragment{Seq: i, Index: 0, Count: 2, Data: []byte("x")}); err != nil { | |
| 134 | + | t.Fatal(err) | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | if len(r.inflight) > maxReassemblyInflight { | |
| 138 | + | t.Fatalf("inflight=%d, want <= %d", len(r.inflight), maxReassemblyInflight) | |
| 139 | + | } | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package dgram | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "errors" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func testCodec(t *testing.T) *Codec { | |||
| 10 | + | t.Helper() | |||
| 11 | + | key := bytes.Repeat([]byte{7}, KeySize) | |||
| 12 | + | c, err := NewCodec(key) | |||
| 13 | + | if err != nil { | |||
| 14 | + | t.Fatal(err) | |||
| 15 | + | } | |||
| 16 | + | return c | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func TestCodecOverheadMatchesSealedPacketGrowth(t *testing.T) { | |||
| 20 | + | c := testCodec(t) | |||
| 21 | + | plaintext := []byte("hello") | |||
| 22 | + | pkt := c.Seal(1, 1, plaintext, nil) | |||
| 23 | + | if got, want := len(pkt), HeaderSize+len(plaintext)+c.Overhead(); got != want { | |||
| 24 | + | t.Fatalf("sealed packet length = %d, want %d", got, want) | |||
| 25 | + | } | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestSealOpen(t *testing.T) { | |||
| 29 | + | c := testCodec(t) | |||
| 30 | + | pkt := c.Seal(1, 42, []byte("hello"), []byte("aad")) | |||
| 31 | + | ctr, pt, err := c.Open(pkt, 1, []byte("aad"), NewReplayWindow()) | |||
| 32 | + | if err != nil { | |||
| 33 | + | t.Fatal(err) | |||
| 34 | + | } | |||
| 35 | + | if ctr != 42 || string(pt) != "hello" { | |||
| 36 | + | t.Fatalf("ctr=%d pt=%q", ctr, pt) | |||
| 37 | + | } | |||
| 38 | + | if _, _, err := c.Open(pkt, 2, []byte("aad"), nil); !errors.Is(err, ErrDirection) { | |||
| 39 | + | t.Fatalf("wrong direction err=%v", err) | |||
| 40 | + | } | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestReplayReject(t *testing.T) { | |||
| 44 | + | c := testCodec(t) | |||
| 45 | + | rw := NewReplayWindow() | |||
| 46 | + | pkt := c.Seal(1, 1, []byte("x"), nil) | |||
| 47 | + | if _, _, err := c.Open(pkt, 1, nil, rw); err != nil { | |||
| 48 | + | t.Fatal(err) | |||
| 49 | + | } | |||
| 50 | + | if _, _, err := c.Open(pkt, 1, nil, rw); !errors.Is(err, ErrReplay) { | |||
| 51 | + | t.Fatalf("err=%v", err) | |||
| 52 | + | } | |||
| 53 | + | old := c.Seal(1, 0, []byte("old"), nil) | |||
| 54 | + | if _, _, err := c.Open(old, 1, nil, rw); err != nil { | |||
| 55 | + | t.Fatalf("within window should pass: %v", err) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func TestFragmentation(t *testing.T) { | |||
| 60 | + | payload := bytes.Repeat([]byte("abcdef"), 400) | |||
| 61 | + | frags, err := FragmentPayload(9, payload, 100) | |||
| 62 | + | if err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | if len(frags) < 2 { | |||
| 66 | + | t.Fatalf("got %d fragments", len(frags)) | |||
| 67 | + | } | |||
| 68 | + | for _, f := range frags { | |||
| 69 | + | b, err := MarshalFragment(f) | |||
| 70 | + | if err != nil { | |||
| 71 | + | t.Fatal(err) | |||
| 72 | + | } | |||
| 73 | + | g, err := UnmarshalFragment(b) | |||
| 74 | + | if err != nil { | |||
| 75 | + | t.Fatal(err) | |||
| 76 | + | } | |||
| 77 | + | if g.Seq != f.Seq || g.Index != f.Index || !bytes.Equal(g.Data, f.Data) { | |||
| 78 | + | t.Fatalf("bad roundtrip") | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestReassemblyDisorderDuplicates(t *testing.T) { | |||
| 84 | + | payload := []byte("abcdefghijklmnopqrstuvwxyz") | |||
| 85 | + | frags, err := FragmentPayload(3, payload, 30) | |||
| 86 | + | if err != nil { | |||
| 87 | + | t.Fatal(err) | |||
| 88 | + | } | |||
| 89 | + | r := NewReassembler() | |||
| 90 | + | order := []int{1, 0, 1} | |||
| 91 | + | var got []byte | |||
| 92 | + | var done bool | |||
| 93 | + | for _, idx := range order { | |||
| 94 | + | out, ok, err := r.Add(frags[idx]) | |||
| 95 | + | if err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | if ok { | |||
| 99 | + | got, done = out, true | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | if !done || !bytes.Equal(got, payload) { | |||
| 103 | + | t.Fatalf("done=%v got=%q", done, got) | |||
| 104 | + | } | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | func TestReassemblyCountMismatchRemovesStaleAssembly(t *testing.T) { | |||
| 108 | + | r := NewReassembler() | |||
| 109 | + | if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 2, Data: []byte("stale")}); err != nil { | |||
| 110 | + | t.Fatal(err) | |||
| 111 | + | } | |||
| 112 | + | if _, _, err := r.Add(Fragment{Seq: 7, Index: 0, Count: 3, Data: []byte("a")}); !errors.Is(err, ErrFragment) { | |||
| 113 | + | t.Fatalf("err=%v, want ErrFragment", err) | |||
| 114 | + | } | |||
| 115 | + | for _, f := range []Fragment{ | |||
| 116 | + | {Seq: 7, Index: 0, Count: 3, Data: []byte("a")}, | |||
| 117 | + | {Seq: 7, Index: 1, Count: 3, Data: []byte("b")}, | |||
| 118 | + | {Seq: 7, Index: 2, Count: 3, Data: []byte("c")}, | |||
| 119 | + | } { | |||
| 120 | + | out, ok, err := r.Add(f) | |||
| 121 | + | if err != nil { | |||
| 122 | + | t.Fatal(err) | |||
| 123 | + | } | |||
| 124 | + | if ok && string(out) != "abc" { | |||
| 125 | + | t.Fatalf("out=%q", out) | |||
| 126 | + | } | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestReassemblyInflightBounded(t *testing.T) { | |||
| 131 | + | r := NewReassembler() | |||
| 132 | + | for i := range uint64(maxReassemblyInflight + 10) { | |||
| 133 | + | if _, _, err := r.Add(Fragment{Seq: i, Index: 0, Count: 2, Data: []byte("x")}); err != nil { | |||
| 134 | + | t.Fatal(err) | |||
| 135 | + | } | |||
| 136 | + | } | |||
| 137 | + | if len(r.inflight) > maxReassemblyInflight { | |||
| 138 | + | t.Fatalf("inflight=%d, want <= %d", len(r.inflight), maxReassemblyInflight) | |||
| 139 | + | } | |||
| 140 | + | } | |||
A pkg/renderer/ack_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package renderer | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestAdvanceOnAckRediffsScrollDamageUntilAcked(t *testing.T) { | |
| 9 | + | r := New(Capabilities{AdvancePolicy: AdvanceOnAck}) | |
| 10 | + | frame := NewFrame(4, 3) | |
| 11 | + | fillFrame(frame, []string{"AAAA", "BBBB", "CCCC"}) | |
| 12 | + | if out, err := r.Draw(frame, nil); err != nil { | |
| 13 | + | t.Fatal(err) | |
| 14 | + | } else if len(out) == 0 { | |
| 15 | + | t.Fatal("initial draw empty") | |
| 16 | + | } | |
| 17 | + | if err := r.Ack(frame); err != nil { | |
| 18 | + | t.Fatal(err) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | scrolled := NewFrame(4, 3) | |
| 22 | + | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |
| 23 | + | for i := 8; i < 12; i++ { | |
| 24 | + | scrolled.Cells[i] = BlankCell() | |
| 25 | + | } | |
| 26 | + | scrolled.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) | |
| 27 | + | damage := []Damage{ | |
| 28 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |
| 29 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | first, err := r.Draw(scrolled, damage) | |
| 33 | + | if err != nil { | |
| 34 | + | t.Fatal(err) | |
| 35 | + | } | |
| 36 | + | again, err := r.Draw(scrolled, damage) | |
| 37 | + | if err != nil { | |
| 38 | + | t.Fatal(err) | |
| 39 | + | } | |
| 40 | + | for name, out := range map[string][]byte{"first": first, "again": again} { | |
| 41 | + | if !strings.Contains(string(out), "\x1b[1;3r") || !strings.Contains(string(out), "N") { | |
| 42 | + | t.Fatalf("%s scroll draw did not include scroll region and exposed row: %q", name, out) | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | if err := r.Ack(scrolled); err != nil { | |
| 46 | + | t.Fatal(err) | |
| 47 | + | } | |
| 48 | + | afterAck, err := r.Draw(scrolled, nil) | |
| 49 | + | if err != nil { | |
| 50 | + | t.Fatal(err) | |
| 51 | + | } | |
| 52 | + | if len(afterAck) != 0 { | |
| 53 | + | t.Fatalf("after ack got %q", afterAck) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestAdvanceOnAckRediffsLostOutput(t *testing.T) { | |
| 58 | + | r := New(Capabilities{AdvancePolicy: AdvanceOnAck}) | |
| 59 | + | f := NewFrame(3, 1) | |
| 60 | + | fillFrame(f, []string{"abc"}) | |
| 61 | + | first, err := r.Draw(f, nil) | |
| 62 | + | if err != nil { | |
| 63 | + | t.Fatal(err) | |
| 64 | + | } | |
| 65 | + | if len(first) == 0 { | |
| 66 | + | t.Fatal("first draw empty") | |
| 67 | + | } | |
| 68 | + | again, err := r.Draw(f, nil) | |
| 69 | + | if err != nil { | |
| 70 | + | t.Fatal(err) | |
| 71 | + | } | |
| 72 | + | if len(again) == 0 { | |
| 73 | + | t.Fatal("lost unacked output was not re-diffed") | |
| 74 | + | } | |
| 75 | + | if err := r.Ack(f); err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | afterAck, err := r.Draw(f, nil) | |
| 79 | + | if err != nil { | |
| 80 | + | t.Fatal(err) | |
| 81 | + | } | |
| 82 | + | if len(afterAck) != 0 { | |
| 83 | + | t.Fatalf("after ack got %q", afterAck) | |
| 84 | + | } | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package renderer | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestAdvanceOnAckRediffsScrollDamageUntilAcked(t *testing.T) { | |||
| 9 | + | r := New(Capabilities{AdvancePolicy: AdvanceOnAck}) | |||
| 10 | + | frame := NewFrame(4, 3) | |||
| 11 | + | fillFrame(frame, []string{"AAAA", "BBBB", "CCCC"}) | |||
| 12 | + | if out, err := r.Draw(frame, nil); err != nil { | |||
| 13 | + | t.Fatal(err) | |||
| 14 | + | } else if len(out) == 0 { | |||
| 15 | + | t.Fatal("initial draw empty") | |||
| 16 | + | } | |||
| 17 | + | if err := r.Ack(frame); err != nil { | |||
| 18 | + | t.Fatal(err) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | scrolled := NewFrame(4, 3) | |||
| 22 | + | copy(scrolled.Cells[0:8], frame.Cells[4:12]) | |||
| 23 | + | for i := 8; i < 12; i++ { | |||
| 24 | + | scrolled.Cells[i] = BlankCell() | |||
| 25 | + | } | |||
| 26 | + | scrolled.Set(0, 2, Cell{Rune: 'N', Style: DefaultStyle()}) | |||
| 27 | + | damage := []Damage{ | |||
| 28 | + | {Kind: DamageScrollUp, X: 0, Y: 0, Width: 4, Height: 3, Count: 1}, | |||
| 29 | + | {Kind: DamageText, X: 0, Y: 2, Width: 4, Height: 1}, | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | first, err := r.Draw(scrolled, damage) | |||
| 33 | + | if err != nil { | |||
| 34 | + | t.Fatal(err) | |||
| 35 | + | } | |||
| 36 | + | again, err := r.Draw(scrolled, damage) | |||
| 37 | + | if err != nil { | |||
| 38 | + | t.Fatal(err) | |||
| 39 | + | } | |||
| 40 | + | for name, out := range map[string][]byte{"first": first, "again": again} { | |||
| 41 | + | if !strings.Contains(string(out), "\x1b[1;3r") || !strings.Contains(string(out), "N") { | |||
| 42 | + | t.Fatalf("%s scroll draw did not include scroll region and exposed row: %q", name, out) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | if err := r.Ack(scrolled); err != nil { | |||
| 46 | + | t.Fatal(err) | |||
| 47 | + | } | |||
| 48 | + | afterAck, err := r.Draw(scrolled, nil) | |||
| 49 | + | if err != nil { | |||
| 50 | + | t.Fatal(err) | |||
| 51 | + | } | |||
| 52 | + | if len(afterAck) != 0 { | |||
| 53 | + | t.Fatalf("after ack got %q", afterAck) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestAdvanceOnAckRediffsLostOutput(t *testing.T) { | |||
| 58 | + | r := New(Capabilities{AdvancePolicy: AdvanceOnAck}) | |||
| 59 | + | f := NewFrame(3, 1) | |||
| 60 | + | fillFrame(f, []string{"abc"}) | |||
| 61 | + | first, err := r.Draw(f, nil) | |||
| 62 | + | if err != nil { | |||
| 63 | + | t.Fatal(err) | |||
| 64 | + | } | |||
| 65 | + | if len(first) == 0 { | |||
| 66 | + | t.Fatal("first draw empty") | |||
| 67 | + | } | |||
| 68 | + | again, err := r.Draw(f, nil) | |||
| 69 | + | if err != nil { | |||
| 70 | + | t.Fatal(err) | |||
| 71 | + | } | |||
| 72 | + | if len(again) == 0 { | |||
| 73 | + | t.Fatal("lost unacked output was not re-diffed") | |||
| 74 | + | } | |||
| 75 | + | if err := r.Ack(f); err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | afterAck, err := r.Draw(f, nil) | |||
| 79 | + | if err != nil { | |||
| 80 | + | t.Fatal(err) | |||
| 81 | + | } | |||
| 82 | + | if len(afterAck) != 0 { | |||
| 83 | + | t.Fatalf("after ack got %q", afterAck) | |||
| 84 | + | } | |||
| 85 | + | } | |||
M pkg/renderer/renderer.go +40 -6
| @@ -12,8 +12,16 @@ var bufferPool = sync.Pool{ | |||
| 12 | 12 | New: func() any { return new(bytes.Buffer) }, | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | + | type AdvancePolicy uint8 | |
| 16 | + | ||
| 17 | + | const ( | |
| 18 | + | AdvanceOnSend AdvancePolicy = iota | |
| 19 | + | AdvanceOnAck | |
| 20 | + | ) | |
| 21 | + | ||
| 15 | 22 | type Capabilities struct { | |
| 16 | 23 | SynchronizedOutput bool | |
| 24 | + | AdvancePolicy AdvancePolicy | |
| 17 | 25 | } | |
| 18 | 26 | ||
| 19 | 27 | type Renderer struct { | |
| @@ -64,7 +72,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 64 | 72 | ||
| 65 | 73 | if !knownSameDimensions { | |
| 66 | 74 | r.writeFull(buf, frame, &st) | |
| 67 | - | r.replaceShadow(frame) | |
| 75 | + | r.advanceShadow(frame) | |
| 68 | 76 | if r.caps.SynchronizedOutput { | |
| 69 | 77 | buf.WriteString(SyncEndCSI) | |
| 70 | 78 | } | |
| @@ -73,7 +81,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 73 | 81 | ||
| 74 | 82 | if needsFull(damage) { | |
| 75 | 83 | r.writeDamage(buf, frame, nil, nil, &st) | |
| 76 | - | r.replaceShadow(frame) | |
| 84 | + | r.advanceShadow(frame) | |
| 77 | 85 | if r.caps.SynchronizedOutput { | |
| 78 | 86 | buf.WriteString(SyncEndCSI) | |
| 79 | 87 | } | |
| @@ -85,9 +93,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 85 | 93 | // emitScrollUp resets the SGR pen to default (matching st's initial | |
| 86 | 94 | // pen) but leaves the cursor wherever the DECSTBM restore put it — | |
| 87 | 95 | // terminal-dependent, so cursor tracking stays invalidated. | |
| 88 | - | r.applyScroll(scroll) | |
| 96 | + | if r.caps.AdvancePolicy == AdvanceOnSend { | |
| 97 | + | r.applyScroll(scroll) | |
| 98 | + | } | |
| 89 | 99 | r.writeDamage(buf, frame, damage, &scroll, &st) | |
| 90 | - | r.syncDamage(frame, damage, &scroll) | |
| 100 | + | r.advanceDamage(frame, damage, &scroll) | |
| 91 | 101 | if r.caps.SynchronizedOutput { | |
| 92 | 102 | buf.WriteString(SyncEndCSI) | |
| 93 | 103 | } | |
| @@ -99,7 +109,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 99 | 109 | // partial damage and leaving the terminal/shadow stale. | |
| 100 | 110 | if hasScrollDamage(damage) { | |
| 101 | 111 | r.writeFull(buf, frame, &st) | |
| 102 | - | r.replaceShadow(frame) | |
| 112 | + | r.advanceShadow(frame) | |
| 103 | 113 | if r.caps.SynchronizedOutput { | |
| 104 | 114 | buf.WriteString(SyncEndCSI) | |
| 105 | 115 | } | |
| @@ -107,7 +117,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||
| 107 | 117 | } | |
| 108 | 118 | ||
| 109 | 119 | r.writeDamage(buf, frame, damage, nil, &st) | |
| 110 | - | r.syncDamage(frame, damage, nil) | |
| 120 | + | r.advanceDamage(frame, damage, nil) | |
| 111 | 121 | if r.caps.SynchronizedOutput { | |
| 112 | 122 | buf.WriteString(SyncEndCSI) | |
| 113 | 123 | } | |
| @@ -208,6 +218,30 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool { | |||
| 208 | 218 | return false | |
| 209 | 219 | } | |
| 210 | 220 | ||
| 221 | + | // Ack advances an AdvanceOnAck renderer's shadow after the caller knows the | |
| 222 | + | // bytes for frame reached the terminal. It is harmless for AdvanceOnSend. | |
| 223 | + | func (r *Renderer) Ack(frame Frame) error { | |
| 224 | + | if err := frame.Validate(); err != nil { | |
| 225 | + | return err | |
| 226 | + | } | |
| 227 | + | r.replaceShadow(frame) | |
| 228 | + | return nil | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func (r *Renderer) advanceShadow(frame Frame) { | |
| 232 | + | if r.caps.AdvancePolicy == AdvanceOnAck { | |
| 233 | + | return | |
| 234 | + | } | |
| 235 | + | r.replaceShadow(frame) | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) { | |
| 239 | + | if r.caps.AdvancePolicy == AdvanceOnAck { | |
| 240 | + | return | |
| 241 | + | } | |
| 242 | + | r.syncDamage(frame, damage, scroll) | |
| 243 | + | } | |
| 244 | + | ||
| 211 | 245 | func (r *Renderer) replaceShadow(frame Frame) { | |
| 212 | 246 | r.width = frame.Width | |
| 213 | 247 | r.height = frame.Height | |
| @@ -12,8 +12,16 @@ var bufferPool = sync.Pool{ | |||||
| 12 | New: func() any { return new(bytes.Buffer) }, | 12 | New: func() any { return new(bytes.Buffer) }, | ||
| 13 | } | 13 | } | ||
| 14 | 14 | ||||
| 15 | + | type AdvancePolicy uint8 | |||
| 16 | + | ||||
| 17 | + | const ( | |||
| 18 | + | AdvanceOnSend AdvancePolicy = iota | |||
| 19 | + | AdvanceOnAck | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 15 | type Capabilities struct { | 22 | type Capabilities struct { | ||
| 16 | SynchronizedOutput bool | 23 | SynchronizedOutput bool | ||
| 24 | + | AdvancePolicy AdvancePolicy | |||
| 17 | } | 25 | } | ||
| 18 | 26 | ||||
| 19 | type Renderer struct { | 27 | type Renderer struct { | ||
| @@ -64,7 +72,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||||
| 64 | 72 | ||||
| 65 | if !knownSameDimensions { | 73 | if !knownSameDimensions { | ||
| 66 | r.writeFull(buf, frame, &st) | 74 | r.writeFull(buf, frame, &st) | ||
| 67 | - | r.replaceShadow(frame) | 75 | + | r.advanceShadow(frame) |
| 68 | if r.caps.SynchronizedOutput { | 76 | if r.caps.SynchronizedOutput { | ||
| 69 | buf.WriteString(SyncEndCSI) | 77 | buf.WriteString(SyncEndCSI) | ||
| 70 | } | 78 | } | ||
| @@ -73,7 +81,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||||
| 73 | 81 | ||||
| 74 | if needsFull(damage) { | 82 | if needsFull(damage) { | ||
| 75 | r.writeDamage(buf, frame, nil, nil, &st) | 83 | r.writeDamage(buf, frame, nil, nil, &st) | ||
| 76 | - | r.replaceShadow(frame) | 84 | + | r.advanceShadow(frame) |
| 77 | if r.caps.SynchronizedOutput { | 85 | if r.caps.SynchronizedOutput { | ||
| 78 | buf.WriteString(SyncEndCSI) | 86 | buf.WriteString(SyncEndCSI) | ||
| 79 | } | 87 | } | ||
| @@ -85,9 +93,11 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||||
| 85 | // emitScrollUp resets the SGR pen to default (matching st's initial | 93 | // emitScrollUp resets the SGR pen to default (matching st's initial | ||
| 86 | // pen) but leaves the cursor wherever the DECSTBM restore put it — | 94 | // pen) but leaves the cursor wherever the DECSTBM restore put it — | ||
| 87 | // terminal-dependent, so cursor tracking stays invalidated. | 95 | // terminal-dependent, so cursor tracking stays invalidated. | ||
| 88 | - | r.applyScroll(scroll) | 96 | + | if r.caps.AdvancePolicy == AdvanceOnSend { |
| 97 | + | r.applyScroll(scroll) | |||
| 98 | + | } | |||
| 89 | r.writeDamage(buf, frame, damage, &scroll, &st) | 99 | r.writeDamage(buf, frame, damage, &scroll, &st) | ||
| 90 | - | r.syncDamage(frame, damage, &scroll) | 100 | + | r.advanceDamage(frame, damage, &scroll) |
| 91 | if r.caps.SynchronizedOutput { | 101 | if r.caps.SynchronizedOutput { | ||
| 92 | buf.WriteString(SyncEndCSI) | 102 | buf.WriteString(SyncEndCSI) | ||
| 93 | } | 103 | } | ||
| @@ -99,7 +109,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||||
| 99 | // partial damage and leaving the terminal/shadow stale. | 109 | // partial damage and leaving the terminal/shadow stale. | ||
| 100 | if hasScrollDamage(damage) { | 110 | if hasScrollDamage(damage) { | ||
| 101 | r.writeFull(buf, frame, &st) | 111 | r.writeFull(buf, frame, &st) | ||
| 102 | - | r.replaceShadow(frame) | 112 | + | r.advanceShadow(frame) |
| 103 | if r.caps.SynchronizedOutput { | 113 | if r.caps.SynchronizedOutput { | ||
| 104 | buf.WriteString(SyncEndCSI) | 114 | buf.WriteString(SyncEndCSI) | ||
| 105 | } | 115 | } | ||
| @@ -107,7 +117,7 @@ func (r *Renderer) Draw(frame Frame, damage []Damage) ([]byte, error) { | |||||
| 107 | } | 117 | } | ||
| 108 | 118 | ||||
| 109 | r.writeDamage(buf, frame, damage, nil, &st) | 119 | r.writeDamage(buf, frame, damage, nil, &st) | ||
| 110 | - | r.syncDamage(frame, damage, nil) | 120 | + | r.advanceDamage(frame, damage, nil) |
| 111 | if r.caps.SynchronizedOutput { | 121 | if r.caps.SynchronizedOutput { | ||
| 112 | buf.WriteString(SyncEndCSI) | 122 | buf.WriteString(SyncEndCSI) | ||
| 113 | } | 123 | } | ||
| @@ -208,6 +218,30 @@ func (r *Renderer) lineDirty(frame Frame, y int) bool { | |||||
| 208 | return false | 218 | return false | ||
| 209 | } | 219 | } | ||
| 210 | 220 | ||||
| 221 | + | // Ack advances an AdvanceOnAck renderer's shadow after the caller knows the | |||
| 222 | + | // bytes for frame reached the terminal. It is harmless for AdvanceOnSend. | |||
| 223 | + | func (r *Renderer) Ack(frame Frame) error { | |||
| 224 | + | if err := frame.Validate(); err != nil { | |||
| 225 | + | return err | |||
| 226 | + | } | |||
| 227 | + | r.replaceShadow(frame) | |||
| 228 | + | return nil | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func (r *Renderer) advanceShadow(frame Frame) { | |||
| 232 | + | if r.caps.AdvancePolicy == AdvanceOnAck { | |||
| 233 | + | return | |||
| 234 | + | } | |||
| 235 | + | r.replaceShadow(frame) | |||
| 236 | + | } | |||
| 237 | + | ||||
| 238 | + | func (r *Renderer) advanceDamage(frame Frame, damage []Damage, scroll *Damage) { | |||
| 239 | + | if r.caps.AdvancePolicy == AdvanceOnAck { | |||
| 240 | + | return | |||
| 241 | + | } | |||
| 242 | + | r.syncDamage(frame, damage, scroll) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 211 | func (r *Renderer) replaceShadow(frame Frame) { | 245 | func (r *Renderer) replaceShadow(frame Frame) { | ||
| 212 | r.width = frame.Width | 246 | r.width = frame.Width | ||
| 213 | r.height = frame.Height | 247 | r.height = frame.Height | ||