detached bdf8e57

fix(daemonmux): classify a missing remote daemon during the handshake

commit
bdf8e57f1106daaf6cf0a3507a70d975d66e3798
author
brice · · 2026-09-23 08:22 UTC
parent
26fe86a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +67 -0
M internal/adapters/daemonmux/connector.go +18 -0
@@ -37,8 +37,10 @@ import (
3737 "context"
3838 "errors"
3939 "fmt"
40+ "os/exec"
4041 "sync"
4142
43+ "github.com/bnema/vev/internal/adapters/sshstdio"
4244 "github.com/bnema/vev/internal/domain"
4345 "github.com/bnema/vev/internal/ports"
4446 )
@@ -180,6 +182,9 @@ func dialError(err error) error {
180182 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
181183 return err
182184 }
185+ if missingRemoteDaemon(err) {
186+ return ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: err}
187+ }
183188 var typed ports.BrokerError
184189 if errors.As(err, &typed) && typed.Validate() == nil {
185190 return err
@@ -187,6 +192,16 @@ func dialError(err error) error {
187192 return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: err}
188193 }
189194
195+// missingRemoteDaemon only accepts the reserved helper exit after SSH has
196+// started; SSH's own failures (including exit 255) remain unavailable.
197+func missingRemoteDaemon(err error) bool {
198+ if !errors.Is(err, sshstdio.ErrMuxSSHExit) {
199+ return false
200+ }
201+ var exit *exec.ExitError
202+ return errors.As(err, &exit) && exit.ExitCode() == sshstdio.MuxExitNoDaemon
203+}
204+
190205 // handshakeError classifies one handshake failure: cancellation and deadline
191206 // stay recognizable, a peer refusal is a policy conflict, a local authority
192207 // mismatch is an incompatibility, and anything else is unavailable.
@@ -197,6 +212,9 @@ func handshakeError(err error) error {
197212 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
198213 return err
199214 }
215+ if missingRemoteDaemon(err) {
216+ return ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: err}
217+ }
200218 var refusal *HandshakeRefusal
201219 if errors.As(err, &refusal) {
202220 return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: err}
@@ -37,8 +37,10 @@ import (
37 "context"37 "context"
38 "errors"38 "errors"
39 "fmt"39 "fmt"
40+ "os/exec"
40 "sync"41 "sync"
41 42
43+ "github.com/bnema/vev/internal/adapters/sshstdio"
42 "github.com/bnema/vev/internal/domain"44 "github.com/bnema/vev/internal/domain"
43 "github.com/bnema/vev/internal/ports"45 "github.com/bnema/vev/internal/ports"
44 )46 )
@@ -180,6 +182,9 @@ func dialError(err error) error {
180 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {182 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
181 return err183 return err
182 }184 }
185+ if missingRemoteDaemon(err) {
186+ return ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: err}
187+ }
183 var typed ports.BrokerError188 var typed ports.BrokerError
184 if errors.As(err, &typed) && typed.Validate() == nil {189 if errors.As(err, &typed) && typed.Validate() == nil {
185 return err190 return err
@@ -187,6 +192,16 @@ func dialError(err error) error {
187 return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: err}192 return ports.BrokerError{Code: ports.BrokerErrorUnavailable, Cause: err}
188 }193 }
189 194
195+// missingRemoteDaemon only accepts the reserved helper exit after SSH has
196+// started; SSH's own failures (including exit 255) remain unavailable.
197+func missingRemoteDaemon(err error) bool {
198+ if !errors.Is(err, sshstdio.ErrMuxSSHExit) {
199+ return false
200+ }
201+ var exit *exec.ExitError
202+ return errors.As(err, &exit) && exit.ExitCode() == sshstdio.MuxExitNoDaemon
203+}
204+
190 // handshakeError classifies one handshake failure: cancellation and deadline205 // handshakeError classifies one handshake failure: cancellation and deadline
191 // stay recognizable, a peer refusal is a policy conflict, a local authority206 // stay recognizable, a peer refusal is a policy conflict, a local authority
192 // mismatch is an incompatibility, and anything else is unavailable.207 // mismatch is an incompatibility, and anything else is unavailable.
@@ -197,6 +212,9 @@ func handshakeError(err error) error {
197 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {212 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
198 return err213 return err
199 }214 }
215+ if missingRemoteDaemon(err) {
216+ return ports.BrokerError{Code: ports.BrokerErrorNoDaemon, Cause: err}
217+ }
200 var refusal *HandshakeRefusal218 var refusal *HandshakeRefusal
201 if errors.As(err, &refusal) {219 if errors.As(err, &refusal) {
202 return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: err}220 return ports.BrokerError{Code: ports.BrokerErrorConflictingPolicy, Cause: err}
M internal/adapters/daemonmux/connector_test.go +49 -0
@@ -2,7 +2,12 @@ package daemonmux
22
33 import (
44 "context"
5+ "errors"
6+ "fmt"
7+ "io"
58 "net"
9+ "os/exec"
10+ "runtime"
611 "sync"
712 "sync/atomic"
813 "testing"
@@ -12,6 +17,7 @@ import (
1217
1318 "github.com/bnema/vev/internal/adapters/clock"
1419 "github.com/bnema/vev/internal/adapters/ipc"
20+ "github.com/bnema/vev/internal/adapters/sshstdio"
1521 "github.com/bnema/vev/internal/domain"
1622 "github.com/bnema/vev/internal/ports"
1723 "github.com/bnema/vev/internal/protocol"
@@ -22,6 +28,49 @@ import (
2228 // admission handshake, never a handshake budget.
2329 const p3cTestDeadline = 5 * time.Second
2430
31+func TestDialErrorClassifiesMissingDaemonSeparately(t *testing.T) {
32+ if runtime.GOOS == "windows" {
33+ t.Skip("test uses the POSIX shell")
34+ }
35+ exitError := func(t *testing.T, code int) error {
36+ t.Helper()
37+ cmd := exec.Command("sh", "-c", fmt.Sprintf("exit %d", code))
38+ err := cmd.Run()
39+ var exit *exec.ExitError
40+ require.ErrorAs(t, err, &exit)
41+ return errors.Join(sshstdio.ErrMuxSSHExit, exit)
42+ }
43+
44+ tests := []struct {
45+ name string
46+ err error
47+ want ports.BrokerErrorCode
48+ }{
49+ {name: "reserved helper status means no daemon", err: exitError(t, sshstdio.MuxExitNoDaemon), want: ports.BrokerErrorNoDaemon},
50+ {name: "ssh transport status remains unavailable", err: exitError(t, 255), want: ports.BrokerErrorUnavailable},
51+ {name: "other helper status remains unavailable", err: exitError(t, 127), want: ports.BrokerErrorUnavailable},
52+ {name: "zero exit after carriage end remains unavailable", err: errors.Join(sshstdio.ErrMuxSSHExit, io.EOF), want: ports.BrokerErrorUnavailable},
53+ {name: "other mux failure remains unavailable", err: errors.New("socket timeout"), want: ports.BrokerErrorUnavailable},
54+ }
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ for _, classify := range []struct {
58+ name string
59+ fn func(error) error
60+ }{
61+ {name: "dial", fn: dialError},
62+ {name: "handshake", fn: handshakeError},
63+ } {
64+ t.Run(classify.name, func(t *testing.T) {
65+ var brokerErr ports.BrokerError
66+ require.ErrorAs(t, classify.fn(tt.err), &brokerErr)
67+ require.Equal(t, tt.want, brokerErr.Code)
68+ })
69+ }
70+ })
71+ }
72+}
73+
2574 // rawCarriagePair returns two independent real raw framed transports joined by
2675 // an in-memory duplex pipe. Each end frames through the shared streamframe
2776 // carriage via the IPC adapter, so the bridge, physical preamble, pump, and
@@ -2,7 +2,12 @@ package daemonmux
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
6+ "fmt"
7+ "io"
5 "net"8 "net"
9+ "os/exec"
10+ "runtime"
6 "sync"11 "sync"
7 "sync/atomic"12 "sync/atomic"
8 "testing"13 "testing"
@@ -12,6 +17,7 @@ import (
12 17
13 "github.com/bnema/vev/internal/adapters/clock"18 "github.com/bnema/vev/internal/adapters/clock"
14 "github.com/bnema/vev/internal/adapters/ipc"19 "github.com/bnema/vev/internal/adapters/ipc"
20+ "github.com/bnema/vev/internal/adapters/sshstdio"
15 "github.com/bnema/vev/internal/domain"21 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"22 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/protocol"23 "github.com/bnema/vev/internal/protocol"
@@ -22,6 +28,49 @@ import (
22 // admission handshake, never a handshake budget.28 // admission handshake, never a handshake budget.
23 const p3cTestDeadline = 5 * time.Second29 const p3cTestDeadline = 5 * time.Second
24 30
31+func TestDialErrorClassifiesMissingDaemonSeparately(t *testing.T) {
32+ if runtime.GOOS == "windows" {
33+ t.Skip("test uses the POSIX shell")
34+ }
35+ exitError := func(t *testing.T, code int) error {
36+ t.Helper()
37+ cmd := exec.Command("sh", "-c", fmt.Sprintf("exit %d", code))
38+ err := cmd.Run()
39+ var exit *exec.ExitError
40+ require.ErrorAs(t, err, &exit)
41+ return errors.Join(sshstdio.ErrMuxSSHExit, exit)
42+ }
43+
44+ tests := []struct {
45+ name string
46+ err error
47+ want ports.BrokerErrorCode
48+ }{
49+ {name: "reserved helper status means no daemon", err: exitError(t, sshstdio.MuxExitNoDaemon), want: ports.BrokerErrorNoDaemon},
50+ {name: "ssh transport status remains unavailable", err: exitError(t, 255), want: ports.BrokerErrorUnavailable},
51+ {name: "other helper status remains unavailable", err: exitError(t, 127), want: ports.BrokerErrorUnavailable},
52+ {name: "zero exit after carriage end remains unavailable", err: errors.Join(sshstdio.ErrMuxSSHExit, io.EOF), want: ports.BrokerErrorUnavailable},
53+ {name: "other mux failure remains unavailable", err: errors.New("socket timeout"), want: ports.BrokerErrorUnavailable},
54+ }
55+ for _, tt := range tests {
56+ t.Run(tt.name, func(t *testing.T) {
57+ for _, classify := range []struct {
58+ name string
59+ fn func(error) error
60+ }{
61+ {name: "dial", fn: dialError},
62+ {name: "handshake", fn: handshakeError},
63+ } {
64+ t.Run(classify.name, func(t *testing.T) {
65+ var brokerErr ports.BrokerError
66+ require.ErrorAs(t, classify.fn(tt.err), &brokerErr)
67+ require.Equal(t, tt.want, brokerErr.Code)
68+ })
69+ }
70+ })
71+ }
72+}
73+
25 // rawCarriagePair returns two independent real raw framed transports joined by74 // rawCarriagePair returns two independent real raw framed transports joined by
26 // an in-memory duplex pipe. Each end frames through the shared streamframe75 // an in-memory duplex pipe. Each end frames through the shared streamframe
27 // carriage via the IPC adapter, so the bridge, physical preamble, pump, and76 // carriage via the IPC adapter, so the bridge, physical preamble, pump, and

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