detached 49ca2bd

fix(remote): address PR review findings

commit
49ca2bd68ee94b15c7ea772451efd89cfd1e33a6
author
brice · · 2026-07-07 04:32 UTC
parent
52e0dc8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +50 -15
M internal/adapters/sshstdio/transport.go +8 -8
@@ -35,15 +35,15 @@ type closeFunc func() error
3535 type eofErrFunc func() error
3636
3737 // NewTransport wraps separate reader/writer streams as a framed Transport.
38-func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {
39- return newTransport(r, w, close, nil)
38+func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport {
39+ return newTransport(r, w, closeFn, nil)
4040 }
4141
42-func newTransport(r io.Reader, w io.Writer, close closeFunc, eofErr eofErrFunc) ports.Transport {
43- if close == nil {
44- close = func() error { return nil }
42+func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport {
43+ if closeFn == nil {
44+ closeFn = func() error { return nil }
4545 }
46- return &transport{r: r, w: w, close: close, eofErr: eofErr}
46+ return &transport{r: r, w: w, close: closeFn, eofErr: eofErr}
4747 }
4848
4949 type transport struct {
@@ -93,7 +93,7 @@ func (t *transport) Recv() (ports.Frame, error) {
9393 t.readBuf = t.readBuf[:n]
9494 }
9595 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {
96- return ports.Frame{}, err
96+ return ports.Frame{}, t.mapEOFError(err)
9797 }
9898
9999 payload := append([]byte(nil), t.readBuf[1:]...)
@@ -101,7 +101,7 @@ func (t *transport) Recv() (ports.Frame, error) {
101101 }
102102
103103 func (t *transport) mapEOFError(err error) error {
104- if t.eofErr == nil || !errors.Is(err, io.EOF) {
104+ if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {
105105 return err
106106 }
107107 if sshErr := t.eofErr(); sshErr != nil {
@@ -35,15 +35,15 @@ type closeFunc func() error
35 type eofErrFunc func() error35 type eofErrFunc func() error
36 36
37 // NewTransport wraps separate reader/writer streams as a framed Transport.37 // NewTransport wraps separate reader/writer streams as a framed Transport.
38-func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {38+func NewTransport(r io.Reader, w io.Writer, closeFn closeFunc) ports.Transport {
39- return newTransport(r, w, close, nil)39+ return newTransport(r, w, closeFn, nil)
40 }40 }
41 41
42-func newTransport(r io.Reader, w io.Writer, close closeFunc, eofErr eofErrFunc) ports.Transport {42+func newTransport(r io.Reader, w io.Writer, closeFn closeFunc, eofErr eofErrFunc) ports.Transport {
43- if close == nil {43+ if closeFn == nil {
44- close = func() error { return nil }44+ closeFn = func() error { return nil }
45 }45 }
46- return &transport{r: r, w: w, close: close, eofErr: eofErr}46+ return &transport{r: r, w: w, close: closeFn, eofErr: eofErr}
47 }47 }
48 48
49 type transport struct {49 type transport struct {
@@ -93,7 +93,7 @@ func (t *transport) Recv() (ports.Frame, error) {
93 t.readBuf = t.readBuf[:n]93 t.readBuf = t.readBuf[:n]
94 }94 }
95 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {95 if _, err := io.ReadFull(t.r, t.readBuf); err != nil {
96- return ports.Frame{}, err96+ return ports.Frame{}, t.mapEOFError(err)
97 }97 }
98 98
99 payload := append([]byte(nil), t.readBuf[1:]...)99 payload := append([]byte(nil), t.readBuf[1:]...)
@@ -101,7 +101,7 @@ func (t *transport) Recv() (ports.Frame, error) {
101 }101 }
102 102
103 func (t *transport) mapEOFError(err error) error {103 func (t *transport) mapEOFError(err error) error {
104- if t.eofErr == nil || !errors.Is(err, io.EOF) {104+ if t.eofErr == nil || (!errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF)) {
105 return err105 return err
106 }106 }
107 if sshErr := t.eofErr(); sshErr != nil {107 if sshErr := t.eofErr(); sshErr != nil {
M internal/adapters/sshstdio/transport_test.go +11 -1
@@ -128,8 +128,18 @@ func TestDialContextCanceledBeforeStart(t *testing.T) {
128128 }
129129 }
130130
131+func TestRecvMapsUnexpectedEOFDuringHeaderToSSHExit(t *testing.T) {
132+ sshErr := errors.New("ssh exited with status 255")
133+ tr := newTransport(strings.NewReader("\x00"), io.Discard, nil, func() error { return sshErr })
134+
135+ _, err := tr.Recv()
136+ if !errors.Is(err, sshErr) {
137+ t.Fatalf("Recv error = %v, want %v", err, sshErr)
138+ }
139+}
140+
131141 func TestRecvReportsSSHExitWhenProcessClosesBeforeFrame(t *testing.T) {
132- cmd := exec.Command("sh", "-c", "echo connection refused >&2; exit 255")
142+ cmd := exec.CommandContext(context.Background(), "sh", "-c", "echo connection refused >&2; exit 255")
133143 stdin, err := cmd.StdinPipe()
134144 if err != nil {
135145 t.Fatalf("StdinPipe: %v", err)
@@ -128,8 +128,18 @@ func TestDialContextCanceledBeforeStart(t *testing.T) {
128 }128 }
129 }129 }
130 130
131+func TestRecvMapsUnexpectedEOFDuringHeaderToSSHExit(t *testing.T) {
132+ sshErr := errors.New("ssh exited with status 255")
133+ tr := newTransport(strings.NewReader("\x00"), io.Discard, nil, func() error { return sshErr })
134+
135+ _, err := tr.Recv()
136+ if !errors.Is(err, sshErr) {
137+ t.Fatalf("Recv error = %v, want %v", err, sshErr)
138+ }
139+}
140+
131 func TestRecvReportsSSHExitWhenProcessClosesBeforeFrame(t *testing.T) {141 func TestRecvReportsSSHExitWhenProcessClosesBeforeFrame(t *testing.T) {
132- cmd := exec.Command("sh", "-c", "echo connection refused >&2; exit 255")142+ cmd := exec.CommandContext(context.Background(), "sh", "-c", "echo connection refused >&2; exit 255")
133 stdin, err := cmd.StdinPipe()143 stdin, err := cmd.StdinPipe()
134 if err != nil {144 if err != nil {
135 t.Fatalf("StdinPipe: %v", err)145 t.Fatalf("StdinPipe: %v", err)
M internal/app/run.go +6 -0
@@ -777,6 +777,9 @@ func parseUDPPortRange(value string) (udpPortRange, error) {
777777 return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
778778 }
779779 if start == 0 {
780+ if hasRange {
781+ return udpPortRange{}, fmt.Errorf("invalid %s %q: port 0 (ephemeral) cannot be combined with a range", envUDPPortRange, value)
782+ }
780783 return udpPortRange{start: 0, end: 0}, nil
781784 }
782785 end := start
@@ -810,6 +813,9 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
810813 if r.start == 0 {
811814 return lc.ListenPacket(ctx, "udp", ":0")
812815 }
816+ if r.start < 0 || r.end < r.start {
817+ return nil, fmt.Errorf("invalid UDP port range %d-%d", r.start, r.end)
818+ }
813819 var lastErr error
814820 for port := r.start; port <= r.end; port++ {
815821 conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf(":%d", port))
@@ -777,6 +777,9 @@ func parseUDPPortRange(value string) (udpPortRange, error) {
777 return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)777 return udpPortRange{}, fmt.Errorf("invalid %s %q: %w", envUDPPortRange, value, err)
778 }778 }
779 if start == 0 {779 if start == 0 {
780+ if hasRange {
781+ return udpPortRange{}, fmt.Errorf("invalid %s %q: port 0 (ephemeral) cannot be combined with a range", envUDPPortRange, value)
782+ }
780 return udpPortRange{start: 0, end: 0}, nil783 return udpPortRange{start: 0, end: 0}, nil
781 }784 }
782 end := start785 end := start
@@ -810,6 +813,9 @@ func listenUDPInRange(ctx context.Context, r udpPortRange) (net.PacketConn, erro
810 if r.start == 0 {813 if r.start == 0 {
811 return lc.ListenPacket(ctx, "udp", ":0")814 return lc.ListenPacket(ctx, "udp", ":0")
812 }815 }
816+ if r.start < 0 || r.end < r.start {
817+ return nil, fmt.Errorf("invalid UDP port range %d-%d", r.start, r.end)
818+ }
813 var lastErr error819 var lastErr error
814 for port := r.start; port <= r.end; port++ {820 for port := r.start; port <= r.end; port++ {
815 conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf(":%d", port))821 conn, err := lc.ListenPacket(ctx, "udp", fmt.Sprintf(":%d", port))
M internal/app/run_test.go +22 -3
@@ -644,6 +644,7 @@ func TestParseUDPPortRange(t *testing.T) {
644644 }{
645645 {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
646646 {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
647+ {name: "zero range rejected", value: "0-100", wantError: true},
647648 {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
648649 {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
649650 {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
@@ -679,7 +680,15 @@ func TestListenUDPInRange(t *testing.T) {
679680 t.Fatal(err)
680681 }
681682 defer func() { _ = occupied.Close() }()
682- busyPort := occupied.LocalAddr().(*net.UDPAddr).Port
683+ udpPort := func(t *testing.T, conn net.PacketConn) int {
684+ t.Helper()
685+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
686+ if !ok {
687+ t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
688+ }
689+ return addr.Port
690+ }
691+ busyPort := udpPort(t, occupied)
683692
684693 t.Run("skips busy port", func(t *testing.T) {
685694 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
@@ -687,7 +696,7 @@ func TestListenUDPInRange(t *testing.T) {
687696 t.Fatalf("listenUDPInRange error = %v", err)
688697 }
689698 defer func() { _ = conn.Close() }()
690- got := conn.LocalAddr().(*net.UDPAddr).Port
699+ got := udpPort(t, conn)
691700 if got == busyPort {
692701 t.Fatalf("bound busy port %d, want a different port in range", busyPort)
693702 }
@@ -706,13 +715,23 @@ func TestListenUDPInRange(t *testing.T) {
706715 }
707716 })
708717
718+ t.Run("invalid direct range errors", func(t *testing.T) {
719+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
720+ if err == nil {
721+ t.Fatal("listenUDPInRange error = nil, want invalid-range error")
722+ }
723+ if !strings.Contains(err.Error(), "invalid UDP port range") {
724+ t.Fatalf("error = %v, want 'invalid UDP port range'", err)
725+ }
726+ })
727+
709728 t.Run("ephemeral binds", func(t *testing.T) {
710729 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
711730 if err != nil {
712731 t.Fatalf("listenUDPInRange ephemeral error = %v", err)
713732 }
714733 defer func() { _ = conn.Close() }()
715- if conn.LocalAddr().(*net.UDPAddr).Port == 0 {
734+ if udpPort(t, conn) == 0 {
716735 t.Fatal("ephemeral bind returned port 0")
717736 }
718737 })
@@ -644,6 +644,7 @@ func TestParseUDPPortRange(t *testing.T) {
644 }{644 }{
645 {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},645 {name: "empty uses default", value: "", want: udpPortRange{start: defaultUDPPortStart, end: defaultUDPPortEnd}},
646 {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},646 {name: "zero is ephemeral", value: "0", want: udpPortRange{start: 0, end: 0}},
647+ {name: "zero range rejected", value: "0-100", wantError: true},
647 {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},648 {name: "single port", value: "61000", want: udpPortRange{start: 61000, end: 61000}},
648 {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},649 {name: "range", value: "61000-61023", want: udpPortRange{start: 61000, end: 61023}},
649 {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},650 {name: "range with spaces", value: " 61000 - 61023 ", want: udpPortRange{start: 61000, end: 61023}},
@@ -679,7 +680,15 @@ func TestListenUDPInRange(t *testing.T) {
679 t.Fatal(err)680 t.Fatal(err)
680 }681 }
681 defer func() { _ = occupied.Close() }()682 defer func() { _ = occupied.Close() }()
682- busyPort := occupied.LocalAddr().(*net.UDPAddr).Port683+ udpPort := func(t *testing.T, conn net.PacketConn) int {
684+ t.Helper()
685+ addr, ok := conn.LocalAddr().(*net.UDPAddr)
686+ if !ok {
687+ t.Fatalf("LocalAddr() = %T, want *net.UDPAddr", conn.LocalAddr())
688+ }
689+ return addr.Port
690+ }
691+ busyPort := udpPort(t, occupied)
683 692
684 t.Run("skips busy port", func(t *testing.T) {693 t.Run("skips busy port", func(t *testing.T) {
685 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})694 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort, end: busyPort + 8})
@@ -687,7 +696,7 @@ func TestListenUDPInRange(t *testing.T) {
687 t.Fatalf("listenUDPInRange error = %v", err)696 t.Fatalf("listenUDPInRange error = %v", err)
688 }697 }
689 defer func() { _ = conn.Close() }()698 defer func() { _ = conn.Close() }()
690- got := conn.LocalAddr().(*net.UDPAddr).Port699+ got := udpPort(t, conn)
691 if got == busyPort {700 if got == busyPort {
692 t.Fatalf("bound busy port %d, want a different port in range", busyPort)701 t.Fatalf("bound busy port %d, want a different port in range", busyPort)
693 }702 }
@@ -706,13 +715,23 @@ func TestListenUDPInRange(t *testing.T) {
706 }715 }
707 })716 })
708 717
718+ t.Run("invalid direct range errors", func(t *testing.T) {
719+ _, err := listenUDPInRange(context.Background(), udpPortRange{start: busyPort + 8, end: busyPort})
720+ if err == nil {
721+ t.Fatal("listenUDPInRange error = nil, want invalid-range error")
722+ }
723+ if !strings.Contains(err.Error(), "invalid UDP port range") {
724+ t.Fatalf("error = %v, want 'invalid UDP port range'", err)
725+ }
726+ })
727+
709 t.Run("ephemeral binds", func(t *testing.T) {728 t.Run("ephemeral binds", func(t *testing.T) {
710 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})729 conn, err := listenUDPInRange(context.Background(), udpPortRange{start: 0, end: 0})
711 if err != nil {730 if err != nil {
712 t.Fatalf("listenUDPInRange ephemeral error = %v", err)731 t.Fatalf("listenUDPInRange ephemeral error = %v", err)
713 }732 }
714 defer func() { _ = conn.Close() }()733 defer func() { _ = conn.Close() }()
715- if conn.LocalAddr().(*net.UDPAddr).Port == 0 {734+ if udpPort(t, conn) == 0 {
716 t.Fatal("ephemeral bind returned port 0")735 t.Fatal("ephemeral bind returned port 0")
717 }736 }
718 })737 })
M scripts/debug-remote-attach.sh +3 -3
@@ -109,7 +109,7 @@ printf 'target: %s\nattach_target: %s\nmode: %s\nduration: %s\n' "$target" "$att
109109 ) >"$out_dir/git.txt" 2>&1 || true
110110
111111 run_capture local-version "$vev_bin" --version
112-ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'command -v vev; vev --version; pgrep -a vev || true; state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; echo "state=$state"' \
112+ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'set -e; command -v vev; vev --version; pgrep -a vev || true; state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; echo "state=$state"' \
113113 >"$out_dir/remote-version.out" 2>"$out_dir/remote-version.err" || echo "$?" >"$out_dir/remote-version.exit"
114114
115115 copy_local_logs before
@@ -119,7 +119,7 @@ attach_log="$out_dir/attach.typescript"
119119 attach_err="$out_dir/attach.err"
120120 cmd=("$vev_bin" attach "$attach_target")
121121 if [[ "$mode" == "default" ]]; then
122- env_cmd=(env -u VEV)
122+ env_cmd=(env -u VEV -u VEV_REMOTE_TRANSPORT)
123123 else
124124 env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode")
125125 fi
@@ -150,7 +150,7 @@ copy_remote_logs after
150150 sed 's/^/ /' "$attach_err" | tail -40
151151 fi
152152 echo "recent local after logs:"
153- find "$out_dir/local-after" -type f -maxdepth 1 -print 2>/dev/null | while read -r f; do
153+ find "$out_dir/local-after" -maxdepth 1 -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do
154154 echo " --- $(basename "$f") ---"
155155 tail -20 "$f" | sed 's/^/ /'
156156 done
@@ -109,7 +109,7 @@ printf 'target: %s\nattach_target: %s\nmode: %s\nduration: %s\n' "$target" "$att
109 ) >"$out_dir/git.txt" 2>&1 || true109 ) >"$out_dir/git.txt" 2>&1 || true
110 110
111 run_capture local-version "$vev_bin" --version111 run_capture local-version "$vev_bin" --version
112-ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'command -v vev; vev --version; pgrep -a vev || true; state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; echo "state=$state"' \112+ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'set -e; command -v vev; vev --version; pgrep -a vev || true; state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; echo "state=$state"' \
113 >"$out_dir/remote-version.out" 2>"$out_dir/remote-version.err" || echo "$?" >"$out_dir/remote-version.exit"113 >"$out_dir/remote-version.out" 2>"$out_dir/remote-version.err" || echo "$?" >"$out_dir/remote-version.exit"
114 114
115 copy_local_logs before115 copy_local_logs before
@@ -119,7 +119,7 @@ attach_log="$out_dir/attach.typescript"
119 attach_err="$out_dir/attach.err"119 attach_err="$out_dir/attach.err"
120 cmd=("$vev_bin" attach "$attach_target")120 cmd=("$vev_bin" attach "$attach_target")
121 if [[ "$mode" == "default" ]]; then121 if [[ "$mode" == "default" ]]; then
122- env_cmd=(env -u VEV)122+ env_cmd=(env -u VEV -u VEV_REMOTE_TRANSPORT)
123 else123 else
124 env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode")124 env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode")
125 fi125 fi
@@ -150,7 +150,7 @@ copy_remote_logs after
150 sed 's/^/ /' "$attach_err" | tail -40150 sed 's/^/ /' "$attach_err" | tail -40
151 fi151 fi
152 echo "recent local after logs:"152 echo "recent local after logs:"
153- find "$out_dir/local-after" -type f -maxdepth 1 -print 2>/dev/null | while read -r f; do153+ find "$out_dir/local-after" -maxdepth 1 -type f -print0 2>/dev/null | while IFS= read -r -d '' f; do
154 echo " --- $(basename "$f") ---"154 echo " --- $(basename "$f") ---"
155 tail -20 "$f" | sed 's/^/ /'155 tail -20 "$f" | sed 's/^/ /'
156 done156 done

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