/* * Copyright 2019 gRPC authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * */ import {EventEmitter} from 'events'; import * as http2 from 'http2'; import {checkServerIdentity, PeerCertificate} from 'tls'; import * as url from 'url'; import {CallCredentialsFilterFactory} from './call-credentials-filter'; import {Call, CallStreamOptions, Deadline, Http2CallStream} from './call-stream'; import {ChannelCredentials} from './channel-credentials'; import {ChannelOptions, recognizedOptions} from './channel-options'; import {CompressionFilterFactory} from './compression-filter'; import {Status} from './constants'; import {DeadlineFilterFactory} from './deadline-filter'; import {FilterStackFactory} from './filter-stack'; import {Metadata} from './metadata'; import {MetadataStatusFilterFactory} from './metadata-status-filter'; import {Http2SubChannel} from './subchannel'; const {version: clientVersion} = require('../../package.json'); const MIN_CONNECT_TIMEOUT_MS = 20000; const INITIAL_BACKOFF_MS = 1000; const BACKOFF_MULTIPLIER = 1.6; const MAX_BACKOFF_MS = 120000; const BACKOFF_JITTER = 0.2; const { HTTP2_HEADER_AUTHORITY, HTTP2_HEADER_CONTENT_TYPE, HTTP2_HEADER_METHOD, HTTP2_HEADER_PATH, HTTP2_HEADER_TE, HTTP2_HEADER_USER_AGENT } = http2.constants; export enum ConnectivityState { CONNECTING, READY, TRANSIENT_FAILURE, IDLE, SHUTDOWN } function uniformRandom(min: number, max: number) { return Math.random() * (max - min) + min; } // todo: maybe we want an interface for load balancing, but no implementation // for anything complicated /** * An interface that represents a communication channel to a server specified * by a given address. */ export interface Channel { /** * Close the channel. This has the same functionality as the existing * grpc.Client.prototype.close */ close(): void; /** * Return the target that this channel connects to */ getTarget(): string; /** * Get the channel's current connectivity state. This method is here mainly * because it is in the existing internal Channel class, and there isn't * another good place to put it. * @param tryToConnect If true, the channel will start connecting if it is * idle. Otherwise, idle channels will only start connecting when a * call starts. */ getConnectivityState(tryToConnect: boolean): ConnectivityState; /** * Watch for connectivity state changes. This is also here mainly because * it is in the existing external Channel class. * @param currentState The state to watch for transitions from. This should * always be populated by calling getConnectivityState immediately * before. * @param deadline A deadline for waiting for a state change * @param callback Called with no error when a state change, or with an * error if the deadline passes without a state change. */ watchConnectivityState( currentState: ConnectivityState, deadline: Date|number, callback: (error?: Error) => void): void; /** * Create a call object. Call is an opaque type that is used by the Client * class. This function is called by the gRPC library when starting a * request. Implementers should return an instance of Call that is returned * from calling createCall on an instance of the provided Channel class. * @param method The full method string to request. * @param deadline The call deadline * @param host A host string override for making the request * @param parentCall A server call to propagate some information from * @param propagateFlags A bitwise combination of elements of grpc.propagate * that indicates what information to propagate from parentCall. */ createCall( method: string, deadline: Deadline|null|undefined, host: string|null|undefined, parentCall: Call|null|undefined, propagateFlags: number|null|undefined): Call; } export class Http2Channel extends EventEmitter implements Channel { private readonly userAgent: string; private readonly target: url.URL; private readonly defaultAuthority: string; private connectivityState: ConnectivityState = ConnectivityState.IDLE; // Helper Promise object only used in the implementation of connect(). private connecting: Promise|null = null; /* For now, we have up to one subchannel, which will exist as long as we are * connecting or trying to connect */ private subChannel: Http2SubChannel|null = null; private filterStackFactory: FilterStackFactory; private subChannelConnectCallback: () => void = () => {}; private subChannelCloseCallback: () => void = () => {}; private backoffTimerId: NodeJS.Timer; private currentBackoff: number = INITIAL_BACKOFF_MS; private currentBackoffDeadline: Date; private handleStateChange( oldState: ConnectivityState, newState: ConnectivityState): void { const now: Date = new Date(); switch (newState) { case ConnectivityState.CONNECTING: if (oldState === ConnectivityState.IDLE) { this.currentBackoff = INITIAL_BACKOFF_MS; this.currentBackoffDeadline = new Date(now.getTime() + INITIAL_BACKOFF_MS); } else if (oldState === ConnectivityState.TRANSIENT_FAILURE) { this.currentBackoff = Math.min( this.currentBackoff * BACKOFF_MULTIPLIER, MAX_BACKOFF_MS); const jitterMagnitude: number = BACKOFF_JITTER * this.currentBackoff; this.currentBackoffDeadline = new Date( now.getTime() + this.currentBackoff + uniformRandom(-jitterMagnitude, jitterMagnitude)); } this.startConnecting(); break; case ConnectivityState.READY: this.emit('connect'); break; case ConnectivityState.TRANSIENT_FAILURE: this.subChannel = null; this.backoffTimerId = setTimeout(() => { this.transitionToState( [ConnectivityState.TRANSIENT_FAILURE], ConnectivityState.CONNECTING); }, this.currentBackoffDeadline.getTime() - now.getTime()); break; case ConnectivityState.IDLE: case ConnectivityState.SHUTDOWN: if (this.subChannel) { this.subChannel.close(); this.subChannel.removeListener( 'connect', this.subChannelConnectCallback); this.subChannel.removeListener('close', this.subChannelCloseCallback); this.subChannel = null; this.emit('shutdown'); clearTimeout(this.backoffTimerId); } break; default: throw new Error('This should never happen'); } } // Transition from any of a set of oldStates to a specific newState private transitionToState( oldStates: ConnectivityState[], newState: ConnectivityState): void { if (oldStates.indexOf(this.connectivityState) > -1) { const oldState: ConnectivityState = this.connectivityState; this.connectivityState = newState; this.handleStateChange(oldState, newState); this.emit('connectivityStateChanged', newState); } } private startConnecting(): void { const connectionOptions: http2.SecureClientSessionOptions = this.credentials._getConnectionOptions() || {}; if (connectionOptions.secureContext !== null) { // If provided, the value of grpc.ssl_target_name_override should be used // to override the target hostname when checking server identity. // This option is used for testing only. if (this.options['grpc.ssl_target_name_override']) { const sslTargetNameOverride = this.options['grpc.ssl_target_name_override']!; connectionOptions.checkServerIdentity = (host: string, cert: PeerCertificate): Error|undefined => { return checkServerIdentity(sslTargetNameOverride, cert); }; connectionOptions.servername = sslTargetNameOverride; } } const subChannel: Http2SubChannel = new Http2SubChannel( this.target, connectionOptions, this.userAgent, this.options); this.subChannel = subChannel; const now = new Date(); const connectionTimeout: number = Math.max( this.currentBackoffDeadline.getTime() - now.getTime(), MIN_CONNECT_TIMEOUT_MS); const connectionTimerId: NodeJS.Timer = setTimeout(() => { // This should trigger the 'close' event, which will send us back to // TRANSIENT_FAILURE subChannel.close(); }, connectionTimeout); this.subChannelConnectCallback = () => { // Connection succeeded clearTimeout(connectionTimerId); this.transitionToState( [ConnectivityState.CONNECTING], ConnectivityState.READY); }; subChannel.once('connect', this.subChannelConnectCallback); this.subChannelCloseCallback = () => { // Connection failed clearTimeout(connectionTimerId); /* TODO(murgatroid99): verify that this works for * CONNECTING->TRANSITIVE_FAILURE see nodejs/node#16645 */ this.transitionToState( [ConnectivityState.CONNECTING, ConnectivityState.READY], ConnectivityState.TRANSIENT_FAILURE); }; subChannel.once('close', this.subChannelCloseCallback); } constructor( address: string, readonly credentials: ChannelCredentials, private readonly options: Partial) { super(); for (const option in options) { if (options.hasOwnProperty(option)) { if (!recognizedOptions.hasOwnProperty(option)) { console.warn( `Unrecognized channel argument '${option}' will be ignored.`); } } } if (credentials._isSecure()) { this.target = new url.URL(`https://${address}`); } else { this.target = new url.URL(`http://${address}`); } // TODO(murgatroid99): Add more centralized handling of channel options if (this.options['grpc.default_authority']) { this.defaultAuthority = this.options['grpc.default_authority'] as string; } else { this.defaultAuthority = this.target.host; } this.filterStackFactory = new FilterStackFactory([ new CallCredentialsFilterFactory(this), new DeadlineFilterFactory(this), new MetadataStatusFilterFactory(this), new CompressionFilterFactory(this) ]); this.currentBackoffDeadline = new Date(); /* The only purpose of these lines is to ensure that this.backoffTimerId has * a value of type NodeJS.Timer. */ this.backoffTimerId = setTimeout(() => {}, 0); // Build user-agent string. this.userAgent = [ options['grpc.primary_user_agent'], `grpc-node-js/${clientVersion}`, options['grpc.secondary_user_agent'] ].filter(e => e).join(' '); // remove falsey values first } _startHttp2Stream( authority: string, methodName: string, stream: Http2CallStream, metadata: Metadata) { const finalMetadata: Promise = stream.filterStack.sendMetadata(Promise.resolve(metadata.clone())); Promise.all([finalMetadata, this.connect()]) .then(([metadataValue]) => { const headers = metadataValue.toHttp2Headers(); headers[HTTP2_HEADER_AUTHORITY] = authority; headers[HTTP2_HEADER_USER_AGENT] = this.userAgent; headers[HTTP2_HEADER_CONTENT_TYPE] = 'application/grpc'; headers[HTTP2_HEADER_METHOD] = 'POST'; headers[HTTP2_HEADER_PATH] = methodName; headers[HTTP2_HEADER_TE] = 'trailers'; if (this.connectivityState === ConnectivityState.READY) { const subChannel: Http2SubChannel = this.subChannel!; subChannel.startCallStream(metadataValue, stream); } else { /* In this case, we lost the connection while finalizing * metadata. That should be very unusual */ setImmediate(() => { this._startHttp2Stream(authority, methodName, stream, metadata); }); } }) .catch((error: Error&{code: number}) => { // We assume the error code isn't 0 (Status.OK) stream.cancelWithStatus( error.code || Status.UNKNOWN, `Getting metadata from plugin failed with error: ${ error.message}`); }); } createCall( method: string, deadline: Deadline|null|undefined, host: string|null|undefined, parentCall: Call|null|undefined, propagateFlags: number|null|undefined): Call { if (this.connectivityState === ConnectivityState.SHUTDOWN) { throw new Error('Channel has been shut down'); } const finalOptions: CallStreamOptions = { deadline: (deadline === null || deadline === undefined) ? Infinity : deadline, flags: propagateFlags || 0, host: host || this.defaultAuthority, parentCall: parentCall || null }; const stream: Http2CallStream = new Http2CallStream( method, this, finalOptions, this.filterStackFactory); return stream; } /** * Attempts to connect, returning a Promise that resolves when the connection * is successful, or rejects if the channel is shut down. */ private connect(): Promise { if (this.connectivityState === ConnectivityState.READY) { return Promise.resolve(); } else if (this.connectivityState === ConnectivityState.SHUTDOWN) { return Promise.reject(new Error('Channel has been shut down')); } else { // In effect, this.connecting is only assigned upon the first attempt to // transition from IDLE to CONNECTING, so this condition could have also // been (connectivityState === IDLE). if (!this.connecting) { this.connecting = new Promise((resolve, reject) => { this.transitionToState( [ConnectivityState.IDLE], ConnectivityState.CONNECTING); const onConnect = () => { this.connecting = null; this.removeListener('shutdown', onShutdown); resolve(); }; const onShutdown = () => { this.connecting = null; this.removeListener('connect', onConnect); reject(new Error('Channel has been shut down')); }; this.once('connect', onConnect); this.once('shutdown', onShutdown); }); } return this.connecting; } } getConnectivityState(tryToConnect: boolean): ConnectivityState { if (tryToConnect) { this.transitionToState( [ConnectivityState.IDLE], ConnectivityState.CONNECTING); } return this.connectivityState; } watchConnectivityState( currentState: ConnectivityState, deadline: Date|number, callback: (error?: Error) => void) { if (this.connectivityState !== currentState) { /* If the connectivity state is different from the provided currentState, * we assume that a state change has successfully occurred */ setImmediate(callback); } else { let deadlineMs = 0; if (deadline instanceof Date) { deadlineMs = deadline.getTime(); } else { deadlineMs = deadline; } let timeout: number = deadlineMs - Date.now(); if (timeout < 0) { timeout = 0; } const timeoutId = setTimeout(() => { this.removeListener('connectivityStateChanged', eventCb); callback(new Error('Channel state did not change before deadline')); }, timeout); const eventCb = () => { clearTimeout(timeoutId); callback(); }; this.once('connectivityStateChanged', eventCb); } } getTarget() { return this.target.toString(); } close() { if (this.connectivityState === ConnectivityState.SHUTDOWN) { throw new Error('Channel has been shut down'); } this.transitionToState( [ ConnectivityState.CONNECTING, ConnectivityState.READY, ConnectivityState.TRANSIENT_FAILURE, ConnectivityState.IDLE ], ConnectivityState.SHUTDOWN); } }