"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
Object.defineProperty(exports, "__esModule", { value: true });
exports.RelayTunnelConnector = exports.maxReconnectDelayMs = void 0;
const dev_tunnels_ssh_1 = require("@microsoft/dev-tunnels-ssh");
const utils_1 = require("./utils");
const sshHelpers_1 = require("./sshHelpers");
const retryingTunnelConnectionEventArgs_1 = require("./retryingTunnelConnectionEventArgs");
// After the 6th attemt, each next attempt will happen after a delay of 2^7 * 100ms = 12.8s
exports.maxReconnectDelayMs = 13000;
// Delay between the 1st and the 2nd attempts.
const reconnectInitialDelayMs = 1000;
// There is no status code information in web socket errors in browser context.
// Instead, connection will retry anyway with limited retry attempts.
const maxBrowserReconnectAttempts = 5;
/**
 * Tunnel connector that connects a tunnel session to a web socket stream in the tunnel Relay service.
 */
class RelayTunnelConnector {
    constructor(tunnelSession) {
        this.tunnelSession = tunnelSession;
    }
    get trace() {
        return this.tunnelSession.trace;
    }
    /**
     * Connect or reconnect tunnel SSH session.
     * @param isReconnect A value indicating if this is a reconnect (true) or regular connect (false).
     * @param cancellation Cancellation token.
     */
    async connectSession(isReconnect, options, cancellation) {
        var _a, _b;
        let disconnectReason;
        let error;
        function throwIfCancellation(e) {
            if ((e instanceof dev_tunnels_ssh_1.CancellationError && (cancellation === null || cancellation === void 0 ? void 0 : cancellation.isCancellationRequested)) ||
                e instanceof dev_tunnels_ssh_1.ObjectDisposedError) {
                error = undefined;
                disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.byApplication;
                throw e;
            }
        }
        function throwError(message) {
            if (error) {
                // Preserve the error object, just replace the message.
                error.message = message;
            }
            else {
                error = new Error(message);
            }
            throw error;
        }
        let browserReconnectAttempt = 0;
        let attemptDelayMs = reconnectInitialDelayMs;
        let isTunnelAccessTokenRefreshed = false;
        let isDelayNeeded = true;
        let errorDescription;
        this.tunnelSession.startConnecting();
        try {
            for (let attempt = 0;; attempt++) {
                if (cancellation === null || cancellation === void 0 ? void 0 : cancellation.isCancellationRequested) {
                    throw new dev_tunnels_ssh_1.CancellationError();
                }
                if (attempt > 0) {
                    if (error) {
                        if (!((_a = options === null || options === void 0 ? void 0 : options.enableRetry) !== null && _a !== void 0 ? _a : true)) {
                            throw error;
                        }
                        const args = new retryingTunnelConnectionEventArgs_1.RetryingTunnelConnectionEventArgs(error, attemptDelayMs);
                        this.tunnelSession.onRetrying(args);
                        if (!args.retry) {
                            // Stop retries.
                            throw error;
                        }
                        if (args.delayMs >= reconnectInitialDelayMs) {
                            attemptDelayMs = args.delayMs;
                        }
                        else {
                            isDelayNeeded = false;
                        }
                    }
                    const retryTiming = isDelayNeeded
                        ? ` in ${attemptDelayMs < 1000
                            ? `0.${attemptDelayMs / 100}s`
                            : `${attemptDelayMs / 1000}s`}`
                        : '';
                    this.trace(dev_tunnels_ssh_1.TraceLevel.Verbose, 0, `Error connecting to tunnel SSH session, retrying${retryTiming}${errorDescription ? `: ${errorDescription}` : ''}`);
                    if (isDelayNeeded) {
                        try {
                            await (0, utils_1.delay)(attemptDelayMs, cancellation);
                        }
                        catch (e) {
                            throwIfCancellation(e);
                            throw e;
                        }
                        if (attemptDelayMs < exports.maxReconnectDelayMs) {
                            attemptDelayMs = attemptDelayMs << 1;
                        }
                    }
                }
                isDelayNeeded = true;
                let stream = undefined;
                errorDescription = undefined;
                disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.connectionLost;
                error = undefined;
                try {
                    const streamAndProtocol = await this.tunnelSession.createSessionStream(options, cancellation);
                    stream = streamAndProtocol.stream;
                    await this.tunnelSession.configureSession(stream, streamAndProtocol.protocol, isReconnect, cancellation);
                    stream = undefined;
                    disconnectReason = undefined;
                    return;
                }
                catch (e) {
                    if (!(e instanceof Error)) {
                        // Not recoverable if we cannot recognize the error object.
                        throwError(`Failed to connect to the tunnel service and start tunnel SSH session: ${e}`);
                    }
                    throwIfCancellation(e);
                    error = e;
                    errorDescription = error.message;
                    // Browser web socket relay error - retry until max number of attempts is exceeded.
                    if (e instanceof sshHelpers_1.BrowserWebSocketRelayError) {
                        if (browserReconnectAttempt++ >= maxBrowserReconnectAttempts) {
                            throw e;
                        }
                        continue;
                    }
                    // SSH reconnection error. Disable reconnection and try again without delay.
                    if (e instanceof dev_tunnels_ssh_1.SshReconnectError) {
                        disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.protocolError;
                        isDelayNeeded = false;
                        isReconnect = false;
                        continue;
                    }
                    // SSH connection error. Only 'connection lost' is recoverable.
                    if (e instanceof dev_tunnels_ssh_1.SshConnectionError) {
                        const reason = e.reason;
                        if (reason === dev_tunnels_ssh_1.SshDisconnectReason.connectionLost) {
                            continue;
                        }
                        disconnectReason = reason || dev_tunnels_ssh_1.SshDisconnectReason.byApplication;
                        throwError(`Failed to start tunnel SSH session: ${errorDescription}`);
                    }
                    // Web socket connection error
                    if (e instanceof sshHelpers_1.RelayConnectionError) {
                        const statusCode = (_b = e.errorContext) === null || _b === void 0 ? void 0 : _b.statusCode;
                        const statusCodeText = statusCode ? ` (${statusCode})` : '';
                        switch (errorDescription) {
                            case 'error.relayClientUnauthorized': {
                                const notAuthorizedText = 'Not authorized' + statusCodeText;
                                disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.authCancelledByUser;
                                if (isTunnelAccessTokenRefreshed) {
                                    // We've already refreshed the tunnel access token once.
                                    throwError(`${notAuthorizedText}. Refreshed tunnel access token also does not work.`);
                                }
                                try {
                                    isTunnelAccessTokenRefreshed = await this.tunnelSession.refreshTunnelAccessToken(cancellation);
                                }
                                catch (refreshError) {
                                    throwIfCancellation(refreshError);
                                    throwError(`${notAuthorizedText}. Refreshing tunnel access token failed with error ${(0, utils_1.getErrorMessage)(refreshError)}`);
                                }
                                if (!isTunnelAccessTokenRefreshed) {
                                    throwError(`${notAuthorizedText}. Provide a fresh tunnel access token with '${this.tunnelSession.tunnelAccessScope}' scope.`);
                                }
                                isDelayNeeded = false;
                                errorDescription =
                                    'The tunnel access token was no longer valid and had just been refreshed.';
                                continue;
                            }
                            case 'error.relayClientForbidden':
                                disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.authCancelledByUser;
                                throwError(`Forbidden${statusCodeText}. Provide a fresh tunnel access token with '${this.tunnelSession.tunnelAccessScope}' scope.`);
                                break;
                            case 'error.tunnelPortNotFound':
                                throwError(`The tunnel or port is not found${statusCodeText}`);
                                break;
                            // Normally nginx choses another healthy pod when it cannot establish connection to a pod.
                            // However, if there are no other pods, it may returns 502 (Bad Gateway) to the client.
                            // This rare case may happen when the cluster recovers from a failure
                            // and the nginx controller has started but Relay service has not yet.
                            // 503 (Service Unavailable) can happen when Relay calls control plane to authenticate the request,
                            // control plane hits 429s from Cosmos DB and replies back with 503.
                            // 429 (Too Many Requests) can happen if client exceeds request rate limits.
                            case 'error.badGateway':
                            case 'error.serviceUnavailable':
                            case 'error.tooManyRequests':
                                errorDescription = errorDescription === 'error.tooManyRequests'
                                    ? `Rate limit exceeded${statusCodeText}. Too many requests in a given amount of time.`
                                    : `Service temporarily unavailable${statusCodeText}`;
                                disconnectReason = dev_tunnels_ssh_1.SshDisconnectReason.serviceNotAvailable;
                                // Do not attempt more than 3 times to not overwhelm the service.
                                if (attempt > 3) {
                                    throwError(errorDescription);
                                }
                                // Increase the attempt delay to reduce load on the service and let it recover.
                                if (attemptDelayMs < exports.maxReconnectDelayMs / 2) {
                                    attemptDelayMs = exports.maxReconnectDelayMs / 2;
                                }
                                continue;
                            default:
                                if (errorDescription === null || errorDescription === void 0 ? void 0 : errorDescription.startsWith('error.relayConnectionError ')) {
                                    const recoverableError = recoverableNetworkErrors.find((s) => errorDescription.includes(s));
                                    if (recoverableError) {
                                        errorDescription = `Failed to connect to Relay server: ${recoverableError}`;
                                        continue;
                                    }
                                }
                        }
                    }
                    // Everything else is not recoverable
                    throw e;
                }
                finally {
                    // Graft SSH disconnect reason on to the error object as 'reason' property.
                    if (error && disconnectReason && !error.reason) {
                        error.reason = disconnectReason;
                    }
                    if (disconnectReason) {
                        await this.tunnelSession.closeSession(disconnectReason, error);
                    }
                    if (stream) {
                        await stream.close(error);
                    }
                }
            }
        }
        finally {
            this.tunnelSession.finishConnecting(disconnectReason, error);
        }
    }
}
exports.RelayTunnelConnector = RelayTunnelConnector;
const recoverableNetworkErrors = [
    'ECONNRESET',
    'ENOTFOUND',
    'ESOCKETTIMEDOUT',
    'ETIMEDOUT',
    'ECONNREFUSED',
    'EHOSTUNREACH',
    'EPIPE',
    'EAI_AGAIN',
    'EBUSY',
];
//# sourceMappingURL=https://github.com/VSCodium/sourcemaps/releases/download/stable-824c4c46a288b839f13b24022655329c2aeb9f81/node_modules-@microsoft-dev-tunnels-connections-relayTunnelConnector.js.map