Skip to content

Commit 2730724

Browse files
committed
cli: apply review feedback to broker dial error classification
- Demote ErrBrokerClosed to unexported errBrokerClosed: the SDK flattens dial errors into text, so error identity never survives end-to-end and an exported sentinel has no consumer. Classification rides the user-visible message; only in-module tests use errors.Is. - Drop the "flashduty: " prefix from the new messages (the SDK adds it), and give the 0xFF refusal its final actionable wording. - Fix "broker parse rights: %w" rendering %!w(<nil>) when the rights parse succeeds but yields no fd. - Reword comments to state production errno behavior (Linux SEQPACKET send -> EPIPE; datagram-style peer death -> ECONNREFUSED) and note the sentinel covers the handshake phase only.
1 parent b150640 commit 2730724

2 files changed

Lines changed: 39 additions & 29 deletions

File tree

‎internal/cli/broker_dial_unix.go‎

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,21 @@ import (
1919
// never returns nil), but defaultNewClient references it on every platform.
2020
var errBrokerUnsupported = errors.New("flashduty: broker mode is not supported on this platform")
2121

22-
// ErrBrokerClosed is returned (wrapped) when the runner-side broker control
22+
// errBrokerClosed is returned (wrapped) when the runner-side broker control
2323
// channel is gone: the runner exited, or reclaimed the channel once the
2424
// command that started this process finished, so fduty calls from a
2525
// long-lived background process fail this way. A live broker declining a
26-
// dial is a different failure (see the 0xFF path in dial). Callers and
27-
// automation can tell the two apart with errors.Is(err, ErrBrokerClosed);
28-
// the wrapping by http.Transport and url.Error preserves that.
29-
var ErrBrokerClosed = errors.New("flashduty: broker control channel closed: the broker that started this process is no longer available")
26+
// dial is a different failure (see the 0xFF path in dial).
27+
//
28+
// The distinction rides the user-visible message, not error identity: the
29+
// SDK flattens dial errors into text ("%v"), so only in-module tests
30+
// classify via errors.Is; humans and agents reading the output tell the two
31+
// failures apart by wording.
32+
//
33+
// It covers the handshake phase only. When an already-dispatched connection
34+
// is torn down, in-flight requests see EOF/reset first; the next dial
35+
// reports this error.
36+
var errBrokerClosed = errors.New("broker channel closed: the command that started this process has finished — rerun it in the foreground of a live session")
3037

3138
// brokerEgressCapable reports whether this build can act as a broker-mode client
3239
// (read FLASHDUTY_CRED_FD and dial over the inherited control fd). The runner
@@ -48,13 +55,13 @@ func (d *brokerDialer) dial(_ context.Context, _, _ string) (net.Conn, error) {
4855
defer d.mu.Unlock()
4956

5057
if err := syscall.Sendmsg(d.credFD, []byte{0x01}, nil, nil, 0); err != nil {
51-
// A dead control channel surfaces on send as EPIPE (stream-style
52-
// sockets) or ECONNREFUSED (datagram-style sockets, where the closed
53-
// peer answers the datagram): classify both as ErrBrokerClosed so
54-
// callers can tell "broker gone" apart from "broker alive but declined
58+
// A dead control channel surfaces on send as EPIPE on the
59+
// production Linux SEQPACKET socket, or ECONNREFUSED when a
60+
// datagram-style peer is gone; classify both as errBrokerClosed so
61+
// "broker gone" reads differently from "broker alive but declined
5562
// this dial" (the 0xFF path below).
5663
if errors.Is(err, syscall.EPIPE) || errors.Is(err, syscall.ECONNREFUSED) {
57-
return nil, fmt.Errorf("%w (handshake send: %v)", ErrBrokerClosed, err)
64+
return nil, fmt.Errorf("%w (handshake send: %v)", errBrokerClosed, err)
5865
}
5966
return nil, fmt.Errorf("broker handshake send: %w", err)
6067
}
@@ -66,12 +73,12 @@ func (d *brokerDialer) dial(_ context.Context, _, _ string) (net.Conn, error) {
6673
}
6774
if n == 0 {
6875
// Orderly EOF: the broker closed the control channel.
69-
return nil, fmt.Errorf("%w (handshake recv: connection closed by peer)", ErrBrokerClosed)
76+
return nil, fmt.Errorf("%w (recvmsg: EOF)", errBrokerClosed)
7077
}
7178
if body[0] == 0xFF {
7279
// ctrlRespErr: the broker is alive but declined this dial (e.g. it
73-
// could not mint a connection). Deliberately not ErrBrokerClosed.
74-
return nil, errors.New("flashduty: broker refused the dial request")
80+
// could not mint a connection). Deliberately not errBrokerClosed.
81+
return nil, errors.New("broker refused the dial request (no request reached Flashduty; retrying is safe)")
7582
}
7683
if body[0] != 0x01 {
7784
return nil, fmt.Errorf("broker handshake: unexpected response byte 0x%02x", body[0])
@@ -84,9 +91,12 @@ func (d *brokerDialer) dial(_ context.Context, _, _ string) (net.Conn, error) {
8491
return nil, fmt.Errorf("broker sent no fd")
8592
}
8693
fds, err := syscall.ParseUnixRights(&scms[0])
87-
if err != nil || len(fds) == 0 {
94+
if err != nil {
8895
return nil, fmt.Errorf("broker parse rights: %w", err)
8996
}
97+
if len(fds) == 0 {
98+
return nil, fmt.Errorf("broker sent no usable fd")
99+
}
90100
f := os.NewFile(uintptr(fds[0]), "broker-conn")
91101
conn, err := net.FileConn(f) // dups + registers with the netpoller
92102
_ = f.Close()

‎internal/cli/broker_dial_unix_test.go‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,8 @@ func TestBrokerHTTPClient_RefusedReturnsError(t *testing.T) {
246246

247247
// TestBrokerDialer_RecvEOF_ClassifiesBrokerClosed covers a broker that closes
248248
// the control channel mid-handshake: Recvmsg returns 0 (orderly EOF) and the
249-
// dial must classify as ErrBrokerClosed with a message that explains the
250-
// channel is gone.
249+
// dial must classify as errBrokerClosed with an actionable user-visible
250+
// message.
251251
func TestBrokerDialer_RecvEOF_ClassifiesBrokerClosed(t *testing.T) {
252252
// SOCK_STREAM because closing the peer end must wake the dialer's blocked
253253
// Recvmsg with EOF on both darwin and Linux; datagram sockets carry no EOF
@@ -272,17 +272,17 @@ func TestBrokerDialer_RecvEOF_ClassifiesBrokerClosed(t *testing.T) {
272272
d := &brokerDialer{credFD: childFD}
273273
_, err = d.dial(context.Background(), "", "")
274274
<-parentGone
275-
if !errors.Is(err, ErrBrokerClosed) {
276-
t.Fatalf("dial after broker close: want ErrBrokerClosed, got: %v", err)
275+
if !errors.Is(err, errBrokerClosed) {
276+
t.Fatalf("dial after broker close: want errBrokerClosed, got: %v", err)
277277
}
278-
if msg := err.Error(); !strings.Contains(msg, "broker control channel") {
279-
t.Fatalf("user-facing message must mention the closed control channel, got: %v", msg)
278+
if msg := err.Error(); !strings.Contains(msg, "rerun it in the foreground of a live session") {
279+
t.Fatalf("user-facing message must tell the user how to recover, got: %v", msg)
280280
}
281281
}
282282

283283
// TestBrokerDialer_SendEPIPE_ClassifiesBrokerClosed covers a broker whose
284284
// control channel is already gone when the handshake starts: Sendmsg fails
285-
// with EPIPE (stream sockets) and the dial must classify as ErrBrokerClosed.
285+
// with EPIPE (stream sockets) and the dial must classify as errBrokerClosed.
286286
func TestBrokerDialer_SendEPIPE_ClassifiesBrokerClosed(t *testing.T) {
287287
pair, err := syscall.Socketpair(syscall.AF_UNIX, syscall.SOCK_STREAM, 0)
288288
if err != nil {
@@ -294,17 +294,17 @@ func TestBrokerDialer_SendEPIPE_ClassifiesBrokerClosed(t *testing.T) {
294294

295295
d := &brokerDialer{credFD: childFD}
296296
_, err = d.dial(context.Background(), "", "")
297-
if !errors.Is(err, ErrBrokerClosed) {
298-
t.Fatalf("dial with dead broker: want ErrBrokerClosed, got: %v", err)
297+
if !errors.Is(err, errBrokerClosed) {
298+
t.Fatalf("dial with dead broker: want errBrokerClosed, got: %v", err)
299299
}
300-
if msg := err.Error(); !strings.Contains(msg, "no longer available") {
301-
t.Fatalf("user-facing message must state the broker is gone, got: %v", msg)
300+
if msg := err.Error(); !strings.Contains(msg, "the command that started this process has finished") {
301+
t.Fatalf("user-facing message must state why the channel is gone, got: %v", msg)
302302
}
303303
}
304304

305305
// TestBrokerDialer_RefusalIsNotBrokerClosed covers a live broker that answers
306306
// the handshake with the 0xFF refusal byte: it must NOT classify as
307-
// ErrBrokerClosed, and the message must read as a refusal.
307+
// errBrokerClosed, and the message must read as a refusal.
308308
func TestBrokerDialer_RefusalIsNotBrokerClosed(t *testing.T) {
309309
pair, err := syscall.Socketpair(syscall.AF_UNIX, controlSockType, 0)
310310
if err != nil {
@@ -326,10 +326,10 @@ func TestBrokerDialer_RefusalIsNotBrokerClosed(t *testing.T) {
326326
if err == nil {
327327
t.Fatal("dial must fail when the broker refuses")
328328
}
329-
if errors.Is(err, ErrBrokerClosed) {
330-
t.Fatalf("a live broker's refusal must not classify as ErrBrokerClosed: %v", err)
329+
if errors.Is(err, errBrokerClosed) {
330+
t.Fatalf("a live broker's refusal must not classify as errBrokerClosed: %v", err)
331331
}
332-
if msg := err.Error(); !strings.Contains(msg, "broker refused the dial request") {
332+
if msg := err.Error(); !strings.Contains(msg, "broker refused the dial request (no request reached Flashduty; retrying is safe)") {
333333
t.Fatalf("user-facing message must say the dial was refused, got: %v", msg)
334334
}
335335
}

0 commit comments

Comments
 (0)