detached 72cf674

fix(remote): close bootstrap pipe on start failure

commit
72cf674e2efa29cba3a34546d30ed754c1c44b1a
author
brice · · 2026-07-06 21:47 UTC
parent
2f5b15c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +26 -13
M internal/adapters/dgram/ssh_dialer.go +1 -0
@@ -97,6 +97,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
9797 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
9898 }
9999 if err := proc.Start(); err != nil {
100+ _ = stdout.Close()
100101 return nil, udpUnavailable("start bootstrap", err, &stderr)
101102 }
102103 cleanup := true
@@ -97,6 +97,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
97 return nil, udpUnavailable("bootstrap stdout", err, &stderr)97 return nil, udpUnavailable("bootstrap stdout", err, &stderr)
98 }98 }
99 if err := proc.Start(); err != nil {99 if err := proc.Start(); err != nil {
100+ _ = stdout.Close()
100 return nil, udpUnavailable("start bootstrap", err, &stderr)101 return nil, udpUnavailable("start bootstrap", err, &stderr)
101 }102 }
102 cleanup := true103 cleanup := true
M internal/adapters/dgram/ssh_dialer_test.go +24 -12
@@ -21,6 +21,13 @@ type fakeBootstrapProcess struct {
2121 waited bool
2222 }
2323
24+type closeTrackReadCloser struct {
25+ io.Reader
26+ closed *bool
27+}
28+
29+func (r closeTrackReadCloser) Close() error { *r.closed = true; return nil }
30+
2431 func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil }
2532 func (p *fakeBootstrapProcess) Start() error { return p.startErr }
2633 func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
@@ -43,22 +50,24 @@ func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error
4350 func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
4451 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
4552 tests := []struct {
46- name string
47- stdout string
48- start error
49- listen error
50- want string
51- killed bool
52- waited bool
53+ name string
54+ stdout string
55+ start error
56+ listen error
57+ want string
58+ killed bool
59+ waited bool
60+ wantStdoutClosed bool
5361 }{
54- {name: "start failure", start: errors.New("ssh missing"), want: "vev: remote UDP transport unavailable: start bootstrap: ssh missing"},
55- {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true},
56- {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true},
57- {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true},
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},
5866 }
5967 for _, tt := range tests {
6068 t.Run(tt.name, func(t *testing.T) {
61- proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader(tt.stdout)), startErr: tt.start}
69+ stdoutClosed := false
70+ proc := &fakeBootstrapProcess{stdout: closeTrackReadCloser{Reader: strings.NewReader(tt.stdout), closed: &stdoutClosed}, startErr: tt.start}
6271 if tt.name == "bootstrap wait failure" {
6372 proc.waitErr = errors.New("exit status 255")
6473 }
@@ -79,6 +88,9 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
7988 if proc.waited != tt.waited {
8089 t.Fatalf("waited=%v, want %v", proc.waited, tt.waited)
8190 }
91+ if stdoutClosed != tt.wantStdoutClosed {
92+ t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed)
93+ }
8294 })
8395 }
8496 }
@@ -21,6 +21,13 @@ type fakeBootstrapProcess struct {
21 waited bool21 waited bool
22 }22 }
23 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+
24 func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil }31 func (p *fakeBootstrapProcess) StdoutPipe() (io.ReadCloser, error) { return p.stdout, nil }
25 func (p *fakeBootstrapProcess) Start() error { return p.startErr }32 func (p *fakeBootstrapProcess) Start() error { return p.startErr }
26 func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }33 func (p *fakeBootstrapProcess) Kill() error { p.killed = true; return nil }
@@ -43,22 +50,24 @@ func withListenUDP(t *testing.T, fn func(context.Context) (net.PacketConn, error
43 func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {50 func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
44 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))51 key := base64.StdEncoding.EncodeToString(make([]byte, pdgram.KeySize))
45 tests := []struct {52 tests := []struct {
46- name string53+ name string
47- stdout string54+ stdout string
48- start error55+ start error
49- listen error56+ listen error
50- want string57+ want string
51- killed bool58+ killed bool
52- waited bool59+ waited bool
60+ wantStdoutClosed bool
53 }{61 }{
54- {name: "start failure", start: errors.New("ssh missing"), want: "vev: remote UDP transport unavailable: start bootstrap: ssh missing"},62+ {name: "start failure", start: errors.New("ssh missing"), want: "vev: remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
55- {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true},63+ {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
56- {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true},64+ {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
57- {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: 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},
58 }66 }
59 for _, tt := range tests {67 for _, tt := range tests {
60 t.Run(tt.name, func(t *testing.T) {68 t.Run(tt.name, func(t *testing.T) {
61- proc := &fakeBootstrapProcess{stdout: io.NopCloser(strings.NewReader(tt.stdout)), startErr: tt.start}69+ stdoutClosed := false
70+ proc := &fakeBootstrapProcess{stdout: closeTrackReadCloser{Reader: strings.NewReader(tt.stdout), closed: &stdoutClosed}, startErr: tt.start}
62 if tt.name == "bootstrap wait failure" {71 if tt.name == "bootstrap wait failure" {
63 proc.waitErr = errors.New("exit status 255")72 proc.waitErr = errors.New("exit status 255")
64 }73 }
@@ -79,6 +88,9 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
79 if proc.waited != tt.waited {88 if proc.waited != tt.waited {
80 t.Fatalf("waited=%v, want %v", proc.waited, tt.waited)89 t.Fatalf("waited=%v, want %v", proc.waited, tt.waited)
81 }90 }
91+ if stdoutClosed != tt.wantStdoutClosed {
92+ t.Fatalf("stdout closed=%v, want %v", stdoutClosed, tt.wantStdoutClosed)
93+ }
82 })94 })
83 }95 }
84 }96 }
M internal/usecase/client/reconnect_toast_test.go +1 -1
@@ -183,7 +183,7 @@ 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))
186+ wrappedLinkDead := errors.Join(fmt.Errorf("remote transport receive failed: %w", linkDead), errors.New("read side closed"))
187187 oldSleep := reconnectSleep
188188 oldSleepWithResize := reconnectSleepWithResize
189189 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
@@ -183,7 +183,7 @@ 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))186+ wrappedLinkDead := errors.Join(fmt.Errorf("remote transport receive failed: %w", linkDead), errors.New("read side closed"))
187 oldSleep := reconnectSleep187 oldSleep := reconnectSleep
188 oldSleepWithResize := reconnectSleepWithResize188 oldSleepWithResize := reconnectSleepWithResize
189 reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }189 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