detached e555c28

fix(remote): restore ssh attach default

commit
e555c28ca2eb8c02e462532b1db27e15a356ca99
author
brice · · 2026-07-07 00:51 UTC
parent
7047f01
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +290 -35
M README.md +2 -2
@@ -120,9 +120,9 @@ vev attach user@host
120120 vev attach user@host:session
121121 ```
122122
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.
123+Remote attach uses SSH stdio by default, so it works anywhere normal SSH works, including jump-host or SSH-config-only routes. `vev` must be installed on the remote host. Omitting `:session` opens an ephemeral remote session.
124124
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.
125+Set `VEV_REMOTE_TRANSPORT=udp` to try the UDP transport: SSH starts a UDP proxy on the remote host, then the session talks directly to that host over UDP. UDP mode requires direct UDP reachability to the remote host and can detect some network failures more promptly than SSH stdio.
126126
127127 ## Terminal color
128128
@@ -120,9 +120,9 @@ vev attach user@host
120 vev attach user@host:session120 vev attach user@host:session
121 ```121 ```
122 122
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.123+Remote attach uses SSH stdio by default, so it works anywhere normal SSH works, including jump-host or SSH-config-only routes. `vev` must be installed on the remote host. Omitting `:session` opens an ephemeral remote session.
124 124
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.125+Set `VEV_REMOTE_TRANSPORT=udp` to try the UDP transport: SSH starts a UDP proxy on the remote host, then the session talks directly to that host over UDP. UDP mode requires direct UDP reachability to the remote host and can detect some network failures more promptly than SSH stdio.
126 126
127 ## Terminal color127 ## Terminal color
128 128
M internal/adapters/sshstdio/transport.go +87 -28
@@ -32,19 +32,25 @@ var (
3232 )
3333
3434 type closeFunc func() error
35+type eofErrFunc func() error
3536
3637 // NewTransport wraps separate reader/writer streams as a framed Transport.
3738 func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {
39+ return newTransport(r, w, close, nil)
40+}
41+
42+func newTransport(r io.Reader, w io.Writer, close closeFunc, eofErr eofErrFunc) ports.Transport {
3843 if close == nil {
3944 close = func() error { return nil }
4045 }
41- return &transport{r: r, w: w, close: close}
46+ return &transport{r: r, w: w, close: close, eofErr: eofErr}
4247 }
4348
4449 type transport struct {
45- r io.Reader
46- w io.Writer
47- close closeFunc
50+ r io.Reader
51+ w io.Writer
52+ close closeFunc
53+ eofErr eofErrFunc
4854
4955 mu sync.Mutex
5056 readBuf []byte
@@ -70,7 +76,7 @@ func (t *transport) Send(f ports.Frame) error {
7076 func (t *transport) Recv() (ports.Frame, error) {
7177 var hdr [frameHeaderLen]byte
7278 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {
73- return ports.Frame{}, err
79+ return ports.Frame{}, t.mapEOFError(err)
7480 }
7581
7682 n := binary.BigEndian.Uint32(hdr[:])
@@ -94,39 +100,91 @@ func (t *transport) Recv() (ports.Frame, error) {
94100 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
95101 }
96102
103+func (t *transport) mapEOFError(err error) error {
104+ if t.eofErr == nil || !errors.Is(err, io.EOF) {
105+ return err
106+ }
107+ if sshErr := t.eofErr(); sshErr != nil {
108+ return sshErr
109+ }
110+ return err
111+}
112+
97113 func (t *transport) Close() error { return t.close() }
98114
99-func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
100- return func() error {
101- _ = stdin.Close()
115+type processWaiter struct {
116+ cmd *exec.Cmd
117+ stdin io.Closer
118+ stderr *bytes.Buffer
119+ timeout time.Duration
120+ log *slog.Logger
121+ target string
122+ session string
123+
124+ waitOnce sync.Once
125+ waitErr error
126+}
127+
128+func newProcessWaiter(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) *processWaiter {
129+ w := &processWaiter{
130+ cmd: cmd,
131+ stdin: stdin,
132+ stderr: stderr,
133+ timeout: timeout,
134+ log: log,
135+ target: target,
136+ session: session,
137+ }
138+ return w
139+}
140+
141+func (w *processWaiter) close() error {
142+ _ = w.stdin.Close()
143+ return w.wait(w.timeout)
144+}
102145
103- waitErr := make(chan error, 1)
104- go func() { waitErr <- cmd.Wait() }()
146+func (w *processWaiter) eofErr() error {
147+ return w.wait(sshCloseTimeout)
148+}
105149
106- var err error
150+func (w *processWaiter) wait(timeout time.Duration) error {
151+ w.waitOnce.Do(func() {
152+ // cmd.Wait must start only after stdout is no longer being read. os/exec
153+ // requires callers to finish reading StdoutPipe before Wait, so DialContext
154+ // calls this from transport Close or after Recv has already observed EOF.
155+ waitCh := make(chan error, 1)
156+ go func() { waitCh <- w.cmd.Wait() }()
107157 select {
108- case err = <-waitErr:
158+ case w.waitErr = <-waitCh:
109159 case <-time.After(timeout):
110- _ = cmd.Process.Kill()
111- err = <-waitErr
112- }
113- if err == nil {
114- return nil
160+ _ = w.cmd.Process.Kill()
161+ w.waitErr = <-waitCh
115162 }
163+ w.waitErr = formatProcessWaitError(w.waitErr, w.stderr, w.log, w.target, w.session)
164+ })
165+ return w.waitErr
166+}
116167
117- stderrText := strings.TrimSpace(stderr.String())
118- if log != nil {
119- attrs := []any{"target", target, "session", session, "err", err}
120- if stderrText != "" {
121- attrs = append(attrs, "stderr", stderrText)
122- }
123- log.Warn("ssh exited non-cleanly", attrs...)
124- }
168+func formatProcessWaitError(err error, stderr *bytes.Buffer, log *slog.Logger, target, session string) error {
169+ if err == nil {
170+ return nil
171+ }
172+ stderrText := strings.TrimSpace(stderr.String())
173+ if log != nil {
174+ attrs := []any{"target", target, "session", session, "err", err}
125175 if stderrText != "" {
126- return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
176+ attrs = append(attrs, "stderr", stderrText)
127177 }
128- return fmt.Errorf("sshstdio: ssh exited: %w", err)
178+ log.Warn("ssh exited non-cleanly", attrs...)
179+ }
180+ if stderrText != "" {
181+ return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
129182 }
183+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
184+}
185+
186+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
187+ return newProcessWaiter(cmd, stdin, stderr, timeout, log, target, session).close
130188 }
131189
132190 // CommandSpec is the exact ssh argv vev will execute locally.
@@ -195,5 +253,6 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo
195253 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
196254 }
197255
198- return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)), nil
256+ waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
257+ return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil
199258 }
@@ -32,19 +32,25 @@ var (
32 )32 )
33 33
34 type closeFunc func() error34 type closeFunc func() error
35+type eofErrFunc func() error
35 36
36 // NewTransport wraps separate reader/writer streams as a framed Transport.37 // NewTransport wraps separate reader/writer streams as a framed Transport.
37 func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {38 func NewTransport(r io.Reader, w io.Writer, close closeFunc) ports.Transport {
39+ return newTransport(r, w, close, nil)
40+}
41+
42+func newTransport(r io.Reader, w io.Writer, close closeFunc, eofErr eofErrFunc) ports.Transport {
38 if close == nil {43 if close == nil {
39 close = func() error { return nil }44 close = func() error { return nil }
40 }45 }
41- return &transport{r: r, w: w, close: close}46+ return &transport{r: r, w: w, close: close, eofErr: eofErr}
42 }47 }
43 48
44 type transport struct {49 type transport struct {
45- r io.Reader50+ r io.Reader
46- w io.Writer51+ w io.Writer
47- close closeFunc52+ close closeFunc
53+ eofErr eofErrFunc
48 54
49 mu sync.Mutex55 mu sync.Mutex
50 readBuf []byte56 readBuf []byte
@@ -70,7 +76,7 @@ func (t *transport) Send(f ports.Frame) error {
70 func (t *transport) Recv() (ports.Frame, error) {76 func (t *transport) Recv() (ports.Frame, error) {
71 var hdr [frameHeaderLen]byte77 var hdr [frameHeaderLen]byte
72 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {78 if _, err := io.ReadFull(t.r, hdr[:]); err != nil {
73- return ports.Frame{}, err79+ return ports.Frame{}, t.mapEOFError(err)
74 }80 }
75 81
76 n := binary.BigEndian.Uint32(hdr[:])82 n := binary.BigEndian.Uint32(hdr[:])
@@ -94,39 +100,91 @@ func (t *transport) Recv() (ports.Frame, error) {
94 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil100 return ports.Frame{Type: ports.MsgType(t.readBuf[0]), Payload: payload}, nil
95 }101 }
96 102
103+func (t *transport) mapEOFError(err error) error {
104+ if t.eofErr == nil || !errors.Is(err, io.EOF) {
105+ return err
106+ }
107+ if sshErr := t.eofErr(); sshErr != nil {
108+ return sshErr
109+ }
110+ return err
111+}
112+
97 func (t *transport) Close() error { return t.close() }113 func (t *transport) Close() error { return t.close() }
98 114
99-func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {115+type processWaiter struct {
100- return func() error {116+ cmd *exec.Cmd
101- _ = stdin.Close()117+ stdin io.Closer
118+ stderr *bytes.Buffer
119+ timeout time.Duration
120+ log *slog.Logger
121+ target string
122+ session string
123+
124+ waitOnce sync.Once
125+ waitErr error
126+}
127+
128+func newProcessWaiter(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) *processWaiter {
129+ w := &processWaiter{
130+ cmd: cmd,
131+ stdin: stdin,
132+ stderr: stderr,
133+ timeout: timeout,
134+ log: log,
135+ target: target,
136+ session: session,
137+ }
138+ return w
139+}
140+
141+func (w *processWaiter) close() error {
142+ _ = w.stdin.Close()
143+ return w.wait(w.timeout)
144+}
102 145
103- waitErr := make(chan error, 1)146+func (w *processWaiter) eofErr() error {
104- go func() { waitErr <- cmd.Wait() }()147+ return w.wait(sshCloseTimeout)
148+}
105 149
106- var err error150+func (w *processWaiter) wait(timeout time.Duration) error {
151+ w.waitOnce.Do(func() {
152+ // cmd.Wait must start only after stdout is no longer being read. os/exec
153+ // requires callers to finish reading StdoutPipe before Wait, so DialContext
154+ // calls this from transport Close or after Recv has already observed EOF.
155+ waitCh := make(chan error, 1)
156+ go func() { waitCh <- w.cmd.Wait() }()
107 select {157 select {
108- case err = <-waitErr:158+ case w.waitErr = <-waitCh:
109 case <-time.After(timeout):159 case <-time.After(timeout):
110- _ = cmd.Process.Kill()160+ _ = w.cmd.Process.Kill()
111- err = <-waitErr161+ w.waitErr = <-waitCh
112- }
113- if err == nil {
114- return nil
115 }162 }
163+ w.waitErr = formatProcessWaitError(w.waitErr, w.stderr, w.log, w.target, w.session)
164+ })
165+ return w.waitErr
166+}
116 167
117- stderrText := strings.TrimSpace(stderr.String())168+func formatProcessWaitError(err error, stderr *bytes.Buffer, log *slog.Logger, target, session string) error {
118- if log != nil {169+ if err == nil {
119- attrs := []any{"target", target, "session", session, "err", err}170+ return nil
120- if stderrText != "" {171+ }
121- attrs = append(attrs, "stderr", stderrText)172+ stderrText := strings.TrimSpace(stderr.String())
122- }173+ if log != nil {
123- log.Warn("ssh exited non-cleanly", attrs...)174+ attrs := []any{"target", target, "session", session, "err", err}
124- }
125 if stderrText != "" {175 if stderrText != "" {
126- return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)176+ attrs = append(attrs, "stderr", stderrText)
127 }177 }
128- return fmt.Errorf("sshstdio: ssh exited: %w", err)178+ log.Warn("ssh exited non-cleanly", attrs...)
179+ }
180+ if stderrText != "" {
181+ return fmt.Errorf("sshstdio: ssh exited: %w: %s", err, stderrText)
129 }182 }
183+ return fmt.Errorf("sshstdio: ssh exited: %w", err)
184+}
185+
186+func newProcessCloser(cmd *exec.Cmd, stdin io.Closer, stderr *bytes.Buffer, timeout time.Duration, log *slog.Logger, target, session string) closeFunc {
187+ return newProcessWaiter(cmd, stdin, stderr, timeout, log, target, session).close
130 }188 }
131 189
132 // CommandSpec is the exact ssh argv vev will execute locally.190 // CommandSpec is the exact ssh argv vev will execute locally.
@@ -195,5 +253,6 @@ func DialContext(ctx context.Context, target, session string, logger ...*slog.Lo
195 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)253 return nil, fmt.Errorf("sshstdio: start ssh: %w", err)
196 }254 }
197 255
198- return NewTransport(stdout, stdin, newProcessCloser(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)), nil256+ waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session)
257+ return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil
199 }258 }
M internal/adapters/sshstdio/transport_test.go +30 -0
@@ -128,6 +128,36 @@ func TestDialContextCanceledBeforeStart(t *testing.T) {
128128 }
129129 }
130130
131+func TestRecvReportsSSHExitWhenProcessClosesBeforeFrame(t *testing.T) {
132+ cmd := exec.Command("sh", "-c", "echo connection refused >&2; exit 255")
133+ stdin, err := cmd.StdinPipe()
134+ if err != nil {
135+ t.Fatalf("StdinPipe: %v", err)
136+ }
137+ stdout, err := cmd.StdoutPipe()
138+ if err != nil {
139+ t.Fatalf("StdoutPipe: %v", err)
140+ }
141+ var stderr bytes.Buffer
142+ cmd.Stderr = &stderr
143+ if err := cmd.Start(); err != nil {
144+ t.Fatalf("Start: %v", err)
145+ }
146+
147+ waiter := newProcessWaiter(cmd, stdin, &stderr, time.Second, nil, "user@example.com", "work")
148+ tr := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
149+
150+ _, err = tr.Recv()
151+ if err == nil {
152+ t.Fatal("Recv error = nil, want ssh exit error")
153+ }
154+ for _, want := range []string{"sshstdio: ssh exited:", "connection refused"} {
155+ if !strings.Contains(err.Error(), want) {
156+ t.Fatalf("Recv error = %q, want substring %q", err, want)
157+ }
158+ }
159+}
160+
131161 func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {
132162 cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")
133163 stdin, err := cmd.StdinPipe()
@@ -128,6 +128,36 @@ func TestDialContextCanceledBeforeStart(t *testing.T) {
128 }128 }
129 }129 }
130 130
131+func TestRecvReportsSSHExitWhenProcessClosesBeforeFrame(t *testing.T) {
132+ cmd := exec.Command("sh", "-c", "echo connection refused >&2; exit 255")
133+ stdin, err := cmd.StdinPipe()
134+ if err != nil {
135+ t.Fatalf("StdinPipe: %v", err)
136+ }
137+ stdout, err := cmd.StdoutPipe()
138+ if err != nil {
139+ t.Fatalf("StdoutPipe: %v", err)
140+ }
141+ var stderr bytes.Buffer
142+ cmd.Stderr = &stderr
143+ if err := cmd.Start(); err != nil {
144+ t.Fatalf("Start: %v", err)
145+ }
146+
147+ waiter := newProcessWaiter(cmd, stdin, &stderr, time.Second, nil, "user@example.com", "work")
148+ tr := newTransport(stdout, stdin, waiter.close, waiter.eofErr)
149+
150+ _, err = tr.Recv()
151+ if err == nil {
152+ t.Fatal("Recv error = nil, want ssh exit error")
153+ }
154+ for _, want := range []string{"sshstdio: ssh exited:", "connection refused"} {
155+ if !strings.Contains(err.Error(), want) {
156+ t.Fatalf("Recv error = %q, want substring %q", err, want)
157+ }
158+ }
159+}
160+
131 func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {161 func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) {
132 cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")162 cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7")
133 stdin, err := cmd.StdinPipe()163 stdin, err := cmd.StdinPipe()
M internal/app/run.go +3 -3
@@ -374,10 +374,10 @@ type runAttachDeps struct {
374374
375375 func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
376376 switch value {
377- case "", string(ports.RemoteTransportUDP):
378- return ports.RemoteTransportUDP, nil
379- case string(ports.RemoteTransportStdio):
377+ case "", string(ports.RemoteTransportStdio):
380378 return ports.RemoteTransportStdio, nil
379+ case string(ports.RemoteTransportUDP):
380+ return ports.RemoteTransportUDP, nil
381381 default:
382382 return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)
383383 }
@@ -374,10 +374,10 @@ type runAttachDeps struct {
374 374
375 func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {375 func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) {
376 switch value {376 switch value {
377- case "", string(ports.RemoteTransportUDP):377+ case "", string(ports.RemoteTransportStdio):
378- return ports.RemoteTransportUDP, nil
379- case string(ports.RemoteTransportStdio):
380 return ports.RemoteTransportStdio, nil378 return ports.RemoteTransportStdio, nil
379+ case string(ports.RemoteTransportUDP):
380+ return ports.RemoteTransportUDP, nil
381 default:381 default:
382 return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)382 return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio)
383 }383 }
M internal/app/run_test.go +3 -2
@@ -448,7 +448,8 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
448448 selectedTransport string
449449 wantMode ports.RemoteTransportMode
450450 }{
451- {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
451+ {name: "default remote mode is stdio", selectedTransport: "", wantMode: ports.RemoteTransportStdio},
452+ {name: "explicit udp mode", selectedTransport: "udp", wantMode: ports.RemoteTransportUDP},
452453 {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
453454 }
454455
@@ -518,7 +519,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
518519 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
519520 factoryErr := errors.New("factory failed")
520521 factory := portsmocks.NewMockRemoteDialerFactory(t)
521- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
522+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(nil, factoryErr)
522523 runClientCalled := false
523524
524525 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
@@ -448,7 +448,8 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
448 selectedTransport string448 selectedTransport string
449 wantMode ports.RemoteTransportMode449 wantMode ports.RemoteTransportMode
450 }{450 }{
451- {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},451+ {name: "default remote mode is stdio", selectedTransport: "", wantMode: ports.RemoteTransportStdio},
452+ {name: "explicit udp mode", selectedTransport: "udp", wantMode: ports.RemoteTransportUDP},
452 {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},453 {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
453 }454 }
454 455
@@ -518,7 +519,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
518 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {519 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
519 factoryErr := errors.New("factory failed")520 factoryErr := errors.New("factory failed")
520 factory := portsmocks.NewMockRemoteDialerFactory(t)521 factory := portsmocks.NewMockRemoteDialerFactory(t)
521- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)522+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(nil, factoryErr)
522 runClientCalled := false523 runClientCalled := false
523 524
524 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{525 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
A scripts/debug-remote-attach.sh +165 -0
@@ -0,0 +1,165 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+usage() {
5+ cat <<'USAGE' >&2
6+Usage: scripts/debug-remote-attach.sh [--mode stdio|udp|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host
7+
8+Runs a real remote attach smoke/debug attempt under a PTY, captures local and
9+remote vev versions, and stores local/remote log tails around the attempt.
10+
11+Examples:
12+ scripts/debug-remote-attach.sh brice@arch
13+ scripts/debug-remote-attach.sh --session work brice@arch
14+ scripts/debug-remote-attach.sh --mode udp brice@arch
15+ go build -o /tmp/vev-debug . && scripts/debug-remote-attach.sh --vev-bin /tmp/vev-debug brice@arch
16+
17+Notes:
18+ - The command times out intentionally if attach succeeds and stays interactive.
19+ - Exit 124 from timeout is treated as a likely successful attach if no early
20+ vev/ssh error is captured; inspect the bundle for confirmation.
21+ - The remote host must be reachable by SSH for log collection.
22+ - The attach attempt unsets VEV so the smoke test can run from inside vev.
23+USAGE
24+}
25+
26+mode="default"
27+session=""
28+duration="8s"
29+vev_bin="${VEV_BIN:-vev}"
30+while [[ $# -gt 0 ]]; do
31+ case "$1" in
32+ --mode)
33+ mode="${2:-}"; shift 2 ;;
34+ --session)
35+ session="${2:-}"; shift 2 ;;
36+ --duration)
37+ duration="${2:-}"; shift 2 ;;
38+ --vev-bin)
39+ vev_bin="${2:-}"; shift 2 ;;
40+ -h|--help)
41+ usage; exit 0 ;;
42+ --)
43+ shift; break ;;
44+ -*)
45+ echo "unknown option: $1" >&2; usage; exit 2 ;;
46+ *)
47+ break ;;
48+ esac
49+done
50+
51+if [[ $# -ne 1 ]]; then
52+ usage
53+ exit 2
54+fi
55+case "$mode" in
56+ default|stdio|udp) ;;
57+ *) echo "invalid --mode $mode (want default, stdio, or udp)" >&2; exit 2 ;;
58+esac
59+
60+target="$1"
61+attach_target="$target"
62+if [[ -n "$session" ]]; then
63+ attach_target="$target:$session"
64+fi
65+
66+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
67+out_dir="${TMPDIR:-/tmp}/vev-remote-attach-$target-$(date +%Y%m%d-%H%M%S)"
68+out_dir="${out_dir//@/_}"
69+out_dir="${out_dir//:/_}"
70+mkdir -p "$out_dir"
71+
72+local_state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"
73+
74+run_capture() {
75+ local name="$1"; shift
76+ {
77+ printf '$'
78+ printf ' %q' "$@"
79+ printf '\n'
80+ "$@"
81+ } >"$out_dir/$name.out" 2>"$out_dir/$name.err" || printf '%s\n' "$?" >"$out_dir/$name.exit"
82+}
83+
84+copy_local_logs() {
85+ local label="$1"
86+ mkdir -p "$out_dir/local-$label"
87+ if [[ -d "$local_state" ]]; then
88+ find "$local_state" -maxdepth 1 -type f -name 'vev*.log' -print0 2>/dev/null | while IFS= read -r -d '' f; do
89+ tail -n 200 "$f" >"$out_dir/local-$label/$(basename "$f")" || true
90+ done
91+ fi
92+}
93+
94+copy_remote_logs() {
95+ local label="$1"
96+ mkdir -p "$out_dir/remote-$label"
97+ ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; if [ -d "$state" ]; then for f in "$state"/vev*.log; do [ -e "$f" ] || continue; echo "===== $f ====="; tail -n 200 "$f"; done; fi' \
98+ >"$out_dir/remote-$label/logs.out" 2>"$out_dir/remote-$label/logs.err" || true
99+}
100+
101+printf 'debug bundle: %s\n' "$out_dir"
102+printf 'target: %s\nattach_target: %s\nmode: %s\nduration: %s\n' "$target" "$attach_target" "$mode" "$duration" >"$out_dir/summary.txt"
103+
104+(
105+ cd "$repo_root"
106+ git rev-parse --show-toplevel
107+ git rev-parse --short HEAD
108+ git status --short
109+) >"$out_dir/git.txt" 2>&1 || true
110+
111+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"' \
113+ >"$out_dir/remote-version.out" 2>"$out_dir/remote-version.err" || echo "$?" >"$out_dir/remote-version.exit"
114+
115+copy_local_logs before
116+copy_remote_logs before
117+
118+attach_log="$out_dir/attach.typescript"
119+attach_err="$out_dir/attach.err"
120+cmd=("$vev_bin" attach "$attach_target")
121+if [[ "$mode" == "default" ]]; then
122+ env_cmd=(env -u VEV)
123+else
124+ env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode")
125+fi
126+
127+set +e
128+if command -v script >/dev/null 2>&1; then
129+ printf -v attach_cmd '%q ' "${cmd[@]}"
130+ "${env_cmd[@]}" timeout "$duration" script -q -e -c "$attach_cmd" "$attach_log" >"$out_dir/attach.stdout" 2>"$attach_err"
131+else
132+ "${env_cmd[@]}" timeout "$duration" "${cmd[@]}" >"$out_dir/attach.stdout" 2>"$attach_err"
133+fi
134+attach_status=$?
135+set -e
136+printf '%s\n' "$attach_status" >"$out_dir/attach.exit"
137+
138+copy_local_logs after
139+copy_remote_logs after
140+
141+{
142+ echo "debug bundle: $out_dir"
143+ echo "attach exit: $attach_status"
144+ echo "local version:"
145+ sed 's/^/ /' "$out_dir/local-version.out" 2>/dev/null || true
146+ echo "remote version:"
147+ sed 's/^/ /' "$out_dir/remote-version.out" 2>/dev/null || true
148+ if [[ -s "$attach_err" ]]; then
149+ echo "attach stderr:"
150+ sed 's/^/ /' "$attach_err" | tail -40
151+ fi
152+ echo "recent local after logs:"
153+ find "$out_dir/local-after" -type f -maxdepth 1 -print 2>/dev/null | while read -r f; do
154+ echo " --- $(basename "$f") ---"
155+ tail -20 "$f" | sed 's/^/ /'
156+ done
157+ echo "recent remote after logs:"
158+ tail -80 "$out_dir/remote-after/logs.out" 2>/dev/null | sed 's/^/ /' || true
159+} | tee -a "$out_dir/summary.txt"
160+
161+if [[ "$attach_status" -eq 124 ]]; then
162+ echo "attach timed out after $duration; this often means the interactive attach stayed alive. Inspect $out_dir for protocol/log errors."
163+ exit 0
164+fi
165+exit "$attach_status"
@@ -0,0 +1,165 @@
1+#!/usr/bin/env bash
2+set -euo pipefail
3+
4+usage() {
5+ cat <<'USAGE' >&2
6+Usage: scripts/debug-remote-attach.sh [--mode stdio|udp|default] [--session NAME] [--duration 8s] [--vev-bin PATH] user@host
7+
8+Runs a real remote attach smoke/debug attempt under a PTY, captures local and
9+remote vev versions, and stores local/remote log tails around the attempt.
10+
11+Examples:
12+ scripts/debug-remote-attach.sh brice@arch
13+ scripts/debug-remote-attach.sh --session work brice@arch
14+ scripts/debug-remote-attach.sh --mode udp brice@arch
15+ go build -o /tmp/vev-debug . && scripts/debug-remote-attach.sh --vev-bin /tmp/vev-debug brice@arch
16+
17+Notes:
18+ - The command times out intentionally if attach succeeds and stays interactive.
19+ - Exit 124 from timeout is treated as a likely successful attach if no early
20+ vev/ssh error is captured; inspect the bundle for confirmation.
21+ - The remote host must be reachable by SSH for log collection.
22+ - The attach attempt unsets VEV so the smoke test can run from inside vev.
23+USAGE
24+}
25+
26+mode="default"
27+session=""
28+duration="8s"
29+vev_bin="${VEV_BIN:-vev}"
30+while [[ $# -gt 0 ]]; do
31+ case "$1" in
32+ --mode)
33+ mode="${2:-}"; shift 2 ;;
34+ --session)
35+ session="${2:-}"; shift 2 ;;
36+ --duration)
37+ duration="${2:-}"; shift 2 ;;
38+ --vev-bin)
39+ vev_bin="${2:-}"; shift 2 ;;
40+ -h|--help)
41+ usage; exit 0 ;;
42+ --)
43+ shift; break ;;
44+ -*)
45+ echo "unknown option: $1" >&2; usage; exit 2 ;;
46+ *)
47+ break ;;
48+ esac
49+done
50+
51+if [[ $# -ne 1 ]]; then
52+ usage
53+ exit 2
54+fi
55+case "$mode" in
56+ default|stdio|udp) ;;
57+ *) echo "invalid --mode $mode (want default, stdio, or udp)" >&2; exit 2 ;;
58+esac
59+
60+target="$1"
61+attach_target="$target"
62+if [[ -n "$session" ]]; then
63+ attach_target="$target:$session"
64+fi
65+
66+repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
67+out_dir="${TMPDIR:-/tmp}/vev-remote-attach-$target-$(date +%Y%m%d-%H%M%S)"
68+out_dir="${out_dir//@/_}"
69+out_dir="${out_dir//:/_}"
70+mkdir -p "$out_dir"
71+
72+local_state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"
73+
74+run_capture() {
75+ local name="$1"; shift
76+ {
77+ printf '$'
78+ printf ' %q' "$@"
79+ printf '\n'
80+ "$@"
81+ } >"$out_dir/$name.out" 2>"$out_dir/$name.err" || printf '%s\n' "$?" >"$out_dir/$name.exit"
82+}
83+
84+copy_local_logs() {
85+ local label="$1"
86+ mkdir -p "$out_dir/local-$label"
87+ if [[ -d "$local_state" ]]; then
88+ find "$local_state" -maxdepth 1 -type f -name 'vev*.log' -print0 2>/dev/null | while IFS= read -r -d '' f; do
89+ tail -n 200 "$f" >"$out_dir/local-$label/$(basename "$f")" || true
90+ done
91+ fi
92+}
93+
94+copy_remote_logs() {
95+ local label="$1"
96+ mkdir -p "$out_dir/remote-$label"
97+ ssh -o BatchMode=yes -o ConnectTimeout=8 "$target" 'state="${XDG_STATE_HOME:-$HOME/.local/state}/vev"; if [ -d "$state" ]; then for f in "$state"/vev*.log; do [ -e "$f" ] || continue; echo "===== $f ====="; tail -n 200 "$f"; done; fi' \
98+ >"$out_dir/remote-$label/logs.out" 2>"$out_dir/remote-$label/logs.err" || true
99+}
100+
101+printf 'debug bundle: %s\n' "$out_dir"
102+printf 'target: %s\nattach_target: %s\nmode: %s\nduration: %s\n' "$target" "$attach_target" "$mode" "$duration" >"$out_dir/summary.txt"
103+
104+(
105+ cd "$repo_root"
106+ git rev-parse --show-toplevel
107+ git rev-parse --short HEAD
108+ git status --short
109+) >"$out_dir/git.txt" 2>&1 || true
110+
111+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"' \
113+ >"$out_dir/remote-version.out" 2>"$out_dir/remote-version.err" || echo "$?" >"$out_dir/remote-version.exit"
114+
115+copy_local_logs before
116+copy_remote_logs before
117+
118+attach_log="$out_dir/attach.typescript"
119+attach_err="$out_dir/attach.err"
120+cmd=("$vev_bin" attach "$attach_target")
121+if [[ "$mode" == "default" ]]; then
122+ env_cmd=(env -u VEV)
123+else
124+ env_cmd=(env -u VEV "VEV_REMOTE_TRANSPORT=$mode")
125+fi
126+
127+set +e
128+if command -v script >/dev/null 2>&1; then
129+ printf -v attach_cmd '%q ' "${cmd[@]}"
130+ "${env_cmd[@]}" timeout "$duration" script -q -e -c "$attach_cmd" "$attach_log" >"$out_dir/attach.stdout" 2>"$attach_err"
131+else
132+ "${env_cmd[@]}" timeout "$duration" "${cmd[@]}" >"$out_dir/attach.stdout" 2>"$attach_err"
133+fi
134+attach_status=$?
135+set -e
136+printf '%s\n' "$attach_status" >"$out_dir/attach.exit"
137+
138+copy_local_logs after
139+copy_remote_logs after
140+
141+{
142+ echo "debug bundle: $out_dir"
143+ echo "attach exit: $attach_status"
144+ echo "local version:"
145+ sed 's/^/ /' "$out_dir/local-version.out" 2>/dev/null || true
146+ echo "remote version:"
147+ sed 's/^/ /' "$out_dir/remote-version.out" 2>/dev/null || true
148+ if [[ -s "$attach_err" ]]; then
149+ echo "attach stderr:"
150+ sed 's/^/ /' "$attach_err" | tail -40
151+ fi
152+ echo "recent local after logs:"
153+ find "$out_dir/local-after" -type f -maxdepth 1 -print 2>/dev/null | while read -r f; do
154+ echo " --- $(basename "$f") ---"
155+ tail -20 "$f" | sed 's/^/ /'
156+ done
157+ echo "recent remote after logs:"
158+ tail -80 "$out_dir/remote-after/logs.out" 2>/dev/null | sed 's/^/ /' || true
159+} | tee -a "$out_dir/summary.txt"
160+
161+if [[ "$attach_status" -eq 124 ]]; then
162+ echo "attach timed out after $duration; this often means the interactive attach stayed alive. Inspect $out_dir for protocol/log errors."
163+ exit 0
164+fi
165+exit "$attach_status"

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