detached 7047f01

Merge pull request #33 from bnema/feat/udp-remote-reconnect

feat(remote): add UDP-first reconnect transport
commit
7047f01f960eb188f751c6db01562c983c980a91
author
bnema · · 2026-07-07 00:27 UTC
committer
GitHub · · 2026-07-07 00:27 UTC
merge of
fd55831 + 5e9cf03 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
13 files · +1041 -84
M README.md +2 -9
@@ -120,16 +120,9 @@ vev attach user@host
120120 vev attach user@host:session
121121 ```
122122
123-Remote attach runs `ssh user@host vev _stdio ...` under the hood, so `vev` must be installed on the remote host and reachable through your normal SSH config. Omitting `:session` opens an ephemeral remote session.
123+Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.
124124
125-For faster reconnects, enable OpenSSH connection reuse:
126-
127-```sshconfig
128-Host *
129- ControlMaster auto
130- ControlPath ~/.ssh/cm-%r@%h:%p
131- ControlPersist 10m
132-```
125+Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH, but it may not notice sleep or Wi-Fi loss promptly.
133126
134127 ## Terminal color
135128
@@ -120,16 +120,9 @@ vev attach user@host
120 vev attach user@host:session120 vev attach user@host:session
121 ```121 ```
122 122
123-Remote attach runs `ssh user@host vev _stdio ...` under the hood, so `vev` must be installed on the remote host and reachable through your normal SSH config. Omitting `:session` opens an ephemeral remote session.123+Remote attach uses SSH only to start a UDP proxy on the remote host; the session itself then talks directly to the remote host over UDP. `vev` must be installed on the remote host, and the host name you pass to `vev attach` must be reachable both by SSH for bootstrap and by UDP for the session transport. Omitting `:session` opens an ephemeral remote session. Named sessions can resume after temporary network disconnects, such as sleep or Wi-Fi changes.
124 124
125-For faster reconnects, enable OpenSSH connection reuse:125+Set `VEV_REMOTE_TRANSPORT=stdio` to use SSH stdio compatibility mode on networks where direct UDP is blocked or the SSH target is only reachable through jump-host or SSH-config-only routing. Stdio mode keeps all traffic inside SSH, but it may not notice sleep or Wi-Fi loss promptly.
126-
127-```sshconfig
128-Host *
129- ControlMaster auto
130- ControlPath ~/.ssh/cm-%r@%h:%p
131- ControlPersist 10m
132-```
133 126
134 ## Terminal color127 ## Terminal color
135 128
M internal/adapters/dgram/ssh_dialer.go +198 -14
@@ -1,19 +1,55 @@
11 package dgram
22
33 import (
4+ "bufio"
45 "context"
6+ "encoding/base64"
7+ "fmt"
8+ "io"
59 "log/slog"
610 "net"
11+ "os/exec"
12+ "strconv"
713 "strings"
814 "time"
915
1016 "github.com/bnema/vev/internal/adapters/sshstdio"
1117 "github.com/bnema/vev/internal/ports"
18+ pdgram "github.com/bnema/vev/pkg/dgram"
1219 )
1320
1421 const (
15- maxBootstrapStderr = 64 * 1024
16- defaultProbeTimeout = 3 * time.Second
22+ maxBootstrapStderr = 64 * 1024
23+ defaultBootstrapTimeout = 10 * time.Second
24+ defaultProbeTimeout = 3 * time.Second
25+)
26+
27+type bootstrapProcess interface {
28+ StdoutPipe() (io.ReadCloser, error)
29+ Start() error
30+ Kill() error
31+ Wait() error
32+}
33+
34+type execBootstrapProcess struct{ cmd *exec.Cmd }
35+
36+func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() }
37+func (p execBootstrapProcess) Start() error { return p.cmd.Start() }
38+func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() }
39+func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
40+
41+var (
42+ startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess {
43+ spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", session)
44+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
45+ cmd.Stderr = stderr
46+ return execBootstrapProcess{cmd: cmd}
47+ }
48+ listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) {
49+ var lc net.ListenConfig
50+ return lc.ListenPacket(ctx, "udp", ":0")
51+ }
52+ probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) }
1753 )
1854
1955 type limitedBuffer struct {
@@ -30,13 +66,14 @@ func (b *limitedBuffer) Write(p []byte) (int, error) {
3066
3167 func (b *limitedBuffer) String() string { return string(b.buf) }
3268
33-// RemoteDialer tries the authenticated datagram bootstrap first and falls back
34-// to ssh stdio whenever bootstrap, UDP resolution, or the UDP probe fails.
69+// RemoteDialer connects to the authenticated datagram bootstrap for UDP remote
70+// attach. SSH stdio is selected only through the explicit stdio transport mode.
3571 type RemoteDialer struct {
36- Target string
37- Session string
38- ProbeTimeout time.Duration
39- Log *slog.Logger
72+ Target string
73+ Session string
74+ BootstrapTimeout time.Duration
75+ ProbeTimeout time.Duration
76+ Log *slog.Logger
4077 }
4178
4279 func NewRemoteDialer(target, session string) RemoteDialer {
@@ -48,15 +85,159 @@ func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteD
4885 }
4986
5087 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
51- // The UDP bootstrap currently requires a long-lived remote proxy process. Do
52- // not select it as the app's remote attach path until that process can detach
53- // from SSH safely; stdio is the bounded fallback path.
88+ if err := ctx.Err(); err != nil {
89+ return nil, err
90+ }
91+ bootstrapTimeout := d.BootstrapTimeout
92+ if bootstrapTimeout <= 0 {
93+ bootstrapTimeout = defaultBootstrapTimeout
94+ }
95+ probeTimeout := d.ProbeTimeout
96+ if probeTimeout <= 0 {
97+ probeTimeout = defaultProbeTimeout
98+ }
99+ bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
100+ defer bootstrapCancel()
101+
102+ var stderr limitedBuffer
103+ proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr)
104+ stdout, err := proc.StdoutPipe()
105+ if err != nil {
106+ return nil, udpUnavailable("bootstrap stdout", err, &stderr)
107+ }
108+ if err := proc.Start(); err != nil {
109+ _ = stdout.Close()
110+ return nil, udpUnavailable("start bootstrap", err, &stderr)
111+ }
112+ cleanup := true
113+ waited := false
114+ defer func() {
115+ if cleanup {
116+ _ = proc.Kill()
117+ }
118+ if !waited {
119+ _ = proc.Wait()
120+ }
121+ _ = stdout.Close()
122+ }()
123+
124+ ready, err := readUDPReadyContext(bootstrapCtx, stdout)
125+ if err != nil {
126+ _ = proc.Kill()
127+ _ = proc.Wait()
128+ waited = true
129+ cleanup = false
130+ return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
131+ }
132+ if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
133+ waited = true
134+ cleanup = false
135+ return nil, udpUnavailable("wait bootstrap", err, &stderr)
136+ }
137+ waited = true
138+ cleanup = false
139+ peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port)
140+ if err != nil {
141+ return nil, udpUnavailable("resolve UDP peer", err, &stderr)
142+ }
143+ pc, err := listenUDPPacket(ctx)
144+ if err != nil {
145+ return nil, udpUnavailable("listen UDP", err, &stderr)
146+ }
147+ t, err := NewTransport(pc, peer, ready.key, 1, 2)
148+ if err != nil {
149+ _ = pc.Close()
150+ return nil, udpUnavailable("create UDP transport", err, &stderr)
151+ }
152+ probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
153+ defer cancel()
154+ if err := probeUDPTransport(probeCtx, t); err != nil {
155+ _ = t.Close()
156+ return nil, udpUnavailable("probe UDP transport", err, &stderr)
157+ }
158+ cleanup = false
159+ return t, nil
160+}
161+
162+func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
163+ type result struct {
164+ ready udpReady
165+ err error
166+ }
167+ ch := make(chan result, 1)
168+ go func() {
169+ ready, err := readUDPReady(r)
170+ ch <- result{ready: ready, err: err}
171+ }()
54172 select {
173+ case res := <-ch:
174+ return res.ready, res.err
55175 case <-ctx.Done():
56- return nil, ctx.Err()
57- default:
176+ return udpReady{}, ctx.Err()
58177 }
59- return sshstdio.DialContext(ctx, d.Target, d.Session, d.Log)
178+}
179+
180+func waitBootstrapContext(ctx context.Context, proc bootstrapProcess) error {
181+ ch := make(chan error, 1)
182+ go func() { ch <- proc.Wait() }()
183+ select {
184+ case err := <-ch:
185+ return err
186+ case <-ctx.Done():
187+ _ = proc.Kill()
188+ return ctx.Err()
189+ }
190+}
191+
192+func resolveUDPPeer(ctx context.Context, target string, port int) (*net.UDPAddr, error) {
193+ host := sshTargetHost(target)
194+ if ip := net.ParseIP(host); ip != nil {
195+ return &net.UDPAddr{IP: ip, Port: port}, nil
196+ }
197+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
198+ if err != nil {
199+ return nil, err
200+ }
201+ if len(addrs) == 0 {
202+ return nil, fmt.Errorf("no UDP peer addresses for %q", host)
203+ }
204+ return &net.UDPAddr{IP: addrs[0].IP, Zone: addrs[0].Zone, Port: port}, nil
205+}
206+
207+type udpReady struct {
208+ port int
209+ key []byte
210+}
211+
212+func readUDPReady(r io.Reader) (udpReady, error) {
213+ line, err := bufio.NewReader(r).ReadString('\n')
214+ if err != nil && line == "" {
215+ return udpReady{}, err
216+ }
217+ fields := strings.Fields(line)
218+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
219+ return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line))
220+ }
221+ port, err := strconv.Atoi(fields[1])
222+ if err != nil || port < 1 || port > 65535 {
223+ return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1])
224+ }
225+ key, err := base64.StdEncoding.DecodeString(fields[2])
226+ if err != nil {
227+ return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)
228+ }
229+ if len(key) != pdgram.KeySize {
230+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))
231+ }
232+ return udpReady{port: port, key: key}, nil
233+}
234+
235+func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
236+ msg := strings.TrimSpace(stderr.String())
237+ if msg != "" {
238+ return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)
239+ }
240+ return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w", action, err)
60241 }
61242
62243 func sshTargetHost(target string) string {
@@ -66,5 +247,8 @@ func sshTargetHost(target string) string {
66247 if h, _, err := net.SplitHostPort(target); err == nil {
67248 return h
68249 }
250+ if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
251+ return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
252+ }
69253 return target
70254 }
@@ -1,19 +1,55 @@
1 package dgram1 package dgram
2 2
3 import (3 import (
4+ "bufio"
4 "context"5 "context"
6+ "encoding/base64"
7+ "fmt"
8+ "io"
5 "log/slog"9 "log/slog"
6 "net"10 "net"
11+ "os/exec"
12+ "strconv"
7 "strings"13 "strings"
8 "time"14 "time"
9 15
10 "github.com/bnema/vev/internal/adapters/sshstdio"16 "github.com/bnema/vev/internal/adapters/sshstdio"
11 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18+ pdgram "github.com/bnema/vev/pkg/dgram"
12 )19 )
13 20
14 const (21 const (
15- maxBootstrapStderr = 64 * 102422+ maxBootstrapStderr = 64 * 1024
16- defaultProbeTimeout = 3 * time.Second23+ defaultBootstrapTimeout = 10 * time.Second
24+ defaultProbeTimeout = 3 * time.Second
25+)
26+
27+type bootstrapProcess interface {
28+ StdoutPipe() (io.ReadCloser, error)
29+ Start() error
30+ Kill() error
31+ Wait() error
32+}
33+
34+type execBootstrapProcess struct{ cmd *exec.Cmd }
35+
36+func (p execBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.cmd.StdoutPipe() }
37+func (p execBootstrapProcess) Start() error { return p.cmd.Start() }
38+func (p execBootstrapProcess) Kill() error { return p.cmd.Process.Kill() }
39+func (p execBootstrapProcess) Wait() error { return p.cmd.Wait() }
40+
41+var (
42+ startUDPBootstrap = func(ctx context.Context, target, session string, stderr io.Writer) bootstrapProcess {
43+ spec := sshstdio.BuildCommandForMode(target, "_udp-bootstrap", session)
44+ cmd := exec.CommandContext(ctx, spec.Path, spec.Args...)
45+ cmd.Stderr = stderr
46+ return execBootstrapProcess{cmd: cmd}
47+ }
48+ listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) {
49+ var lc net.ListenConfig
50+ return lc.ListenPacket(ctx, "udp", ":0")
51+ }
52+ probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) }
17 )53 )
18 54
19 type limitedBuffer struct {55 type limitedBuffer struct {
@@ -30,13 +66,14 @@ func (b *limitedBuffer) Write(p []byte) (int, error) {
30 66
31 func (b *limitedBuffer) String() string { return string(b.buf) }67 func (b *limitedBuffer) String() string { return string(b.buf) }
32 68
33-// RemoteDialer tries the authenticated datagram bootstrap first and falls back69+// RemoteDialer connects to the authenticated datagram bootstrap for UDP remote
34-// to ssh stdio whenever bootstrap, UDP resolution, or the UDP probe fails.70+// attach. SSH stdio is selected only through the explicit stdio transport mode.
35 type RemoteDialer struct {71 type RemoteDialer struct {
36- Target string72+ Target string
37- Session string73+ Session string
38- ProbeTimeout time.Duration74+ BootstrapTimeout time.Duration
39- Log *slog.Logger75+ ProbeTimeout time.Duration
76+ Log *slog.Logger
40 }77 }
41 78
42 func NewRemoteDialer(target, session string) RemoteDialer {79 func NewRemoteDialer(target, session string) RemoteDialer {
@@ -48,15 +85,159 @@ func NewRemoteDialerWithLogger(target, session string, log *slog.Logger) RemoteD
48 }85 }
49 86
50 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {87 func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
51- // The UDP bootstrap currently requires a long-lived remote proxy process. Do88+ if err := ctx.Err(); err != nil {
52- // not select it as the app's remote attach path until that process can detach89+ return nil, err
53- // from SSH safely; stdio is the bounded fallback path.90+ }
91+ bootstrapTimeout := d.BootstrapTimeout
92+ if bootstrapTimeout <= 0 {
93+ bootstrapTimeout = defaultBootstrapTimeout
94+ }
95+ probeTimeout := d.ProbeTimeout
96+ if probeTimeout <= 0 {
97+ probeTimeout = defaultProbeTimeout
98+ }
99+ bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
100+ defer bootstrapCancel()
101+
102+ var stderr limitedBuffer
103+ proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr)
104+ stdout, err := proc.StdoutPipe()
105+ if err != nil {
106+ return nil, udpUnavailable("bootstrap stdout", err, &stderr)
107+ }
108+ if err := proc.Start(); err != nil {
109+ _ = stdout.Close()
110+ return nil, udpUnavailable("start bootstrap", err, &stderr)
111+ }
112+ cleanup := true
113+ waited := false
114+ defer func() {
115+ if cleanup {
116+ _ = proc.Kill()
117+ }
118+ if !waited {
119+ _ = proc.Wait()
120+ }
121+ _ = stdout.Close()
122+ }()
123+
124+ ready, err := readUDPReadyContext(bootstrapCtx, stdout)
125+ if err != nil {
126+ _ = proc.Kill()
127+ _ = proc.Wait()
128+ waited = true
129+ cleanup = false
130+ return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
131+ }
132+ if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
133+ waited = true
134+ cleanup = false
135+ return nil, udpUnavailable("wait bootstrap", err, &stderr)
136+ }
137+ waited = true
138+ cleanup = false
139+ peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port)
140+ if err != nil {
141+ return nil, udpUnavailable("resolve UDP peer", err, &stderr)
142+ }
143+ pc, err := listenUDPPacket(ctx)
144+ if err != nil {
145+ return nil, udpUnavailable("listen UDP", err, &stderr)
146+ }
147+ t, err := NewTransport(pc, peer, ready.key, 1, 2)
148+ if err != nil {
149+ _ = pc.Close()
150+ return nil, udpUnavailable("create UDP transport", err, &stderr)
151+ }
152+ probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
153+ defer cancel()
154+ if err := probeUDPTransport(probeCtx, t); err != nil {
155+ _ = t.Close()
156+ return nil, udpUnavailable("probe UDP transport", err, &stderr)
157+ }
158+ cleanup = false
159+ return t, nil
160+}
161+
162+func readUDPReadyContext(ctx context.Context, r io.Reader) (udpReady, error) {
163+ type result struct {
164+ ready udpReady
165+ err error
166+ }
167+ ch := make(chan result, 1)
168+ go func() {
169+ ready, err := readUDPReady(r)
170+ ch <- result{ready: ready, err: err}
171+ }()
54 select {172 select {
173+ case res := <-ch:
174+ return res.ready, res.err
55 case <-ctx.Done():175 case <-ctx.Done():
56- return nil, ctx.Err()176+ return udpReady{}, ctx.Err()
57- default:
58 }177 }
59- return sshstdio.DialContext(ctx, d.Target, d.Session, d.Log)178+}
179+
180+func waitBootstrapContext(ctx context.Context, proc bootstrapProcess) error {
181+ ch := make(chan error, 1)
182+ go func() { ch <- proc.Wait() }()
183+ select {
184+ case err := <-ch:
185+ return err
186+ case <-ctx.Done():
187+ _ = proc.Kill()
188+ return ctx.Err()
189+ }
190+}
191+
192+func resolveUDPPeer(ctx context.Context, target string, port int) (*net.UDPAddr, error) {
193+ host := sshTargetHost(target)
194+ if ip := net.ParseIP(host); ip != nil {
195+ return &net.UDPAddr{IP: ip, Port: port}, nil
196+ }
197+ addrs, err := net.DefaultResolver.LookupIPAddr(ctx, host)
198+ if err != nil {
199+ return nil, err
200+ }
201+ if len(addrs) == 0 {
202+ return nil, fmt.Errorf("no UDP peer addresses for %q", host)
203+ }
204+ return &net.UDPAddr{IP: addrs[0].IP, Zone: addrs[0].Zone, Port: port}, nil
205+}
206+
207+type udpReady struct {
208+ port int
209+ key []byte
210+}
211+
212+func readUDPReady(r io.Reader) (udpReady, error) {
213+ line, err := bufio.NewReader(r).ReadString('\n')
214+ if err != nil && line == "" {
215+ return udpReady{}, err
216+ }
217+ fields := strings.Fields(line)
218+ if len(fields) != 3 || fields[0] != "VEV-UDP" {
219+ return udpReady{}, fmt.Errorf("malformed readiness line %q", strings.TrimSpace(line))
220+ }
221+ port, err := strconv.Atoi(fields[1])
222+ if err != nil || port < 1 || port > 65535 {
223+ return udpReady{}, fmt.Errorf("invalid UDP port %q", fields[1])
224+ }
225+ key, err := base64.StdEncoding.DecodeString(fields[2])
226+ if err != nil {
227+ return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)
228+ }
229+ if len(key) != pdgram.KeySize {
230+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))
231+ }
232+ return udpReady{port: port, key: key}, nil
233+}
234+
235+func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
236+ msg := strings.TrimSpace(stderr.String())
237+ if msg != "" {
238+ return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)
239+ }
240+ return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w", action, err)
60 }241 }
61 242
62 func sshTargetHost(target string) string {243 func sshTargetHost(target string) string {
@@ -66,5 +247,8 @@ func sshTargetHost(target string) string {
66 if h, _, err := net.SplitHostPort(target); err == nil {247 if h, _, err := net.SplitHostPort(target); err == nil {
67 return h248 return h
68 }249 }
250+ if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
251+ return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
252+ }
69 return target253 return target
70 }254 }
M internal/adapters/dgram/ssh_dialer_test.go +181 -0
@@ -1,10 +1,191 @@
11 package dgram
22
33 import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "io"
8+ "net"
49 "strings"
510 "testing"
11+ "time"
12+
13+ pdgram "github.com/bnema/vev/pkg/dgram"
614 )
715
16+type fakeBootstrapProcess struct {
17+ stdout io.ReadCloser
18+ startErr error
19+ waitErr error
20+ killed bool
21+ waited bool
22+}
23+
24+type closeTrackReadCloser struct {
25+ io.Reader
26+ closed *bool
27+}
28+
29+func (r closeTrackReadCloser) Close() error { *r.closed = true; return nil }
30+
31+func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil }
32+func (p *fakeBootstrapProcess) Start() error { return p.startErr }
33+func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
34+func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }
35+
36+func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, io.Writer) bootstrapProcess) {
37+ t.Helper()
38+ old := startUDPBootstrap
39+ startUDPBootstrap = fn
40+ t.Cleanup(func() { startUDPBootstrap = old })
41+}
42+
43+func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) {
44+ t.Helper()
45+ old := listenUDPPacket
46+ listenUDPPacket = fn
47+ t.Cleanup(func() { listenUDPPacket = old })
48+}
49+
50+func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
51+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
52+ tests := []struct {
53+ name string
54+ stdout string
55+ start error
56+ listen error
57+ want string
58+ killed bool
59+ waited bool
60+ wantStdoutClosed bool
61+ }{
62+ {name: "start failure", start: errors.New("ssh missing"), want: "vev: remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63+ {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
64+ {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
65+ {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ stdoutClosed := false
70+ proc := &fakeBootstrapProcess{stdout: closeTrackReadCloser{Reader: strings.NewReader(tt.stdout), closed: &stdoutClosed}, startErr: tt.start}
71+ if tt.name == "bootstrap wait failure" {
72+ proc.waitErr = errors.New("exit status 255")
73+ }
74+ withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })
75+ if tt.listen != nil {
76+ withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
77+ }
78+ tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background())
79+ if err == nil || !strings.Contains(err.Error(), tt.want) {
80+ t.Fatalf("Dial() error = %v, want containing %q", err, tt.want)
81+ }
82+ if tr != nil {
83+ t.Fatalf("Dial() transport = %T, want nil", tr)
84+ }
85+ if proc.killed != tt.killed {
86+ t.Fatalf("killed=%v, want %v", proc.killed, tt.killed)
87+ }
88+ if proc.waited != tt.waited {
89+ t.Fatalf("waited=%v, want %v", proc.waited, tt.waited)
90+ }
91+ if stdoutClosed != tt.wantStdoutClosed {
92+ t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed)
93+ }
94+ })
95+ }
96+}
97+
98+func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) {
99+ pc, err := listenUDPPacket(context.Background())
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ defer func() { _ = pc.Close() }()
104+ addr, ok := pc.LocalAddr().(*net.UDPAddr)
105+ if !ok {
106+ t.Fatalf("local addr = %T, want *net.UDPAddr", pc.LocalAddr())
107+ }
108+ if !addr.IP.IsUnspecified() {
109+ t.Fatalf("listen IP = %v, want unspecified wildcard", addr.IP)
110+ }
111+}
112+
113+func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
114+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
115+ proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
116+ withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess {
117+ if target != "127.0.0.1" || session != "work" {
118+ t.Fatalf("bootstrap target/session = %q/%q", target, session)
119+ }
120+ return proc
121+ })
122+ var lc net.ListenConfig
123+ pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ closed := false
128+ withListenUDP(t, func(context.Context) (net.PacketConn, error) {
129+ return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil
130+ })
131+ d := NewRemoteDialer("127.0.0.1", "work")
132+ d.ProbeTimeout = time.Nanosecond
133+ tr, err := d.Dial(context.Background())
134+ if err == nil || !strings.Contains(err.Error(), "vev: remote UDP transport unavailable: probe UDP transport") {
135+ t.Fatalf("Dial() error = %v, want probe unavailable", err)
136+ }
137+ if tr != nil {
138+ t.Fatalf("transport=%T, want nil", tr)
139+ }
140+ if !closed {
141+ t.Fatal("packet conn not closed after probe failure")
142+ }
143+ if proc.killed {
144+ t.Fatal("bootstrap process killed after it was already waited")
145+ }
146+ if !proc.waited {
147+ t.Fatal("bootstrap process not waited after probe failure")
148+ }
149+}
150+
151+type closeTrackPacketConn struct {
152+ net.PacketConn
153+ closed *bool
154+}
155+
156+func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
157+
158+func TestReadUDPReady(t *testing.T) {
159+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
160+ tests := []struct {
161+ name, line string
162+ wantPort int
163+ wantErr string
164+ }{
165+ {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
166+ {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"},
167+ {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"},
168+ {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
169+ }
170+ for _, tt := range tests {
171+ t.Run(tt.name, func(t *testing.T) {
172+ got, err := readUDPReady(strings.NewReader(tt.line))
173+ if tt.wantErr != "" {
174+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
175+ t.Fatalf("err=%v, want %q", err, tt.wantErr)
176+ }
177+ return
178+ }
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ if got.port != tt.wantPort || len(got.key) != pdgram.KeySize {
183+ t.Fatalf("ready=%+v keylen=%d", got, len(got.key))
184+ }
185+ })
186+ }
187+}
188+
8189 func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
9190 var b limitedBuffer
10191 chunk := strings.Repeat("x", maxBootstrapStderr+1024)
@@ -1,10 +1,191 @@
1 package dgram1 package dgram
2 2
3 import (3 import (
4+ "context"
5+ "encoding/base64"
6+ "errors"
7+ "io"
8+ "net"
4 "strings"9 "strings"
5 "testing"10 "testing"
11+ "time"
12+
13+ pdgram "github.com/bnema/vev/pkg/dgram"
6 )14 )
7 15
16+type fakeBootstrapProcess struct {
17+ stdout io.ReadCloser
18+ startErr error
19+ waitErr error
20+ killed bool
21+ waited bool
22+}
23+
24+type closeTrackReadCloser struct {
25+ io.Reader
26+ closed *bool
27+}
28+
29+func (r closeTrackReadCloser) Close() error { *r.closed = true; return nil }
30+
31+func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil }
32+func (p *fakeBootstrapProcess) Start() error { return p.startErr }
33+func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
34+func (p *fakeBootstrapProcess) Wait() error { p.waited = true; return p.waitErr }
35+
36+func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string, io.Writer) bootstrapProcess) {
37+ t.Helper()
38+ old := startUDPBootstrap
39+ startUDPBootstrap = fn
40+ t.Cleanup(func() { startUDPBootstrap = old })
41+}
42+
43+func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) {
44+ t.Helper()
45+ old := listenUDPPacket
46+ listenUDPPacket = fn
47+ t.Cleanup(func() { listenUDPPacket = old })
48+}
49+
50+func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
51+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
52+ tests := []struct {
53+ name string
54+ stdout string
55+ start error
56+ listen error
57+ want string
58+ killed bool
59+ waited bool
60+ wantStdoutClosed bool
61+ }{
62+ {name: "start failure", start: errors.New("ssh missing"), want: "vev: remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
63+ {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
64+ {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
65+ {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
66+ }
67+ for _, tt := range tests {
68+ t.Run(tt.name, func(t *testing.T) {
69+ stdoutClosed := false
70+ proc := &fakeBootstrapProcess{stdout: closeTrackReadCloser{Reader: strings.NewReader(tt.stdout), closed: &stdoutClosed}, startErr: tt.start}
71+ if tt.name == "bootstrap wait failure" {
72+ proc.waitErr = errors.New("exit status 255")
73+ }
74+ withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })
75+ if tt.listen != nil {
76+ withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
77+ }
78+ tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background())
79+ if err == nil || !strings.Contains(err.Error(), tt.want) {
80+ t.Fatalf("Dial() error = %v, want containing %q", err, tt.want)
81+ }
82+ if tr != nil {
83+ t.Fatalf("Dial() transport = %T, want nil", tr)
84+ }
85+ if proc.killed != tt.killed {
86+ t.Fatalf("killed=%v, want %v", proc.killed, tt.killed)
87+ }
88+ if proc.waited != tt.waited {
89+ t.Fatalf("waited=%v, want %v", proc.waited, tt.waited)
90+ }
91+ if stdoutClosed != tt.wantStdoutClosed {
92+ t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed)
93+ }
94+ })
95+ }
96+}
97+
98+func TestListenUDPPacketUsesDualStackWildcard(t *testing.T) {
99+ pc, err := listenUDPPacket(context.Background())
100+ if err != nil {
101+ t.Fatal(err)
102+ }
103+ defer func() { _ = pc.Close() }()
104+ addr, ok := pc.LocalAddr().(*net.UDPAddr)
105+ if !ok {
106+ t.Fatalf("local addr = %T, want *net.UDPAddr", pc.LocalAddr())
107+ }
108+ if !addr.IP.IsUnspecified() {
109+ t.Fatalf("listen IP = %v, want unspecified wildcard", addr.IP)
110+ }
111+}
112+
113+func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
114+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
115+ proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader("VEV-UDP 4444 " + key + "\n"))}
116+ withBootstrapStarter(t, func(_ context.Context, target, session string, _ io.Writer) bootstrapProcess {
117+ if target != "127.0.0.1" || session != "work" {
118+ t.Fatalf("bootstrap target/session = %q/%q", target, session)
119+ }
120+ return proc
121+ })
122+ var lc net.ListenConfig
123+ pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
124+ if err != nil {
125+ t.Fatal(err)
126+ }
127+ closed := false
128+ withListenUDP(t, func(context.Context) (net.PacketConn, error) {
129+ return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil
130+ })
131+ d := NewRemoteDialer("127.0.0.1", "work")
132+ d.ProbeTimeout = time.Nanosecond
133+ tr, err := d.Dial(context.Background())
134+ if err == nil || !strings.Contains(err.Error(), "vev: remote UDP transport unavailable: probe UDP transport") {
135+ t.Fatalf("Dial() error = %v, want probe unavailable", err)
136+ }
137+ if tr != nil {
138+ t.Fatalf("transport=%T, want nil", tr)
139+ }
140+ if !closed {
141+ t.Fatal("packet conn not closed after probe failure")
142+ }
143+ if proc.killed {
144+ t.Fatal("bootstrap process killed after it was already waited")
145+ }
146+ if !proc.waited {
147+ t.Fatal("bootstrap process not waited after probe failure")
148+ }
149+}
150+
151+type closeTrackPacketConn struct {
152+ net.PacketConn
153+ closed *bool
154+}
155+
156+func (c closeTrackPacketConn) Close() error { *c.closed = true; return c.PacketConn.Close() }
157+
158+func TestReadUDPReady(t *testing.T) {
159+ key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
160+ tests := []struct {
161+ name, line string
162+ wantPort int
163+ wantErr string
164+ }{
165+ {name: "valid", line: "VEV-UDP 1234 " + key + "\n", wantPort: 1234},
166+ {name: "bad prefix", line: "NOPE 1234 " + key + "\n", wantErr: "malformed readiness"},
167+ {name: "bad port", line: "VEV-UDP 99999 " + key + "\n", wantErr: "invalid UDP port"},
168+ {name: "bad key", line: "VEV-UDP 1234 !!!\n", wantErr: "invalid bootstrap key"},
169+ }
170+ for _, tt := range tests {
171+ t.Run(tt.name, func(t *testing.T) {
172+ got, err := readUDPReady(strings.NewReader(tt.line))
173+ if tt.wantErr != "" {
174+ if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
175+ t.Fatalf("err=%v, want %q", err, tt.wantErr)
176+ }
177+ return
178+ }
179+ if err != nil {
180+ t.Fatal(err)
181+ }
182+ if got.port != tt.wantPort || len(got.key) != pdgram.KeySize {
183+ t.Fatalf("ready=%+v keylen=%d", got, len(got.key))
184+ }
185+ })
186+ }
187+}
188+
8 func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {189 func TestLimitedBufferCapsBootstrapStderr(t *testing.T) {
9 var b limitedBuffer190 var b limitedBuffer
10 chunk := strings.Repeat("x", maxBootstrapStderr+1024)191 chunk := strings.Repeat("x", maxBootstrapStderr+1024)
M internal/adapters/dgram/transport.go +1 -1
@@ -127,7 +127,7 @@ func (t *Transport) Close() error {
127127 func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
128128
129129 // 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.
130+// response or for ctx to expire. It is intended for UDP bootstrap reachability checks.
131131 func (t *Transport) Probe(ctx context.Context) error {
132132 id, ch, err := t.registerProbe()
133133 if err != nil {
@@ -127,7 +127,7 @@ func (t *Transport) Close() error {
127 func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }127 func (t *Transport) Peer() net.Addr { t.mu.Lock(); defer t.mu.Unlock(); return t.peer }
128 128
129 // Probe sends an authenticated datagram and waits for the peer to authenticate a129 // 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.130+// response or for ctx to expire. It is intended for UDP bootstrap reachability checks.
131 func (t *Transport) Probe(ctx context.Context) error {131 func (t *Transport) Probe(ctx context.Context) error {
132 id, ch, err := t.registerProbe()132 id, ch, err := t.registerProbe()
133 if err != nil {133 if err != nil {
A internal/adapters/remote/remote_factory.go +45 -0
@@ -0,0 +1,45 @@
1+package remote
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+
8+ "github.com/bnema/vev/internal/adapters/dgram"
9+ "github.com/bnema/vev/internal/adapters/sshstdio"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// DialerFactory selects the requested remote transport adapter.
14+type DialerFactory struct{}
15+
16+var _ ports.RemoteDialerFactory = DialerFactory{}
17+
18+func NewDialerFactory() DialerFactory { return DialerFactory{} }
19+
20+func (DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
21+ switch mode {
22+ case ports.RemoteTransportUDP:
23+ if log != nil {
24+ log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
25+ }
26+ return dgram.NewRemoteDialerWithLogger(target, session, log), nil
27+ case ports.RemoteTransportStdio:
28+ if log != nil {
29+ log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
30+ }
31+ return stdioDialer{target: target, session: session, log: log}, nil
32+ default:
33+ return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
34+ }
35+}
36+
37+type stdioDialer struct {
38+ target string
39+ session string
40+ log *slog.Logger
41+}
42+
43+func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
44+ return sshstdio.DialContext(ctx, d.target, d.session, d.log)
45+}
@@ -0,0 +1,45 @@
1+package remote
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log/slog"
7+
8+ "github.com/bnema/vev/internal/adapters/dgram"
9+ "github.com/bnema/vev/internal/adapters/sshstdio"
10+ "github.com/bnema/vev/internal/ports"
11+)
12+
13+// DialerFactory selects the requested remote transport adapter.
14+type DialerFactory struct{}
15+
16+var _ ports.RemoteDialerFactory = DialerFactory{}
17+
18+func NewDialerFactory() DialerFactory { return DialerFactory{} }
19+
20+func (DialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
21+ switch mode {
22+ case ports.RemoteTransportUDP:
23+ if log != nil {
24+ log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
25+ }
26+ return dgram.NewRemoteDialerWithLogger(target, session, log), nil
27+ case ports.RemoteTransportStdio:
28+ if log != nil {
29+ log.Info("remote transport selected", "mode", mode, "target", target, "session", session)
30+ }
31+ return stdioDialer{target: target, session: session, log: log}, nil
32+ default:
33+ return nil, fmt.Errorf("vev: unsupported remote transport %q", mode)
34+ }
35+}
36+
37+type stdioDialer struct {
38+ target string
39+ session string
40+ log *slog.Logger
41+}
42+
43+func (d stdioDialer) Dial(ctx context.Context) (ports.Transport, error) {
44+ return sshstdio.DialContext(ctx, d.target, d.session, d.log)
45+}
A internal/adapters/remote/remote_factory_test.go +78 -0
@@ -0,0 +1,78 @@
1+package remote
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/adapters/dgram"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestDialerFactorySelectsExplicitModes(t *testing.T) {
13+ factory := NewDialerFactory()
14+
15+ tests := []struct {
16+ name string
17+ mode ports.RemoteTransportMode
18+ wantType any
19+ }{
20+ {name: "udp", mode: ports.RemoteTransportUDP, wantType: dgram.RemoteDialer{}},
21+ {name: "stdio", mode: ports.RemoteTransportStdio, wantType: stdioDialer{}},
22+ }
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ dialer, err := factory.DialerForRemote("remote.example", "work", tt.mode, nil)
26+ if err != nil {
27+ t.Fatalf("DialerForRemote() error = %v", err)
28+ }
29+ switch tt.wantType.(type) {
30+ case dgram.RemoteDialer:
31+ got, ok := dialer.(dgram.RemoteDialer)
32+ if !ok {
33+ t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})
34+ }
35+ if got.Target != "remote.example" || got.Session != "work" {
36+ t.Fatalf("dialer = %+v, want target/session copied", got)
37+ }
38+ case stdioDialer:
39+ got, ok := dialer.(stdioDialer)
40+ if !ok {
41+ t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})
42+ }
43+ if got.target != "remote.example" || got.session != "work" {
44+ t.Fatalf("dialer = %+v, want target/session copied", got)
45+ }
46+ }
47+ })
48+ }
49+}
50+
51+func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
52+ factory := NewDialerFactory()
53+
54+ dialer, err := factory.DialerForRemote("remote.example", "work", ports.RemoteTransportMode("serial"), nil)
55+ if err == nil {
56+ t.Fatal("DialerForRemote() error = nil, want unsupported mode error")
57+ }
58+ if dialer != nil {
59+ t.Fatalf("DialerForRemote() dialer = %T, want nil", dialer)
60+ }
61+ if got, want := err.Error(), "vev: unsupported remote transport \"serial\""; got != want {
62+ t.Fatalf("error = %q, want %q", got, want)
63+ }
64+}
65+
66+func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {
67+ dialer := stdioDialer{target: "remote.example", session: "work"}
68+ ctx, cancel := context.WithCancel(context.Background())
69+ cancel()
70+
71+ transport, err := dialer.Dial(ctx)
72+ if !errors.Is(err, context.Canceled) {
73+ t.Fatalf("Dial() error = %v, want context canceled", err)
74+ }
75+ if transport != nil {
76+ t.Fatalf("Dial() transport = %T, want nil", transport)
77+ }
78+}
@@ -0,0 +1,78 @@
1+package remote
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/bnema/vev/internal/adapters/dgram"
9+ "github.com/bnema/vev/internal/ports"
10+)
11+
12+func TestDialerFactorySelectsExplicitModes(t *testing.T) {
13+ factory := NewDialerFactory()
14+
15+ tests := []struct {
16+ name string
17+ mode ports.RemoteTransportMode
18+ wantType any
19+ }{
20+ {name: "udp", mode: ports.RemoteTransportUDP, wantType: dgram.RemoteDialer{}},
21+ {name: "stdio", mode: ports.RemoteTransportStdio, wantType: stdioDialer{}},
22+ }
23+ for _, tt := range tests {
24+ t.Run(tt.name, func(t *testing.T) {
25+ dialer, err := factory.DialerForRemote("remote.example", "work", tt.mode, nil)
26+ if err != nil {
27+ t.Fatalf("DialerForRemote() error = %v", err)
28+ }
29+ switch tt.wantType.(type) {
30+ case dgram.RemoteDialer:
31+ got, ok := dialer.(dgram.RemoteDialer)
32+ if !ok {
33+ t.Fatalf("dialer type = %T, want %T", dialer, dgram.RemoteDialer{})
34+ }
35+ if got.Target != "remote.example" || got.Session != "work" {
36+ t.Fatalf("dialer = %+v, want target/session copied", got)
37+ }
38+ case stdioDialer:
39+ got, ok := dialer.(stdioDialer)
40+ if !ok {
41+ t.Fatalf("dialer type = %T, want %T", dialer, stdioDialer{})
42+ }
43+ if got.target != "remote.example" || got.session != "work" {
44+ t.Fatalf("dialer = %+v, want target/session copied", got)
45+ }
46+ }
47+ })
48+ }
49+}
50+
51+func TestDialerFactoryRejectsUnsupportedMode(t *testing.T) {
52+ factory := NewDialerFactory()
53+
54+ dialer, err := factory.DialerForRemote("remote.example", "work", ports.RemoteTransportMode("serial"), nil)
55+ if err == nil {
56+ t.Fatal("DialerForRemote() error = nil, want unsupported mode error")
57+ }
58+ if dialer != nil {
59+ t.Fatalf("DialerForRemote() dialer = %T, want nil", dialer)
60+ }
61+ if got, want := err.Error(), "vev: unsupported remote transport \"serial\""; got != want {
62+ t.Fatalf("error = %q, want %q", got, want)
63+ }
64+}
65+
66+func TestStdioDialerUsesContextBeforeStartingSSH(t *testing.T) {
67+ dialer := stdioDialer{target: "remote.example", session: "work"}
68+ ctx, cancel := context.WithCancel(context.Background())
69+ cancel()
70+
71+ transport, err := dialer.Dial(ctx)
72+ if !errors.Is(err, context.Canceled) {
73+ t.Fatalf("Dial() error = %v, want context canceled", err)
74+ }
75+ if transport != nil {
76+ t.Fatalf("Dial() transport = %T, want nil", transport)
77+ }
78+}
M internal/app/remote.go +16 -0
@@ -11,6 +11,22 @@ func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
1111 return "", "", false
1212 }
1313 afterAt := s[at+1:]
14+ if strings.HasPrefix(afterAt, "[") {
15+ closeBracket := strings.IndexByte(afterAt, ']')
16+ if closeBracket < 0 {
17+ return "", "", false
18+ }
19+ suffix := afterAt[closeBracket+1:]
20+ switch {
21+ case suffix == "":
22+ return s, "", true
23+ case strings.HasPrefix(suffix, ":"):
24+ targetEnd := at + 1 + closeBracket + 1
25+ return s[:targetEnd], suffix[1:], true
26+ default:
27+ return "", "", false
28+ }
29+ }
1430 colon := strings.LastIndexByte(afterAt, ':')
1531 if colon < 0 {
1632 return s, "", true
@@ -11,6 +11,22 @@ func parseRemoteAttachTarget(s string) (target, session string, ok bool) {
11 return "", "", false11 return "", "", false
12 }12 }
13 afterAt := s[at+1:]13 afterAt := s[at+1:]
14+ if strings.HasPrefix(afterAt, "[") {
15+ closeBracket := strings.IndexByte(afterAt, ']')
16+ if closeBracket < 0 {
17+ return "", "", false
18+ }
19+ suffix := afterAt[closeBracket+1:]
20+ switch {
21+ case suffix == "":
22+ return s, "", true
23+ case strings.HasPrefix(suffix, ":"):
24+ targetEnd := at + 1 + closeBracket + 1
25+ return s[:targetEnd], suffix[1:], true
26+ default:
27+ return "", "", false
28+ }
29+ }
14 colon := strings.LastIndexByte(afterAt, ':')30 colon := strings.LastIndexByte(afterAt, ':')
15 if colon < 0 {31 if colon < 0 {
16 return s, "", true32 return s, "", true
M internal/app/remote_test.go +4 -0
@@ -17,6 +17,10 @@ func TestParseRemoteAttachTarget(t *testing.T) {
1717 {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},
1818 {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},
1919 {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},
20+ {name: "bracketed ipv6 no session", input: "user@[2001:db8::1]", wantTarget: "user@[2001:db8::1]", wantOK: true},
21+ {name: "bracketed ipv6 with session", input: "user@[2001:db8::1]:work", wantTarget: "user@[2001:db8::1]", wantSession: "work", wantOK: true},
22+ {name: "malformed bracketed ipv6", input: "user@[2001:db8::1"},
23+ {name: "malformed suffix after bracket", input: "user@[2001:db8::1]work"},
2024 {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},
2125 }
2226
@@ -17,6 +17,10 @@ func TestParseRemoteAttachTarget(t *testing.T) {
17 {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},17 {name: "remote no session", input: "user@example.com", wantTarget: "user@example.com", wantOK: true},
18 {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},18 {name: "remote with session", input: "user@example.com:work", wantTarget: "user@example.com", wantSession: "work", wantOK: true},
19 {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},19 {name: "remote empty session allowed", input: "user@example.com:", wantTarget: "user@example.com", wantOK: true},
20+ {name: "bracketed ipv6 no session", input: "user@[2001:db8::1]", wantTarget: "user@[2001:db8::1]", wantOK: true},
21+ {name: "bracketed ipv6 with session", input: "user@[2001:db8::1]:work", wantTarget: "user@[2001:db8::1]", wantSession: "work", wantOK: true},
22+ {name: "malformed bracketed ipv6", input: "user@[2001:db8::1"},
23+ {name: "malformed suffix after bracket", input: "user@[2001:db8::1]work"},
20 {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},24 {name: "target with at in userinfo", input: "first@user@example.com:work", wantTarget: "first@user@example.com", wantSession: "work", wantOK: true},
21 }25 }
22 26
M internal/app/run.go +134 -30
@@ -5,6 +5,7 @@
55 package app
66
77 import (
8+ "bufio"
89 "context"
910 "crypto/rand"
1011 "encoding/base64"
@@ -16,6 +17,7 @@ import (
1617 "net/http"
1718 _ "net/http/pprof"
1819 "os"
20+ "os/exec"
1921 "os/signal"
2022 "path/filepath"
2123 "syscall"
@@ -28,6 +30,7 @@ import (
2830 "github.com/bnema/vev/internal/adapters/dgram"
2931 "github.com/bnema/vev/internal/adapters/ipc"
3032 "github.com/bnema/vev/internal/adapters/pty"
33+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
3134 "github.com/bnema/vev/internal/adapters/shellcmd"
3235 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
3336 "github.com/bnema/vev/internal/adapters/sshstdio"
@@ -53,6 +56,7 @@ const (
5356 kindDaemon
5457 kindStdio
5558 kindUDPBootstrap
59+ kindUDPProxy
5660 kindHelp
5761 kindVersion
5862 )
@@ -136,6 +140,15 @@ func parseArgs(args []string) (command, error) {
136140 cmd.name = args[1]
137141 }
138142 return cmd, nil
143+ case "_udp-proxy":
144+ if len(args) > 2 {
145+ return command{}, usagef("`_udp-proxy` accepts at most one session name")
146+ }
147+ cmd := command{kind: kindUDPProxy}
148+ if len(args) == 2 {
149+ cmd.name = args[1]
150+ }
151+ return cmd, nil
139152 case "new":
140153 if len(args) < 2 || args[1] == "" {
141154 return command{}, usagef("`new` requires a session name")
@@ -210,6 +223,8 @@ func dispatch(ctx context.Context, cmd command) error {
210223 return runStdio(ctx)
211224 case kindUDPBootstrap:
212225 return runUDPBootstrap(ctx, cmd.name)
226+ case kindUDPProxy:
227+ return runUDPProxy(ctx, cmd.name, os.Stdout)
213228 case kindList:
214229 return runList(ctx)
215230 case kindKill:
@@ -326,26 +341,30 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
326341 defer func() { _ = logCloser.Close() }()
327342
328343 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
329- localDialer: defaultLocalDialer,
330- remoteDialer: defaultRemoteDialer,
331- runClient: client.Run,
332- createDetached: createDetachedLocalSession,
333- clipboard: clipboard.New(),
344+ localDialer: defaultLocalDialer,
345+ remoteDialerFactory: defaultRemoteDialerFactory(),
346+ selectedRemoteTransport: os.Getenv(envRemoteTransport),
347+ runClient: client.Run,
348+ createDetached: createDetachedLocalSession,
349+ clipboard: clipboard.New(),
334350 })
335351 }
336352
353+const envRemoteTransport = "VEV_REMOTE_TRANSPORT"
354+
337355 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
338356
339-func defaultRemoteDialer(target, session string, log *slog.Logger) ports.Dialer {
340- return dgram.NewRemoteDialerWithLogger(target, session, log)
357+func defaultRemoteDialerFactory() ports.RemoteDialerFactory {
358+ return remoteadapter.NewDialerFactory()
341359 }
342360
343361 type runAttachDeps struct {
344- attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
345- localDialer func() ports.Dialer
346- remoteDialer func(target, session string, log *slog.Logger) ports.Dialer
347- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
348- createDetached func(context.Context, string) error
362+ attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
363+ localDialer func() ports.Dialer
364+ remoteDialerFactory ports.RemoteDialerFactory
365+ selectedRemoteTransport string
366+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
367+ createDetached func(context.Context, string) error
349368 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
350369 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
351370 // Only used for the remote-dialer branch below; local attaches never
@@ -353,6 +372,17 @@ type runAttachDeps struct {
353372 clipboard ports.ClipboardReader
354373 }
355374
375+func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
376+ switch value {
377+ case "", string(ports.RemoteTransportUDP):
378+ return ports.RemoteTransportUDP, nil
379+ case string(ports.RemoteTransportStdio):
380+ return ports.RemoteTransportStdio, nil
381+ default:
382+ return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)
383+ }
384+}
385+
356386 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
357387 if activeSession != "" {
358388 if remoteTarget == "" && intent == ports.IntentNew {
@@ -366,14 +396,22 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
366396 runClient = client.Run
367397 }
368398 if remoteTarget != "" {
369- remoteDialer := deps.remoteDialer
370- if remoteDialer == nil {
371- remoteDialer = defaultRemoteDialer
399+ mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
400+ if err != nil {
401+ return err
402+ }
403+ factory := deps.remoteDialerFactory
404+ if factory == nil {
405+ factory = defaultRemoteDialerFactory()
372406 }
373407 if log != nil {
374- log.Info("attaching to remote session", "target", remoteTarget, "name", name)
408+ log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
375409 }
376- return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log)
410+ dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
411+ if err != nil {
412+ return err
413+ }
414+ return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log)
377415 }
378416
379417 attachLocal := deps.attachLocal
@@ -584,24 +622,91 @@ func runStdio(ctx context.Context) error {
584622 return proxyTransports(ctx, stdio, transport, log)
585623 }
586624
587-// runUDPBootstrap is the hidden remote-side bootstrap used before switching to
588-// authenticated datagrams. It deliberately prints only an ephemeral UDP port and
589-// random session key to stdout so callers can fall back to _stdio if setup fails.
625+var (
626+ udpBootstrapTimeout = 5 * time.Second
627+ udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd {
628+ return exec.CommandContext(ctx, exe, args...)
629+ }
630+)
631+
632+// runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
633+// line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
590634 func runUDPBootstrap(ctx context.Context, session string) error {
591- log, logCloser, err := configureLogging(logging.Stdio, false)
635+ r, w, err := os.Pipe()
592636 if err != nil {
593637 return err
594638 }
595- defer func() { _ = logCloser.Close() }()
596- log.Debug("udp bootstrap starting", "session", session)
639+ defer func() { _ = r.Close() }()
640+
641+ exe, err := os.Executable()
642+ if err != nil {
643+ _ = w.Close()
644+ return err
645+ }
646+ args := []string{"_udp-proxy"}
647+ if session != "" {
648+ args = append(args, session)
649+ }
650+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
651+ if err != nil {
652+ _ = w.Close()
653+ return err
654+ }
655+ defer func() { _ = devNull.Close() }()
656+
657+ cmd := udpProxyCommand(ctx, exe, args...)
658+ // _udp-proxy writes diagnostics through configureLogging(logging.Stdio, false);
659+ // stdio is detached here so the bootstrap SSH channel can close.
660+ cmd.Stdin = devNull
661+ cmd.Stdout = w
662+ cmd.Stderr = devNull
663+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
664+ if err := cmd.Start(); err != nil {
665+ _ = w.Close()
666+ return err
667+ }
668+ _ = w.Close()
597669
670+ readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout)
671+ defer cancel()
672+ lineCh := make(chan string, 1)
673+ errCh := make(chan error, 1)
674+ go func() {
675+ line, err := bufio.NewReader(r).ReadString('\n')
676+ if err != nil {
677+ errCh <- err
678+ return
679+ }
680+ lineCh <- line
681+ }()
598682 select {
599- case <-ctx.Done():
600- return ctx.Err()
601- default:
683+ case line := <-lineCh:
684+ if err := cmd.Process.Release(); err != nil {
685+ return err
686+ }
687+ _, err := fmt.Fprint(os.Stdout, line)
688+ return err
689+ case err := <-errCh:
690+ _ = cmd.Wait()
691+ return err
692+ case <-readyCtx.Done():
693+ _ = cmd.Process.Kill()
694+ _ = cmd.Wait()
695+ return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err())
602696 }
697+}
698+
699+// runUDPProxy is the detached long-lived remote-side datagram proxy.
700+func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
701+ log, logCloser, err := configureLogging(logging.Stdio, false)
702+ if err != nil {
703+ return err
704+ }
705+ defer func() { _ = logCloser.Close() }()
706+ log.Debug("udp proxy starting", "session", session)
707+
603708 var lc net.ListenConfig
604- conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0")
709+ conn, err := lc.ListenPacket(ctx, "udp", ":0")
605710 if err != nil {
606711 return err
607712 }
@@ -623,12 +728,11 @@ func runUDPBootstrap(ctx context.Context, session string) error {
623728
624729 addr, ok := conn.LocalAddr().(*net.UDPAddr)
625730 if !ok {
626- return errors.New("vev: udp bootstrap did not get a UDP address")
731+ return errors.New("vev: udp proxy did not get a UDP address")
627732 }
628- if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {
733+ if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {
629734 return err
630735 }
631- _ = os.Stdout.Sync()
632736
633737 var dgramTr ports.Transport = dg
634738 if session != "" {
@@ -5,6 +5,7 @@
5 package app5 package app
6 6
7 import (7 import (
8+ "bufio"
8 "context"9 "context"
9 "crypto/rand"10 "crypto/rand"
10 "encoding/base64"11 "encoding/base64"
@@ -16,6 +17,7 @@ import (
16 "net/http"17 "net/http"
17 _ "net/http/pprof"18 _ "net/http/pprof"
18 "os"19 "os"
20+ "os/exec"
19 "os/signal"21 "os/signal"
20 "path/filepath"22 "path/filepath"
21 "syscall"23 "syscall"
@@ -28,6 +30,7 @@ import (
28 "github.com/bnema/vev/internal/adapters/dgram"30 "github.com/bnema/vev/internal/adapters/dgram"
29 "github.com/bnema/vev/internal/adapters/ipc"31 "github.com/bnema/vev/internal/adapters/ipc"
30 "github.com/bnema/vev/internal/adapters/pty"32 "github.com/bnema/vev/internal/adapters/pty"
33+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
31 "github.com/bnema/vev/internal/adapters/shellcmd"34 "github.com/bnema/vev/internal/adapters/shellcmd"
32 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"35 snapshotadapter "github.com/bnema/vev/internal/adapters/snapshot"
33 "github.com/bnema/vev/internal/adapters/sshstdio"36 "github.com/bnema/vev/internal/adapters/sshstdio"
@@ -53,6 +56,7 @@ const (
53 kindDaemon56 kindDaemon
54 kindStdio57 kindStdio
55 kindUDPBootstrap58 kindUDPBootstrap
59+ kindUDPProxy
56 kindHelp60 kindHelp
57 kindVersion61 kindVersion
58 )62 )
@@ -136,6 +140,15 @@ func parseArgs(args []string) (command, error) {
136 cmd.name = args[1]140 cmd.name = args[1]
137 }141 }
138 return cmd, nil142 return cmd, nil
143+ case "_udp-proxy":
144+ if len(args) > 2 {
145+ return command{}, usagef("`_udp-proxy` accepts at most one session name")
146+ }
147+ cmd := command{kind: kindUDPProxy}
148+ if len(args) == 2 {
149+ cmd.name = args[1]
150+ }
151+ return cmd, nil
139 case "new":152 case "new":
140 if len(args) < 2 || args[1] == "" {153 if len(args) < 2 || args[1] == "" {
141 return command{}, usagef("`new` requires a session name")154 return command{}, usagef("`new` requires a session name")
@@ -210,6 +223,8 @@ func dispatch(ctx context.Context, cmd command) error {
210 return runStdio(ctx)223 return runStdio(ctx)
211 case kindUDPBootstrap:224 case kindUDPBootstrap:
212 return runUDPBootstrap(ctx, cmd.name)225 return runUDPBootstrap(ctx, cmd.name)
226+ case kindUDPProxy:
227+ return runUDPProxy(ctx, cmd.name, os.Stdout)
213 case kindList:228 case kindList:
214 return runList(ctx)229 return runList(ctx)
215 case kindKill:230 case kindKill:
@@ -326,26 +341,30 @@ func runAttach(ctx context.Context, intent uint8, name, remoteTarget string) err
326 defer func() { _ = logCloser.Close() }()341 defer func() { _ = logCloser.Close() }()
327 342
328 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{343 return runAttachWithDeps(ctx, intent, name, remoteTarget, os.Getenv("VEV"), log, runAttachDeps{
329- localDialer: defaultLocalDialer,344+ localDialer: defaultLocalDialer,
330- remoteDialer: defaultRemoteDialer,345+ remoteDialerFactory: defaultRemoteDialerFactory(),
331- runClient: client.Run,346+ selectedRemoteTransport: os.Getenv(envRemoteTransport),
332- createDetached: createDetachedLocalSession,347+ runClient: client.Run,
333- clipboard: clipboard.New(),348+ createDetached: createDetachedLocalSession,
349+ clipboard: clipboard.New(),
334 })350 })
335 }351 }
336 352
353+const envRemoteTransport = "VEV_REMOTE_TRANSPORT"
354+
337 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }355 func defaultLocalDialer() ports.Dialer { return localDaemonDialer{dir: ipc.SocketDir()} }
338 356
339-func defaultRemoteDialer(target, session string, log *slog.Logger) ports.Dialer {357+func defaultRemoteDialerFactory() ports.RemoteDialerFactory {
340- return dgram.NewRemoteDialerWithLogger(target, session, log)358+ return remoteadapter.NewDialerFactory()
341 }359 }
342 360
343 type runAttachDeps struct {361 type runAttachDeps struct {
344- attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests362+ attachLocal func(context.Context, uint8, string, *slog.Logger) error // compatibility hook for focused tests
345- localDialer func() ports.Dialer363+ localDialer func() ports.Dialer
346- remoteDialer func(target, session string, log *slog.Logger) ports.Dialer364+ remoteDialerFactory ports.RemoteDialerFactory
347- runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error365+ selectedRemoteTransport string
348- createDetached func(context.Context, string) error366+ runClient func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error
367+ createDetached func(context.Context, string) error
349 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see368 // clipboard reads a clipboard image on a remote attach's Ctrl+V (see
350 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).369 // docs/superpowers/specs/2026-07-04-clipboard-image-transfer-design.md).
351 // Only used for the remote-dialer branch below; local attaches never370 // Only used for the remote-dialer branch below; local attaches never
@@ -353,6 +372,17 @@ type runAttachDeps struct {
353 clipboard ports.ClipboardReader372 clipboard ports.ClipboardReader
354 }373 }
355 374
375+func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
376+ switch value {
377+ case "", string(ports.RemoteTransportUDP):
378+ return ports.RemoteTransportUDP, nil
379+ case string(ports.RemoteTransportStdio):
380+ return ports.RemoteTransportStdio, nil
381+ default:
382+ return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)
383+ }
384+}
385+
356 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {386 func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, activeSession string, log *slog.Logger, deps runAttachDeps) error {
357 if activeSession != "" {387 if activeSession != "" {
358 if remoteTarget == "" && intent == ports.IntentNew {388 if remoteTarget == "" && intent == ports.IntentNew {
@@ -366,14 +396,22 @@ func runAttachWithDeps(ctx context.Context, intent uint8, name, remoteTarget, ac
366 runClient = client.Run396 runClient = client.Run
367 }397 }
368 if remoteTarget != "" {398 if remoteTarget != "" {
369- remoteDialer := deps.remoteDialer399+ mode, err := remoteTransportModeFromEnv(deps.selectedRemoteTransport)
370- if remoteDialer == nil {400+ if err != nil {
371- remoteDialer = defaultRemoteDialer401+ return err
402+ }
403+ factory := deps.remoteDialerFactory
404+ if factory == nil {
405+ factory = defaultRemoteDialerFactory()
372 }406 }
373 if log != nil {407 if log != nil {
374- log.Info("attaching to remote session", "target", remoteTarget, "name", name)408+ log.Info("attaching to remote session", "target", remoteTarget, "name", name, "transport", string(mode))
375 }409 }
376- return runClient(ctx, remoteDialer(remoteTarget, name, log), term.New(), clock.New(), intent, name, true, deps.clipboard, log)410+ dialer, err := factory.DialerForRemote(remoteTarget, name, mode, log)
411+ if err != nil {
412+ return err
413+ }
414+ return runClient(ctx, dialer, term.New(), clock.New(), intent, name, true, deps.clipboard, log)
377 }415 }
378 416
379 attachLocal := deps.attachLocal417 attachLocal := deps.attachLocal
@@ -584,24 +622,91 @@ func runStdio(ctx context.Context) error {
584 return proxyTransports(ctx, stdio, transport, log)622 return proxyTransports(ctx, stdio, transport, log)
585 }623 }
586 624
587-// runUDPBootstrap is the hidden remote-side bootstrap used before switching to625+var (
588-// authenticated datagrams. It deliberately prints only an ephemeral UDP port and626+ udpBootstrapTimeout = 5 * time.Second
589-// random session key to stdout so callers can fall back to _stdio if setup fails.627+ udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd {
628+ return exec.CommandContext(ctx, exe, args...)
629+ }
630+)
631+
632+// runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
633+// line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
590 func runUDPBootstrap(ctx context.Context, session string) error {634 func runUDPBootstrap(ctx context.Context, session string) error {
591- log, logCloser, err := configureLogging(logging.Stdio, false)635+ r, w, err := os.Pipe()
592 if err != nil {636 if err != nil {
593 return err637 return err
594 }638 }
595- defer func() { _ = logCloser.Close() }()639+ defer func() { _ = r.Close() }()
596- log.Debug("udp bootstrap starting", "session", session)640+
641+ exe, err := os.Executable()
642+ if err != nil {
643+ _ = w.Close()
644+ return err
645+ }
646+ args := []string{"_udp-proxy"}
647+ if session != "" {
648+ args = append(args, session)
649+ }
650+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
651+ if err != nil {
652+ _ = w.Close()
653+ return err
654+ }
655+ defer func() { _ = devNull.Close() }()
656+
657+ cmd := udpProxyCommand(ctx, exe, args...)
658+ // _udp-proxy writes diagnostics through configureLogging(logging.Stdio, false);
659+ // stdio is detached here so the bootstrap SSH channel can close.
660+ cmd.Stdin = devNull
661+ cmd.Stdout = w
662+ cmd.Stderr = devNull
663+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
664+ if err := cmd.Start(); err != nil {
665+ _ = w.Close()
666+ return err
667+ }
668+ _ = w.Close()
597 669
670+ readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout)
671+ defer cancel()
672+ lineCh := make(chan string, 1)
673+ errCh := make(chan error, 1)
674+ go func() {
675+ line, err := bufio.NewReader(r).ReadString('\n')
676+ if err != nil {
677+ errCh <- err
678+ return
679+ }
680+ lineCh <- line
681+ }()
598 select {682 select {
599- case <-ctx.Done():683+ case line := <-lineCh:
600- return ctx.Err()684+ if err := cmd.Process.Release(); err != nil {
601- default:685+ return err
686+ }
687+ _, err := fmt.Fprint(os.Stdout, line)
688+ return err
689+ case err := <-errCh:
690+ _ = cmd.Wait()
691+ return err
692+ case <-readyCtx.Done():
693+ _ = cmd.Process.Kill()
694+ _ = cmd.Wait()
695+ return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err())
602 }696 }
697+}
698+
699+// runUDPProxy is the detached long-lived remote-side datagram proxy.
700+func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
701+ log, logCloser, err := configureLogging(logging.Stdio, false)
702+ if err != nil {
703+ return err
704+ }
705+ defer func() { _ = logCloser.Close() }()
706+ log.Debug("udp proxy starting", "session", session)
707+
603 var lc net.ListenConfig708 var lc net.ListenConfig
604- conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0")709+ conn, err := lc.ListenPacket(ctx, "udp", ":0")
605 if err != nil {710 if err != nil {
606 return err711 return err
607 }712 }
@@ -623,12 +728,11 @@ func runUDPBootstrap(ctx context.Context, session string) error {
623 728
624 addr, ok := conn.LocalAddr().(*net.UDPAddr)729 addr, ok := conn.LocalAddr().(*net.UDPAddr)
625 if !ok {730 if !ok {
626- return errors.New("vev: udp bootstrap did not get a UDP address")731+ return errors.New("vev: udp proxy did not get a UDP address")
627 }732 }
628- if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {733+ if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {
629 return err734 return err
630 }735 }
631- _ = os.Stdout.Sync()
632 736
633 var dgramTr ports.Transport = dg737 var dgramTr ports.Transport = dg
634 if session != "" {738 if session != "" {
M internal/app/run_test.go +160 -30
@@ -7,6 +7,7 @@ import (
77 "io"
88 "log/slog"
99 "os"
10+ "os/exec"
1011 "path/filepath"
1112 "strings"
1213 "testing"
@@ -16,8 +17,10 @@ import (
1617 "github.com/bnema/vev/internal/domain"
1718 "github.com/bnema/vev/internal/persist"
1819 "github.com/bnema/vev/internal/ports"
20+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1921 "github.com/bnema/vev/internal/usecase/client"
2022 "github.com/bnema/vev/internal/usecase/confirm"
23+ "github.com/stretchr/testify/mock"
2124 "github.com/stretchr/testify/require"
2225 )
2326
@@ -64,6 +67,9 @@ func TestParseArgs(t *testing.T) {
6467 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
6568 {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
6669 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
70+ {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},
71+ {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},
72+ {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
6773 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
6874 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
6975 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -436,40 +442,97 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
436442 // never actually invoked in these wiring tests.
437443 type fakeClipboardReader struct{ ports.ClipboardReader }
438444
439-func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {
440- // The local client still owns terminal capability detection for remote attach;
441- // sshstdio is only a framed transport to `vev _stdio` on the remote host.
442- var gotTarget, gotSession, gotDialer string
443- var gotRemote bool
444- var gotClipboard ports.ClipboardReader
445- clip := &fakeClipboardReader{}
446- err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
447- remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer {
448- gotTarget, gotSession = target, session
449- return namedDialer{name: "remote"}
450- },
451- clipboard: clip,
452- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
453- gotDialer = d.(namedDialer).name
454- gotRemote = remote
455- gotClipboard = clipboard
456- if intent != ports.IntentAttach || name != "work" {
457- t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
445+func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
446+ tests := []struct {
447+ name string
448+ selectedTransport string
449+ wantMode ports.RemoteTransportMode
450+ }{
451+ {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
452+ {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
453+ }
454+
455+ for _, tt := range tests {
456+ t.Run(tt.name, func(t *testing.T) {
457+ var gotDialer string
458+ var gotRemote bool
459+ var gotClipboard ports.ClipboardReader
460+ clip := &fakeClipboardReader{}
461+ factory := portsmocks.NewMockRemoteDialerFactory(t)
462+ factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
463+
464+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
465+ remoteDialerFactory: factory,
466+ selectedRemoteTransport: tt.selectedTransport,
467+ clipboard: clip,
468+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
469+ nd, ok := d.(namedDialer)
470+ if !ok {
471+ t.Fatalf("dialer type = %T, want namedDialer", d)
472+ }
473+ gotDialer = nd.name
474+ gotRemote = remote
475+ gotClipboard = clipboard
476+ if intent != ports.IntentAttach || name != "work" {
477+ t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
478+ }
479+ return nil
480+ },
481+ })
482+ if err != nil {
483+ t.Fatalf("runAttachWithDeps returned error: %v", err)
484+ }
485+ if gotDialer != "remote" {
486+ t.Fatalf("remote dialer = %q, want remote", gotDialer)
458487 }
488+ if !gotRemote {
489+ t.Fatal("remote attach must pass remote=true to runClient")
490+ }
491+ if gotClipboard != ports.ClipboardReader(clip) {
492+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
493+ }
494+ })
495+ }
496+}
497+
498+func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
499+ factory := portsmocks.NewMockRemoteDialerFactory(t)
500+ runClientCalled := false
501+
502+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
503+ remoteDialerFactory: factory,
504+ selectedRemoteTransport: "serial",
505+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
506+ runClientCalled = true
459507 return nil
460508 },
461509 })
462- if err != nil {
463- t.Fatalf("runAttachWithDeps returned error: %v", err)
510+ if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
511+ t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
464512 }
465- if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {
466- t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)
513+ if runClientCalled {
514+ t.Fatal("runClient called after invalid remote transport")
467515 }
468- if !gotRemote {
469- t.Fatal("remote attach must pass remote=true to runClient")
516+}
517+
518+func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
519+ factoryErr := errors.New("factory failed")
520+ factory := portsmocks.NewMockRemoteDialerFactory(t)
521+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
522+ runClientCalled := false
523+
524+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
525+ remoteDialerFactory: factory,
526+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
527+ runClientCalled = true
528+ return nil
529+ },
530+ })
531+ if !errors.Is(err, factoryErr) {
532+ t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
470533 }
471- if gotClipboard != ports.ClipboardReader(clip) {
472- t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
534+ if runClientCalled {
535+ t.Fatal("runClient called after remote factory error")
473536 }
474537 }
475538
@@ -477,11 +540,17 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
477540 var gotDialer string
478541 gotRemote := true
479542 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
543+ factory := portsmocks.NewMockRemoteDialerFactory(t)
480544 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
481- localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
482- clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
545+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
546+ remoteDialerFactory: factory,
547+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
483548 runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
484- gotDialer = d.(namedDialer).name
549+ nd, ok := d.(namedDialer)
550+ if !ok {
551+ t.Fatalf("dialer type = %T, want namedDialer", d)
552+ }
553+ gotDialer = nd.name
485554 gotRemote = remote
486555 gotClipboard = clipboard
487556 if intent != ports.IntentEphemeral || name != "" {
@@ -503,3 +572,64 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
503572 t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
504573 }
505574 }
575+
576+func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
577+ oldTimeout := udpBootstrapTimeout
578+ udpBootstrapTimeout = time.Second
579+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
580+ oldCommand := udpProxyCommand
581+ var gotCmd *exec.Cmd
582+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
583+ cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
584+ gotCmd = cmd
585+ return cmd
586+ }
587+ t.Cleanup(func() { udpProxyCommand = oldCommand })
588+
589+ got := captureStdout(t, func() {
590+ if err := runUDPBootstrap(context.Background(), "work"); err != nil {
591+ t.Fatalf("runUDPBootstrap() error = %v", err)
592+ }
593+ })
594+ if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
595+ t.Fatalf("readiness = %q", got)
596+ }
597+ if gotCmd.Stdin == nil || gotCmd.Stderr == nil {
598+ t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr)
599+ }
600+ if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin {
601+ t.Fatalf("proxy inherited SSH stdio handles")
602+ }
603+}
604+
605+func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
606+ oldTimeout := udpBootstrapTimeout
607+ udpBootstrapTimeout = time.Second
608+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
609+ oldCommand := udpProxyCommand
610+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
611+ return exec.Command("/bin/sh", "-c", "exit 7")
612+ }
613+ t.Cleanup(func() { udpProxyCommand = oldCommand })
614+
615+ err := runUDPBootstrap(context.Background(), "work")
616+ if !errors.Is(err, io.EOF) {
617+ t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
618+ }
619+}
620+
621+func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
622+ oldTimeout := udpBootstrapTimeout
623+ udpBootstrapTimeout = 10 * time.Millisecond
624+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
625+ oldCommand := udpProxyCommand
626+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
627+ return exec.Command("/bin/sh", "-c", "sleep 5")
628+ }
629+ t.Cleanup(func() { udpProxyCommand = oldCommand })
630+
631+ err := runUDPBootstrap(context.Background(), "work")
632+ if !errors.Is(err, context.DeadlineExceeded) {
633+ t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
634+ }
635+}
@@ -7,6 +7,7 @@ import (
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9 "os"9 "os"
10+ "os/exec"
10 "path/filepath"11 "path/filepath"
11 "strings"12 "strings"
12 "testing"13 "testing"
@@ -16,8 +17,10 @@ import (
16 "github.com/bnema/vev/internal/domain"17 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/persist"18 "github.com/bnema/vev/internal/persist"
18 "github.com/bnema/vev/internal/ports"19 "github.com/bnema/vev/internal/ports"
20+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/usecase/client"21 "github.com/bnema/vev/internal/usecase/client"
20 "github.com/bnema/vev/internal/usecase/confirm"22 "github.com/bnema/vev/internal/usecase/confirm"
23+ "github.com/stretchr/testify/mock"
21 "github.com/stretchr/testify/require"24 "github.com/stretchr/testify/require"
22 )25 )
23 26
@@ -64,6 +67,9 @@ func TestParseArgs(t *testing.T) {
64 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},67 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
65 {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},68 {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
66 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},69 {name: "stdio too many args", args: []string{"_stdio", "work", "extra"}, wantErr: true},
70+ {name: "udp bootstrap", args: []string{"_udp-bootstrap", "work"}, wantKind: kindUDPBootstrap, wantName: "work"},
71+ {name: "udp proxy", args: []string{"_udp-proxy", "work"}, wantKind: kindUDPProxy, wantName: "work"},
72+ {name: "udp proxy too many args", args: []string{"_udp-proxy", "work", "extra"}, wantErr: true},
67 {name: "help", args: []string{"--help"}, wantKind: kindHelp},73 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
68 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},74 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
69 {name: "version", args: []string{"--version"}, wantKind: kindVersion},75 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -436,40 +442,97 @@ func (d namedDialer) Dial(context.Context) (ports.Transport, error) {
436 // never actually invoked in these wiring tests.442 // never actually invoked in these wiring tests.
437 type fakeClipboardReader struct{ ports.ClipboardReader }443 type fakeClipboardReader struct{ ports.ClipboardReader }
438 444
439-func TestRunAttachWithDepsBuildsRemoteDialer(t *testing.T) {445+func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
440- // The local client still owns terminal capability detection for remote attach;446+ tests := []struct {
441- // sshstdio is only a framed transport to `vev _stdio` on the remote host.447+ name string
442- var gotTarget, gotSession, gotDialer string448+ selectedTransport string
443- var gotRemote bool449+ wantMode ports.RemoteTransportMode
444- var gotClipboard ports.ClipboardReader450+ }{
445- clip := &fakeClipboardReader{}451+ {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
446- err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{452+ {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
447- remoteDialer: func(target, session string, _ *slog.Logger) ports.Dialer {453+ }
448- gotTarget, gotSession = target, session454+
449- return namedDialer{name: "remote"}455+ for _, tt := range tests {
450- },456+ t.Run(tt.name, func(t *testing.T) {
451- clipboard: clip,457+ var gotDialer string
452- runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {458+ var gotRemote bool
453- gotDialer = d.(namedDialer).name459+ var gotClipboard ports.ClipboardReader
454- gotRemote = remote460+ clip := &fakeClipboardReader{}
455- gotClipboard = clipboard461+ factory := portsmocks.NewMockRemoteDialerFactory(t)
456- if intent != ports.IntentAttach || name != "work" {462+ factory.EXPECT().DialerForRemote("remote.example", "work", tt.wantMode, mock.Anything).Return(namedDialer{name: "remote"}, nil)
457- t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)463+
464+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
465+ remoteDialerFactory: factory,
466+ selectedRemoteTransport: tt.selectedTransport,
467+ clipboard: clip,
468+ runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
469+ nd, ok := d.(namedDialer)
470+ if !ok {
471+ t.Fatalf("dialer type = %T, want namedDialer", d)
472+ }
473+ gotDialer = nd.name
474+ gotRemote = remote
475+ gotClipboard = clipboard
476+ if intent != ports.IntentAttach || name != "work" {
477+ t.Fatalf("intent/name = %d/%q, want attach/work", intent, name)
478+ }
479+ return nil
480+ },
481+ })
482+ if err != nil {
483+ t.Fatalf("runAttachWithDeps returned error: %v", err)
484+ }
485+ if gotDialer != "remote" {
486+ t.Fatalf("remote dialer = %q, want remote", gotDialer)
458 }487 }
488+ if !gotRemote {
489+ t.Fatal("remote attach must pass remote=true to runClient")
490+ }
491+ if gotClipboard != ports.ClipboardReader(clip) {
492+ t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)
493+ }
494+ })
495+ }
496+}
497+
498+func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.T) {
499+ factory := portsmocks.NewMockRemoteDialerFactory(t)
500+ runClientCalled := false
501+
502+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
503+ remoteDialerFactory: factory,
504+ selectedRemoteTransport: "serial",
505+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
506+ runClientCalled = true
459 return nil507 return nil
460 },508 },
461 })509 })
462- if err != nil {510+ if err == nil || err.Error() != `vev: invalid remote transport "serial" (want "udp" or "stdio")` {
463- t.Fatalf("runAttachWithDeps returned error: %v", err)511+ t.Fatalf("runAttachWithDeps error = %v, want invalid transport", err)
464 }512 }
465- if gotTarget != "remote.example" || gotSession != "work" || gotDialer != "remote" {513+ if runClientCalled {
466- t.Fatalf("remote dialer wiring = target %q session %q dialer %q", gotTarget, gotSession, gotDialer)514+ t.Fatal("runClient called after invalid remote transport")
467 }515 }
468- if !gotRemote {516+}
469- t.Fatal("remote attach must pass remote=true to runClient")517+
518+func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
519+ factoryErr := errors.New("factory failed")
520+ factory := portsmocks.NewMockRemoteDialerFactory(t)
521+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
522+ runClientCalled := false
523+
524+ err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
525+ remoteDialerFactory: factory,
526+ runClient: func(context.Context, ports.Dialer, ports.Terminal, ports.Clock, uint8, string, bool, ports.ClipboardReader, *slog.Logger) error {
527+ runClientCalled = true
528+ return nil
529+ },
530+ })
531+ if !errors.Is(err, factoryErr) {
532+ t.Fatalf("runAttachWithDeps error = %v, want %v", err, factoryErr)
470 }533 }
471- if gotClipboard != ports.ClipboardReader(clip) {534+ if runClientCalled {
472- t.Fatalf("remote attach must thread the configured ClipboardReader through, got %#v", gotClipboard)535+ t.Fatal("runClient called after remote factory error")
473 }536 }
474 }537 }
475 538
@@ -477,11 +540,17 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
477 var gotDialer string540 var gotDialer string
478 gotRemote := true541 gotRemote := true
479 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})542 gotClipboard := ports.ClipboardReader(&fakeClipboardReader{})
543+ factory := portsmocks.NewMockRemoteDialerFactory(t)
480 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{544 err := runAttachWithDeps(context.Background(), ports.IntentEphemeral, "", "", "", nil, runAttachDeps{
481- localDialer: func() ports.Dialer { return namedDialer{name: "local"} },545+ localDialer: func() ports.Dialer { return namedDialer{name: "local"} },
482- clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach546+ remoteDialerFactory: factory,
547+ clipboard: &fakeClipboardReader{}, // must NOT reach runClient for a local attach
483 runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {548 runClient: func(_ context.Context, d ports.Dialer, _ ports.Terminal, _ ports.Clock, intent uint8, name string, remote bool, clipboard ports.ClipboardReader, _ *slog.Logger) error {
484- gotDialer = d.(namedDialer).name549+ nd, ok := d.(namedDialer)
550+ if !ok {
551+ t.Fatalf("dialer type = %T, want namedDialer", d)
552+ }
553+ gotDialer = nd.name
485 gotRemote = remote554 gotRemote = remote
486 gotClipboard = clipboard555 gotClipboard = clipboard
487 if intent != ports.IntentEphemeral || name != "" {556 if intent != ports.IntentEphemeral || name != "" {
@@ -503,3 +572,64 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
503 t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)572 t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
504 }573 }
505 }574 }
575+
576+func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
577+ oldTimeout := udpBootstrapTimeout
578+ udpBootstrapTimeout = time.Second
579+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
580+ oldCommand := udpProxyCommand
581+ var gotCmd *exec.Cmd
582+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
583+ cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
584+ gotCmd = cmd
585+ return cmd
586+ }
587+ t.Cleanup(func() { udpProxyCommand = oldCommand })
588+
589+ got := captureStdout(t, func() {
590+ if err := runUDPBootstrap(context.Background(), "work"); err != nil {
591+ t.Fatalf("runUDPBootstrap() error = %v", err)
592+ }
593+ })
594+ if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
595+ t.Fatalf("readiness = %q", got)
596+ }
597+ if gotCmd.Stdin == nil || gotCmd.Stderr == nil {
598+ t.Fatalf("proxy stdio = stdin %T stderr %T, want detached from SSH channels", gotCmd.Stdin, gotCmd.Stderr)
599+ }
600+ if gotCmd.Stdout == os.Stdout || gotCmd.Stderr == os.Stderr || gotCmd.Stdin == os.Stdin {
601+ t.Fatalf("proxy inherited SSH stdio handles")
602+ }
603+}
604+
605+func TestRunUDPBootstrapReturnsReadinessEOF(t *testing.T) {
606+ oldTimeout := udpBootstrapTimeout
607+ udpBootstrapTimeout = time.Second
608+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
609+ oldCommand := udpProxyCommand
610+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
611+ return exec.Command("/bin/sh", "-c", "exit 7")
612+ }
613+ t.Cleanup(func() { udpProxyCommand = oldCommand })
614+
615+ err := runUDPBootstrap(context.Background(), "work")
616+ if !errors.Is(err, io.EOF) {
617+ t.Fatalf("runUDPBootstrap() error = %v, want EOF", err)
618+ }
619+}
620+
621+func TestRunUDPBootstrapTimesOutWaitingForReadiness(t *testing.T) {
622+ oldTimeout := udpBootstrapTimeout
623+ udpBootstrapTimeout = 10 * time.Millisecond
624+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
625+ oldCommand := udpProxyCommand
626+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
627+ return exec.Command("/bin/sh", "-c", "sleep 5")
628+ }
629+ t.Cleanup(func() { udpProxyCommand = oldCommand })
630+
631+ err := runUDPBootstrap(context.Background(), "work")
632+ if !errors.Is(err, context.DeadlineExceeded) {
633+ t.Fatalf("runUDPBootstrap() error = %v, want deadline exceeded", err)
634+ }
635+}
M internal/ports/mocks/mocks.go +108 -0
@@ -7,6 +7,7 @@ package portsmocks
77 import (
88 "context"
99 "io"
10+ "log/slog"
1011 "time"
1112
1213 "github.com/bnema/vev/internal/domain"
@@ -2534,3 +2535,110 @@ func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.
25342535 _c.Call.Return(run)
25352536 return _c
25362537 }
2538+
2539+// NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2540+// The first argument is typically a *testing.T value.
2541+func NewMockRemoteDialerFactory(t interface {
2542+ mock.TestingT
2543+ Cleanup(func())
2544+}) *MockRemoteDialerFactory {
2545+ mock := &MockRemoteDialerFactory{}
2546+ mock.Mock.Test(t)
2547+
2548+ t.Cleanup(func() { mock.AssertExpectations(t) })
2549+
2550+ return mock
2551+}
2552+
2553+// MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type
2554+type MockRemoteDialerFactory struct {
2555+ mock.Mock
2556+}
2557+
2558+type MockRemoteDialerFactory_Expecter struct {
2559+ mock *mock.Mock
2560+}
2561+
2562+func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter {
2563+ return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock}
2564+}
2565+
2566+// DialerForRemote provides a mock function for the type MockRemoteDialerFactory
2567+func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
2568+ ret := _mock.Called(target, session, mode, log)
2569+
2570+ if len(ret) == 0 {
2571+ panic("no return value specified for DialerForRemote")
2572+ }
2573+
2574+ var r0 ports.Dialer
2575+ var r1 error
2576+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok {
2577+ return returnFunc(target, session, mode, log)
2578+ }
2579+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok {
2580+ r0 = returnFunc(target, session, mode, log)
2581+ } else {
2582+ if ret.Get(0) != nil {
2583+ r0 = ret.Get(0).(ports.Dialer)
2584+ }
2585+ }
2586+ if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok {
2587+ r1 = returnFunc(target, session, mode, log)
2588+ } else {
2589+ r1 = ret.Error(1)
2590+ }
2591+ return r0, r1
2592+}
2593+
2594+// MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote'
2595+type MockRemoteDialerFactory_DialerForRemote_Call struct {
2596+ *mock.Call
2597+}
2598+
2599+// DialerForRemote is a helper method to define mock.On call
2600+// - target string
2601+// - session string
2602+// - mode ports.RemoteTransportMode
2603+// - log *slog.Logger
2604+func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call {
2605+ return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)}
2606+}
2607+
2608+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call {
2609+ _c.Call.Run(func(args mock.Arguments) {
2610+ var arg0 string
2611+ if args[0] != nil {
2612+ arg0 = args[0].(string)
2613+ }
2614+ var arg1 string
2615+ if args[1] != nil {
2616+ arg1 = args[1].(string)
2617+ }
2618+ var arg2 ports.RemoteTransportMode
2619+ if args[2] != nil {
2620+ arg2 = args[2].(ports.RemoteTransportMode)
2621+ }
2622+ var arg3 *slog.Logger
2623+ if args[3] != nil {
2624+ arg3 = args[3].(*slog.Logger)
2625+ }
2626+ run(
2627+ arg0,
2628+ arg1,
2629+ arg2,
2630+ arg3,
2631+ )
2632+ })
2633+ return _c
2634+}
2635+
2636+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call {
2637+ _c.Call.Return(dialer, err)
2638+ return _c
2639+}
2640+
2641+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call {
2642+ _c.Call.Return(run)
2643+ return _c
2644+}
@@ -7,6 +7,7 @@ package portsmocks
7 import (7 import (
8 "context"8 "context"
9 "io"9 "io"
10+ "log/slog"
10 "time"11 "time"
11 12
12 "github.com/bnema/vev/internal/domain"13 "github.com/bnema/vev/internal/domain"
@@ -2534,3 +2535,110 @@ func (_c *MockClipboardReader_ReadImage_Call) RunAndReturn(run func(ctx context.
2534 _c.Call.Return(run)2535 _c.Call.Return(run)
2535 return _c2536 return _c
2536 }2537 }
2538+
2539+// NewMockRemoteDialerFactory creates a new instance of MockRemoteDialerFactory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2540+// The first argument is typically a *testing.T value.
2541+func NewMockRemoteDialerFactory(t interface {
2542+ mock.TestingT
2543+ Cleanup(func())
2544+}) *MockRemoteDialerFactory {
2545+ mock := &MockRemoteDialerFactory{}
2546+ mock.Mock.Test(t)
2547+
2548+ t.Cleanup(func() { mock.AssertExpectations(t) })
2549+
2550+ return mock
2551+}
2552+
2553+// MockRemoteDialerFactory is an autogenerated mock type for the RemoteDialerFactory type
2554+type MockRemoteDialerFactory struct {
2555+ mock.Mock
2556+}
2557+
2558+type MockRemoteDialerFactory_Expecter struct {
2559+ mock *mock.Mock
2560+}
2561+
2562+func (_m *MockRemoteDialerFactory) EXPECT() *MockRemoteDialerFactory_Expecter {
2563+ return &MockRemoteDialerFactory_Expecter{mock: &_m.Mock}
2564+}
2565+
2566+// DialerForRemote provides a mock function for the type MockRemoteDialerFactory
2567+func (_mock *MockRemoteDialerFactory) DialerForRemote(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error) {
2568+ ret := _mock.Called(target, session, mode, log)
2569+
2570+ if len(ret) == 0 {
2571+ panic("no return value specified for DialerForRemote")
2572+ }
2573+
2574+ var r0 ports.Dialer
2575+ var r1 error
2576+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) (ports.Dialer, error)); ok {
2577+ return returnFunc(target, session, mode, log)
2578+ }
2579+ if returnFunc, ok := ret.Get(0).(func(string, string, ports.RemoteTransportMode, *slog.Logger) ports.Dialer); ok {
2580+ r0 = returnFunc(target, session, mode, log)
2581+ } else {
2582+ if ret.Get(0) != nil {
2583+ r0 = ret.Get(0).(ports.Dialer)
2584+ }
2585+ }
2586+ if returnFunc, ok := ret.Get(1).(func(string, string, ports.RemoteTransportMode, *slog.Logger) error); ok {
2587+ r1 = returnFunc(target, session, mode, log)
2588+ } else {
2589+ r1 = ret.Error(1)
2590+ }
2591+ return r0, r1
2592+}
2593+
2594+// MockRemoteDialerFactory_DialerForRemote_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DialerForRemote'
2595+type MockRemoteDialerFactory_DialerForRemote_Call struct {
2596+ *mock.Call
2597+}
2598+
2599+// DialerForRemote is a helper method to define mock.On call
2600+// - target string
2601+// - session string
2602+// - mode ports.RemoteTransportMode
2603+// - log *slog.Logger
2604+func (_e *MockRemoteDialerFactory_Expecter) DialerForRemote(target any, session any, mode any, log any) *MockRemoteDialerFactory_DialerForRemote_Call {
2605+ return &MockRemoteDialerFactory_DialerForRemote_Call{Call: _e.mock.On("DialerForRemote", target, session, mode, log)}
2606+}
2607+
2608+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Run(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger)) *MockRemoteDialerFactory_DialerForRemote_Call {
2609+ _c.Call.Run(func(args mock.Arguments) {
2610+ var arg0 string
2611+ if args[0] != nil {
2612+ arg0 = args[0].(string)
2613+ }
2614+ var arg1 string
2615+ if args[1] != nil {
2616+ arg1 = args[1].(string)
2617+ }
2618+ var arg2 ports.RemoteTransportMode
2619+ if args[2] != nil {
2620+ arg2 = args[2].(ports.RemoteTransportMode)
2621+ }
2622+ var arg3 *slog.Logger
2623+ if args[3] != nil {
2624+ arg3 = args[3].(*slog.Logger)
2625+ }
2626+ run(
2627+ arg0,
2628+ arg1,
2629+ arg2,
2630+ arg3,
2631+ )
2632+ })
2633+ return _c
2634+}
2635+
2636+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) Return(dialer ports.Dialer, err error) *MockRemoteDialerFactory_DialerForRemote_Call {
2637+ _c.Call.Return(dialer, err)
2638+ return _c
2639+}
2640+
2641+func (_c *MockRemoteDialerFactory_DialerForRemote_Call) RunAndReturn(run func(target string, session string, mode ports.RemoteTransportMode, log *slog.Logger) (ports.Dialer, error)) *MockRemoteDialerFactory_DialerForRemote_Call {
2642+ _c.Call.Return(run)
2643+ return _c
2644+}
A internal/ports/remote.go +17 -0
@@ -0,0 +1,17 @@
1+package ports
2+
3+import "log/slog"
4+
5+// RemoteTransportMode selects the transport used for remote attach after CLI parsing.
6+type RemoteTransportMode string
7+
8+const (
9+ RemoteTransportUDP RemoteTransportMode = "udp"
10+ RemoteTransportStdio RemoteTransportMode = "stdio"
11+)
12+
13+// RemoteDialerFactory builds the transport-specific dialer for a remote target.
14+// Implementations live in adapters; app wiring consumes this interface.
15+type RemoteDialerFactory interface {
16+ DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
17+}
@@ -0,0 +1,17 @@
1+package ports
2+
3+import "log/slog"
4+
5+// RemoteTransportMode selects the transport used for remote attach after CLI parsing.
6+type RemoteTransportMode string
7+
8+const (
9+ RemoteTransportUDP RemoteTransportMode = "udp"
10+ RemoteTransportStdio RemoteTransportMode = "stdio"
11+)
12+
13+// RemoteDialerFactory builds the transport-specific dialer for a remote target.
14+// Implementations live in adapters; app wiring consumes this interface.
15+type RemoteDialerFactory interface {
16+ DialerForRemote(target string, session string, mode RemoteTransportMode, log *slog.Logger) (Dialer, error)
17+}
M internal/usecase/client/reconnect_toast_test.go +97 -0
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "fmt"
78 "io"
89 "log/slog"
910 "strings"
@@ -75,6 +76,54 @@ type reconnectToastRecv struct {
7576 err error
7677 }
7778
79+type reconnectToastSequenceDialer struct {
80+ transports []ports.Transport
81+ calls int
82+}
83+
84+func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, error) {
85+ if d.calls >= len(d.transports) {
86+ return nil, io.EOF
87+ }
88+ tr := d.transports[d.calls]
89+ d.calls++
90+ return tr, nil
91+}
92+
93+type reconnectToastRecordingTransport struct {
94+ recvs []reconnectToastRecv
95+ sends []ports.Frame
96+ closed bool
97+}
98+
99+func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {
100+ t.sends = append(t.sends, f)
101+ return nil
102+}
103+
104+func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
105+ if len(t.recvs) == 0 {
106+ return ports.Frame{}, io.EOF
107+ }
108+ recv := t.recvs[0]
109+ t.recvs = t.recvs[1:]
110+ return recv.frame, recv.err
111+}
112+
113+func (t *reconnectToastRecordingTransport) Close() error {
114+ t.closed = true
115+ return nil
116+}
117+
118+func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {
119+ t.Helper()
120+ require.NotEmpty(t, tr.sends)
121+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
122+ hello, err := ports.UnmarshalHello(tr.sends[0].Payload)
123+ require.NoError(t, err)
124+ return hello
125+}
126+
78127 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
79128 var out bytes.Buffer
80129 size := domain.Size{Cols: 80, Rows: 24}
@@ -132,6 +181,54 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
132181 require.True(t, <-done)
133182 }
134183
184+func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
185+ linkDead := errors.New("remote link dead")
186+ wrappedLinkDead := errors.Join(
187+ fmt.Errorf("remote transport receive failed: %w", io.EOF),
188+ linkDead,
189+ )
190+ oldSleep := reconnectSleep
191+ oldSleepWithResize := reconnectSleepWithResize
192+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
193+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
194+ return true
195+ }
196+ defer func() {
197+ reconnectSleep = oldSleep
198+ reconnectSleepWithResize = oldSleepWithResize
199+ }()
200+
201+ term := newReconnectToastTerminalHarness(t)
202+ defer term.closeInput()
203+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
204+ {frame: reconnectToastWelcome(44)},
205+ {err: wrappedLinkDead},
206+ }}
207+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
208+ {frame: reconnectToastWelcome(55)},
209+ {frame: reconnectToastDetach(ports.ReasonDetach)},
210+ }}
211+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
212+
213+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
214+ require.NoError(t, err)
215+ require.True(t, tr1.closed)
216+ require.True(t, tr2.closed)
217+ require.Equal(t, 2, dialer.calls)
218+
219+ firstHello := reconnectToastHelloFromSend(t, tr1)
220+ resumeHello := reconnectToastHelloFromSend(t, tr2)
221+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
222+ require.Zero(t, firstHello.ResumeToken)
223+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
224+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
225+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
226+
227+ out := term.out.String()
228+ require.Contains(t, out, reconnectToastMessage)
229+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
230+}
231+
135232 func TestRemoteReconnectToastLifecycle(t *testing.T) {
136233 tests := []struct {
137234 name string
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "fmt"
7 "io"8 "io"
8 "log/slog"9 "log/slog"
9 "strings"10 "strings"
@@ -75,6 +76,54 @@ type reconnectToastRecv struct {
75 err error76 err error
76 }77 }
77 78
79+type reconnectToastSequenceDialer struct {
80+ transports []ports.Transport
81+ calls int
82+}
83+
84+func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, error) {
85+ if d.calls >= len(d.transports) {
86+ return nil, io.EOF
87+ }
88+ tr := d.transports[d.calls]
89+ d.calls++
90+ return tr, nil
91+}
92+
93+type reconnectToastRecordingTransport struct {
94+ recvs []reconnectToastRecv
95+ sends []ports.Frame
96+ closed bool
97+}
98+
99+func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {
100+ t.sends = append(t.sends, f)
101+ return nil
102+}
103+
104+func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
105+ if len(t.recvs) == 0 {
106+ return ports.Frame{}, io.EOF
107+ }
108+ recv := t.recvs[0]
109+ t.recvs = t.recvs[1:]
110+ return recv.frame, recv.err
111+}
112+
113+func (t *reconnectToastRecordingTransport) Close() error {
114+ t.closed = true
115+ return nil
116+}
117+
118+func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {
119+ t.Helper()
120+ require.NotEmpty(t, tr.sends)
121+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
122+ hello, err := ports.UnmarshalHello(tr.sends[0].Payload)
123+ require.NoError(t, err)
124+ return hello
125+}
126+
78 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {127 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
79 var out bytes.Buffer128 var out bytes.Buffer
80 size := domain.Size{Cols: 80, Rows: 24}129 size := domain.Size{Cols: 80, Rows: 24}
@@ -132,6 +181,54 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
132 require.True(t, <-done)181 require.True(t, <-done)
133 }182 }
134 183
184+func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
185+ linkDead := errors.New("remote link dead")
186+ wrappedLinkDead := errors.Join(
187+ fmt.Errorf("remote transport receive failed: %w", io.EOF),
188+ linkDead,
189+ )
190+ oldSleep := reconnectSleep
191+ oldSleepWithResize := reconnectSleepWithResize
192+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
193+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
194+ return true
195+ }
196+ defer func() {
197+ reconnectSleep = oldSleep
198+ reconnectSleepWithResize = oldSleepWithResize
199+ }()
200+
201+ term := newReconnectToastTerminalHarness(t)
202+ defer term.closeInput()
203+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
204+ {frame: reconnectToastWelcome(44)},
205+ {err: wrappedLinkDead},
206+ }}
207+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
208+ {frame: reconnectToastWelcome(55)},
209+ {frame: reconnectToastDetach(ports.ReasonDetach)},
210+ }}
211+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
212+
213+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
214+ require.NoError(t, err)
215+ require.True(t, tr1.closed)
216+ require.True(t, tr2.closed)
217+ require.Equal(t, 2, dialer.calls)
218+
219+ firstHello := reconnectToastHelloFromSend(t, tr1)
220+ resumeHello := reconnectToastHelloFromSend(t, tr2)
221+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
222+ require.Zero(t, firstHello.ResumeToken)
223+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
224+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
225+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
226+
227+ out := term.out.String()
228+ require.Contains(t, out, reconnectToastMessage)
229+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
230+}
231+
135 func TestRemoteReconnectToastLifecycle(t *testing.T) {232 func TestRemoteReconnectToastLifecycle(t *testing.T) {
136 tests := []struct {233 tests := []struct {
137 name string234 name string

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close