1111// [Connect] hides those transport details: it returns a [grpc.ClientConn]
1212// reaching the daemon, whatever the transport of the current endpoint (unix
1313// or npipe socket, tcp with or without TLS, or a connection helper such as
14- // ssh://), falling back to the legacy upgrade endpoint for older daemons.
15- // Note that the legacy endpoint only exposes the daemon's built-in gRPC
16- // services: services published by daemon extensions require a daemon serving
17- // gRPC natively.
14+ // ssh://), falling back to the legacy upgrade endpoint for older daemons —
15+ // or when an intermediary between the client and the daemon does not relay
16+ // HTTP/2 even though the daemon's API version advertises native support
17+ // (Docker Desktop's API proxy, at the time of writing). Note that the legacy
18+ // endpoint only exposes the daemon's built-in gRPC services: services
19+ // published by daemon extensions require a daemon serving gRPC natively.
1820package grpc
1921
2022import (
@@ -29,8 +31,11 @@ import (
2931 "github.com/moby/moby/client"
3032 "github.com/moby/moby/client/pkg/versions"
3133 "google.golang.org/grpc"
34+ "google.golang.org/grpc/codes"
3235 "google.golang.org/grpc/credentials"
3336 "google.golang.org/grpc/credentials/insecure"
37+ healthpb "google.golang.org/grpc/health/grpc_health_v1"
38+ "google.golang.org/grpc/status"
3439)
3540
3641// nativeAPIVersion is the minimum API version where the daemon serves gRPC
@@ -87,9 +92,9 @@ func WithDialOptions(opts ...grpc.DialOption) Opt {
8792}
8893
8994// Connect returns a gRPC client connection to the daemon of dockerCLI's
90- // current endpoint. ctx is only used to query the daemon's API version; the
91- // returned connection is lazy: it is not bound to ctx , and is only
92- // established when the first RPC is made .
95+ // current endpoint. ctx bounds the daemon API version query and the probing
96+ // of native connections; the returned connection is not bound to it , and
97+ // behaves lazily afterwards .
9398func Connect (ctx context.Context , dockerCLI DockerCLI , opts ... Opt ) (* grpc.ClientConn , error ) {
9499 return Dial (ctx , dockerCLI .Client (), dockerCLI .DockerEndpoint (), opts ... )
95100}
@@ -105,11 +110,34 @@ func Dial(ctx context.Context, apiClient APIClient, ep docker.Endpoint, opts ...
105110 return nil , fmt .Errorf ("establishing gRPC connection to the daemon: %w" , err )
106111 }
107112 if ping .APIVersion != "" && ! versions .LessThan (ping .APIVersion , nativeAPIVersion ) {
108- return dialNative (apiClient , ep , & cfg )
113+ conn , err := dialNative (apiClient , ep , & cfg )
114+ if err != nil {
115+ return nil , err
116+ }
117+ if probeTransport (ctx , conn ) == nil {
118+ return conn , nil
119+ }
120+ // The daemon's API version advertises native gRPC support but the
121+ // transport doesn't get through: an intermediary between the client
122+ // and the daemon (Docker Desktop's API proxy, at the time of writing)
123+ // may not relay HTTP/2. Fall back to the legacy upgrade endpoint.
124+ _ = conn .Close ()
109125 }
110126 return dialLegacy (apiClient , & cfg )
111127}
112128
129+ // probeTransport verifies conn actually reaches an HTTP/2 server. Any
130+ // RPC-level outcome — including Unimplemented from a daemon not serving the
131+ // health service — proves the transport; only transport-level failures
132+ // (codes.Unavailable) are reported.
133+ func probeTransport (ctx context.Context , conn * grpc.ClientConn ) error {
134+ _ , err := healthpb .NewHealthClient (conn ).Check (ctx , & healthpb.HealthCheckRequest {})
135+ if status .Code (err ) == codes .Unavailable {
136+ return err
137+ }
138+ return nil
139+ }
140+
113141// dialNative connects to a daemon serving gRPC natively on its API endpoint:
114142// ALPN-negotiated HTTP/2 for TLS endpoints, cleartext HTTP/2 (h2c) over the
115143// API client's own dialer otherwise. The client dialer covers unix and npipe
0 commit comments