Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1dad211
Add staged connection diagnosis for dial failures
rossnelson Jul 8, 2026
f5ae5ea
Add unit tests for error classification and summary rendering
rossnelson Jul 8, 2026
f55c4ef
Fix Windows CI: connection-refused detection and plaintext test race
rossnelson Jul 11, 2026
e23832e
Refactor connection errors through terminal reports
rossnelson Jul 22, 2026
880f6b9
Document structured error handling transition
rossnelson Jul 22, 2026
e637b1b
Refactor CLI error handling
rossnelson Jul 23, 2026
47410b7
Narrow error handling to connection failures
rossnelson Jul 24, 2026
c236290
Merge remote-tracking branch 'origin/main' into connection-error-diag…
rossnelson Jul 24, 2026
3243367
Fix Windows connection error command assertion
rossnelson Jul 24, 2026
d4b6811
Stabilize TLS cancellation diagnosis test
rossnelson Jul 24, 2026
8f9e46c
Merge branch 'main' into connection-error-diagnosis
chaptersix Jul 28, 2026
ed2528e
Merge branch 'main' into connection-error-diagnosis
chaptersix Aug 20, 2026
4f2ebe7
Merge branch 'main' into connection-error-diagnosis
chaptersix Aug 25, 2026
c1d08fc
Address Codex review of the connection diagnosis
rossnelson Aug 28, 2026
73cff1e
Update the Windows command assertion to the bare rendering
rossnelson Aug 28, 2026
0d0295c
Merge remote-tracking branch 'origin/main' into connection-error-diag…
rossnelson Aug 28, 2026
79259a9
Address the second Codex review
rossnelson Aug 28, 2026
80e85fb
Address the third Codex review, and correct the authority fix
rossnelson Aug 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 56 additions & 2 deletions internal/temporalcli/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ package temporalcli

import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"os/user"

Expand Down Expand Up @@ -46,7 +48,6 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client.
}
c.Identity = "temporal-cli:" + username + "@" + hostname
}

// Build client options using cliext
builder := &cliext.ClientOptionsBuilder{
CommonOptions: cctx.RootCommand.CommonOptions,
Expand All @@ -56,6 +57,15 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client.
}
clientOpts, err := builder.Build(cctx)
if err != nil {
// Build did not return authoritative effective options. Preserve the
// original setup error instead of attaching a guessed address or profile.
var pathErr *fs.PathError
if errors.As(err, &pathErr) {
return nil, nil, newConnectError(&connectDiagnosis{
Cause: causeCertFileUnreadable,
Detail: pathErr.Path,
}, connectMeta{}, err)
}
return nil, nil, err
}

Expand Down Expand Up @@ -87,7 +97,7 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client.

cl, err := client.DialContext(dialCtx, clientOpts)
if err != nil {
return nil, nil, err
return nil, nil, dialConnectError(cctx, dialCtx, clientOpts, err)
}

// Since this namespace value is used by many commands after this call,
Expand All @@ -97,6 +107,50 @@ func dialClientWithCodec(cctx *CommandContext, c *cliext.ClientOptions) (client.
return cl, builder.PayloadCodec, nil
}

// dialConnectError enriches a client.DialContext failure with a staged
// connection diagnosis and a suggested fix. See connectdiag.go.
func dialConnectError(
cctx *CommandContext,
dialCtx context.Context,
clientOpts client.Options,
origErr error,
) error {
// If a CLI-side timeout fired, surface its descriptive cause, which is
// otherwise lost ("context deadline exceeded" wins over "command timed
// out after 5s").
if dialCtx.Err() != nil {
if cause := context.Cause(dialCtx); cause != nil && !errors.Is(cause, dialCtx.Err()) {
origErr = fmt.Errorf("%w (%v)", origErr, cause)
}
}
// Never probe after an interrupt or when the whole command timed out.
if cctx.Err() != nil {
return origErr
}
if v, _ := cctx.Options.EnvLookup.LookupEnv("TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS"); v != "" {
return fmt.Errorf("failed connecting to Temporal server at %v: %w", clientOpts.HostPort, origErr)
}
// Diagnosis uses the remaining command context and applies its own 3s cap.
// ClientConnectTimeout governs only the original dial.
diag := diagnoseConnection(cctx, connectTarget{
Address: clientOpts.HostPort,
Authority: clientOpts.ConnectionOptions.Authority,
TLS: clientOpts.ConnectionOptions.TLS,
// Set by embedders; the CLI itself never supplies dial options that
// replace the transport.
CustomDialer: len(cctx.Options.AdditionalClientGRPCDialOptions) > 0,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not treat every extra dial option as a custom dialer

When an embedder adds a non-transport option such as grpc.WithChainUnaryInterceptor—a supported pattern already used at internal/temporalcli/commands.workflow_exec_test.go:77-88—this condition incorrectly marks the connection as using a custom dialer. Any subsequent ordinary TCP/TLS connection failure is therefore reported with “checks skipped: a custom gRPC dialer is configured,” suppressing the new diagnosis even though the configured address was actually dialed. Track custom-dialer use explicitly rather than inferring it from the presence of any additional dial option.

Useful? React with 👍 / 👎.

}, origErr)
return newConnectError(diag, connectMetaFromOptions(clientOpts), origErr)
}

