detached 5e9cf03

fix(remote): bound udp bootstrap readiness

commit
5e9cf036ad20227212cceb189dee2fd5b17d61a5
author
brice · · 2026-07-06 21:58 UTC
parent
72cf674
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +104 -12
M internal/adapters/dgram/ssh_dialer.go +64 -10
@@ -19,8 +19,9 @@ import (
1919 )
2020
2121 const (
22- maxBootstrapStderr = 64 * 1024
23- defaultProbeTimeout = 3 * time.Second
22+ maxBootstrapStderr = 64 * 1024
23+ defaultBootstrapTimeout = 10 * time.Second
24+ defaultProbeTimeout = 3 * time.Second
2425 )
2526
2627 type bootstrapProcess interface {
@@ -68,10 +69,11 @@ func (b *limitedBuffer) String() string { return string(b.buf) }
6869 // RemoteDialer connects to the authenticated datagram bootstrap for UDP remote
6970 // attach. SSH stdio is selected only through the explicit stdio transport mode.
7071 type RemoteDialer struct {
71- Target string
72- Session string
73- ProbeTimeout time.Duration
74- Log *slog.Logger
72+ Target string
73+ Session string
74+ BootstrapTimeout time.Duration
75+ ProbeTimeout time.Duration
76+ Log *slog.Logger
7577 }
7678
7779 func NewRemoteDialer(target, session string) RemoteDialer {
@@ -86,12 +88,19 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
8688 if err := ctx.Err(); err != nil {
8789 return nil, err
8890 }
91+ bootstrapTimeout := d.BootstrapTimeout
92+ if bootstrapTimeout <= 0 {
93+ bootstrapTimeout = defaultBootstrapTimeout
94+ }
8995 probeTimeout := d.ProbeTimeout
9096 if probeTimeout <= 0 {
9197 probeTimeout = defaultProbeTimeout
9298 }
99+ bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
100+ defer bootstrapCancel()
101+
93102 var stderr limitedBuffer
94- proc := startUDPBootstrap(ctx, d.Target, d.Session, &stderr)
103+ proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr)
95104 stdout, err := proc.StdoutPipe()
96105 if err != nil {
97106 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
@@ -112,7 +121,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
112121 _ = stdout.Close()
113122 }()
114123
115- ready, err := readUDPReady(stdout)
124+ ready, err := readUDPReadyContext(bootstrapCtx, stdout)
116125 if err != nil {
117126 _ = proc.Kill()
118127 _ = proc.Wait()
@@ -120,14 +129,14 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
120129 cleanup = false
121130 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
122131 }
123- if err := proc.Wait(); err != nil {
132+ if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
124133 waited = true
125134 cleanup = false
126135 return nil, udpUnavailable("wait bootstrap", err, &stderr)
127136 }
128137 waited = true
129138 cleanup = false
130- peer, err := net.ResolveUDPAddr("udp", net.JoinHostPort(sshTargetHost(d.Target), strconv.Itoa(ready.port)))
139+ peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port)
131140 if err != nil {
132141 return nil, udpUnavailable("resolve UDP peer", err, &stderr)
133142 }
@@ -150,6 +159,51 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
150159 return t, nil
151160 }
152161
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+ }()
172+ select {
173+ case res := <-ch:
174+ return res.ready, res.err
175+ case <-ctx.Done():
176+ return udpReady{}, ctx.Err()
177+ }
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+
153207 type udpReady struct {
154208 port int
155209 key []byte
@@ -19,8 +19,9 @@ import (
19 )19 )
20 20
21 const (21 const (
22- maxBootstrapStderr = 64 * 102422+ maxBootstrapStderr = 64 * 1024
23- defaultProbeTimeout = 3 * time.Second23+ defaultBootstrapTimeout = 10 * time.Second
24+ defaultProbeTimeout = 3 * time.Second
24 )25 )
25 26
26 type bootstrapProcess interface {27 type bootstrapProcess interface {
@@ -68,10 +69,11 @@ func (b *limitedBuffer) String() string { return string(b.buf) }
68 // RemoteDialer connects to the authenticated datagram bootstrap for UDP remote69 // RemoteDialer connects to the authenticated datagram bootstrap for UDP remote
69 // attach. SSH stdio is selected only through the explicit stdio transport mode.70 // attach. SSH stdio is selected only through the explicit stdio transport mode.
70 type RemoteDialer struct {71 type RemoteDialer struct {
71- Target string72+ Target string
72- Session string73+ Session string
73- ProbeTimeout time.Duration74+ BootstrapTimeout time.Duration
74- Log *slog.Logger75+ ProbeTimeout time.Duration
76+ Log *slog.Logger
75 }77 }
76 78
77 func NewRemoteDialer(target, session string) RemoteDialer {79 func NewRemoteDialer(target, session string) RemoteDialer {
@@ -86,12 +88,19 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
86 if err := ctx.Err(); err != nil {88 if err := ctx.Err(); err != nil {
87 return nil, err89 return nil, err
88 }90 }
91+ bootstrapTimeout := d.BootstrapTimeout
92+ if bootstrapTimeout <= 0 {
93+ bootstrapTimeout = defaultBootstrapTimeout
94+ }
89 probeTimeout := d.ProbeTimeout95 probeTimeout := d.ProbeTimeout
90 if probeTimeout <= 0 {96 if probeTimeout <= 0 {
91 probeTimeout = defaultProbeTimeout97 probeTimeout = defaultProbeTimeout
92 }98 }
99+ bootstrapCtx, bootstrapCancel := context.WithTimeout(ctx, bootstrapTimeout)
100+ defer bootstrapCancel()
101+
93 var stderr limitedBuffer102 var stderr limitedBuffer
94- proc := startUDPBootstrap(ctx, d.Target, d.Session, &stderr)103+ proc := startUDPBootstrap(bootstrapCtx, d.Target, d.Session, &stderr)
95 stdout, err := proc.StdoutPipe()104 stdout, err := proc.StdoutPipe()
96 if err != nil {105 if err != nil {
97 return nil, udpUnavailable("bootstrap stdout", err, &stderr)106 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
@@ -112,7 +121,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
112 _ = stdout.Close()121 _ = stdout.Close()
113 }()122 }()
114 123
115- ready, err := readUDPReady(stdout)124+ ready, err := readUDPReadyContext(bootstrapCtx, stdout)
116 if err != nil {125 if err != nil {
117 _ = proc.Kill()126 _ = proc.Kill()
118 _ = proc.Wait()127 _ = proc.Wait()
@@ -120,14 +129,14 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
120 cleanup = false129 cleanup = false
121 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)130 return nil, udpUnavailable("read bootstrap readiness", err, &stderr)
122 }131 }
123- if err := proc.Wait(); err != nil {132+ if err := waitBootstrapContext(bootstrapCtx, proc); err != nil {
124 waited = true133 waited = true
125 cleanup = false134 cleanup = false
126 return nil, udpUnavailable("wait bootstrap", err, &stderr)135 return nil, udpUnavailable("wait bootstrap", err, &stderr)
127 }136 }
128 waited = true137 waited = true
129 cleanup = false138 cleanup = false
130- peer, err := net.ResolveUDPAddr("udp", net.JoinHostPort(sshTargetHost(d.Target), strconv.Itoa(ready.port)))139+ peer, err := resolveUDPPeer(bootstrapCtx, d.Target, ready.port)
131 if err != nil {140 if err != nil {
132 return nil, udpUnavailable("resolve UDP peer", err, &stderr)141 return nil, udpUnavailable("resolve UDP peer", err, &stderr)
133 }142 }
@@ -150,6 +159,51 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
150 return t, nil159 return t, nil
151 }160 }
152 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+ }()
172+ select {
173+ case res := <-ch:
174+ return res.ready, res.err
175+ case <-ctx.Done():
176+ return udpReady{}, ctx.Err()
177+ }
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+
153 type udpReady struct {207 type udpReady struct {
154 port int208 port int
155 key []byte209 key []byte
M internal/adapters/dgram/ssh_dialer_test.go +2 -1
@@ -119,7 +119,8 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
119119 }
120120 return proc
121121 })
122- pc, err := net.ListenPacket("udp", "127.0.0.1:0")
122+ var lc net.ListenConfig
123+ pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
123124 if err != nil {
124125 t.Fatal(err)
125126 }
@@ -119,7 +119,8 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
119 }119 }
120 return proc120 return proc
121 })121 })
122- pc, err := net.ListenPacket("udp", "127.0.0.1:0")122+ var lc net.ListenConfig
123+ pc, err := lc.ListenPacket(context.Background(), "udp", "127.0.0.1:0")
123 if err != nil {124 if err != nil {
124 t.Fatal(err)125 t.Fatal(err)
125 }126 }
M internal/app/run.go +2 -0
@@ -655,6 +655,8 @@ func runUDPBootstrap(ctx context.Context, session string) error {
655655 defer func() { _ = devNull.Close() }()
656656
657657 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.
658660 cmd.Stdin = devNull
659661 cmd.Stdout = w
660662 cmd.Stderr = devNull
@@ -655,6 +655,8 @@ func runUDPBootstrap(ctx context.Context, session string) error {
655 defer func() { _ = devNull.Close() }()655 defer func() { _ = devNull.Close() }()
656 656
657 cmd := udpProxyCommand(ctx, exe, args...)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.
658 cmd.Stdin = devNull660 cmd.Stdin = devNull
659 cmd.Stdout = w661 cmd.Stdout = w
660 cmd.Stderr = devNull662 cmd.Stderr = devNull
M internal/app/run_test.go +32 -0
@@ -601,3 +601,35 @@ func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
601601 t.Fatalf("proxy inherited SSH stdio handles")
602602 }
603603 }
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+}
@@ -601,3 +601,35 @@ func TestRunUDPBootstrapForwardsReadinessAndExits(t *testing.T) {
601 t.Fatalf("proxy inherited SSH stdio handles")601 t.Fatalf("proxy inherited SSH stdio handles")
602 }602 }
603 }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/usecase/client/reconnect_toast_test.go +4 -1
@@ -183,7 +183,10 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
183183
184184 func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
185185 linkDead := errors.New("remote link dead")
186- wrappedLinkDead := errors.Join(fmt.Errorf("remote transport receive failed: %w", linkDead), errors.New("read side closed"))
186+ wrappedLinkDead := errors.Join(
187+ fmt.Errorf("remote transport receive failed: %w", io.EOF),
188+ linkDead,
189+ )
187190 oldSleep := reconnectSleep
188191 oldSleepWithResize := reconnectSleepWithResize
189192 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
@@ -183,7 +183,10 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
183 183
184 func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {184 func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
185 linkDead := errors.New("remote link dead")185 linkDead := errors.New("remote link dead")
186- wrappedLinkDead := errors.Join(fmt.Errorf("remote transport receive failed: %w", linkDead), errors.New("read side closed"))186+ wrappedLinkDead := errors.Join(
187+ fmt.Errorf("remote transport receive failed: %w", io.EOF),
188+ linkDead,
189+ )
187 oldSleep := reconnectSleep190 oldSleep := reconnectSleep
188 oldSleepWithResize := reconnectSleepWithResize191 oldSleepWithResize := reconnectSleepWithResize
189 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }192 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }

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 2

Tags 13

↑↓ moveenter openesc close