345 lines
12 KiB
Python
345 lines
12 KiB
Python
|
"""Terminal management for exposing terminals to a web interface using Tornado.
|
||
|
"""
|
||
|
# Copyright (c) Jupyter Development Team
|
||
|
# Copyright (c) 2014, Ramalingam Saravanan <sarava@sarava.net>
|
||
|
# Distributed under the terms of the Simplified BSD License.
|
||
|
|
||
|
from __future__ import absolute_import, print_function
|
||
|
|
||
|
import sys
|
||
|
if sys.version_info[0] < 3:
|
||
|
byte_code = ord
|
||
|
else:
|
||
|
byte_code = lambda x: x
|
||
|
unicode = str
|
||
|
|
||
|
from collections import deque
|
||
|
import itertools
|
||
|
import logging
|
||
|
import os
|
||
|
import signal
|
||
|
|
||
|
try:
|
||
|
from ptyprocess import PtyProcessUnicode
|
||
|
except ImportError:
|
||
|
from winpty import PtyProcess as PtyProcessUnicode
|
||
|
|
||
|
from tornado import gen
|
||
|
from tornado.ioloop import IOLoop
|
||
|
|
||
|
ENV_PREFIX = "PYXTERM_" # Environment variable prefix
|
||
|
|
||
|
DEFAULT_TERM_TYPE = "xterm"
|
||
|
|
||
|
class PtyWithClients(object):
|
||
|
def __init__(self, ptyproc):
|
||
|
self.ptyproc = ptyproc
|
||
|
self.clients = []
|
||
|
# Store the last few things read, so when a new client connects,
|
||
|
# it can show e.g. the most recent prompt, rather than absolutely
|
||
|
# nothing.
|
||
|
self.read_buffer = deque([], maxlen=10)
|
||
|
|
||
|
def resize_to_smallest(self):
|
||
|
"""Set the terminal size to that of the smallest client dimensions.
|
||
|
|
||
|
A terminal not using the full space available is much nicer than a
|
||
|
terminal trying to use more than the available space, so we keep it
|
||
|
sized to the smallest client.
|
||
|
"""
|
||
|
minrows = mincols = 10001
|
||
|
for client in self.clients:
|
||
|
rows, cols = client.size
|
||
|
if rows is not None and rows < minrows:
|
||
|
minrows = rows
|
||
|
if cols is not None and cols < mincols:
|
||
|
mincols = cols
|
||
|
|
||
|
if minrows == 10001 or mincols == 10001:
|
||
|
return
|
||
|
|
||
|
rows, cols = self.ptyproc.getwinsize()
|
||
|
if (rows, cols) != (minrows, mincols):
|
||
|
self.ptyproc.setwinsize(minrows, mincols)
|
||
|
|
||
|
def kill(self, sig=signal.SIGTERM):
|
||
|
"""Send a signal to the process in the pty"""
|
||
|
self.ptyproc.kill(sig)
|
||
|
|
||
|
def killpg(self, sig=signal.SIGTERM):
|
||
|
"""Send a signal to the process group of the process in the pty"""
|
||
|
if os.name == 'nt':
|
||
|
return self.ptyproc.kill(sig)
|
||
|
pgid = os.getpgid(self.ptyproc.pid)
|
||
|
os.killpg(pgid, sig)
|
||
|
|
||
|
@gen.coroutine
|
||
|
def terminate(self, force=False):
|
||
|
'''This forces a child process to terminate. It starts nicely with
|
||
|
SIGHUP and SIGINT. If "force" is True then moves onto SIGKILL. This
|
||
|
returns True if the child was terminated. This returns False if the
|
||
|
child could not be terminated. '''
|
||
|
if os.name == 'nt':
|
||
|
signals = [signal.SIGINT, signal.SIGTERM]
|
||
|
else:
|
||
|
signals = [signal.SIGHUP, signal.SIGCONT, signal.SIGINT,
|
||
|
signal.SIGTERM]
|
||
|
|
||
|
loop = IOLoop.current()
|
||
|
sleep = lambda : gen.sleep(self.ptyproc.delayafterterminate)
|
||
|
|
||
|
if not self.ptyproc.isalive():
|
||
|
raise gen.Return(True)
|
||
|
try:
|
||
|
for sig in signals:
|
||
|
self.kill(sig)
|
||
|
yield sleep()
|
||
|
if not self.ptyproc.isalive():
|
||
|
raise gen.Return(True)
|
||
|
if force:
|
||
|
self.kill(signal.SIGKILL)
|
||
|
yield sleep()
|
||
|
if not self.ptyproc.isalive():
|
||
|
raise gen.Return(True)
|
||
|
else:
|
||
|
raise gen.Return(False)
|
||
|
raise gen.Return(False)
|
||
|
except OSError:
|
||
|
# I think there are kernel timing issues that sometimes cause
|
||
|
# this to happen. I think isalive() reports True, but the
|
||
|
# process is dead to the kernel.
|
||
|
# Make one last attempt to see if the kernel is up to date.
|
||
|
yield sleep()
|
||
|
if not self.ptyproc.isalive():
|
||
|
raise gen.Return(True)
|
||
|
else:
|
||
|
raise gen.Return(False)
|
||
|
|
||
|
def _update_removing(target, changes):
|
||
|
"""Like dict.update(), but remove keys where the value is None.
|
||
|
"""
|
||
|
for k, v in changes.items():
|
||
|
if v is None:
|
||
|
target.pop(k, None)
|
||
|
else:
|
||
|
target[k] = v
|
||
|
|
||
|
class TermManagerBase(object):
|
||
|
"""Base class for a terminal manager."""
|
||
|
def __init__(self, shell_command, server_url="", term_settings={},
|
||
|
extra_env=None, ioloop=None):
|
||
|
self.shell_command = shell_command
|
||
|
self.server_url = server_url
|
||
|
self.term_settings = term_settings
|
||
|
self.extra_env = extra_env
|
||
|
self.log = logging.getLogger(__name__)
|
||
|
|
||
|
self.ptys_by_fd = {}
|
||
|
|
||
|
if ioloop is not None:
|
||
|
self.ioloop = ioloop
|
||
|
else:
|
||
|
import tornado.ioloop
|
||
|
self.ioloop = tornado.ioloop.IOLoop.instance()
|
||
|
|
||
|
def make_term_env(self, height=25, width=80, winheight=0, winwidth=0, **kwargs):
|
||
|
"""Build the environment variables for the process in the terminal."""
|
||
|
env = os.environ.copy()
|
||
|
env["TERM"] = self.term_settings.get("type",DEFAULT_TERM_TYPE)
|
||
|
dimensions = "%dx%d" % (width, height)
|
||
|
if winwidth and winheight:
|
||
|
dimensions += ";%dx%d" % (winwidth, winheight)
|
||
|
env[ENV_PREFIX+"DIMENSIONS"] = dimensions
|
||
|
env["COLUMNS"] = str(width)
|
||
|
env["LINES"] = str(height)
|
||
|
|
||
|
if self.server_url:
|
||
|
env[ENV_PREFIX+"URL"] = self.server_url
|
||
|
|
||
|
if self.extra_env:
|
||
|
_update_removing(env, self.extra_env)
|
||
|
|
||
|
return env
|
||
|
|
||
|
def new_terminal(self, **kwargs):
|
||
|
"""Make a new terminal, return a :class:`PtyWithClients` instance."""
|
||
|
options = self.term_settings.copy()
|
||
|
options['shell_command'] = self.shell_command
|
||
|
options.update(kwargs)
|
||
|
argv = options['shell_command']
|
||
|
env = self.make_term_env(**options)
|
||
|
pty = PtyProcessUnicode.spawn(argv, env=env, cwd=options.get('cwd', None))
|
||
|
return PtyWithClients(pty)
|
||
|
|
||
|
def start_reading(self, ptywclients):
|
||
|
"""Connect a terminal to the tornado event loop to read data from it."""
|
||
|
fd = ptywclients.ptyproc.fd
|
||
|
self.ptys_by_fd[fd] = ptywclients
|
||
|
self.ioloop.add_handler(fd, self.pty_read, self.ioloop.READ)
|
||
|
|
||
|
def on_eof(self, ptywclients):
|
||
|
"""Called when the pty has closed.
|
||
|
"""
|
||
|
# Stop trying to read from that terminal
|
||
|
fd = ptywclients.ptyproc.fd
|
||
|
self.log.info("EOF on FD %d; stopping reading", fd)
|
||
|
del self.ptys_by_fd[fd]
|
||
|
self.ioloop.remove_handler(fd)
|
||
|
|
||
|
# This closes the fd, and should result in the process being reaped.
|
||
|
ptywclients.ptyproc.close()
|
||
|
|
||
|
def pty_read(self, fd, events=None):
|
||
|
"""Called by the event loop when there is pty data ready to read."""
|
||
|
ptywclients = self.ptys_by_fd[fd]
|
||
|
try:
|
||
|
s = ptywclients.ptyproc.read(65536)
|
||
|
ptywclients.read_buffer.append(s)
|
||
|
for client in ptywclients.clients:
|
||
|
client.on_pty_read(s)
|
||
|
except EOFError:
|
||
|
self.on_eof(ptywclients)
|
||
|
for client in ptywclients.clients:
|
||
|
client.on_pty_died()
|
||
|
|
||
|
def get_terminal(self, url_component=None):
|
||
|
"""Override in a subclass to give a terminal to a new websocket connection
|
||
|
|
||
|
The :class:`TermSocket` handler works with zero or one URL components
|
||
|
(capturing groups in the URL spec regex). If it receives one, it is
|
||
|
passed as the ``url_component`` parameter; otherwise, this is None.
|
||
|
"""
|
||
|
raise NotImplementedError
|
||
|
|
||
|
def client_disconnected(self, websocket):
|
||
|
"""Override this to e.g. kill terminals on client disconnection.
|
||
|
"""
|
||
|
pass
|
||
|
|
||
|
@gen.coroutine
|
||
|
def shutdown(self):
|
||
|
yield self.kill_all()
|
||
|
|
||
|
@gen.coroutine
|
||
|
def kill_all(self):
|
||
|
futures = []
|
||
|
for term in self.ptys_by_fd.values():
|
||
|
futures.append(term.terminate(force=True))
|
||
|
# wait for futures to finish
|
||
|
for f in futures:
|
||
|
yield f
|
||
|
|
||
|
|
||
|
class SingleTermManager(TermManagerBase):
|
||
|
"""All connections to the websocket share a common terminal."""
|
||
|
def __init__(self, **kwargs):
|
||
|
super(SingleTermManager, self).__init__(**kwargs)
|
||
|
self.terminal = None
|
||
|
|
||
|
def get_terminal(self, url_component=None):
|
||
|
if self.terminal is None:
|
||
|
self.terminal = self.new_terminal()
|
||
|
self.start_reading(self.terminal)
|
||
|
return self.terminal
|
||
|
|
||
|
@gen.coroutine
|
||
|
def kill_all(self):
|
||
|
yield super(SingleTermManager, self).kill_all()
|
||
|
self.terminal = None
|
||
|
|
||
|
class MaxTerminalsReached(Exception):
|
||
|
def __init__(self, max_terminals):
|
||
|
self.max_terminals = max_terminals
|
||
|
|
||
|
def __str__(self):
|
||
|
return "Cannot create more than %d terminals" % self.max_terminals
|
||
|
|
||
|
class UniqueTermManager(TermManagerBase):
|
||
|
"""Give each websocket a unique terminal to use."""
|
||
|
def __init__(self, max_terminals=None, **kwargs):
|
||
|
super(UniqueTermManager, self).__init__(**kwargs)
|
||
|
self.max_terminals = max_terminals
|
||
|
|
||
|
def get_terminal(self, url_component=None):
|
||
|
if self.max_terminals and len(self.ptys_by_fd) >= self.max_terminals:
|
||
|
raise MaxTerminalsReached(self.max_terminals)
|
||
|
|
||
|
term = self.new_terminal()
|
||
|
self.start_reading(term)
|
||
|
return term
|
||
|
|
||
|
def client_disconnected(self, websocket):
|
||
|
"""Send terminal SIGHUP when client disconnects."""
|
||
|
self.log.info("Websocket closed, sending SIGHUP to terminal.")
|
||
|
if websocket.terminal:
|
||
|
if os.name == 'nt':
|
||
|
websocket.terminal.kill()
|
||
|
# Immediately call the pty reader to process
|
||
|
# the eof and free up space
|
||
|
self.pty_read(websocket.terminal.ptyproc.fd)
|
||
|
return
|
||
|
websocket.terminal.killpg(signal.SIGHUP)
|
||
|
|
||
|
|
||
|
class NamedTermManager(TermManagerBase):
|
||
|
"""Share terminals between websockets connected to the same endpoint.
|
||
|
"""
|
||
|
def __init__(self, max_terminals=None, **kwargs):
|
||
|
super(NamedTermManager, self).__init__(**kwargs)
|
||
|
self.max_terminals = max_terminals
|
||
|
self.terminals = {}
|
||
|
|
||
|
def get_terminal(self, term_name):
|
||
|
assert term_name is not None
|
||
|
|
||
|
if term_name in self.terminals:
|
||
|
return self.terminals[term_name]
|
||
|
|
||
|
if self.max_terminals and len(self.terminals) >= self.max_terminals:
|
||
|
raise MaxTerminalsReached(self.max_terminals)
|
||
|
|
||
|
# Create new terminal
|
||
|
self.log.info("New terminal with specified name: %s", term_name)
|
||
|
term = self.new_terminal()
|
||
|
term.term_name = term_name
|
||
|
self.terminals[term_name] = term
|
||
|
self.start_reading(term)
|
||
|
return term
|
||
|
|
||
|
name_template = "%d"
|
||
|
|
||
|
def _next_available_name(self):
|
||
|
for n in itertools.count(start=1):
|
||
|
name = self.name_template % n
|
||
|
if name not in self.terminals:
|
||
|
return name
|
||
|
|
||
|
def new_named_terminal(self, **kwargs):
|
||
|
name = self._next_available_name()
|
||
|
term = self.new_terminal(**kwargs)
|
||
|
self.log.info("New terminal with automatic name: %s", name)
|
||
|
term.term_name = name
|
||
|
self.terminals[name] = term
|
||
|
self.start_reading(term)
|
||
|
return name, term
|
||
|
|
||
|
def kill(self, name, sig=signal.SIGTERM):
|
||
|
term = self.terminals[name]
|
||
|
term.kill(sig) # This should lead to an EOF
|
||
|
|
||
|
@gen.coroutine
|
||
|
def terminate(self, name, force=False):
|
||
|
term = self.terminals[name]
|
||
|
yield term.terminate(force=force)
|
||
|
|
||
|
def on_eof(self, ptywclients):
|
||
|
super(NamedTermManager, self).on_eof(ptywclients)
|
||
|
name = ptywclients.term_name
|
||
|
self.log.info("Terminal %s closed", name)
|
||
|
self.terminals.pop(name, None)
|
||
|
|
||
|
@gen.coroutine
|
||
|
def kill_all(self):
|
||
|
yield super(NamedTermManager, self).kill_all()
|
||
|
self.terminals = {}
|