UNPKG

@jupyterlab/services

Version:

Client APIs for the Jupyter services REST APIs

411 lines (353 loc) 11.7 kB
// Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. import { URLExt } from '@jupyterlab/coreutils'; import { JSONPrimitive, PromiseDelegate } from '@lumino/coreutils'; import { ISignal, Signal } from '@lumino/signaling'; import { ServerConnection } from '..'; import * as Terminal from './terminal'; import { shutdownTerminal, TERMINAL_SERVICE_URL } from './restapi'; /** * An implementation of a terminal interface. */ export class TerminalConnection implements Terminal.ITerminalConnection { /** * Construct a new terminal session. */ constructor(options: Terminal.ITerminalConnection.IOptions) { this._name = options.model.name; this.serverSettings = options.serverSettings ?? ServerConnection.makeSettings(); this._createSocket(); } /** * A signal emitted when the session is disposed. */ get disposed(): ISignal<this, void> { return this._disposed; } /** * A signal emitted when a message is received from the server. */ get messageReceived(): ISignal<this, Terminal.IMessage> { return this._messageReceived; } /** * Get the name of the terminal session. */ get name(): string { return this._name; } /** * Get the model for the terminal session. */ get model(): Terminal.IModel { return { name: this._name }; } /** * The server settings for the session. */ readonly serverSettings: ServerConnection.ISettings; /** * Test whether the session is disposed. */ get isDisposed(): boolean { return this._isDisposed; } /** * Dispose of the resources held by the session. */ dispose(): void { if (this._isDisposed) { return; } this._isDisposed = true; this._disposed.emit(); this._updateConnectionStatus('disconnected'); this._clearSocket(); Signal.clearData(this); } /** * Send a message to the terminal session. * * #### Notes * If the connection is down, the message will be queued for sending when * the connection comes back up. */ send(message: Terminal.IMessage): void { this._sendMessage(message); } /** * Send a message on the websocket, or possibly queue for later sending. * * @param queue - whether to queue the message if it cannot be sent */ _sendMessage(message: Terminal.IMessage, queue = true): void { if (this._isDisposed || !message.content) { return; } if (this.connectionStatus === 'connected' && this._ws) { const msg = [message.type, ...message.content]; this._ws.send(JSON.stringify(msg)); } else if (queue) { this._pendingMessages.push(message); } else { throw new Error(`Could not send message: ${JSON.stringify(message)}`); } } /** * Send pending messages to the kernel. */ private _sendPending(): void { // We check to make sure we are still connected each time. For // example, if a websocket buffer overflows, it may close, so we should // stop sending messages. while ( this.connectionStatus === 'connected' && this._pendingMessages.length > 0 ) { this._sendMessage(this._pendingMessages[0], false); // We shift the message off the queue after the message is sent so that // if there is an exception, the message is still pending. this._pendingMessages.shift(); } } /** * Reconnect to a terminal. * * #### Notes * This may try multiple times to reconnect to a terminal, and will sever * any existing connection. */ reconnect(): Promise<void> { this._errorIfDisposed(); const result = new PromiseDelegate<void>(); // Set up a listener for the connection status changing, which accepts or // rejects after the retries are done. const fulfill = (sender: this, status: Terminal.ConnectionStatus) => { if (status === 'connected') { result.resolve(); this.connectionStatusChanged.disconnect(fulfill, this); } else if (status === 'disconnected') { result.reject(new Error('Terminal connection disconnected')); this.connectionStatusChanged.disconnect(fulfill, this); } }; this.connectionStatusChanged.connect(fulfill, this); // Reset the reconnect limit so we start the connection attempts fresh this._reconnectAttempt = 0; // Start the reconnection process, which will also clear any existing // connection. this._reconnect(); // Return the promise that should resolve on connection or reject if the // retries don't work. return result.promise; } /** * Attempt a connection if we have not exhausted connection attempts. */ _reconnect(): void { this._errorIfDisposed(); // Clear any existing reconnection attempt clearTimeout(this._reconnectTimeout); // Update the connection status and schedule a possible reconnection. if (this._reconnectAttempt < this._reconnectLimit) { this._updateConnectionStatus('connecting'); // The first reconnect attempt should happen immediately, and subsequent // attempts should pick a random number in a growing range so that we // don't overload the server with synchronized reconnection attempts // across multiple kernels. const timeout = Private.getRandomIntInclusive( 0, 1e3 * (Math.pow(2, this._reconnectAttempt) - 1) ); console.error( `Connection lost, reconnecting in ${Math.floor( timeout / 1000 )} seconds.` ); this._reconnectTimeout = setTimeout(this._createSocket, timeout); this._reconnectAttempt += 1; } else { this._updateConnectionStatus('disconnected'); } // Clear the websocket event handlers and the socket itself. this._clearSocket(); } /** * Forcefully clear the socket state. * * #### Notes * This will clear all socket state without calling any handlers and will * not update the connection status. If you call this method, you are * responsible for updating the connection status as needed and recreating * the socket if you plan to reconnect. */ private _clearSocket(): void { if (this._ws !== null) { // Clear the websocket event handlers and the socket itself. this._ws.onopen = this._noOp; this._ws.onclose = this._noOp; this._ws.onerror = this._noOp; this._ws.onmessage = this._noOp; this._ws.close(); this._ws = null; } } /** * Shut down the terminal session. */ async shutdown(): Promise<void> { await shutdownTerminal(this.name, this.serverSettings); this.dispose(); } /** * Clone the current terminal connection. */ clone(): Terminal.ITerminalConnection { return new TerminalConnection(this); } /** * Create the terminal websocket connection and add socket status handlers. * * #### Notes * You are responsible for updating the connection status as appropriate. */ private _createSocket = () => { this._errorIfDisposed(); // Make sure the socket is clear this._clearSocket(); // Update the connection status to reflect opening a new connection. this._updateConnectionStatus('connecting'); const name = this._name; const settings = this.serverSettings; let url = URLExt.join( settings.wsUrl, 'terminals', 'websocket', encodeURIComponent(name) ); // If token authentication is in use. const token = settings.token; if (settings.appendToken && token !== '') { url = url + `?token=${encodeURIComponent(token)}`; } this._ws = new settings.WebSocket(url); this._ws.onmessage = this._onWSMessage; this._ws.onclose = this._onWSClose; this._ws.onerror = this._onWSClose; }; // Websocket messages events are defined as variables to bind `this` private _onWSMessage = (event: MessageEvent) => { if (this._isDisposed) { return; } const data = JSON.parse(event.data) as JSONPrimitive[]; // Handle a disconnect message. if (data[0] === 'disconnect') { this.dispose(); } if (this._connectionStatus === 'connecting') { // After reconnection, ignore all messages until a 'setup' message // before we are truly connected. Setting the connection status to // connected only then means that if we do not get a setup message // before our retry timeout, we will delete the websocket and try again. if (data[0] === 'setup') { this._updateConnectionStatus('connected'); } return; } this._messageReceived.emit({ type: data[0] as Terminal.MessageType, content: data.slice(1) }); }; private _onWSClose = (event: CloseEvent) => { console.warn(`Terminal websocket closed: ${event.code}`); if (!this.isDisposed) { this._reconnect(); } }; /** * Handle connection status changes. */ private _updateConnectionStatus( connectionStatus: Terminal.ConnectionStatus ): void { if (this._connectionStatus === connectionStatus) { return; } this._connectionStatus = connectionStatus; // If we are not 'connecting', stop any reconnection attempts. if (connectionStatus !== 'connecting') { this._reconnectAttempt = 0; clearTimeout(this._reconnectTimeout); } // Send the pending messages if we just connected. if (connectionStatus === 'connected') { this._sendPending(); } // Notify others that the connection status changed. this._connectionStatusChanged.emit(connectionStatus); } /** * Utility function to throw an error if this instance is disposed. */ private _errorIfDisposed() { if (this.isDisposed) { throw new Error('Terminal connection is disposed'); } } /** * A signal emitted when the terminal connection status changes. */ get connectionStatusChanged(): ISignal<this, Terminal.ConnectionStatus> { return this._connectionStatusChanged; } /** * The current connection status of the terminal connection. */ get connectionStatus(): Terminal.ConnectionStatus { return this._connectionStatus; } private _connectionStatus: Terminal.ConnectionStatus = 'connecting'; private _connectionStatusChanged = new Signal< this, Terminal.ConnectionStatus >(this); private _isDisposed = false; private _disposed = new Signal<this, void>(this); private _messageReceived = new Signal<this, Terminal.IMessage>(this); private _name: string; private _reconnectTimeout: any = null; private _ws: WebSocket | null = null; private _noOp = () => { /* no-op */ }; private _reconnectLimit = 7; private _reconnectAttempt = 0; private _pendingMessages: Terminal.IMessage[] = []; } namespace Private { /** * Get the url for a terminal. */ export function getTermUrl(baseUrl: string, name: string): string { return URLExt.join(baseUrl, TERMINAL_SERVICE_URL, encodeURIComponent(name)); } /** * Get a random integer between min and max, inclusive of both. * * #### Notes * From * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Math/random#Getting_a_random_integer_between_two_values_inclusive * * From the MDN page: It might be tempting to use Math.round() to accomplish * that, but doing so would cause your random numbers to follow a non-uniform * distribution, which may not be acceptable for your needs. */ export function getRandomIntInclusive(min: number, max: number): number { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1)) + min; } }