func connectMetaFromOptions(resolved client.Options) connectMeta {
return connectMeta{
Address: resolved.HostPort,
Namespace: resolved.Namespace,
TLSConfigured: resolved.ConnectionOptions.TLS != nil,
}
}

func fixedHeaderOverrideInterceptor(
ctx context.Context,
method string, req, reply any,
Expand Down
234 changes: 230 additions & 4 deletions internal/temporalcli/client_test.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,31 @@
package temporalcli_test

import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestClientConnectTimeout(t *testing.T) {
// Start a listener that accepts connections but never responds
// startBlackHoleListener starts a listener that accepts connections but never
// responds.
func startBlackHoleListener(t *testing.T) net.Listener {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer ln.Close()
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
Expand All @@ -24,6 +36,62 @@ func TestClientConnectTimeout(t *testing.T) {
select {}
}
}()
return ln
}

// startMTLSListener starts a TLS listener that requires client certificates,
// returning its address and the server CA cert as PEM.
func startMTLSListener(t *testing.T) (addr, caPEM string) {
t.Helper()
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
template := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "client-test"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().Add(time.Hour),
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
require.NoError(t, err)
leaf, err := x509.ParseCertificate(der)
require.NoError(t, err)
pool := x509.NewCertPool()
pool.AddCert(leaf)

ln, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{
Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}},
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: pool,
})
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go func() {
if tlsConn, ok := conn.(*tls.Conn); ok {
_ = tlsConn.HandshakeContext(context.Background())
buf := make([]byte, 1)
_ = tlsConn.SetReadDeadline(time.Now().Add(2 * time.Second))
_, _ = tlsConn.Read(buf)
}
conn.Close()
}()
}
}()
return ln.Addr().String(), string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}))
}

