Browse Source

Merge pull request #7674 from jasongrout/terminalrework

Rework services terminal code
Steven Silvester 5 years ago
parent
commit
dfeaf98cf3

+ 4 - 3
examples/terminal/src/index.ts

@@ -12,7 +12,7 @@ import '../index.css';
 
 import { DockPanel, Widget } from '@lumino/widgets';
 
-import { TerminalSession } from '@jupyterlab/services';
+import { TerminalManager } from '@jupyterlab/services';
 
 import { Terminal } from '@jupyterlab/terminal';
 
@@ -28,12 +28,13 @@ async function main(): Promise<void> {
     dock.fit();
   });
 
-  const s1 = await TerminalSession.startNew();
+  const manager = new TerminalManager();
+  const s1 = await manager.startNew();
   const term1 = new Terminal(s1, { theme: 'light' });
   term1.title.closable = true;
   dock.addWidget(term1);
 
-  const s2 = await TerminalSession.startNew();
+  const s2 = await manager.startNew();
   const term2 = new Terminal(s2, { theme: 'dark' });
   term2.title.closable = true;
   dock.addWidget(term2, { mode: 'tab-before' });

+ 3 - 1
packages/docregistry/src/context.ts

@@ -548,7 +548,9 @@ export class Context<T extends DocumentRegistry.IModel>
           throw err;
         }
       )
