fix(remote): restore ssh attach default
- commit
- e555c28ca2eb8c02e462532b1db27e15a356ca99
- author
- brice · · 2026-07-07 00:51 UTC
- parent
- 7047f01
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +290 -35
M README.md +2 -2
| @@ -120,9 +120,9 @@ vev attach user@host | |||
| 120 | 120 | 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 | 127 | ## Terminal color | |
| 128 | 128 | ||
| @@ -120,9 +120,9 @@ vev attach user@host | |||||
| 120 | vev attach user@host:session | 120 | 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 color | 127 | ## Terminal color | ||
| 128 | 128 | ||||
M internal/adapters/sshstdio/transport.go +87 -28
| @@ -32,19 +32,25 @@ var ( | |||
| 32 | 32 | ) | |
| 33 | 33 | ||
| 34 | 34 | type closeFunc func() error | |
| 35 | + | type eofErrFunc func() error | |
| 35 | 36 | ||
| 36 | 37 | // NewTransport wraps separate reader/writer streams as a framed Transport. | |
| 37 | 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 | 43 | if close == nil { | |
| 39 | 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 | 49 | 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 | |
| 48 | 54 | ||
| 49 | 55 | mu sync.Mutex | |
| 50 | 56 | readBuf []byte | |
| @@ -70,7 +76,7 @@ func (t *transport) Send(f ports.Frame) error { | |||
| 70 | 76 | func (t *transport) Recv() (ports.Frame, error) { | |
| 71 | 77 | var hdr [frameHeaderLen]byte | |
| 72 | 78 | if _, err := io.ReadFull(t.r, hdr[:]); err != nil { | |
| 73 | - | return ports.Frame{}, err | |
| 79 | + | return ports.Frame{}, t.mapEOFError(err) | |
| 74 | 80 | } | |
| 75 | 81 | ||
| 76 | 82 | n := binary.BigEndian.Uint32(hdr[:]) | |
| @@ -94,39 +100,91 @@ func (t *transport) Recv() (ports.Frame, error) { | |||
| 94 | 100 | 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 | 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 { | |
| 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 | + | } | |
| 102 | 145 | ||
| 103 | - | waitErr := make(chan error, 1) | |
| 104 | - | go func() { waitErr <- cmd.Wait() }() | |
| 146 | + | func (w *processWaiter) eofErr() error { | |
| 147 | + | return w.wait(sshCloseTimeout) | |
| 148 | + | } | |
| 105 | 149 | ||
| 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() }() | |
| 107 | 157 | select { | |
| 108 | - | case err = <-waitErr: | |
| 158 | + | case w.waitErr = <-waitCh: | |
| 109 | 159 | 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 | |
| 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()) | |
| 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} | |
| 125 | 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 | 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 | 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)), nil | |
| 256 | + | waiter := newProcessWaiter(cmd, stdin, &stderr, sshCloseTimeout, log, target, session) | |
| 257 | + | return newTransport(stdout, stdin, waiter.close, waiter.eofErr), nil | |
| 199 | 258 | } | |
| @@ -32,19 +32,25 @@ var ( | |||||
| 32 | ) | 32 | ) | ||
| 33 | 33 | ||||
| 34 | type closeFunc func() error | 34 | 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.Reader | 50 | + | r io.Reader |
| 46 | - | w io.Writer | 51 | + | w io.Writer |
| 47 | - | close closeFunc | 52 | + | close closeFunc |
| 53 | + | eofErr eofErrFunc | |||
| 48 | 54 | ||||
| 49 | mu sync.Mutex | 55 | mu sync.Mutex | ||
| 50 | readBuf []byte | 56 | 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]byte | 77 | 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{}, err | 79 | + | 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}, nil | 100 | 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 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() }() | |||
| 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 = <-waitErr | 161 | + | 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)), nil | 256 | + | 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) { | |||
| 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 | 161 | func TestProcessCloserLogsNonCleanExitStderr(t *testing.T) { | |
| 132 | 162 | cmd := exec.Command("sh", "-c", "echo remote failure >&2; exit 7") | |
| 133 | 163 | 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 { | |||
| 374 | 374 | ||
| 375 | 375 | func remoteTransportModeFromEnv(value string) (ports.RemoteTransportMode, error) { | |
| 376 | 376 | switch value { | |
| 377 | - | case "", string(ports.RemoteTransportUDP): | |
| 378 | - | return ports.RemoteTransportUDP, nil | |
| 379 | - | case string(ports.RemoteTransportStdio): | |
| 377 | + | case "", string(ports.RemoteTransportStdio): | |
| 380 | 378 | return ports.RemoteTransportStdio, nil | |
| 379 | + | case string(ports.RemoteTransportUDP): | |
| 380 | + | return ports.RemoteTransportUDP, nil | |
| 381 | 381 | default: | |
| 382 | 382 | return "", fmt.Errorf("vev: invalid remote transport %q (want %q or %q)", value, ports.RemoteTransportUDP, ports.RemoteTransportStdio) | |
| 383 | 383 | } | |
| @@ -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, nil | 378 | 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) { | |||
| 448 | 448 | selectedTransport string | |
| 449 | 449 | 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 | 453 | {name: "explicit stdio mode", selectedTransport: "stdio", wantMode: ports.RemoteTransportStdio}, | |
| 453 | 454 | } | |
| 454 | 455 | ||
| @@ -518,7 +519,7 @@ func TestRunAttachWithDepsRejectsInvalidRemoteTransportBeforeDialing(t *testing. | |||
| 518 | 519 | func TestRunAttachWithDepsReturnsFactoryErrorBeforeRunClient(t *testing.T) { | |
| 519 | 520 | factoryErr := errors.New("factory failed") | |
| 520 | 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 | 523 | runClientCalled := false | |
| 523 | 524 | ||
| 524 | 525 | err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "remote.example", "", nil, runAttachDeps{ | |
| @@ -448,7 +448,8 @@ func TestRunAttachWithDepsSelectsRemoteTransport(t *testing.T) { | |||||
| 448 | selectedTransport string | 448 | selectedTransport string | ||
| 449 | wantMode ports.RemoteTransportMode | 449 | 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 := false | 523 | 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" | |||