Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make sure that all process output makes it to the terminal #76

Merged
merged 2 commits into from
Sep 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion terminado/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,4 @@
# Prevent a warning about no attached handlers in Python 2
logging.getLogger(__name__).addHandler(logging.NullHandler())

__version__ = '0.8.3'
__version__ = '0.8.4'
60 changes: 39 additions & 21 deletions terminado/management.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
if sys.version_info[0] < 3:
byte_code = ord
else:
byte_code = lambda x: x
def byte_code(x): return x
unicode = str

from collections import deque
Expand All @@ -31,18 +31,23 @@

DEFAULT_TERM_TYPE = "xterm"


class PtyWithClients(object):
def __init__(self, ptyproc):
self.ptyproc = ptyproc
def __init__(self, argv, env=[], cwd=None):
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.
# If you start the process and then construct this object from it,
# output generated by the process prior to the object's creation
# is lost. Hence the change from 0.8.3.
# Buffer output until a client connects; then let the client
# drain the buffer.
# We keep the same read_buffer as before
self.read_buffer = deque([], maxlen=10)
self.preopen_buffer = deque([])
self.ptyproc = PtyProcessUnicode.spawn(argv, env=env, cwd=cwd)

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.
Expand All @@ -57,7 +62,7 @@ def resize_to_smallest(self):

if minrows == 10001 or mincols == 10001:
return

rows, cols = self.ptyproc.getwinsize()
if (rows, cols) != (minrows, mincols):
self.ptyproc.setwinsize(minrows, mincols)
Expand All @@ -72,7 +77,7 @@ def killpg(self, sig=signal.SIGTERM):
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
Expand All @@ -86,7 +91,7 @@ def terminate(self, force=False):
signal.SIGTERM]

loop = IOLoop.current()
sleep = lambda : gen.sleep(self.ptyproc.delayafterterminate)
def sleep(): return gen.sleep(self.ptyproc.delayafterterminate)

if not self.ptyproc.isalive():
raise gen.Return(True)
Expand Down Expand Up @@ -115,6 +120,7 @@ def terminate(self, force=False):
else:
raise gen.Return(False)


def _update_removing(target, changes):
"""Like dict.update(), but remove keys where the value is None.
"""
Expand All @@ -124,8 +130,10 @@ def _update_removing(target, changes):
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
Expand All @@ -141,11 +149,11 @@ def __init__(self, shell_command, server_url="", term_settings={},
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)
env["TERM"] = self.term_settings.get("type", DEFAULT_TERM_TYPE)
dimensions = "%dx%d" % (width, height)
if winwidth and winheight:
dimensions += ";%dx%d" % (winwidth, winheight)
Expand All @@ -168,8 +176,8 @@ def new_terminal(self, **kwargs):
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)
cwd = options.get('cwd', None)
return PtyWithClients(argv, env, cwd)

def start_reading(self, ptywclients):
"""Connect a terminal to the tornado event loop to read data from it."""
Expand All @@ -194,7 +202,12 @@ def pty_read(self, fd, events=None):
ptywclients = self.ptys_by_fd[fd]
try:
s = ptywclients.ptyproc.read(65536)
client_list = ptywclients.clients
ptywclients.read_buffer.append(s)
if not client_list:
# No one to consume our output: buffer it.
ptywclients.preopen_buffer.append(s)
return
for client in ptywclients.clients:
client.on_pty_read(s)
except EOFError:
Expand All @@ -204,7 +217,7 @@ def pty_read(self, fd, events=None):

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.
Expand Down Expand Up @@ -232,6 +245,7 @@ def kill_all(self):

class SingleTermManager(TermManagerBase):
"""All connections to the websocket share a common terminal."""

def __init__(self, **kwargs):
super(SingleTermManager, self).__init__(**kwargs)
self.terminal = None
Expand All @@ -241,21 +255,24 @@ def get_terminal(self, url_component=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
Expand Down Expand Up @@ -284,17 +301,18 @@ def client_disconnected(self, websocket):
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)

Expand Down Expand Up @@ -331,13 +349,13 @@ def kill(self, name, sig=signal.SIGTERM):
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()
Expand Down
23 changes: 16 additions & 7 deletions terminado/websocket.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,16 @@
import tornado.web
import tornado.websocket


def _cast_unicode(s):
if isinstance(s, bytes):
return s.decode('utf-8')
return s


class TermSocket(tornado.websocket.WebSocketHandler):
"""Handler for a terminal websocket"""

def initialize(self, term_manager):
self.term_manager = term_manager
self.term_name = ""
Expand All @@ -39,7 +42,7 @@ def origin_check(self, origin=None):

def open(self, url_component=None):
"""Websocket connection opened.

Call our terminal manager to get a terminal, and connect to it as a
client.
"""
Expand All @@ -52,12 +55,18 @@ def open(self, url_component=None):
url_component = _cast_unicode(url_component)
self.term_name = url_component or 'tty'
self.terminal = self.term_manager.get_terminal(url_component)
for s in self.terminal.read_buffer:
self.on_pty_read(s)
self.terminal.clients.append(self)

self.send_json_message(["setup", {}])
self._logger.info("TermSocket.open: Opened %s", self.term_name)
# Now drain the preopen buffer, if it exists.
buffered = ""
while True:
if not self.terminal.preopen_buffer:
break
s = self.terminal.preopen_buffer.popleft()
buffered += s
if buffered:
self.on_pty_read(buffered)

def on_pty_read(self, text):
"""Data read from pty; send to frontend"""
Expand All @@ -69,13 +78,13 @@ def send_json_message(self, content):

def on_message(self, message):
"""Handle incoming websocket message

We send JSON arrays, where the first element is a string indicating
what kind of message this is. Data associated with the message follows.
"""
##logging.info("TermSocket.on_message: %s - (%s) %s", self.term_name, type(message), len(message) if isinstance(message, bytes) else message[:250])
command = json.loads(message)
msg_type = command[0]
msg_type = command[0]

if msg_type == "stdin":
self.terminal.ptyproc.write(command[1])
Expand All @@ -85,7 +94,7 @@ def on_message(self, message):

def on_close(self):
"""Handle websocket closing.

Disconnect from our terminal, and tell the terminal manager we're
disconnecting.
"""
Expand Down