func TestClientConnectTimeout(t *testing.T) {
ln := startBlackHoleListener(t)

h := NewCommandHarness(t)

Expand All @@ -36,6 +104,164 @@ func TestClientConnectTimeout(t *testing.T) {
elapsed := time.Since(start)

require.Error(t, res.Err)
assert.Contains(t, res.Err.Error(), "failed connecting to Temporal server at")
assert.Contains(t, res.Err.Error(), "deadline exceeded")
assert.Less(t, elapsed, time.Second, "dial should have timed out quickly")
// The friendly cause attached to the connect timeout must survive.
assert.Contains(t, res.Err.Error(), "command timed out after 50ms")
assert.Less(t, elapsed, 4*time.Second, "diagnosis should respect its independent three-second cap")
}

func TestConnectDiagnosis_MTLSRequired(t *testing.T) {
addr, caPEM := startMTLSListener(t)

h := NewCommandHarness(t)
res := h.Execute(
"workflow", "list",
"--address", addr,
"--tls",
"--tls-ca-data", caPEM,
"--client-connect-timeout", "2s",
)

require.Error(t, res.Err)
assert.Contains(t, res.Err.Error(), "failed connecting to Temporal server at "+addr)
assert.Contains(t, res.Err.Error(), "server requires client certificate (mTLS)")
assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error")
}

func TestConnectDiagnosis_Refused(t *testing.T) {
// Grab a port and close it so nothing is listening.
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := ln.Addr().String()
require.NoError(t, ln.Close())

h := NewCommandHarness(t)
res := h.Execute(
"workflow", "list",
"--address", addr,
"--client-connect-timeout", "2s",
)

require.Error(t, res.Err)
assert.Contains(t, res.Err.Error(), "failed connecting to Temporal server at "+addr)
assert.Contains(t, res.Err.Error(), "connection refused")
assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error")
}

func TestConnectDiagnosis_DNSFailure(t *testing.T) {
h := NewCommandHarness(t)
res := h.Execute(
"workflow", "list",
// .invalid is reserved (RFC 2606) and can never resolve.
"--address", "does-not-exist.invalid:7233",
"--client-connect-timeout", "5s",
)

require.Error(t, res.Err)
assert.Contains(t, res.Err.Error(), "failed connecting to Temporal server at does-not-exist.invalid:7233")
assert.Contains(t, res.Err.Error(), "could not resolve host")
assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error")
}

func TestConnectDiagnosis_JSONOutputHasNoANSI(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := ln.Addr().String()
require.NoError(t, ln.Close())

h := NewCommandHarness(t)
res := h.Execute(
"workflow", "list",
"--address", addr,
"--client-connect-timeout", "2s",
"-o", "json",
)

require.Error(t, res.Err)
assert.NotContains(t, res.Err.Error(), "\x1b[", "connection errors must remain uncolored")
assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error")
assert.Empty(t, res.Stdout.String(), "connection failures must not write to stdout")
}

func TestConnectDiagnosis_Disabled(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
addr := ln.Addr().String()
require.NoError(t, ln.Close())

h := NewCommandHarness(t)
h.Options.EnvLookup = EnvLookupMap{"TEMPORAL_CLI_DISABLE_CONNECT_DIAGNOSIS": "1"}
res := h.Execute(
"workflow", "list",
"--address", addr,
"--client-connect-timeout", "2s",
)

require.Error(t, res.Err)
msg := res.Err.Error()
assert.Contains(t, msg, "failed connecting to Temporal server at "+addr)
assert.NotContains(t, msg, "✗", "diagnosis must be suppressed when disabled")
assert.NotContains(t, msg, "Namespace:")
assert.NotContains(t, msg, "TLS:")
assert.NotContains(t, msg, "Connection checks")
}

func TestConnectDiagnosis_CertFileMissing(t *testing.T) {
h := NewCommandHarness(t)
res := h.Execute(
"workflow", "list",
"--address", "127.0.0.1:7233",
"--tls-cert-path", "/definitely/does/not/exist.pem",
"--tls-key-path", "/definitely/does/not/exist.key",
)

require.Error(t, res.Err)
var pathErr *os.PathError
require.ErrorAs(t, res.Err, &pathErr)
msg := res.Err.Error()
assert.Contains(t, msg, "failed preparing Temporal server connection")
assert.NotContains(t, msg, "server connection at", "Build returned no authoritative effective address")
assert.Contains(t, msg, "cannot read file")
assert.Contains(t, msg, "/definitely/does/not/exist")
assert.Empty(t, res.Stderr.String(), "the test harness overrides Fail and captures the error")
}

func TestConnectDiagnosis_ProfileAddressUsesEffectiveAddressWithoutGuessingProvenance(t *testing.T) {
f, err := os.CreateTemp("", "")
require.NoError(t, err)
t.Cleanup(func() { os.Remove(f.Name()) })
_, err = f.WriteString(`
[profile.default]
address = "does-not-exist.invalid:7233"
`)
require.NoError(t, err)
require.NoError(t, f.Close())

h := NewCommandHarness(t)
h.Options.EnvLookup = EnvLookupMap{"TEMPORAL_CONFIG_FILE": f.Name()}
res := h.Execute(
"workflow", "list",
"--client-connect-timeout", "5s",
)

require.Error(t, res.Err)
msg := res.Err.Error()
assert.Contains(t, msg, "failed connecting to Temporal server at does-not-exist.invalid:7233")
assert.NotContains(t, msg, "config profile")
assert.NotContains(t, msg, "temporal config get")
}

func TestConnectDiagnosis_CommandTimeoutCauseSurvives(t *testing.T) {
ln := startBlackHoleListener(t)

h := NewCommandHarness(t)
res := h.Execute(
"workflow", "list",
"--address", ln.Addr().String(),
"--command-timeout", "1s",
)

require.Error(t, res.Err)
assert.Contains(t, res.Err.Error(), "command timed out after 1s")
}
Loading