You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
173 lines
6.7 KiB
Python
173 lines
6.7 KiB
Python
"""A MultiTerminalManager for use in the notebook webserver
|
|
- raises HTTPErrors
|
|
- creates REST API models
|
|
"""
|
|
# Copyright (c) Jupyter Development Team.
|
|
# Distributed under the terms of the Modified BSD License.
|
|
from __future__ import annotations
|
|
|
|
import typing as t
|
|
from datetime import timedelta
|
|
|
|
from jupyter_server._tz import isoformat, utcnow
|
|
from jupyter_server.prometheus import metrics
|
|
from terminado.management import NamedTermManager, PtyWithClients
|
|
from tornado import web
|
|
from tornado.ioloop import IOLoop, PeriodicCallback
|
|
from traitlets import Integer
|
|
from traitlets.config import LoggingConfigurable
|
|
|
|
RUNNING_TOTAL = metrics.TERMINAL_CURRENTLY_RUNNING_TOTAL
|
|
|
|
MODEL = t.Dict[str, t.Any]
|
|
|
|
|
|
class TerminalManager(LoggingConfigurable, NamedTermManager): # type:ignore[misc]
|
|
"""A MultiTerminalManager for use in the notebook webserver"""
|
|
|
|
_culler_callback = None
|
|
|
|
_initialized_culler = False
|
|
|
|
cull_inactive_timeout = Integer(
|
|
0,
|
|
config=True,
|
|
help="""Timeout (in seconds) in which a terminal has been inactive and ready to be culled.
|
|
Values of 0 or lower disable culling.""",
|
|
)
|
|
|
|
cull_interval_default = 300 # 5 minutes
|
|
cull_interval = Integer(
|
|
cull_interval_default,
|
|
config=True,
|
|
help="""The interval (in seconds) on which to check for terminals exceeding the inactive timeout value.""",
|
|
)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Methods for managing terminals
|
|
# -------------------------------------------------------------------------
|
|
def create(self, **kwargs: t.Any) -> MODEL:
|
|
"""Create a new terminal."""
|
|
name, term = self.new_named_terminal(**kwargs)
|
|
# Monkey-patch last-activity, similar to kernels. Should we need
|
|
# more functionality per terminal, we can look into possible sub-
|
|
# classing or containment then.
|
|
term.last_activity = utcnow() # type:ignore[attr-defined]
|
|
model = self.get_terminal_model(name)
|
|
# Increase the metric by one because a new terminal was created
|
|
RUNNING_TOTAL.inc()
|
|
# Ensure culler is initialized
|
|
self._initialize_culler()
|
|
return model
|
|
|
|
def get(self, name: str) -> MODEL:
|
|
"""Get terminal 'name'."""
|
|
return self.get_terminal_model(name)
|
|
|
|
def list(self) -> list[MODEL]:
|
|
"""Get a list of all running terminals."""
|
|
models = [self.get_terminal_model(name) for name in self.terminals]
|
|
|
|
# Update the metric below to the length of the list 'terms'
|
|
RUNNING_TOTAL.set(len(models))
|
|
return models
|
|
|
|
async def terminate(self, name: str, force: bool = False) -> None:
|
|
"""Terminate terminal 'name'."""
|
|
self._check_terminal(name)
|
|
await super().terminate(name, force=force)
|
|
|
|
# Decrease the metric below by one
|
|
# because a terminal has been shutdown
|
|
RUNNING_TOTAL.dec()
|
|
|
|
async def terminate_all(self) -> None:
|
|
"""Terminate all terminals."""
|
|
terms = list(self.terminals)
|
|
for term in terms:
|
|
await self.terminate(term, force=True)
|
|
|
|
def get_terminal_model(self, name: str) -> MODEL:
|
|
"""Return a JSON-safe dict representing a terminal.
|
|
For use in representing terminals in the JSON APIs.
|
|
"""
|
|
self._check_terminal(name)
|
|
term = self.terminals[name]
|
|
return {
|
|
"name": name,
|
|
"last_activity": isoformat(term.last_activity), # type:ignore[attr-defined]
|
|
}
|
|
|
|
def _check_terminal(self, name: str) -> None:
|
|
"""Check a that terminal 'name' exists and raise 404 if not."""
|
|
if name not in self.terminals:
|
|
raise web.HTTPError(404, "Terminal not found: %s" % name)
|
|
|
|
def _initialize_culler(self) -> None:
|
|
"""Start culler if 'cull_inactive_timeout' is greater than zero.
|
|
Regardless of that value, set flag that we've been here.
|
|
"""
|
|
if not self._initialized_culler and self.cull_inactive_timeout > 0: # noqa: SIM102
|
|
if self._culler_callback is None:
|
|
_ = IOLoop.current()
|
|
if self.cull_interval <= 0: # handle case where user set invalid value
|
|
self.log.warning(
|
|
"Invalid value for 'cull_interval' detected (%s) - using default value (%s).",
|
|
self.cull_interval,
|
|
self.cull_interval_default,
|
|
)
|
|
self.cull_interval = self.cull_interval_default
|
|
self._culler_callback = PeriodicCallback(
|
|
self._cull_terminals, 1000 * self.cull_interval
|
|
)
|
|
self.log.info(
|
|
"Culling terminals with inactivity > %s seconds at %s second intervals ...",
|
|
self.cull_inactive_timeout,
|
|
self.cull_interval,
|
|
)
|
|
self._culler_callback.start()
|
|
|
|
self._initialized_culler = True
|
|
|
|
async def _cull_terminals(self) -> None:
|
|
self.log.debug(
|
|
"Polling every %s seconds for terminals inactive for > %s seconds...",
|
|
self.cull_interval,
|
|
self.cull_inactive_timeout,
|
|
)
|
|
# Create a separate list of terminals to avoid conflicting updates while iterating
|
|
for name in list(self.terminals):
|
|
try:
|
|
await self._cull_inactive_terminal(name)
|
|
except Exception as e:
|
|
self.log.exception(
|
|
"The following exception was encountered while checking the "
|
|
"activity of terminal %s: %s",
|
|
name,
|
|
e,
|
|
)
|
|
|
|
async def _cull_inactive_terminal(self, name: str) -> None:
|
|
try:
|
|
term = self.terminals[name]
|
|
except KeyError:
|
|
return # KeyErrors are somewhat expected since the terminal can be terminated as the culling check is made.
|
|
|
|
self.log.debug("name=%s, last_activity=%s", name, term.last_activity) # type:ignore[attr-defined]
|
|
if hasattr(term, "last_activity"):
|
|
dt_now = utcnow()
|
|
dt_inactive = dt_now - term.last_activity
|
|
# Compute idle properties
|
|
is_time = dt_inactive > timedelta(seconds=self.cull_inactive_timeout)
|
|
# Cull the kernel if all three criteria are met
|
|
if is_time:
|
|
inactivity = int(dt_inactive.total_seconds())
|
|
self.log.warning(
|
|
"Culling terminal '%s' due to %s seconds of inactivity.", name, inactivity
|
|
)
|
|
await self.terminate(name, force=True)
|
|
|
|
def pre_pty_read_hook(self, ptywclients: PtyWithClients) -> None:
|
|
"""The pre-pty read hook."""
|
|
ptywclients.last_activity = utcnow() # type:ignore[attr-defined]
|