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