detached 5db9466

fix(remote): keep udp default, make probe failure actionable

The mitigation commit flipped the default remote transport to stdio, which
hid the real UDP path instead of fixing it. Restore udp as the default and,
when the UDP reachability probe fails, surface actionable guidance: a failed
probe is almost always a remote firewall dropping datagrams to vev's
ephemeral UDP proxy port, not a vev bug. Point at the two real fixes
(open UDP on the remote, e.g. ufw allow in on tailscale0, or
VEV_REMOTE_TRANSPORT=stdio) and drop the doubled 'vev:' prefix.

Keeps the branch's useful sshstdio EOF->ssh-exit error surfacing and the
debug-remote-attach.sh script.
commit
5db946641e30ac6b142efbe16fc83b92ea081199
author
brice · · 2026-07-07 01:13 UTC
parent
e555c28
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +30 -13
M README.md +4 -2
@@ -120,9 +120,11 @@ vev attach user@host
120120 vev attach user@host:session
121121 ```
122122
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.
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.
124124
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.
125+The UDP proxy binds an ephemeral high port on the remote, so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP on the remote to fix it — for example, on a trusted Tailscale network, `sudo ufw allow in on tailscale0`.
126+
127+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.
126128
127129 ## Terminal color
128130
@@ -120,9 +120,11 @@ vev attach user@host
120 vev attach user@host:session120 vev attach user@host:session
121 ```121 ```
122 122
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.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.
124 124
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.125+The UDP proxy binds an ephemeral high port on the remote, so a host firewall that only allows SSH will drop the session datagrams and attach fails with `remote UDP transport unavailable: probe UDP transport`. Allow inbound UDP on the remote to fix it — for example, on a trusted Tailscale network, `sudo ufw allow in on tailscale0`.
126+
127+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.
126 128
127 ## Terminal color129 ## Terminal color
128 130
M internal/adapters/dgram/ssh_dialer.go +14 -3
@@ -153,7 +153,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
153153 defer cancel()
154154 if err := probeUDPTransport(probeCtx, t); err != nil {
155155 _ = t.Close()
156- return nil, udpUnavailable("probe UDP transport", err, &stderr)
156+ return nil, udpProbeUnreachable(d.Target, err, &stderr)
157157 }
158158 cleanup = false
159159 return t, nil
@@ -235,9 +235,20 @@ func readUDPReady(r io.Reader) (udpReady, error) {
235235 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
236236 msg := strings.TrimSpace(stderr.String())
237237 if msg != "" {
238- return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)
238+ return fmt.Errorf("remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)
239239 }
240- return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w", action, err)
240+ return fmt.Errorf("remote UDP transport unavailable: %s: %w", action, err)
241+}
242+
243+// udpProbeUnreachable wraps a probe failure with actionable guidance. A failed
244+// probe almost always means a firewall on the remote is dropping datagrams to
245+// vev's UDP proxy rather than a vev bug, so name the two real fixes instead of
246+// surfacing a bare deadline error.
247+func udpProbeUnreachable(target string, err error, stderr fmt.Stringer) error {
248+ return fmt.Errorf("%w\n"+
249+ " the remote host is not reachable over UDP; a firewall is likely dropping the datagrams.\n"+
250+ " open UDP on the remote (e.g. `sudo ufw allow in on tailscale0`) or attach over SSH with `VEV_REMOTE_TRANSPORT=stdio vev attach %s`",
251+ udpUnavailable("probe UDP transport", err, stderr), target)
241252 }
242253
243254 func sshTargetHost(target string) string {
@@ -153,7 +153,7 @@ func (d RemoteDialer) Dial(ctx context.Context) (ports.Transport, error) {
153 defer cancel()153 defer cancel()
154 if err := probeUDPTransport(probeCtx, t); err != nil {154 if err := probeUDPTransport(probeCtx, t); err != nil {
155 _ = t.Close()155 _ = t.Close()
156- return nil, udpUnavailable("probe UDP transport", err, &stderr)156+ return nil, udpProbeUnreachable(d.Target, err, &stderr)
157 }157 }
158 cleanup = false158 cleanup = false
159 return t, nil159 return t, nil
@@ -235,9 +235,20 @@ func readUDPReady(r io.Reader) (udpReady, error) {
235 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {235 func udpUnavailable(action string, err error, stderr fmt.Stringer) error {
236 msg := strings.TrimSpace(stderr.String())236 msg := strings.TrimSpace(stderr.String())
237 if msg != "" {237 if msg != "" {
238- return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)238+ return fmt.Errorf("remote UDP transport unavailable: %s: %w (stderr: %s)", action, err, msg)
239 }239 }
240- return fmt.Errorf("vev: remote UDP transport unavailable: %s: %w", action, err)240+ return fmt.Errorf("remote UDP transport unavailable: %s: %w", action, err)
241+}
242+
243+// udpProbeUnreachable wraps a probe failure with actionable guidance. A failed
244+// probe almost always means a firewall on the remote is dropping datagrams to
245+// vev's UDP proxy rather than a vev bug, so name the two real fixes instead of
246+// surfacing a bare deadline error.
247+func udpProbeUnreachable(target string, err error, stderr fmt.Stringer) error {
248+ return fmt.Errorf("%w\n"+
249+ " the remote host is not reachable over UDP; a firewall is likely dropping the datagrams.\n"+
250+ " open UDP on the remote (e.g. `sudo ufw allow in on tailscale0`) or attach over SSH with `VEV_REMOTE_TRANSPORT=stdio vev attach %s`",
251+ udpUnavailable("probe UDP transport", err, stderr), target)
241 }252 }
242 253
243 func sshTargetHost(target string) string {254 func sshTargetHost(target string) string {
M internal/adapters/dgram/ssh_dialer_test.go +7 -2
@@ -59,7 +59,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
5959 waited bool
6060 wantStdoutClosed bool
6161 }{
62- {name: "start failure", start: errors.New("ssh missing"), want: "vev: remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
62+ {name: "start failure", start: errors.New("ssh missing"), want: "remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},
6363 {name: "malformed readiness", stdout: "hello\n", want: "malformed readiness line", killed: true, waited: true, wantStdoutClosed: true},
6464 {name: "bootstrap wait failure", stdout: "VEV-UDP 4444 " + key + "\n", want: "wait bootstrap: exit status 255", waited: true, wantStdoutClosed: true},
6565 {name: "packet listen failure", stdout: "VEV-UDP 4444 " + key + "\n", listen: errors.New("bind denied"), want: "listen UDP: bind denied", waited: true, wantStdoutClosed: true},
@@ -131,9 +131,14 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
131131 d := NewRemoteDialer("127.0.0.1", "work")
132132 d.ProbeTimeout = time.Nanosecond
133133 tr, err := d.Dial(context.Background())
134- if err == nil || !strings.Contains(err.Error(), "vev: remote UDP transport unavailable: probe UDP transport") {
134+ if err == nil || !strings.Contains(err.Error(), "remote UDP transport unavailable: probe UDP transport") {
135135 t.Fatalf("Dial() error = %v, want probe unavailable", err)
136136 }
137+ for _, want := range []string{"firewall", "VEV_REMOTE_TRANSPORT=stdio vev attach 127.0.0.1"} {
138+ if !strings.Contains(err.Error(), want) {
139+ t.Fatalf("Dial() error = %q, want actionable hint %q", err, want)
140+ }
141+ }
137142 if tr != nil {
138143 t.Fatalf("transport=%T, want nil", tr)
139144 }
@@ -59,7 +59,7 @@ func TestRemoteDialerUDPBootstrapFailures(t *testing.T) {
59 waited bool59 waited bool
60 wantStdoutClosed bool60 wantStdoutClosed bool
61 }{61 }{
62- {name: "start failure", start: errors.New("ssh missing"), want: "vev: remote UDP transport unavailable: start bootstrap: ssh missing", wantStdoutClosed: true},62+ {name: "start failure", start: errors.New("ssh missing"), want: "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},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},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},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},
@@ -131,9 +131,14 @@ func TestRemoteDialerProbeFailureCleansUpWithoutStdioFallback(t *testing.T) {
131 d := NewRemoteDialer("127.0.0.1", "work")131 d := NewRemoteDialer("127.0.0.1", "work")
132 d.ProbeTimeout = time.Nanosecond132 d.ProbeTimeout = time.Nanosecond
133 tr, err := d.Dial(context.Background())133 tr, err := d.Dial(context.Background())
134- if err == nil || !strings.Contains(err.Error(), "vev: remote UDP transport unavailable: probe UDP transport") {134+ if err == nil || !strings.Contains(err.Error(), "remote UDP transport unavailable: probe UDP transport") {
135 t.Fatalf("Dial() error = %v, want probe unavailable", err)135 t.Fatalf("Dial() error = %v, want probe unavailable", err)
136 }136 }
137+ for _, want := range []string{"firewall", "VEV_REMOTE_TRANSPORT=stdio vev attach 127.0.0.1"} {
138+ if !strings.Contains(err.Error(), want) {
139+ t.Fatalf("Dial() error = %q, want actionable hint %q", err, want)
140+ }
141+ }
137 if tr != nil {142 if tr != nil {
138 t.Fatalf("transport=%T, want nil", tr)143 t.Fatalf("transport=%T, want nil", tr)
139 }144 }
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.RemoteTransportStdio):
378- return ports.RemoteTransportStdio, nil
379- case string(ports.RemoteTransportUDP):
377+ case "", string(ports.RemoteTransportUDP):
380378 return ports.RemoteTransportUDP, nil
379+ case string(ports.RemoteTransportStdio):
380+ return ports.RemoteTransportStdio, 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.RemoteTransportStdio):377+ case "", string(ports.RemoteTransportUDP):
378- return ports.RemoteTransportStdio, nil
379- case string(ports.RemoteTransportUDP):
380 return ports.RemoteTransportUDP, nil378 return ports.RemoteTransportUDP, nil
379+ case string(ports.RemoteTransportStdio):
380+ return ports.RemoteTransportStdio, 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 +2 -3
@@ -448,8 +448,7 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) {
448448 selectedTransport string
449449 wantMode ports.RemoteTransportMode
450450 }{
451- {name: "default remote mode is stdio", selectedTransport: "", wantMode: ports.RemoteTransportStdio},
452- {name: "explicit udp mode", selectedTransport: "udp", wantMode: ports.RemoteTransportUDP},
451+ {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
453452 {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
454453 }
455454
@@ -519,7 +518,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
519518 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
520519 factoryErr := errors.New("factory failed")
521520 factory := portsmocks.NewMockRemoteDialerFactory(t)
522- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(nil, factoryErr)
521+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
523522 runClientCalled := false
524523
525524 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{
@@ -448,8 +448,7 @@ 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 stdio", selectedTransport: "", wantMode: ports.RemoteTransportStdio},451+ {name: "default remote mode is udp", selectedTransport: "", wantMode: ports.RemoteTransportUDP},
452- {name: "explicit udp mode", selectedTransport: "udp", wantMode: ports.RemoteTransportUDP},
453 {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},452 {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio},
454 }453 }
455 454
@@ -519,7 +518,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing.
519 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {518 func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) {
520 factoryErr := errors.New("factory failed")519 factoryErr := errors.New("factory failed")
521 factory := portsmocks.NewMockRemoteDialerFactory(t)520 factory := portsmocks.NewMockRemoteDialerFactory(t)
522- factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportStdio, mock.Anything).Return(nil, factoryErr)521+ factory.EXPECT().DialerForRemote("remote.example", "work", ports.RemoteTransportUDP, mock.Anything).Return(nil, factoryErr)
523 runClientCalled := false522 runClientCalled := false
524 523
525 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{524 err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{

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