detached 283db0f

feat(remote): detach udp proxy from ssh bootstrap

commit
283db0fdafb1d585811f261c7079db4b1a430dcb
author
brice · · 2026-07-06 21:13 UTC
parent
380112a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +132 -19
M internal/adapters/dgram/ssh_dialer.go +14 -3
@@ -15,6 +15,7 @@ import (
1515
1616 "github.com/bnema/vev/internal/adapters/sshstdio"
1717 "github.com/bnema/vev/internal/ports"
18+ pdgram "github.com/bnema/vev/pkg/dgram"
1819 )
1920
2021 const (
@@ -41,7 +42,11 @@ var (
4142 cmd.Stderr = stderr
4243 return execBootstrapProcess{cmd: cmd}
4344 }
44- listenUDPPacket = func() (net.PacketConn, error) { return net.ListenPacket("udp", ":0") }
45+ listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) {
46+ var lc net.ListenConfig
47+ return lc.ListenPacket(ctx, "udp", ":0")
48+ }
49+ probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) }
4550 )
4651
4752 type limitedBuffer struct {
@@ -108,7 +113,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
108113 if err != nil {
109114 return nil, udpUnavailable("resolve UDP peer", err, &stderr)
110115 }
111- pc, err := listenUDPPacket()
116+ pc, err := listenUDPPacket(ctx)
112117 if err != nil {
113118 return nil, udpUnavailable("listen UDP", err, &stderr)
114119 }
@@ -119,7 +124,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
119124 }
120125 probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
121126 defer cancel()
122- if err := t.Probe(probeCtx); err != nil {
127+ if err := probeUDPTransport(probeCtx, t); err != nil {
123128 _ = t.Close()
124129 return nil, udpUnavailable("probe UDP transport", err, &stderr)
125130 }
@@ -149,6 +154,9 @@ func readUDPReady(r io.Reader) (udpReady, error) {
149154 if err != nil {
150155 return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)
151156 }
157+ if len(key) != pdgram.KeySize {
158+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))
159+ }
152160 return udpReady{port: port, key: key}, nil
153161 }
154162
@@ -167,5 +175,8 @@ func sshTargetHost(target string) string {
167175 if h, _, err := net.SplitHostPort(target); err == nil {
168176 return h
169177 }
178+ if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
179+ return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
180+ }
170181 return target
171182 }
@@ -15,6 +15,7 @@ import (
15 15
16 "github.com/bnema/vev/internal/adapters/sshstdio"16 "github.com/bnema/vev/internal/adapters/sshstdio"
17 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18+ pdgram "github.com/bnema/vev/pkg/dgram"
18 )19 )
19 20
20 const (21 const (
@@ -41,7 +42,11 @@ var (
41 cmd.Stderr = stderr42 cmd.Stderr = stderr
42 return execBootstrapProcess{cmd: cmd}43 return execBootstrapProcess{cmd: cmd}
43 }44 }
44- listenUDPPacket = func() (net.PacketConn, error) { return net.ListenPacket("udp", ":0") }45+ listenUDPPacket = func(ctx context.Context) (net.PacketConn, error) {
46+ var lc net.ListenConfig
47+ return lc.ListenPacket(ctx, "udp", ":0")
48+ }
49+ probeUDPTransport = func(ctx context.Context, t *Transport) error { return t.Probe(ctx) }
45 )50 )
46 51
47 type limitedBuffer struct {52 type limitedBuffer struct {
@@ -108,7 +113,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
108 if err != nil {113 if err != nil {
109 return nil, udpUnavailable("resolve UDP peer", err, &stderr)114 return nil, udpUnavailable("resolve UDP peer", err, &stderr)
110 }115 }
111- pc, err := listenUDPPacket()116+ pc, err := listenUDPPacket(ctx)
112 if err != nil {117 if err != nil {
113 return nil, udpUnavailable("listen UDP", err, &stderr)118 return nil, udpUnavailable("listen UDP", err, &stderr)
114 }119 }
@@ -119,7 +124,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
119 }124 }
120 probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)125 probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
121 defer cancel()126 defer cancel()
122- if err := t.Probe(probeCtx); err != nil {127+ if err := probeUDPTransport(probeCtx, t); err != nil {
123 _ = t.Close()128 _ = t.Close()
124 return nil, udpUnavailable("probe UDP transport", err, &stderr)129 return nil, udpUnavailable("probe UDP transport", err, &stderr)
125 }130 }
@@ -149,6 +154,9 @@ func readUDPReady(r io.Reader) (udpReady, error) {
149 if err != nil {154 if err != nil {
150 return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)155 return udpReady{}, fmt.Errorf("invalid bootstrap key: %w", err)
151 }156 }
157+ if len(key) != pdgram.KeySize {
158+ return udpReady{}, fmt.Errorf("invalid bootstrap key length %d", len(key))
159+ }
152 return udpReady{port: port, key: key}, nil160 return udpReady{port: port, key: key}, nil
153 }161 }
154 162
@@ -167,5 +175,8 @@ func sshTargetHost(target string) string {
167 if h, _, err := net.SplitHostPort(target); err == nil {175 if h, _, err := net.SplitHostPort(target); err == nil {
168 return h176 return h
169 }177 }
178+ if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") {
179+ return strings.TrimPrefix(strings.TrimSuffix(target, "]"), "[")
180+ }
170 return target181 return target
171 }182 }
M internal/adapters/dgram/ssh_dialer_test.go +5 -3
@@ -99,7 +99,7 @@ func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string,
9999 t.Cleanup(func() { startUDPBootstrap = old })
100100 }
101101
102-func withListenUDP(t *testing.T, fn func() (net.PacketConn, error)) {
102+func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) {
103103 t.Helper()
104104 old := listenUDPPacket
105105 listenUDPPacket = fn
@@ -125,7 +125,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
125125 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader(tt.stdout)), startErr: tt.start}
126126 withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })
127127 if tt.listen != nil {
128- withListenUDP(t, func() (net.PacketConn, error) { return nil, tt.listen })
128+ withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
129129 }
130130 tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background())
131131 if err == nil || !strings.Contains(err.Error(), tt.want) {
@@ -155,7 +155,9 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
155155 t.Fatal(err)
156156 }
157157 closed := false
158- withListenUDP(t, func() (net.PacketConn, error) { return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil })
158+ withListenUDP(t, func(context.Context) (net.PacketConn, error) {
159+ return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil
160+ })
159161 d := NewRemoteDialer("127.0.0.1", "work")
160162 d.ProbeTimeout = time.Nanosecond
161163 tr, err := d.Dial(context.Background())
@@ -99,7 +99,7 @@ func withBootstrapStarter(t *testing.T, fn func(context.Context, string, string,
99 t.Cleanup(func() { startUDPBootstrap = old })99 t.Cleanup(func() { startUDPBootstrap = old })
100 }100 }
101 101
102-func withListenUDP(t *testing.T, fn func() (net.PacketConn, error)) {102+func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error)) {
103 t.Helper()103 t.Helper()
104 old := listenUDPPacket104 old := listenUDPPacket
105 listenUDPPacket = fn105 listenUDPPacket = fn
@@ -125,7 +125,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
125 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader(tt.stdout)), startErr: tt.start}125 proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader(tt.stdout)), startErr: tt.start}
126 withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })126 withBootstrapStarter(t, func(context.Context, string, string, io.Writer) bootstrapProcess { return proc })
127 if tt.listen != nil {127 if tt.listen != nil {
128- withListenUDP(t, func() (net.PacketConn, error) { return nil, tt.listen })128+ withListenUDP(t, func(context.Context) (net.PacketConn, error) { return nil, tt.listen })
129 }129 }
130 tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background())130 tr, err := NewRemoteDialer("127.0.0.1", "work").Dial(context.Background())
131 if err == nil || !strings.Contains(err.Error(), tt.want) {131 if err == nil || !strings.Contains(err.Error(), tt.want) {
@@ -155,7 +155,9 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
155 t.Fatal(err)155 t.Fatal(err)
156 }156 }
157 closed := false157 closed := false
158- withListenUDP(t, func() (net.PacketConn, error) { return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil })158+ withListenUDP(t, func(context.Context) (net.PacketConn, error) {
159+ return closeTrackPacketConn{PacketConn: pc, closed: &closed}, nil
160+ })
159 d := NewRemoteDialer("127.0.0.1", "work")161 d := NewRemoteDialer("127.0.0.1", "work")
160 d.ProbeTimeout = time.Nanosecond162 d.ProbeTimeout = time.Nanosecond
161 tr, err := d.Dial(context.Background())163 tr, err := d.Dial(context.Background())
M internal/app/run.go +81 -13
@@ -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"
@@ -53,6 +55,7 @@ const (
5355 kindDaemon
5456 kindStdio
5557 kindUDPBootstrap
58+ kindUDPProxy
5659 kindHelp
5760 kindVersion
5861 )
@@ -136,6 +139,15 @@ func parseArgs(args []string) (command, error) {
136139 cmd.name = args[1]
137140 }
138141 return cmd, nil
142+ case "_udp-proxy":
143+ if len(args) > 2 {
144+ return command{}, usagef("`_udp-proxy` accepts at most one session name")
145+ }
146+ cmd := command{kind: kindUDPProxy}
147+ if len(args) == 2 {
148+ cmd.name = args[1]
149+ }
150+ return cmd, nil
139151 case "new":
140152 if len(args) < 2 || args[1] == "" {
141153 return command{}, usagef("`new` requires a session name")
@@ -210,6 +222,8 @@ func dispatch(ctx context.Context, cmd command) error {
210222 return runStdio(ctx)
211223 case kindUDPBootstrap:
212224 return runUDPBootstrap(ctx, cmd.name)
225+ case kindUDPProxy:
226+ return runUDPProxy(ctx, cmd.name, os.Stdout)
213227 case kindList:
214228 return runList(ctx)
215229 case kindKill:
@@ -607,23 +621,78 @@ func runStdio(ctx context.Context) error {
607621 return proxyTransports(ctx, stdio, transport, log)
608622 }
609623
610-// runUDPBootstrap is the hidden remote-side bootstrap used before switching to
611-// authenticated datagrams. It prints only an ephemeral UDP port and random
612-// session key to stdout; stdio remains available only as an explicit remote
613-// transport mode.
624+var (
625+ udpBootstrapTimeout = 5 * time.Second
626+ udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd {
627+ return exec.CommandContext(ctx, exe, args...)
628+ }
629+)
630+
631+// runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
632+// line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
614633 func runUDPBootstrap(ctx context.Context, session string) error {
615- log, logCloser, err := configureLogging(logging.Stdio, false)
634+ r, w, err := os.Pipe()
616635 if err != nil {
617636 return err
618637 }
619- defer func() { _ = logCloser.Close() }()
620- log.Debug("udp bootstrap starting", "session", session)
638+ defer func() { _ = r.Close() }()
639+
640+ exe, err := os.Executable()
641+ if err != nil {
642+ _ = w.Close()
643+ return err
644+ }
645+ args := []string{"_udp-proxy"}
646+ if session != "" {
647+ args = append(args, session)
648+ }
649+ cmd := udpProxyCommand(ctx, exe, args...)
650+ cmd.Stdin = nil
651+ cmd.Stdout = w
652+ cmd.Stderr = os.Stderr
653+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
654+ if err := cmd.Start(); err != nil {
655+ _ = w.Close()
656+ return err
657+ }
658+ _ = w.Close()
621659
660+ readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout)
661+ defer cancel()
662+ lineCh := make(chan string, 1)
663+ errCh := make(chan error, 1)
664+ go func() {
665+ line, err := bufio.NewReader(r).ReadString('\n')
666+ if err != nil {
667+ errCh <- err
668+ return
669+ }
670+ lineCh <- line
671+ }()
622672 select {
623- case <-ctx.Done():
624- return ctx.Err()
625- default:
673+ case line := <-lineCh:
674+ if err := cmd.Process.Release(); err != nil {
675+ return err
676+ }
677+ _, err := fmt.Fprint(os.Stdout, line)
678+ return err
679+ case err := <-errCh:
680+ return err
681+ case <-readyCtx.Done():
682+ _ = cmd.Process.Kill()
683+ return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err())
684+ }
685+}
686+
687+// runUDPProxy is the detached long-lived remote-side datagram proxy.
688+func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
689+ log, logCloser, err := configureLogging(logging.Stdio, false)
690+ if err != nil {
691+ return err
626692 }
693+ defer func() { _ = logCloser.Close() }()
694+ log.Debug("udp proxy starting", "session", session)
695+
627696 var lc net.ListenConfig
628697 conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0")
629698 if err != nil {
@@ -647,12 +716,11 @@ func runUDPBootstrap(ctx context.Context, session string) error {
647716
648717 addr, ok := conn.LocalAddr().(*net.UDPAddr)
649718 if !ok {
650- return errors.New("vev: udp bootstrap did not get a UDP address")
719+ return errors.New("vev: udp proxy did not get a UDP address")
651720 }
652- if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {
721+ if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {
653722 return err
654723 }
655- _ = os.Stdout.Sync()
656724
657725 var dgramTr ports.Transport = dg
658726 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"
@@ -53,6 +55,7 @@ const (
53 kindDaemon55 kindDaemon
54 kindStdio56 kindStdio
55 kindUDPBootstrap57 kindUDPBootstrap
58+ kindUDPProxy
56 kindHelp59 kindHelp
57 kindVersion60 kindVersion
58 )61 )
@@ -136,6 +139,15 @@ func parseArgs(args []string) (command, error) {
136 cmd.name = args[1]139 cmd.name = args[1]
137 }140 }
138 return cmd, nil141 return cmd, nil
142+ case "_udp-proxy":
143+ if len(args) > 2 {
144+ return command{}, usagef("`_udp-proxy` accepts at most one session name")
145+ }
146+ cmd := command{kind: kindUDPProxy}
147+ if len(args) == 2 {
148+ cmd.name = args[1]
149+ }
150+ return cmd, nil
139 case "new":151 case "new":
140 if len(args) < 2 || args[1] == "" {152 if len(args) < 2 || args[1] == "" {
141 return command{}, usagef("`new` requires a session name")153 return command{}, usagef("`new` requires a session name")
@@ -210,6 +222,8 @@ func dispatch(ctx context.Context, cmd command) error {
210 return runStdio(ctx)222 return runStdio(ctx)
211 case kindUDPBootstrap:223 case kindUDPBootstrap:
212 return runUDPBootstrap(ctx, cmd.name)224 return runUDPBootstrap(ctx, cmd.name)
225+ case kindUDPProxy:
226+ return runUDPProxy(ctx, cmd.name, os.Stdout)
213 case kindList:227 case kindList:
214 return runList(ctx)228 return runList(ctx)
215 case kindKill:229 case kindKill:
@@ -607,23 +621,78 @@ func runStdio(ctx context.Context) error {
607 return proxyTransports(ctx, stdio, transport, log)621 return proxyTransports(ctx, stdio, transport, log)
608 }622 }
609 623
610-// runUDPBootstrap is the hidden remote-side bootstrap used before switching to624+var (
611-// authenticated datagrams. It prints only an ephemeral UDP port and random625+ udpBootstrapTimeout = 5 * time.Second
612-// session key to stdout; stdio remains available only as an explicit remote626+ udpProxyCommand = func(ctx context.Context, exe string, args ...string) *exec.Cmd {
613-// transport mode.627+ return exec.CommandContext(ctx, exe, args...)
628+ }
629+)
630+
631+// runUDPBootstrap starts a detached _udp-proxy, forwards its single readiness
632+// line, and exits so SSH stdio can close without owning the UDP proxy lifetime.
614 func runUDPBootstrap(ctx context.Context, session string) error {633 func runUDPBootstrap(ctx context.Context, session string) error {
615- log, logCloser, err := configureLogging(logging.Stdio, false)634+ r, w, err := os.Pipe()
616 if err != nil {635 if err != nil {
617 return err636 return err
618 }637 }
619- defer func() { _ = logCloser.Close() }()638+ defer func() { _ = r.Close() }()
620- log.Debug("udp bootstrap starting", "session", session)639+
640+ exe, err := os.Executable()
641+ if err != nil {
642+ _ = w.Close()
643+ return err
644+ }
645+ args := []string{"_udp-proxy"}
646+ if session != "" {
647+ args = append(args, session)
648+ }
649+ cmd := udpProxyCommand(ctx, exe, args...)
650+ cmd.Stdin = nil
651+ cmd.Stdout = w
652+ cmd.Stderr = os.Stderr
653+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
654+ if err := cmd.Start(); err != nil {
655+ _ = w.Close()
656+ return err
657+ }
658+ _ = w.Close()
621 659
660+ readyCtx, cancel := context.WithTimeout(ctx, udpBootstrapTimeout)
661+ defer cancel()
662+ lineCh := make(chan string, 1)
663+ errCh := make(chan error, 1)
664+ go func() {
665+ line, err := bufio.NewReader(r).ReadString('\n')
666+ if err != nil {
667+ errCh <- err
668+ return
669+ }
670+ lineCh <- line
671+ }()
622 select {672 select {
623- case <-ctx.Done():673+ case line := <-lineCh:
624- return ctx.Err()674+ if err := cmd.Process.Release(); err != nil {
625- default:675+ return err
676+ }
677+ _, err := fmt.Fprint(os.Stdout, line)
678+ return err
679+ case err := <-errCh:
680+ return err
681+ case <-readyCtx.Done():
682+ _ = cmd.Process.Kill()
683+ return fmt.Errorf("vev: udp bootstrap readiness: %w", readyCtx.Err())
684+ }
685+}
686+
687+// runUDPProxy is the detached long-lived remote-side datagram proxy.
688+func runUDPProxy(ctx context.Context, session string, ready io.Writer) error {
689+ log, logCloser, err := configureLogging(logging.Stdio, false)
690+ if err != nil {
691+ return err
626 }692 }
693+ defer func() { _ = logCloser.Close() }()
694+ log.Debug("udp proxy starting", "session", session)
695+
627 var lc net.ListenConfig696 var lc net.ListenConfig
628 conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0")697 conn, err := lc.ListenPacket(ctx, "udp", "0.0.0.0:0")
629 if err != nil {698 if err != nil {
@@ -647,12 +716,11 @@ func runUDPBootstrap(ctx context.Context, session string) error {
647 716
648 addr, ok := conn.LocalAddr().(*net.UDPAddr)717 addr, ok := conn.LocalAddr().(*net.UDPAddr)
649 if !ok {718 if !ok {
650- return errors.New("vev: udp bootstrap did not get a UDP address")719+ return errors.New("vev: udp proxy did not get a UDP address")
651 }720 }
652- if _, err := fmt.Fprintf(os.Stdout, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {721+ if _, err := fmt.Fprintf(ready, "VEV-UDP %d %s\n", addr.Port, base64.StdEncoding.EncodeToString(key)); err != nil {
653 return err722 return err
654 }723 }
655- _ = os.Stdout.Sync()
656 724
657 var dgramTr ports.Transport = dg725 var dgramTr ports.Transport = dg
658 if session != "" {726 if session != "" {
M internal/app/run_test.go +32 -0
@@ -7,6 +7,7 @@ import (
77 "io"
88 "log/slog"
99 "os"
10+ "os/exec"
1011 "path/filepath"
1112 "strings"
1213 "testing"
@@ -66,6 +67,9 @@ func TestParseArgs(t *testing.T) {
6667 {name: "stdio with session", args: []string{"_stdio", "work"}, wantKind: kindStdio, wantName: "work"},
6768 {name: "stdio preserves legacy unsafe name", args: []string{"_stdio", "my work"}, wantKind: kindStdio, wantName: "my work"},
6869 {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},
6973 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
7074 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
7175 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -560,3 +564,31 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
560564 t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
561565 }
562566 }
567+
568+func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
569+ oldTimeout := udpBootstrapTimeout
570+ udpBootstrapTimeout = time.Second
571+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
572+ oldCommand := udpProxyCommand
573+ r, w, err := os.Pipe()
574+ if err != nil {
575+ t.Fatal(err)
576+ }
577+ defer func() { _ = r.Close() }()
578+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
579+ cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
580+ cmd.Stdout = w
581+ return cmd
582+ }
583+ t.Cleanup(func() { udpProxyCommand = oldCommand })
584+ defer func() { _ = w.Close() }()
585+
586+ got := captureStdout(t, func() {
587+ if err := runUDPBootstrap(context.Background(), "work"); err != nil {
588+ t.Fatalf("runUDPBootstrap() error = %v", err)
589+ }
590+ })
591+ if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
592+ t.Fatalf("readiness = %q", got)
593+ }
594+}
@@ -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"
@@ -66,6 +67,9 @@ func TestParseArgs(t *testing.T) {
66 {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"},
67 {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"},
68 {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},
69 {name: "help", args: []string{"--help"}, wantKind: kindHelp},73 {name: "help", args: []string{"--help"}, wantKind: kindHelp},
70 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},74 {name: "help subcommand", args: []string{"help"}, wantKind: kindHelp},
71 {name: "version", args: []string{"--version"}, wantKind: kindVersion},75 {name: "version", args: []string{"--version"}, wantKind: kindVersion},
@@ -560,3 +564,31 @@ func TestRunAttachWithDepsBuildsLocalDialer(t *testing.T) {
560 t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)564 t.Fatalf("local attach must not thread a ClipboardReader through, got %#v", gotClipboard)
561 }565 }
562 }566 }
567+
568+func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
569+ oldTimeout := udpBootstrapTimeout
570+ udpBootstrapTimeout = time.Second
571+ t.Cleanup(func() { udpBootstrapTimeout = oldTimeout })
572+ oldCommand := udpProxyCommand
573+ r, w, err := os.Pipe()
574+ if err != nil {
575+ t.Fatal(err)
576+ }
577+ defer func() { _ = r.Close() }()
578+ udpProxyCommand = func(context.Context, string, ...string) *exec.Cmd {
579+ cmd := exec.Command("/bin/sh", "-c", "printf 'VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\\n'")
580+ cmd.Stdout = w
581+ return cmd
582+ }
583+ t.Cleanup(func() { udpProxyCommand = oldCommand })
584+ defer func() { _ = w.Close() }()
585+
586+ got := captureStdout(t, func() {
587+ if err := runUDPBootstrap(context.Background(), "work"); err != nil {
588+ t.Fatalf("runUDPBootstrap() error = %v", err)
589+ }
590+ })
591+ if got != "VEV-UDP 4242 AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\n" {
592+ t.Fatalf("readiness = %q", got)
593+ }
594+}

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