-      .catch();
+      .catch(() => {
+        /* no-op */
+      });
   }
 
   /**

+ 4 - 3
packages/services/examples/browser/src/terminal.ts

@@ -1,7 +1,7 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { TerminalSession } from '@jupyterlab/services';
+import { Terminal, TerminalManager } from '@jupyterlab/services';
 
 import { log } from './log';
 
@@ -9,9 +9,10 @@ export async function main() {
   log('Terminal');
 
   // See if terminals are available
-  if (TerminalSession.isAvailable()) {
+  if (Terminal.isAvailable()) {
+    let manager = new TerminalManager();
     // Create a named terminal session and send some data.
-    let session = await TerminalSession.startNew();
+    let session = await manager.startNew();
     session.send({ type: 'stdin', content: ['foo'] });
   }
 }

+ 28 - 26
packages/services/src/kernel/default.ts

@@ -52,22 +52,6 @@ export class KernelConnection implements Kernel.IKernelConnection {
     this._username = options.username ?? '';
     this.handleComms = options.handleComms ?? true;
 
-    this.connectionStatusChanged.connect((sender, connectionStatus) => {
-      // Send pending messages and update status to be consistent.
-      if (connectionStatus === 'connected' && this.status !== 'dead') {
-        // Make sure we send at least one message to get kernel status back.
-        if (this._pendingMessages.length > 0) {
-          this._sendPending();
-        } else {
-          void this.requestKernelInfo();
-        }
-      } else {
-        // If the connection is down, then we don't know what is happening with
-        // the kernel, so set the status to unknown.
-        this._updateStatus('unknown');
-      }
-    });
-
     this._createSocket();
 
     // Immediately queue up a request for initial kernel info.
@@ -264,7 +248,6 @@ export class KernelConnection implements Kernel.IKernelConnection {
     this._updateConnectionStatus('disconnected');
     this._clearKernelState();
     this._clearSocket();
-    clearTimeout(this._reconnectTimeout);
 
     // Clear Lumino signals
     Signal.clearData(this);
@@ -398,10 +381,8 @@ export class KernelConnection implements Kernel.IKernelConnection {
     // Send if the ws allows it, otherwise buffer the message.
     if (this.connectionStatus === 'connected') {
       this._ws!.send(serialize.serialize(msg));
-      // console.log(`SENT WS message to ${this.id}`, msg);
     } else if (queue) {
       this._pendingMessages.push(msg);
-      // console.log(`PENDING WS message to ${this.id}`, msg);
     } else {
       throw new Error('Could not send message');
     }
@@ -1034,8 +1015,6 @@ export class KernelConnection implements Kernel.IKernelConnection {
    * Handle status iopub messages from the kernel.
    */
   private _updateStatus(status: KernelMessage.Status): void {
-    // "unknown" | "starting" | "idle" | "busy" | "restarting" | "autorestarting" | "dead"
-
     if (this._status === status || this._status === 'dead') {
       return;
     }
@@ -1194,14 +1173,16 @@ export class KernelConnection implements Kernel.IKernelConnection {
 
   /**
    * Create the kernel websocket connection and add socket status handlers.
-   *
-   * #### Notes
-   * You are responsible for clearing the old socket so that the handlers
-   * from it are gone. For example, calling ._clearSocket(), etc.
    */
   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');
+
     let settings = this.serverSettings;
     let partialUrl = URLExt.join(
       settings.wsUrl,
@@ -1246,6 +1227,28 @@ export class KernelConnection implements Kernel.IKernelConnection {
 
     this._connectionStatus = connectionStatus;
 
+    // If we are not 'connecting', reset any reconnection attempts.
+    if (connectionStatus !== 'connecting') {
+      this._reconnectAttempt = 0;
+      clearTimeout(this._reconnectTimeout);
+    }
+
+    if (this.status !== 'dead') {
+      if (connectionStatus === 'connected') {
+        // Send pending messages, and make sure we send at least one message
+        // to get kernel status back.
+        if (this._pendingMessages.length > 0) {
+          this._sendPending();
+        } else {
+          void this.requestKernelInfo();
+        }
+      } else {
+        // If the connection is down, then we do not know what is happening
+        // with the kernel, so set the status to unknown.
+        this._updateStatus('unknown');
+      }
+    }
+
     // Notify others that the connection status changed.
     this._connectionStatusChanged.emit(connectionStatus);
   }
@@ -1386,7 +1389,6 @@ export class KernelConnection implements Kernel.IKernelConnection {
    * Handle a websocket open event.
    */
   private _onWSOpen = (evt: Event) => {
-    this._reconnectAttempt = 0;
     this._updateConnectionStatus('connected');
   };
 

+ 11 - 0
packages/services/src/kernel/manager.ts

@@ -87,6 +87,9 @@ export class KernelManager extends BaseManager implements Kernel.IManager {
    * Dispose of the resources used by the manager.
    */
   dispose(): void {
+    if (this.isDisposed) {
+      return;
+    }
     this._models.clear();
     this._kernelConnections.forEach(x => x.dispose());
     this._pollModels.dispose();
@@ -292,6 +295,14 @@ export class KernelManager extends BaseManager implements Kernel.IManager {
 
   private _onDisposed(kernelConnection: KernelConnection) {
     this._kernelConnections.delete(kernelConnection);
+    // A dispose emission could mean the server session is deleted, or that
+    // the kernel JS object is disposed and the kernel still exists on the
+    // server, so we refresh from the server to make sure we reflect the
+    // server state.
+
+    void this.refreshRunning().catch(() => {
+      /* no-op */
+    });
   }
 
   private _onStatusChanged(

+ 2 - 2
packages/services/src/manager.ts

@@ -19,7 +19,7 @@ import { Session, SessionManager } from './session';
 
 import { Setting, SettingManager } from './setting';
 
-import { TerminalSession, TerminalManager } from './terminal';
+import { Terminal, TerminalManager } from './terminal';
 
 import { ServerConnection } from './serverconnection';
 
@@ -219,7 +219,7 @@ export namespace ServiceManager {
     /**
      * The terminals manager for the manager.
      */
-    readonly terminals: TerminalSession.IManager;
+    readonly terminals: Terminal.IManager;
 
     /**
      * The workspace manager for the manager.

+ 3 - 0
packages/services/src/session/manager.ts

@@ -87,6 +87,9 @@ export class SessionManager extends BaseManager implements Session.IManager {
    * Dispose of the resources used by the manager.
    */
   dispose(): void {
+    if (this.isDisposed) {
+      return;
+    }
     this._models.clear();
     this._sessionConnections.forEach(x => x.dispose());
     this._pollModels.dispose();

+ 263 - 351
packages/services/src/terminal/default.ts

@@ -1,48 +1,42 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { PageConfig, URLExt } from '@jupyterlab/coreutils';
+import { URLExt } from '@jupyterlab/coreutils';
 
-import { ArrayExt, each, map, toArray } from '@lumino/algorithm';
-
-import { JSONPrimitive } from '@lumino/coreutils';
+import { JSONPrimitive, PromiseDelegate } from '@lumino/coreutils';
 
 import { ISignal, Signal } from '@lumino/signaling';
 
 import { ServerConnection } from '..';
 
-import { TerminalSession } from './terminal';
-
-/**
- * The url for the terminal service.
- */
-const TERMINAL_SERVICE_URL = 'api/terminals';
+import * as Terminal from './terminal';
+import { shutdownTerminal, TERMINAL_SERVICE_URL } from './restapi';
 
 /**
  * An implementation of a terminal interface.
  */
-export class DefaultTerminalSession implements TerminalSession.ISession {
+export class TerminalConnection implements Terminal.ITerminalConnection {
   /**
    * Construct a new terminal session.
    */
-  constructor(name: string, options: TerminalSession.IOptions = {}) {
-    this._name = name;
+  constructor(options: Terminal.ITerminalConnection.IOptions) {
+    this._name = options.model.name;
     this.serverSettings =
       options.serverSettings ?? ServerConnection.makeSettings();
-    this._readyPromise = this._initializeSocket();
+    this._createSocket();
   }
 
   /**
-   * A signal emitted when the session is shut down.
+   * A signal emitted when the session is disposed.
    */
-  get terminated(): Signal<this, void> {
-    return this._terminated;
+  get disposed(): ISignal<this, void> {
+    return this._disposed;
   }
 
   /**
    * A signal emitted when a message is received from the server.
    */
-  get messageReceived(): ISignal<this, TerminalSession.IMessage> {
+  get messageReceived(): ISignal<this, Terminal.IMessage> {
     return this._messageReceived;
   }
 
@@ -56,7 +50,7 @@ export class DefaultTerminalSession implements TerminalSession.ISession {
   /**
    * Get the model for the terminal session.
    */
-  get model(): TerminalSession.IModel {
+  get model(): Terminal.IModel {
     return { name: this._name };
   }
 
@@ -65,20 +59,6 @@ export class DefaultTerminalSession implements TerminalSession.ISession {
    */
   readonly serverSettings: ServerConnection.ISettings;
 
-  /**
-   * Test whether the session is ready.
-   */
-  get isReady(): boolean {
-    return this._isReady;
-  }
-
-  /**
-   * A promise that fulfills when the session is ready.
-   */
-  get ready(): Promise<void> {
-    return this._readyPromise;
-  }
-
   /**
    * Test whether the session is disposed.
    */
@@ -94,403 +74,335 @@ export class DefaultTerminalSession implements TerminalSession.ISession {
       return;
     }
 
-    this.terminated.emit(undefined);
     this._isDisposed = true;
-    if (this._ws) {
-      this._ws.close();
-      this._ws = null;
-    }
-    delete Private.running[this._url];
+    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: TerminalSession.IMessage): void {
+  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;
     }
-
-    const msg = [message.type, ...message.content];
-    const socket = this._ws;
-    const value = JSON.stringify(msg);
-
-    if (this._isReady && socket) {
-      socket.send(value);
-      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)}`);
     }
+  }
 
-    void this.ready.then(() => {
-      const socket = this._ws;
-
-      if (socket) {
-        socket.send(value);
-      }
-    });
+  /**
+   * 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 the terminal.
+   * Reconnect to a terminal.
    *
-   * @returns A promise that resolves when the terminal has reconnected.
+   * #### Notes
+   * This may try multiple times to reconnect to a terminal, and will sever
+   * any existing connection.
    */
   reconnect(): Promise<void> {
+    this._errorIfDisposed();
+    let result = new PromiseDelegate<void>();
+
+    // Set up a listener for the connection status changing, which accepts or
+    // rejects after the retries are done.
+    let 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;
-    this._readyPromise = this._initializeSocket();
-    return this._readyPromise;
-  }
 
-  /**
-   * Shut down the terminal session.
-   */
-  shutdown(): Promise<void> {
-    const { name, serverSettings } = this;
-    return DefaultTerminalSession.shutdown(name, serverSettings);
+    // 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;
   }
 
   /**
-   * Clone the current session object.
+   * Attempt a connection if we have not exhausted connection attempts.
    */
-  clone(): TerminalSession.ISession {
-    const { name, serverSettings } = this;
-    return new DefaultTerminalSession(name, { serverSettings });
+  _reconnect() {
+    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
+      // attemps should pick a random number in a growing range so that we
+      // don't overload the server with synchronized reconnection attempts
+      // across multiple kernels.
+      let 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();
   }
 
   /**
-   * Connect to the websocket.
+   * 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 _initializeSocket(): Promise<void> {
-    const name = this._name;
-    let socket = this._ws;
-
-    if (socket) {
+  private _clearSocket(): void {
+    if (this._ws !== null) {
       // Clear the websocket event handlers and the socket itself.
-      socket.onopen = this._noOp;
-      socket.onclose = this._noOp;
-      socket.onerror = this._noOp;
-      socket.onmessage = this._noOp;
-      socket.close();
+      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;
     }
-    this._isReady = false;
+  }
 
-    return new Promise<void>((resolve, reject) => {
-      const settings = this.serverSettings;
-      const token = this.serverSettings.token;
+  /**
+   * Shut down the terminal session.
+   */
+  async shutdown(): Promise<void> {
+    await shutdownTerminal(this.name, this.serverSettings);
+    this.dispose();
+  }
 
-      this._url = Private.getTermUrl(settings.baseUrl, this._name);
-      Private.running[this._url] = this;
+  /**
+   * Clone the current terminal connection.
+   */
+  clone(): Terminal.ITerminalConnection {
+    return new TerminalConnection(this);
+  }
 
-      let wsUrl = URLExt.join(settings.wsUrl, `terminals/websocket/${name}`);
+  /**
+   * 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();
 
-      if (token) {
-        wsUrl = wsUrl + `?token=${encodeURIComponent(token)}`;
-      }
+    // Make sure the socket is clear
+    this._clearSocket();
 
-      socket = this._ws = new settings.WebSocket(wsUrl);
-
-      socket.onmessage = (event: MessageEvent) => {
-        if (this._isDisposed) {
-          return;
-        }
-
-        const data = JSON.parse(event.data) as JSONPrimitive[];
-
-        // Handle a disconnect message.
-        if (data[0] === 'disconnect') {
-          this._disconnected = true;
-        }
-
-        if (this._reconnectAttempt > 0) {
-          // After reconnection, ignore all messages until a 'setup' message.
-          if (data[0] === 'setup') {
-            this._reconnectAttempt = 0;
-          }
-          return;
-        }
-
-        this._messageReceived.emit({
-          type: data[0] as TerminalSession.MessageType,
-          content: data.slice(1)
-        });
-      };
-
-      socket.onopen = (event: MessageEvent) => {
-        if (!this._isDisposed) {
-          this._isReady = true;
-          this._disconnected = false;
-          resolve(undefined);
-        }
-      };
-
-      socket.onerror = (event: Event) => {
-        if (!this._isDisposed) {
-          reject(event);
-        }
-      };
-
-      socket.onclose = (event: CloseEvent) => {
-        console.warn(`Terminal websocket closed: ${event.code}`);
-        if (this._disconnected) {
-          this.dispose();
-        }
-        this._reconnectSocket();
-      };
-    });
-  }
+    // Update the connection status to reflect opening a new connection.
+    this._updateConnectionStatus('connecting');
 
-  private _reconnectSocket(): void {
-    if (this._isDisposed || !this._ws || this._disconnected) {
-      return;
+    const name = this._name;
+    const settings = this.serverSettings;
+
+    let url = URLExt.join(
+      settings.wsUrl,
+      'terminals',
+      'websocket',
+      encodeURIComponent(name)
+    );
+
+    const token = settings.token;
+    if (token !== '') {
+      url = url + `?token=${encodeURIComponent(token)}`;
     }
+    this._ws = new settings.WebSocket(url);
 
-    const attempt = this._reconnectAttempt;
-    const limit = this._reconnectLimit;
+    this._ws.onmessage = this._onWSMessage;
+    this._ws.onclose = this._onWSClose;
+    this._ws.onerror = this._onWSClose;
+  };
 
-    if (attempt >= limit) {
-      console.log(`Terminal reconnect aborted: ${attempt} attempts`);
+  // 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[];
 
-    const timeout = Math.pow(2, attempt);
-
-    console.log(`Terminal will attempt to reconnect in ${timeout}s`);
-    this._isReady = false;
-    this._reconnectAttempt += 1;
+    // Handle a disconnect message.
+    if (data[0] === 'disconnect') {
+      this.dispose();
+    }
 
-    setTimeout(() => {
-      if (this.isDisposed) {
-        return;
+    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');
       }
-      this._initializeSocket()
-        .then(() => {
-          console.log('Terminal reconnected');
-        })
-        .catch(reason => {
-          console.warn(`Terminal reconnect failed`, reason);
-        });
-    }, 1e3 * timeout);
-  }
+      return;
+    }
 
-  private _isDisposed = false;
-  private _isReady = false;
-  private _messageReceived = new Signal<this, TerminalSession.IMessage>(this);
-  private _terminated = new Signal<this, void>(this);
-  private _name: string;
-  private _readyPromise: Promise<void>;
-  private _url: string;
-  private _ws: WebSocket | null = null;
-  private _noOp = () => {
-    /* no-op */
+    this._messageReceived.emit({
+      type: data[0] as Terminal.MessageType,
+      content: data.slice(1)
+    });
   };
-  private _reconnectLimit = 7;
-  private _reconnectAttempt = 0;
-  private _disconnected = false;
-}
 
-/**
- * The static namespace for `DefaultTerminalSession`.
- */
-export namespace DefaultTerminalSession {
-  /**
-   * Whether the terminal service is available.
-   */
-  export function isAvailable(): boolean {
-    let available = String(PageConfig.getOption('terminalsAvailable'));
-    return available.toLowerCase() === 'true';
-  }
+  private _onWSClose = (event: CloseEvent) => {
+    console.warn(`Terminal websocket closed: ${event.code}`);
+    if (!this.isDisposed) {
+      this._reconnect();
+    }
+  };
 
   /**
-   * Start a new terminal session.
-   *
-   * @param options - The session options to use.
-   *
-   * @returns A promise that resolves with the session instance.
+   * Handle connection status changes.
    */
-  export function startNew(
-    options: TerminalSession.IOptions = {}
-  ): Promise<TerminalSession.ISession> {
-    if (!TerminalSession.isAvailable()) {
-      throw Private.unavailableMsg;
+  private _updateConnectionStatus(
+    connectionStatus: Terminal.ConnectionStatus
+  ): void {
+    if (this._connectionStatus === connectionStatus) {
+      return;
     }
-    let serverSettings =
-      options.serverSettings ?? ServerConnection.makeSettings();
-    let url = Private.getServiceUrl(serverSettings.baseUrl);
-    let init = { method: 'POST' };
-
-    return ServerConnection.makeRequest(url, init, serverSettings)
-      .then(response => {
-        if (response.status !== 200) {
-          throw new ServerConnection.ResponseError(response);
-        }
-        return response.json();
-      })
-      .then((data: TerminalSession.IModel) => {
-        let name = data.name;
-        return new DefaultTerminalSession(name, { ...options, serverSettings });
-      });
-  }
 
-  /*
-   * Connect to a running session.
-   *
-   * @param name - The name of the target session.
-   *
-   * @param options - The session options to use.
-   *
-   * @returns A promise that resolves with the new session instance.
-   *
-   * #### Notes
-   * If the session was already started via `startNew`, the existing
-   * session object is used as the fulfillment value.
-   *
-   * Otherwise, if `options` are given, we resolve the promise after
-   * confirming that the session exists on the server.
-   *
-   * If the session does not exist on the server, the promise is rejected.
-   */
-  export function connectTo(
-    name: string,
-    options: TerminalSession.IOptions = {}
-  ): Promise<TerminalSession.ISession> {
-    if (!TerminalSession.isAvailable()) {
-      return Promise.reject(Private.unavailableMsg);
+    this._connectionStatus = connectionStatus;
+
+    // If we are not 'connecting', stop any reconnection attempts.
+    if (connectionStatus !== 'connecting') {
+      this._reconnectAttempt = 0;
+      clearTimeout(this._reconnectTimeout);
     }
-    let serverSettings =
-      options.serverSettings ?? ServerConnection.makeSettings();
-    let url = Private.getTermUrl(serverSettings.baseUrl, name);
-    if (url in Private.running) {
-      return Promise.resolve(Private.running[url].clone());
+
+    // Send the pending messages if we just connected.
+    if (connectionStatus === 'connected') {
+      this._sendPending();
     }
-    return listRunning(serverSettings).then(models => {
-      let index = ArrayExt.findFirstIndex(models, model => {
-        return model.name === name;
-      });
-      if (index !== -1) {
-        let session = new DefaultTerminalSession(name, {
-          ...options,
-          serverSettings
-        });
-        return Promise.resolve(session);
-      }
-      return Promise.reject<TerminalSession.ISession>('Could not find session');
-    });
+
+    // Notify others that the connection status changed.
+    this._connectionStatusChanged.emit(connectionStatus);
   }
 
   /**
-   * List the running terminal sessions.
-   *
-   * @param settings - The server settings to use.
-   *
-   * @returns A promise that resolves with the list of running session models.
+   * Utility function to throw an error if this instance is disposed.
    */
-  export function listRunning(
-    settings: ServerConnection.ISettings = ServerConnection.makeSettings()
-  ): Promise<TerminalSession.IModel[]> {
-    if (!TerminalSession.isAvailable()) {
-      return Promise.reject(Private.unavailableMsg);
+  private _errorIfDisposed() {
+    if (this.isDisposed) {
+      throw new Error('Terminal connection is disposed');
     }
-    let url = Private.getServiceUrl(settings.baseUrl);
-    return ServerConnection.makeRequest(url, {}, settings)
-      .then(response => {
-        if (response.status !== 200) {
-          throw new ServerConnection.ResponseError(response);
-        }
-        return response.json();
-      })
-      .then((data: TerminalSession.IModel[]) => {
-        if (!Array.isArray(data)) {
-          throw new Error('Invalid terminal data');
-        }
-        // Update the local data store.
-        let urls = toArray(
-          map(data, item => {
-            return URLExt.join(url, item.name);
-          })
-        );
-        each(Object.keys(Private.running), runningUrl => {
-          if (urls.indexOf(runningUrl) === -1) {
-            let session = Private.running[runningUrl];
-            session.dispose();
-          }
-        });
-        return data;
-      });
   }
 
   /**
-   * Shut down a terminal session by name.
-   *
-   * @param name - The name of the target session.
-   *
-   * @param settings - The server settings to use.
-   *
-   * @returns A promise that resolves when the session is shut down.
+   * A signal emitted when the terminal connection status changes.
    */
-  export function shutdown(
-    name: string,
-    settings: ServerConnection.ISettings = ServerConnection.makeSettings()
-  ): Promise<void> {
-    if (!TerminalSession.isAvailable()) {
-      return Promise.reject(Private.unavailableMsg);
-    }
-    let url = Private.getTermUrl(settings.baseUrl, name);
-    let init = { method: 'DELETE' };
-    return ServerConnection.makeRequest(url, init, settings).then(response => {
-      if (response.status === 404) {
-        return response.json().then(data => {
-          console.warn(data['message']);
-        });
-      }
-      if (response.status !== 204) {
-        throw new ServerConnection.ResponseError(response);
-      }
-    });
+  get connectionStatusChanged(): ISignal<this, Terminal.ConnectionStatus> {
+    return this._connectionStatusChanged;
   }
 
   /**
-   * Shut down all terminal sessions.
-   *
-   * @param settings - The server settings to use.
-   *
-   * @returns A promise that resolves when all the sessions are shut down.
+   * The current connection status of the terminal connection.
    */
-  export async function shutdownAll(
-    settings: ServerConnection.ISettings = ServerConnection.makeSettings()
-  ): Promise<void> {
-    const running = await listRunning(settings);
-    await Promise.all(running.map(s => shutdown(s.name, settings)));
+  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[] = [];
 }
 
-/**
- * A namespace for private data.
- */
 namespace Private {
-  /**
-   * A mapping of running terminals by url.
-   */
-  export const running: {
-    [key: string]: DefaultTerminalSession;
-  } = Object.create(null);
-
-  /**
-   * A promise returned for when terminals are unavailable.
-   */
-  export const unavailableMsg = 'Terminals Unavailable';
-
   /**
    * Get the url for a terminal.
    */
   export function getTermUrl(baseUrl: string, name: string): string {
-    return URLExt.join(baseUrl, TERMINAL_SERVICE_URL, name);
+    return URLExt.join(baseUrl, TERMINAL_SERVICE_URL, encodeURIComponent(name));
   }
 
   /**
-   * Get the base url.
+   * 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 getServiceUrl(baseUrl: string): string {
-    return URLExt.join(baseUrl, TERMINAL_SERVICE_URL);
+  export function getRandomIntInclusive(min: number, max: number) {
+    min = Math.ceil(min);
+    max = Math.floor(max);
+    return Math.floor(Math.random() * (max - min + 1)) + min;
   }
 }

+ 4 - 1
packages/services/src/terminal/index.ts

@@ -1,5 +1,8 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
+import * as Terminal from './terminal';
+import * as TerminalAPI from './restapi';
+
 export * from './manager';
-export * from './terminal';
+export { Terminal, TerminalAPI };

+ 144 - 186
packages/services/src/terminal/manager.ts

@@ -1,9 +1,7 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { ArrayExt, IIterator, iter } from '@lumino/algorithm';
-
-import { JSONExt } from '@lumino/coreutils';
+import { IIterator, iter } from '@lumino/algorithm';
 
 import { Poll } from '@lumino/polling';
 
@@ -11,14 +9,20 @@ import { ISignal, Signal } from '@lumino/signaling';
 
 import { ServerConnection } from '..';
 
-import { TerminalSession } from './terminal';
+import * as Terminal from './terminal';
 import { BaseManager } from '../basemanager';
+import {
+  isAvailable,
+  startNew,
+  shutdownTerminal,
+  listRunning
+} from './restapi';
+import { TerminalConnection } from './default';
 
 /**
  * A terminal session manager.
  */
-export class TerminalManager extends BaseManager
-  implements TerminalSession.IManager {
+export class TerminalManager extends BaseManager implements Terminal.IManager {
   /**
    * Construct a new terminal manager.
    */
@@ -26,23 +30,12 @@ export class TerminalManager extends BaseManager
     super(options);
 
     // Check if terminals are available
-    if (!TerminalSession.isAvailable()) {
+    if (!this.isAvailable()) {
       this._ready = Promise.reject('Terminals unavailable');
       this._ready.catch(_ => undefined);
       return;
     }
 
-    // Initialize internal data then start polling.
-    this._ready = this.requestRunning()
-      .then(_ => undefined)
-      .catch(_ => undefined)
-      .then(() => {
-        if (this.isDisposed) {
-          return;
-        }
-        this._isReady = true;
-      });
-
     // Start polling with exponential backoff.
     this._pollModels = new Poll({
       auto: false,
@@ -55,23 +48,13 @@ export class TerminalManager extends BaseManager
       name: `@jupyterlab/services:TerminalManager#models`,
       standby: options.standby ?? 'when-hidden'
     });
-    void this.ready.then(() => {
-      void this._pollModels.start();
-    });
-  }
 
-  /**
-   * A signal emitted when the running terminals change.
-   */
-  get runningChanged(): ISignal<this, TerminalSession.IModel[]> {
-    return this._runningChanged;
-  }
-
-  /**
-   * A signal emitted when there is a connection failure.
-   */
-  get connectionFailure(): ISignal<this, Error> {
-    return this._connectionFailure;
+    // Initialize internal data.
+    this._ready = (async () => {
+      await this._pollModels.start();
+      await this._pollModels.tick;
+      this._isReady = true;
+    })();
   }
 
   /**
@@ -87,83 +70,89 @@ export class TerminalManager extends BaseManager
   }
 
   /**
-   * Dispose of the resources used by the manager.
+   * A promise that fulfills when the manager is ready.
    */
-  dispose(): void {
-    this._models.length = 0;
-    this._pollModels.dispose();
-    super.dispose();
+  get ready(): Promise<void> {
+    return this._ready;
   }
 
   /**
-   * A promise that fulfills when the manager is ready.
+   * A signal emitted when the running terminals change.
    */
-  get ready(): Promise<void> {
-    return this._ready;
+  get runningChanged(): ISignal<this, Terminal.IModel[]> {
+    return this._runningChanged;
   }
 
   /**
-   * Whether the terminal service is available.
+   * A signal emitted when there is a connection failure.
    */
-  isAvailable(): boolean {
-    return TerminalSession.isAvailable();
+  get connectionFailure(): ISignal<this, Error> {
+    return this._connectionFailure;
   }
 
   /**
-   * Create an iterator over the most recent running terminals.
-   *
-   * @returns A new iterator over the running terminals.
+   * Dispose of the resources used by the manager.
    */
-  running(): IIterator<TerminalSession.IModel> {
-    return iter(this._models);
+  dispose(): void {
+    if (this.isDisposed) {
+      return;
+    }
+    this._names.length = 0;
+    this._terminalConnections.forEach(x => x.dispose());
+    this._pollModels.dispose();
+    super.dispose();
   }
 
   /**
-   * Create a new terminal session.
-   *
-   * @param options - The options used to connect to the session.
-   *
-   * @returns A promise that resolves with the terminal instance.
-   *
-   * #### Notes
-   * The manager `serverSettings` will be used unless overridden in the
-   * options.
+   * Whether the terminal service is available.
    */
-  async startNew(
-    options?: TerminalSession.IOptions
-  ): Promise<TerminalSession.ISession> {
-    const session = await TerminalSession.startNew(this._getOptions(options));
-    this._onStarted(session);
-    return session;
+  isAvailable(): boolean {
+    return isAvailable();
   }
 
   /*
-   * Connect to a running session.
+   * Connect to a running terminal.
    *
-   * @param name - The name of the target session.
+   * @param name - The name of the target terminal.
    *
-   * @param options - The options used to connect to the session.
+   * @param options - The options used to connect to the terminal.
    *
-   * @returns A promise that resolves with the new session instance.
+   * @returns A promise that resolves to the new terminal connection instance.
    *
    * #### Notes
-   * The manager `serverSettings` will be used unless overridden in the
-   * options.
+   * The manager `serverSettings` will be used.
    */
-  async connectTo(
-    name: string,
-    options?: TerminalSession.IOptions
-  ): Promise<TerminalSession.ISession> {
-    const session = await TerminalSession.connectTo(
-      name,
-      this._getOptions(options)
-    );
-    this._onStarted(session);
-    return session;
+  connectTo(
+    options: Omit<Terminal.ITerminalConnection.IOptions, 'serverSettings'>
+  ): Terminal.ITerminalConnection {
+    const terminalConnection = new TerminalConnection({
+      ...options,
+      serverSettings: this.serverSettings
+    });
+    this._onStarted(terminalConnection);
+    if (!this._names.includes(options.model.name)) {
+      // We trust the user to connect to an existing session, but we verify
+      // asynchronously.
+      void this.refreshRunning().catch(() => {
+        /* no-op */
+      });
+    }
+    return terminalConnection;
   }
 
   /**
-   * Force a refresh of the running sessions.
+   * Create an iterator over the most recent running terminals.
+   *
+   * @returns A new iterator over the running terminals.
+   */
+  running(): IIterator<Terminal.IModel> {
+    return iter(this._models);
+  }
+
+  /**
+   * Force a refresh of the running terminals.
+   *
+   * @returns A promise that with the list of running terminals.
    *
    * #### Notes
    * This is intended to be called only in response to a user action,
@@ -174,31 +163,27 @@ export class TerminalManager extends BaseManager
     await this._pollModels.tick;
   }
 
+  /**
+   * Create a new terminal session.
+   *
+   * @returns A promise that resolves with the terminal instance.
+   *
+   * #### Notes
+   * The manager `serverSettings` will be used unless overridden in the
+   * options.
+   */
+  async startNew(): Promise<Terminal.ITerminalConnection> {
+    const model = await startNew(this.serverSettings);
+    await this.refreshRunning();
+    return this.connectTo({ model });
+  }
+
   /**
    * Shut down a terminal session by name.
    */
   async shutdown(name: string): Promise<void> {
-    const models = this._models;
-    const sessions = this._sessions;
-    const index = ArrayExt.findFirstIndex(models, model => model.name === name);
-    if (index === -1) {
-      return;
-    }
-
-    // Proactively remove the model.
-    models.splice(index, 1);
-    this._runningChanged.emit(models.slice());
-
-    // Delete and dispose the session locally.
-    sessions.forEach(session => {
-      if (session.name === name) {
-        sessions.delete(session);
-        session.dispose();
-      }
-    });
-
-    // Shut down the remote session.
-    await TerminalSession.shutdown(name, this.serverSettings);
+    await shutdownTerminal(name, this.serverSettings);
+    await this.refreshRunning();
   }
 
   /**
@@ -207,118 +192,91 @@ export class TerminalManager extends BaseManager
    * @returns A promise that resolves when all of the sessions are shut down.
    */
   async shutdownAll(): Promise<void> {
-    // Update the list of models then shut down every session.
-    try {
-      await this.requestRunning();
-      await Promise.all(
-        this._models.map(({ name }) =>
-          TerminalSession.shutdown(name, this.serverSettings)
-        )
-      );
-    } finally {
-      // Dispose every kernel and clear the set.
-      this._sessions.forEach(session => {
-        session.dispose();
-      });
-      this._sessions.clear();
+    // Update the list of models to make sure our list is current.
+    await this.refreshRunning();
 
-      // Remove all models even if we had an error.
-      if (this._models.length) {
-        this._models.length = 0;
-        this._runningChanged.emit([]);
-      }
-    }
+    // Shut down all models.
+    await Promise.all(
+      this._names.map(name => shutdownTerminal(name, this.serverSettings))
+    );
+
+    // Update the list of models to clear out our state.
+    await this.refreshRunning();
   }
 
   /**
    * Execute a request to the server to poll running terminals and update state.
    */
   protected async requestRunning(): Promise<void> {
-    const models = await TerminalSession.listRunning(this.serverSettings).catch(
-      err => {
-        // Check for a network error, or a 503 error, which is returned
-        // by a JupyterHub when a server is shut down.
-        if (
-          err instanceof ServerConnection.NetworkError ||
-          err.response?.status === 503
-        ) {
-          this._connectionFailure.emit(err);
-          return [] as TerminalSession.IModel[];
-        }
-        throw err;
+    let models: Terminal.IModel[];
+    try {
+      models = await listRunning(this.serverSettings);
+    } catch (err) {
+      // Check for a network error, or a 503 error, which is returned
+      // by a JupyterHub when a server is shut down.
+      if (
+        err instanceof ServerConnection.NetworkError ||
+        err.response?.status === 503
+      ) {
+        this._connectionFailure.emit(err);
+        // TODO: why do we care about resetting models if we are throwing right away?
+        models = [];
       }
-    );
+      throw err;
+    }
+
     if (this.isDisposed) {
       return;
     }
-    if (!JSONExt.deepEqual(models, this._models)) {
-      const names = models.map(({ name }) => name);
-      const sessions = this._sessions;
-      sessions.forEach(session => {
-        if (names.indexOf(session.name) === -1) {
-          session.dispose();
-          sessions.delete(session);
-        }
-      });
-      this._models = models.slice();
-      this._runningChanged.emit(models);
+
+    const names = models.map(({ name }) => name).sort();
+    if (names === this._names) {
+      // Identical models list, so just return
+      return;
     }
-  }
 
-  /**
-   * Get a set of options to pass.
-   */
-  private _getOptions(
-    options: TerminalSession.IOptions = {}
-  ): TerminalSession.IOptions {
-    return { ...options, serverSettings: this.serverSettings };
+    this._names = names;
+    this._terminalConnections.forEach(tc => {
+      if (!names.includes(tc.name)) {
+        tc.dispose();
+      }
+    });
+    this._runningChanged.emit(this._models);
   }
 
   /**
    * Handle a session starting.
    */
-  private _onStarted(session: TerminalSession.ISession): void {
-    let name = session.name;
-    this._sessions.add(session);
-    let index = ArrayExt.findFirstIndex(
-      this._models,
-      value => value.name === name
-    );
-    if (index === -1) {
-      this._models.push(session.model);
-      this._runningChanged.emit(this._models.slice());
-    }
-    session.terminated.connect(() => {
-      this._onTerminated(name);
-    });
+  private _onStarted(terminalConnection: Terminal.ITerminalConnection): void {
+    this._terminalConnections.add(terminalConnection);
+    terminalConnection.disposed.connect(this._onDisposed, this);
   }
 
   /**
    * Handle a session terminating.
    */
-  private _onTerminated(name: string): void {
-    let index = ArrayExt.findFirstIndex(
-      this._models,
-      value => value.name === name
-    );
-    if (index !== -1) {
-      this._models.splice(index, 1);
-      this._runningChanged.emit(this._models.slice());
-    }
-    const sessions = this._sessions;
-    sessions.forEach(session => {
-      if (session.name === name) {
-        sessions.delete(session);
-      }
+  private _onDisposed(terminalConnection: Terminal.ITerminalConnection): void {
+    this._terminalConnections.delete(terminalConnection);
+    // Update the running models to make sure we reflect the server state
+    void this.refreshRunning().catch(() => {
+      /* no-op */
     });
   }
 
   private _isReady = false;
-  private _models: TerminalSession.IModel[] = [];
+
+  // As an optimization, we unwrap the models to just store the names.
+  private _names: string[] = [];
+  private get _models(): Terminal.IModel[] {
+    return this._names.map(name => {
+      return { name };
+    });
+  }
+
   private _pollModels: Poll;
-  private _sessions = new Set<TerminalSession.ISession>();
+  private _terminalConnections = new Set<Terminal.ITerminalConnection>();
   private _ready: Promise<void>;
-  private _runningChanged = new Signal<this, TerminalSession.IModel[]>(this);
+  private _runningChanged = new Signal<this, Terminal.IModel[]>(this);
   private _connectionFailure = new Signal<this, Error>(this);
 }
 

+ 116 - 0
packages/services/src/terminal/restapi.ts

@@ -0,0 +1,116 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import { URLExt, PageConfig } from '@jupyterlab/coreutils';
+import { ServerConnection } from '../serverconnection';
+
+/**
+ * The url for the terminal service.
+ */
+export const TERMINAL_SERVICE_URL = 'api/terminals';
+
+/**
+ * Whether the terminal service is available.
+ */
+export function isAvailable(): boolean {
+  let available = String(PageConfig.getOption('terminalsAvailable'));
+  return available.toLowerCase() === 'true';
+}
+
+/**
+ * The server model for a terminal session.
+ */
+export interface IModel {
+  /**
+   * The name of the terminal session.
+   */
+  readonly name: string;
+}
+
+/**
+ * Start a new terminal session.
+ *
+ * @param options - The session options to use.
+ *
+ * @returns A promise that resolves with the session instance.
+ */
+export async function startNew(
+  settings: ServerConnection.ISettings = ServerConnection.makeSettings()
+): Promise<IModel> {
+  Private.errorIfNotAvailable();
+  let url = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL);
+  let init = { method: 'POST' };
+
+  let response = await ServerConnection.makeRequest(url, init, settings);
+  if (response.status !== 200) {
+    throw new ServerConnection.ResponseError(response);
+  }
+  let data = await response.json();
+  // TODO: Validate model
+  return data;
+}
+
+/**
+ * List the running terminal sessions.
+ *
+ * @param settings - The server settings to use.
+ *
+ * @returns A promise that resolves with the list of running session models.
+ */
+export async function listRunning(
+  settings: ServerConnection.ISettings = ServerConnection.makeSettings()
+): Promise<IModel[]> {
+  Private.errorIfNotAvailable();
+  let url = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL);
+  let response = await ServerConnection.makeRequest(url, {}, settings);
+  if (response.status !== 200) {
+    throw new ServerConnection.ResponseError(response);
+  }
+  let data = await response.json();
+
+  if (!Array.isArray(data)) {
+    throw new Error('Invalid terminal list');
+  }
+
+  // TODO: validate each model
+  return data;
+}
+
+/**
+ * Shut down a terminal session by name.
+ *
+ * @param name - The name of the target session.
+ *
+ * @param settings - The server settings to use.
+ *
+ * @returns A promise that resolves when the session is shut down.
+ */
+export async function shutdownTerminal(
+  name: string,
+  settings: ServerConnection.ISettings = ServerConnection.makeSettings()
+): Promise<void> {
+  Private.errorIfNotAvailable();
+  let url = URLExt.join(settings.baseUrl, TERMINAL_SERVICE_URL, name);
+  let init = { method: 'DELETE' };
+  let response = await ServerConnection.makeRequest(url, init, settings);
+  if (response.status === 404) {
+    let data = await response.json();
+    let msg =
+      data.message ??
+      `The terminal session "${name}"" does not exist on the server`;
+    console.warn(msg);
+  } else if (response.status !== 204) {
+    throw new ServerConnection.ResponseError(response);
+  }
+}
+
+namespace Private {
+  /**
+   * Throw an error if terminals are not available.
+   */
+  export function errorIfNotAvailable() {
+    if (!isAvailable()) {
+      throw new Error('Terminals Unavailable');
+    }
+  }
+}

+ 142 - 222
packages/services/src/terminal/terminal.ts

@@ -3,287 +3,207 @@
 
 import { IIterator } from '@lumino/algorithm';
 
-import { JSONPrimitive, JSONObject } from '@lumino/coreutils';
+import { JSONPrimitive } from '@lumino/coreutils';
 
-import { IDisposable } from '@lumino/disposable';
+import { IObservableDisposable } from '@lumino/disposable';
 
 import { ISignal } from '@lumino/signaling';
 
 import { ServerConnection } from '..';
 
-import { DefaultTerminalSession } from './default';
 import { IManager as IBaseManager } from '../basemanager';
 
+import { IModel, isAvailable } from './restapi';
+export { IModel, isAvailable };
+
 /**
- * The namespace for ISession statics.
+ * An interface for a terminal session.
  */
-export namespace TerminalSession {
+export interface ITerminalConnection extends IObservableDisposable {
   /**
-   * An interface for a terminal session.
+   * A signal emitted when a message is received from the server.
    */
-  export interface ISession extends IDisposable {
-    /**
-     * A signal emitted when the session is shut down.
-     */
-    terminated: ISignal<ISession, void>;
-
-    /**
-     * A signal emitted when a message is received from the server.
-     */
-    messageReceived: ISignal<ISession, IMessage>;
+  messageReceived: ISignal<ITerminalConnection, IMessage>;
 
-    /**
-     * Get the name of the terminal session.
-     */
-    readonly name: string;
-
-    /**
-     * The model associated with the session.
-     */
-    readonly model: IModel;
-
-    /**
-     * The server settings for the session.
-     */
-    readonly serverSettings: ServerConnection.ISettings;
-
-    /**
-     * Test whether the session is ready.
-     */
-    readonly isReady: boolean;
-
-    /**
-     * A promise that fulfills when the session is initially ready.
-     */
-    readonly ready: Promise<void>;
-
-    /**
-     * Send a message to the terminal session.
-     */
-    send(message: IMessage): void;
-
-    /**
-     * Reconnect to the terminal.
-     *
-     * @returns A promise that resolves when the terminal has reconnected.
-     */
-    reconnect(): Promise<void>;
-
-    /**
-     * Shut down the terminal session.
-     */
-    shutdown(): Promise<void>;
-  }
+  /**
+   * Get the name of the terminal session.
+   */
+  readonly name: string;
 
   /**
-   * Test whether the terminal service is available.
+   * The model associated with the session.
    */
-  export function isAvailable(): boolean {
-    return DefaultTerminalSession.isAvailable();
-  }
+  readonly model: IModel;
 
   /**
-   * Start a new terminal session.
-   *
-   * @param options - The session options to use.
-   *
-   * @returns A promise that resolves with the session instance.
+   * The server settings for the session.
    */
-  export function startNew(options?: IOptions): Promise<ISession> {
-    return DefaultTerminalSession.startNew(options);
-  }
+  readonly serverSettings: ServerConnection.ISettings;
 
-  /*
-   * Connect to a running session.
-   *
-   * @param name - The name of the target session.
-   *
-   * @param options - The session options to use.
-   *
-   * @returns A promise that resolves with the new session instance.
-   *
-   * #### Notes
-   * If the session was already started via `startNew`, the existing
-   * session object is used as the fulfillment value.
-   *
-   * Otherwise, if `options` are given, we resolve the promise after
-   * confirming that the session exists on the server.
-   *
-   * If the session does not exist on the server, the promise is rejected.
+  /**
+   * The current connection status of the terminal.
    */
-  export function connectTo(
-    name: string,
-    options?: IOptions
-  ): Promise<ISession> {
-    return DefaultTerminalSession.connectTo(name, options);
-  }
+  readonly connectionStatus: ConnectionStatus;
 
   /**
-   * List the running terminal sessions.
-   *
-   * @param settings - The server settings to use.
-   *
-   * @returns A promise that resolves with the list of running session models.
+   * A signal emitted when the terminal connection status changes.
    */
-  export function listRunning(
-    settings?: ServerConnection.ISettings
-  ): Promise<IModel[]> {
-    return DefaultTerminalSession.listRunning(settings);
-  }
+  connectionStatusChanged: ISignal<this, ConnectionStatus>;
 
   /**
-   * Shut down a terminal session by name.
-   *
-   * @param name - The name of the target session.
-   *
-   * @param settings - The server settings to use.
-   *
-   * @returns A promise that resolves when the session is shut down.
+   * Send a message to the terminal session.
    */
-  export function shutdown(
-    name: string,
-    settings?: ServerConnection.ISettings
-  ): Promise<void> {
-    return DefaultTerminalSession.shutdown(name, settings);
-  }
+  send(message: IMessage): void;
 
   /**
-   * Shut down all terminal sessions.
+   * Reconnect to the terminal.
    *
-   * @returns A promise that resolves when all of the sessions are shut down.
+   * @returns A promise that resolves when the terminal has reconnected.
    */
-  export function shutdownAll(
-    settings?: ServerConnection.ISettings
-  ): Promise<void> {
-    return DefaultTerminalSession.shutdownAll(settings);
-  }
+  reconnect(): Promise<void>;
 
   /**
-   * The options for initializing a terminal session object.
+   * Shut down the terminal session.
    */
+  shutdown(): Promise<void>;
+}
+
+export namespace ITerminalConnection {
   export interface IOptions {
     /**
-     * The server settings for the session.
+     * Terminal model.
+     */
+    model: IModel;
+
+    /**
+     * The server settings.
      */
     serverSettings?: ServerConnection.ISettings;
   }
+}
 
+/**
+ * A message from the terminal session.
+ */
+export interface IMessage {
   /**
-   * The server model for a terminal session.
+   * The type of the message.
    */
-  export interface IModel extends JSONObject {
-    /**
-     * The name of the terminal session.
-     */
-    readonly name: string;
-  }
+  readonly type: MessageType;
 
   /**
-   * A message from the terminal session.
+   * The content of the message.
    */
-  export interface IMessage {
-    /**
-     * The type of the message.
-     */
-    readonly type: MessageType;
+  readonly content?: JSONPrimitive[];
+}
 
-    /**
-     * The content of the message.
-     */
-    readonly content?: JSONPrimitive[];
-  }
+/**
+ * Valid message types for the terminal.
+ */
+export type MessageType = 'stdout' | 'disconnect' | 'set_size' | 'stdin';
 
+/**
+ * The interface for a terminal manager.
+ *
+ * #### Notes
+ * The manager is responsible for maintaining the state of running
+ * terminal sessions.
+ */
+export interface IManager extends IBaseManager {
   /**
-   * Valid message types for the terminal.
+   * A signal emitted when the running terminals change.
    */
-  export type MessageType = 'stdout' | 'disconnect' | 'set_size' | 'stdin';
+  runningChanged: ISignal<IManager, IModel[]>;
 
   /**
-   * The interface for a terminal manager.
-   *
-   * #### Notes
-   * The manager is responsible for maintaining the state of running
-   * terminal sessions.
+   * A signal emitted when there is a connection failure.
    */
-  export interface IManager extends IBaseManager {
-    /**
-     * A signal emitted when the running terminals change.
-     */
-    runningChanged: ISignal<IManager, IModel[]>;
+  connectionFailure: ISignal<IManager, ServerConnection.NetworkError>;
 
-    /**
-     * A signal emitted when there is a connection failure.
-     */
-    connectionFailure: ISignal<IManager, ServerConnection.NetworkError>;
+  /**
+   * Test whether the manager is ready.
+   */
+  readonly isReady: boolean;
 
-    /**
-     * Test whether the manager is ready.
-     */
-    readonly isReady: boolean;
+  /**
+   * A promise that fulfills when the manager is ready.
+   */
+  readonly ready: Promise<void>;
 
-    /**
-     * A promise that fulfills when the manager is ready.
-     */
-    readonly ready: Promise<void>;
+  /**
+   * Whether the terminal service is available.
+   */
+  isAvailable(): boolean;
 
-    /**
-     * Whether the terminal service is available.
-     */
-    isAvailable(): boolean;
+  /**
+   * Create an iterator over the known running terminals.
+   *
+   * @returns A new iterator over the running terminals.
+   */
+  running(): IIterator<IModel>;
 
-    /**
-     * Create an iterator over the known running terminals.
-     *
-     * @returns A new iterator over the running terminals.
-     */
-    running(): IIterator<IModel>;
+  /**
+   * Create a new terminal session.
+   *
+   * @param options - The options used to create the session.
+   *
+   * @returns A promise that resolves with the terminal instance.
+   *
+   * #### Notes
+   * The manager `serverSettings` will be always be used.
+   */
+  startNew(
+    options?: ITerminalConnection.IOptions
+  ): Promise<ITerminalConnection>;
 
-    /**
-     * Create a new terminal session.
-     *
-     * @param options - The options used to create the session.
-     *
-     * @returns A promise that resolves with the terminal instance.
-     *
-     * #### Notes
-     * The manager `serverSettings` will be always be used.
-     */
-    startNew(options?: IOptions): Promise<ISession>;
-
-    /*
-     * Connect to a running session.
-     *
-     * @param name - The name of the target session.
-     *
-     * @returns A promise that resolves with the new session instance.
-     */
-    connectTo(name: string): Promise<ISession>;
+  /*
+   * Connect to a running session.
+   *
+   * @param name - The name of the target session.
+   *
+   * @returns A promise that resolves with the new session instance.
+   */
+  connectTo(
+    options: Omit<ITerminalConnection.IOptions, 'serverSettings'>
+  ): ITerminalConnection;
 
-    /**
-     * Shut down a terminal session by name.
-     *
-     * @param name - The name of the terminal session.
-     *
-     * @returns A promise that resolves when the session is shut down.
-     */
-    shutdown(name: string): Promise<void>;
+  /**
+   * Shut down a terminal session by name.
+   *
+   * @param name - The name of the terminal session.
+   *
+   * @returns A promise that resolves when the session is shut down.
+   */
+  shutdown(name: string): Promise<void>;
 
-    /**
-     * Shut down all terminal sessions.
-     *
-     * @returns A promise that resolves when all of the sessions are shut down.
-     */
-    shutdownAll(): Promise<void>;
+  /**
+   * Shut down all terminal sessions.
+   *
+   * @returns A promise that resolves when all of the sessions are shut down.
+   */
+  shutdownAll(): Promise<void>;
 
-    /**
-     * Force a refresh of the running terminal sessions.
-     *
-     * @returns A promise that with the list of running sessions.
-     *
-     * #### Notes
-     * This is not typically meant to be called by the user, since the
-     * manager maintains its own internal state.
-     */
-    refreshRunning(): Promise<void>;
-  }
+  /**
+   * Force a refresh of the running terminal sessions.
+   *
+   * @returns A promise that with the list of running sessions.
+   *
+   * #### Notes
+   * This is not typically meant to be called by the user, since the
+   * manager maintains its own internal state.
+   */
+  refreshRunning(): Promise<void>;
 }
+
+/**
+ * The valid terminal connection states.
+ *
+ * #### Notes
+ * The status states are:
+ * * `connected`: The terminal connection is live.
+ * * `connecting`: The terminal connection is not live, but we are attempting
+ *   to reconnect to the terminal.
+ * * `disconnected`: The terminal connection is down, we are not
+ *   trying to reconnect.
+ */
+export type ConnectionStatus = 'connected' | 'connecting' | 'disconnected';

+ 2 - 2
packages/statusbar/src/defaults/runningSessions.tsx

@@ -7,7 +7,7 @@ import { VDomRenderer, VDomModel } from '@jupyterlab/apputils';
 
 import {
   ServiceManager,
-  TerminalSession,
+  Terminal,
   TerminalManager,
   SessionManager,
   Session
@@ -151,7 +151,7 @@ export class RunningSessions extends VDomRenderer<RunningSessions.Model> {
    */
   private _onTerminalsRunningChanged(
     manager: TerminalManager,
-    terminals: TerminalSession.IModel[]
+    terminals: Terminal.IModel[]
   ): void {
     this.model!.terminals = terminals.length;
   }

+ 4 - 6
packages/terminal-extension/src/index.ts

@@ -16,7 +16,7 @@ import {
   WidgetTracker
 } from '@jupyterlab/apputils';
 
-import { TerminalSession } from '@jupyterlab/services';
+import { Terminal } from '@jupyterlab/services';
 
 import { ILauncher } from '@jupyterlab/launcher';
 
@@ -293,7 +293,7 @@ function addRunningSessionManager(
   });
 
   class RunningTerminal implements IRunningSessions.IRunningItem {
-    constructor(model: TerminalSession.IModel) {
+    constructor(model: Terminal.IModel) {
       this._model = model;
     }
     open() {
@@ -309,7 +309,7 @@ function addRunningSessionManager(
       return manager.shutdown(this._model.name);
     }
 
-    private _model: TerminalSession.IModel;
+    private _model: Terminal.IModel;
   }
 }
 
@@ -342,9 +342,7 @@ export function addCommands(
       const name = args['name'] as string;
 
       const session = await (name
-        ? serviceManager.terminals
-            .connectTo(name)
-            .catch(() => serviceManager.terminals.startNew())
+        ? serviceManager.terminals.connectTo({ model: { name } })
         : serviceManager.terminals.startNew());
 
       const term = new Terminal(session, options);

+ 2 - 2
packages/terminal/src/tokens.ts

@@ -7,7 +7,7 @@ import { Token } from '@lumino/coreutils';
 
 import { IWidgetTracker, MainAreaWidget } from '@jupyterlab/apputils';
 
-import { TerminalSession } from '@jupyterlab/services';
+import { Terminal } from '@jupyterlab/services';
 
 /**
  * A class that tracks editor widgets.
@@ -33,7 +33,7 @@ export namespace ITerminal {
     /**
      * The terminal session associated with the widget.
      */
-    session: TerminalSession.ISession;
+    session: Terminal.ITerminalConnection;
 
     /**
      * Get a config option for the terminal.

+ 35 - 19
packages/terminal/src/widget.ts

@@ -1,7 +1,7 @@
 // Copyright (c) Jupyter Development Team.
 // Distributed under the terms of the Modified BSD License.
 
-import { TerminalSession } from '@jupyterlab/services';
+import { Terminal as TerminalNS } from '@jupyterlab/services';
 
 import { Platform } from '@lumino/domutils';
 
@@ -37,7 +37,7 @@ export class Terminal extends Widget implements ITerminal.ITerminal {
    * @param options - The terminal configuration options.
    */
   constructor(
-    session: TerminalSession.ISession,
+    session: TerminalNS.ITerminalConnection,
     options: Partial<ITerminal.IOptions> = {}
   ) {
     super();
@@ -66,28 +66,44 @@ export class Terminal extends Widget implements ITerminal.ITerminal {
     this.title.label = 'Terminal';
 
     session.messageReceived.connect(this._onMessage, this);
-    session.terminated.connect(this.dispose, this);
+    session.disposed.connect(this.dispose, this);
 
-    void session.ready.then(() => {
-      if (this.isDisposed) {
-        return;
-      }
+    if (session.connectionStatus === 'connected') {
+      this._initialConnection();
+    } else {
+      session.connectionStatusChanged.connect(this._initialConnection, this);
+    }
+  }
 
-      this.title.label = `Terminal ${session.name}`;
-      this._setSessionSize();
-      if (this._options.initialCommand) {
-        this.session.send({
-          type: 'stdin',
-          content: [this._options.initialCommand + '\r']
-        });
-      }
-    });
+  private _initialConnection() {
+    if (this.isDisposed) {
+      return;
+    }
+
+    if (this.session.connectionStatus !== 'connected') {
+      return;
+    }
+
+    this.title.label = `Terminal ${this.session.name}`;
+    this._setSessionSize();
+    if (this._options.initialCommand) {
+      this.session.send({
+        type: 'stdin',
+        content: [this._options.initialCommand + '\r']
+      });
+    }
+
+    // Only run this initial connection logic once.
+    this.session.connectionStatusChanged.disconnect(
+      this._initialConnection,
+      this
+    );
   }
 
   /**
    * The terminal session associated with the widget.
    */
-  readonly session: TerminalSession.ISession;
+  readonly session: TerminalNS.ITerminalConnection;
 
   /**
    * Get a config option for the terminal.
@@ -283,8 +299,8 @@ export class Terminal extends Widget implements ITerminal.ITerminal {
    * Handle a message from the terminal session.
    */
   private _onMessage(
-    sender: TerminalSession.ISession,
-    msg: TerminalSession.IMessage
+    sender: TerminalNS.ITerminalConnection,
+    msg: TerminalNS.IMessage
   ): void {
     switch (msg.type) {
       case 'stdout':

+ 2 - 2
tests/test-services/src/kernel/ikernel.spec.ts

@@ -48,7 +48,7 @@ describe('Kernel.IKernel', () => {
   });
 
   describe('#disposed', () => {
-    it('should be emitted when the kernel is disposed', async () => {
+    it('should be emitted when the kernel is disposed', () => {
       let called = false;
       defaultKernel.disposed.connect((sender, args) => {
         expect(sender).to.equal(defaultKernel);
@@ -501,6 +501,7 @@ describe('Kernel.IKernel', () => {
       });
       tester.sendStatus(UUID.uuid4(), 'dead');
       await dead;
+      expect(kernel.status).to.equal('dead');
 
       const msg = KernelMessage.createMessage({
         msgType: 'kernel_info_request',
@@ -511,7 +512,6 @@ describe('Kernel.IKernel', () => {
       });
       expect(() => {
         kernel.sendShellMessage(msg, true);
-        expect(false).to.equal(true);
       }).to.throw(/Kernel is dead/);
     });
 

+ 36 - 37
tests/test-services/src/terminal/manager.spec.ts

@@ -7,69 +7,69 @@ import { toArray } from '@lumino/algorithm';
 
 import {
   ServerConnection,
-  TerminalSession,
-  TerminalManager
+  Terminal,
+  TerminalManager,
+  TerminalAPI
 } from '@jupyterlab/services';
 
 import { testEmission } from '@jupyterlab/testutils';
 
 describe('terminal', () => {
-  let manager: TerminalSession.IManager;
-  let session: TerminalSession.ISession;
+  let manager: Terminal.IManager;
 
-  beforeAll(async () => {
-    session = await TerminalSession.startNew();
-  });
-
-  beforeEach(() => {
+  beforeEach(async () => {
     manager = new TerminalManager({ standby: 'never' });
-    return manager.ready;
+    await manager.ready;
   });
 
   afterEach(() => {
     manager.dispose();
   });
 
-  afterAll(() => {
-    return TerminalSession.shutdownAll();
+  afterAll(async () => {
+    let models = await TerminalAPI.listRunning();
+    await Promise.all(models.map(m => TerminalAPI.shutdownTerminal(m.name)));
   });
 
   describe('TerminalManager', () => {
     describe('#constructor()', () => {
-      it('should accept no options', () => {
-        manager.dispose();
-        manager = new TerminalManager({ standby: 'never' });
+      it('should accept no options', async () => {
+        const manager = new TerminalManager({ standby: 'never' });
+        await manager.ready;
         expect(manager).to.be.an.instanceof(TerminalManager);
+        manager.dispose();
       });
 
-      it('should accept options', () => {
-        manager.dispose();
-        manager = new TerminalManager({
+      it('should accept options', async () => {
+        const manager = new TerminalManager({
           serverSettings: ServerConnection.makeSettings(),
           standby: 'never'
         });
+        await manager.ready;
         expect(manager).to.be.an.instanceof(TerminalManager);
+        manager.dispose();
       });
     });
 
     describe('#serverSettings', () => {
-      it('should get the server settings', () => {
-        manager.dispose();
+      it('should get the server settings', async () => {
         const serverSettings = ServerConnection.makeSettings();
         const standby = 'never';
         const token = serverSettings.token;
-        manager = new TerminalManager({ serverSettings, standby });
+        const manager = new TerminalManager({ serverSettings, standby });
+        await manager.ready;
         expect(manager.serverSettings.token).to.equal(token);
+        manager.dispose();
       });
     });
 
     describe('#isReady', () => {
       it('should test whether the manager is ready', async () => {
-        manager.dispose();
-        manager = new TerminalManager({ standby: 'never' });
+        const manager = new TerminalManager({ standby: 'never' });
         expect(manager.isReady).to.equal(false);
         await manager.ready;
         expect(manager.isReady).to.equal(true);
+        manager.dispose();
       });
     });
 
@@ -81,12 +81,13 @@ describe('terminal', () => {
 
     describe('#isAvailable()', () => {
       it('should test whether terminal sessions are available', () => {
-        expect(TerminalSession.isAvailable()).to.equal(true);
+        expect(Terminal.isAvailable()).to.equal(true);
       });
     });
 
     describe('#running()', () => {
       it('should give an iterator over the list of running models', async () => {
+        await TerminalAPI.startNew();
         await manager.refreshRunning();
         const running = toArray(manager.running());
         expect(running.length).to.be.greaterThan(0);
@@ -95,7 +96,7 @@ describe('terminal', () => {
 
     describe('#startNew()', () => {
       it('should startNew a new terminal session', async () => {
-        session = await manager.startNew();
+        let session = await manager.startNew();
         expect(session.name).to.be.ok;
       });
 
@@ -111,9 +112,10 @@ describe('terminal', () => {
 
     describe('#connectTo()', () => {
       it('should connect to an existing session', async () => {
-        const name = session.name;
-        session = await manager.connectTo(name);
-        expect(session.name).to.equal(name);
+        let session = await manager.startNew();
+        let session2 = manager.connectTo({ model: session.model });
+        expect(session).to.not.equal(session2);
+        expect(session2.name).to.equal(session.name);
       });
     });
 
@@ -125,14 +127,13 @@ describe('terminal', () => {
       });
 
       it('should emit a runningChanged signal', async () => {
-        session = await manager.startNew();
-        const emission = testEmission(manager.runningChanged, {
-          test: () => {
-            expect(session.isDisposed).to.equal(false);
-          }
+        let session = await manager.startNew();
+        let called = false;
+        manager.runningChanged.connect(() => {
+          called = true;
         });
         await manager.shutdown(session.name);
-        await emission;
+        expect(called).to.be.true;
       });
     });
 
@@ -151,10 +152,8 @@ describe('terminal', () => {
 
     describe('#refreshRunning()', () => {
       it('should update the running session models', async () => {
-        let model: TerminalSession.IModel;
         const before = toArray(manager.running()).length;
-        session = await TerminalSession.startNew();
-        model = session.model;
+        let model = await TerminalAPI.startNew();
         await manager.refreshRunning();
         const running = toArray(manager.running());
         expect(running.length).to.equal(before + 1);

+ 22 - 93
tests/test-services/src/terminal/terminal.spec.ts

@@ -5,20 +5,19 @@ import { expect } from 'chai';
 
 import { PageConfig } from '@jupyterlab/coreutils';
 
-import { UUID } from '@lumino/coreutils';
-
-import { TerminalSession } from '@jupyterlab/services';
+import { Terminal, TerminalManager } from '@jupyterlab/services';
 
 import { testEmission } from '@jupyterlab/testutils';
 
 import { handleRequest } from '../utils';
 
 describe('terminal', () => {
-  let defaultSession: TerminalSession.ISession;
-  let session: TerminalSession.ISession;
+  let defaultSession: Terminal.ITerminalConnection;
+  let session: Terminal.ITerminalConnection;
+  let manager = new TerminalManager();
 
   beforeAll(async () => {
-    defaultSession = await TerminalSession.startNew();
+    defaultSession = await manager.startNew();
   });
 
   afterEach(async () => {
@@ -27,79 +26,25 @@ describe('terminal', () => {
     }
   });
 
-  describe('TerminalSession', () => {
+  describe('Terminal', () => {
     describe('.isAvailable()', () => {
       it('should test whether terminal sessions are available', () => {
-        expect(TerminalSession.isAvailable()).to.equal(true);
-      });
-    });
-
-    describe('.startNew()', () => {
-      it('should startNew a terminal session', async () => {
-        session = await TerminalSession.startNew();
-        expect(session.name).to.be.ok;
-      });
-    });
-
-    describe('.connectTo', () => {
-      it('should give back an existing session', async () => {
-        const newSession = await TerminalSession.connectTo(defaultSession.name);
-        expect(newSession.name).to.equal(defaultSession.name);
-        expect(newSession).to.not.equal(defaultSession);
-      });
-
-      it('should reject if the session does not exist on the server', async () => {
-        try {
-          await TerminalSession.connectTo(UUID.uuid4());
-          throw Error('should not get here');
-        } catch (e) {
-          expect(e.message).to.not.equal('should not get here');
-        }
-      });
-    });
-
-    describe('.shutdown()', () => {
-      it('should shut down a terminal session by name', async () => {
-        session = await TerminalSession.startNew();
-        await TerminalSession.shutdown(session.name);
-      });
-
-      it('should handle a 404 status', () => {
-        return TerminalSession.shutdown('ThisTerminalDoesNotExist');
-      });
-    });
-
-    describe('.listRunning()', () => {
-      it('should list the running session models', async () => {
-        const models = await TerminalSession.listRunning();
-        expect(models.length).to.be.greaterThan(0);
+        expect(Terminal.isAvailable()).to.equal(true);
       });
     });
   });
 
-  describe('.ISession', () => {
-    describe('#terminated', () => {
-      it('should be emitted when the session is disposed', async () => {
-        session = await TerminalSession.startNew();
-        let called = false;
-        session.terminated.connect((sender, args) => {
-          expect(sender).to.equal(session);
-          expect(args).to.be.undefined;
-          called = true;
-        });
-        session.dispose();
-        expect(called).to.equal(true);
-      });
-    });
-
+  describe('.ITerminalConnection', () => {
     describe('#messageReceived', () => {
       it('should be emitted when a message is received', async () => {
-        session = await TerminalSession.startNew();
-        await testEmission(session.messageReceived, {
+        session = await manager.startNew();
+        let emission = testEmission(session.messageReceived, {
           test: (sender, msg) => {
             return msg.type === 'stdout';
           }
         });
+        session.send({ type: 'stdin', content: ['cd\r'] });
+        await emission;
       });
     });
 
@@ -119,69 +64,53 @@ describe('terminal', () => {
 
     describe('#isDisposed', () => {
       it('should test whether the object is disposed', async () => {
-        session = await TerminalSession.startNew();
+        session = await manager.startNew();
         const name = session.name;
         expect(session.isDisposed).to.equal(false);
         session.dispose();
         expect(session.isDisposed).to.equal(true);
-        await TerminalSession.shutdown(name);
+        await manager.shutdown(name);
       });
     });
 
     describe('#dispose()', () => {
       it('should dispose of the resources used by the session', async () => {
-        session = await TerminalSession.startNew();
+        session = await manager.startNew();
         const name = session.name;
         session.dispose();
         expect(session.isDisposed).to.equal(true);
-        await TerminalSession.shutdown(name);
+        await manager.shutdown(name);
       });
 
       it('should be safe to call more than once', async () => {
-        session = await TerminalSession.startNew();
+        session = await manager.startNew();
         const name = session.name;
         session.dispose();
         session.dispose();
         expect(session.isDisposed).to.equal(true);
-        await TerminalSession.shutdown(name);
-      });
-    });
-
-    describe('#isReady', () => {
-      it('should test whether the terminal is ready', async () => {
-        session = await TerminalSession.startNew();
-        expect(session.isReady).to.equal(false);
-        await session.ready;
-        expect(session.isReady).to.equal(true);
-      });
-    });
-
-    describe('#ready', () => {
-      it('should resolve when the terminal is ready', () => {
-        return defaultSession.ready;
+        await manager.shutdown(name);
       });
     });
 
     describe('#send()', () => {
       it('should send a message to the socket', async () => {
-        await defaultSession.ready;
         session.send({ type: 'stdin', content: [1, 2] });
       });
     });
 
     describe('#reconnect()', () => {
       it('should reconnect to the socket', async () => {
-        const session = await TerminalSession.startNew();
+        const session = await manager.startNew();
         const promise = session.reconnect();
-        expect(session.isReady).to.equal(false);
+        expect(session.connectionStatus).to.equal('connecting');
         await promise;
-        expect(session.isReady).to.equal(true);
+        expect(session.connectionStatus).to.equal('connected');
       });
     });
 
     describe('#shutdown()', () => {
       it('should shut down the terminal session', async () => {
-        session = await TerminalSession.startNew();
+        session = await manager.startNew();
         await session.shutdown();
       });
 

+ 5 - 5
tests/test-services/src/utils.ts

@@ -13,7 +13,7 @@ import { Response } from 'node-fetch';
 
 import {
   Contents,
-  TerminalSession,
+  Terminal,
   ServerConnection,
   KernelManager,
   SessionManager
@@ -692,7 +692,7 @@ export class TerminalTester extends SocketTester {
   /**
    * Register the message callback with the websocket server.
    */
-  onMessage(cb: (msg: TerminalSession.IMessage) => void) {
+  onMessage(cb: (msg: Terminal.IMessage) => void) {
     this._onMessage = cb;
   }
 
@@ -702,8 +702,8 @@ export class TerminalTester extends SocketTester {
       const onMessage = this._onMessage;
       if (onMessage) {
         const data = JSON.parse(msg) as JSONPrimitive[];
-        const termMsg: TerminalSession.IMessage = {
-          type: data[0] as TerminalSession.MessageType,
+        const termMsg: Terminal.IMessage = {
+          type: data[0] as Terminal.MessageType,
           content: data.slice(1)
         };
         onMessage(termMsg);
@@ -711,7 +711,7 @@ export class TerminalTester extends SocketTester {
     });
   }
 
-  private _onMessage: ((msg: TerminalSession.IMessage) => void) | null = null;
+  private _onMessage: ((msg: Terminal.IMessage) => void) | null = null;
 }
 
 /**

+ 10 - 5
tests/test-terminal/src/terminal.spec.ts

@@ -3,7 +3,7 @@
 
 import { expect } from 'chai';
 
-import { TerminalSession } from '@jupyterlab/services';
+import { Terminal as TerminalNS, TerminalManager } from '@jupyterlab/services';
 
 import { Message, MessageLoop } from '@lumino/messaging';
 
@@ -11,7 +11,7 @@ import { Widget } from '@lumino/widgets';
 
 import { Terminal } from '@jupyterlab/terminal';
 
-import { framePromise } from '@jupyterlab/testutils';
+import { framePromise, testEmission } from '@jupyterlab/testutils';
 
 class LogTerminal extends Terminal {
   methods: string[] = [];
@@ -50,10 +50,11 @@ class LogTerminal extends Terminal {
 describe('terminal/index', () => {
   describe('Terminal', () => {
     let widget: LogTerminal;
-    let session: TerminalSession.ISession;
+    let session: TerminalNS.ITerminalConnection;
+    let manager = new TerminalManager();
 
     before(async () => {
-      session = await TerminalSession.startNew();
+      session = await manager.startNew();
     });
 
     beforeEach(() => {
@@ -78,7 +79,11 @@ describe('terminal/index', () => {
       });
 
       it('should set the title when ready', async () => {
-        await session.ready;
+        if (session.connectionStatus !== 'connected') {
+          await testEmission(session.connectionStatusChanged, {
+            find: (_, status) => status === 'connected'
+          });
+        }
         expect(widget.title.label).to.contain(session.name);
       });
     });