#-----------------------------------------------------------------------------
# Copyright (c) Anaconda, Inc., and Bokeh Contributors.
# All rights reserved.
#
# The full license is in the file LICENSE.txt, distributed with this software.
#-----------------------------------------------------------------------------
''' Provides the ``ServerSession`` class.
'''
#-----------------------------------------------------------------------------
# Boilerplate
#-----------------------------------------------------------------------------
from __future__ import annotations
import logging # isort:skip
log = logging.getLogger(__name__)
#-----------------------------------------------------------------------------
# Imports
#-----------------------------------------------------------------------------
# Standard library imports
import asyncio
import inspect
import threading
import time
from copy import copy
from dataclasses import dataclass
from functools import partial, wraps
from typing import (
TYPE_CHECKING,
Any,
Awaitable,
Callable,
cast,
)
# Bokeh imports
from ..document.callbacks import invoke_with_curdoc
from ..events import ConnectionLost
from ..io.doc import patch_curdoc
from ..util.asyncio import Loop, _asyncio_loop
from ..util.token import generate_jwt_token
from ..util.tornado import _run_in_executor
from .callbacks import DocumentCallbackGroup
from .executor import _await_cancellation_safe
if TYPE_CHECKING:
from ..core.types import ID
from ..document.document import Document
from ..document.events import (
DocumentPatchedEvent,
SessionCallbackAdded,
SessionCallbackRemoved,
)
from ..protocol import Protocol, messages as msg
from ..protocol.message import Message
from .callbacks import Callback, SessionCallback
from .connection import ServerConnection
from .executor import _ServerExecutor
#-----------------------------------------------------------------------------
# Globals and constants
#-----------------------------------------------------------------------------
__all__ = (
'current_time',
'ServerSession',
)
#-----------------------------------------------------------------------------
# Private API
#-----------------------------------------------------------------------------
@dataclass(frozen=True)
class _PendingPatch:
event: DocumentPatchedEvent
protocol: Protocol
connections: tuple[ServerConnection, ...]
def _serialize_patches(pending: list[_PendingPatch]) -> list[tuple[Message[Any], tuple[ServerConnection, ...]]]:
messages: list[tuple[Message[Any], tuple[ServerConnection, ...]]] = []
for patch in pending:
with patch_curdoc(patch.event.document):
message = patch.protocol.create("PATCH-DOC", [patch.event])
message.prepare()
messages.append((message, patch.connections))
return messages
def _serialize_pull_reply(protocol: Protocol, request_id: ID, document: Document) -> Message[Any]:
with patch_curdoc(document):
message = protocol.create("PULL-DOC-REPLY", request_id, document)
message.prepare()
return message
def _log_connection_lost_error(task: asyncio.Task[Any]) -> None:
if not task.cancelled() and (error := task.exception()) is not None:
log.error("Failed to notify connection loss: %s", error, exc_info=error)
def _needs_document_lock[**P](
func: Callable[P, Any],
*,
offload: bool = True,
) -> Callable[P, Awaitable[Any]]:
'''Decorator that adds the necessary locking and post-processing
to manipulate the session's document. Expects to decorate a
method on ServerSession and transforms it into a coroutine
if it wasn't already.
'''
@wraps(func)
async def _needs_document_lock_wrapper(self: ServerSession, *args: Any, **kwargs: Any) -> Any:
# while we wait for and hold the lock, prevent the session
# from being discarded. This avoids potential weirdness
# with the session vanishing in the middle of some async
# task.
if self.destroyed:
log.debug("Ignoring locked callback on already-destroyed session.")
return None
self.block_expiration()
try:
async with self._lock:
if self._pending_writes is not None:
raise RuntimeError("internal class invariant violated: _pending_writes " + \
"should be None if lock is not held")
self._pending_writes = []
error: BaseException | None = None
result: Any = None
try:
callback = cast(Callable[..., Any], func)
if offload:
result = await _run_in_executor(callback, self, *args, **kwargs)
else:
result = callback(self, *args, **kwargs)
if inspect.isawaitable(result):
# Async callbacks continue on the event loop while
# retaining the document lock across awaits.
result = await result
except BaseException as callback_error:
error = callback_error
finally:
pending_writes = self._pending_writes
self._pending_writes = None
try:
# Finish response generation and writes before releasing
# the document lock, even if the callback was cancelled.
if pending_writes:
await _await_cancellation_safe(self._send_pending_patches(pending_writes))
except BaseException as write_error:
if error is None:
error = write_error
if error is not None:
raise error
return result
finally:
self.unblock_expiration()
return cast(Callable[P, Awaitable[Any]], _needs_document_lock_wrapper)
def _needs_document_lock_on_loop[**P](
func: Callable[P, Any],
) -> Callable[P, Awaitable[Any]]:
return _needs_document_lock(func, offload=False)
#-----------------------------------------------------------------------------
# General API
#-----------------------------------------------------------------------------
[docs]
def current_time() -> float:
'''Return the time in milliseconds since the epoch as a floating
point number.
'''
return time.monotonic() * 1000
[docs]
class ServerSession:
''' Hosts an application "instance" (an instantiated Document) for one or more connections.
'''
_subscribed_connections: set[ServerConnection]
_current_patch_connection: ServerConnection | None
_pending_writes: list[_PendingPatch] | None
def __init__(self, session_id: ID, document: Document, io_loop: Loop | None = None,
token: str | None = None, executor: _ServerExecutor | None = None) -> None:
if session_id is None:
raise ValueError("Sessions must have an id")
if document is None:
raise ValueError("Sessions must have a document")
self._id = session_id
self._token = token
self._document = document
self._loop = io_loop
self._executor = executor
self._subscribed_connections = set()
self._connections_lock = threading.Lock()
self._last_unsubscribe_time = current_time()
self._lock = asyncio.Lock()
self._current_patch_connection = None
self._document.callbacks.on_change_dispatch_to(self)
self._callbacks = DocumentCallbackGroup(cast(Any, io_loop))
self._pending_writes = None
self._destroyed = False
self._expiration_requested = False
self._expiration_blocked_count = 0
wrapped_callbacks = [self._wrap_session_callback(cb) for cb in self._document.session_callbacks]
self._callbacks.add_session_callbacks(wrapped_callbacks)
@property
def document(self) -> Document:
return self._document
@property
def id(self) -> ID:
return self._id
@property
def token(self) -> str:
''' A JWT token to authenticate the session. '''
if self._token:
return self._token
return generate_jwt_token(self.id)
@property
def destroyed(self) -> bool:
return self._destroyed
@property
def expiration_requested(self) -> bool:
return self._expiration_requested
@property
def expiration_blocked(self) -> bool:
return self._expiration_blocked_count > 0
@property
def expiration_blocked_count(self) -> int:
return self._expiration_blocked_count
def destroy(self) -> None:
self._destroyed = True
self._document.destroy(self)
del self._document
self._callbacks.remove_all_callbacks()
del self._callbacks
def _stop_callbacks(self) -> None:
"""Prevent new document callbacks while orderly shutdown takes the lock."""
self._callbacks.remove_all_callbacks()
[docs]
def request_expiration(self) -> None:
""" Used in test suite for now. Forces immediate expiration if no connections."""
self._expiration_requested = True
def block_expiration(self) -> None:
self._expiration_blocked_count += 1
def unblock_expiration(self) -> None:
if self._expiration_blocked_count <= 0:
raise RuntimeError("mismatched block_expiration / unblock_expiration")
self._expiration_blocked_count -= 1
[docs]
def subscribe(self, connection: ServerConnection) -> None:
"""This should only be called by ``ServerConnection.subscribe_session`` or our book-keeping will be broken"""
with self._connections_lock:
self._subscribed_connections.add(connection)
[docs]
def unsubscribe(self, connection: ServerConnection) -> None:
"""This should only be called by ``ServerConnection.unsubscribe_session`` or our book-keeping will be broken"""
with self._connections_lock:
self._subscribed_connections.discard(connection)
self._last_unsubscribe_time = current_time()
@property
def connection_count(self) -> int:
with self._connections_lock:
return len(self._subscribed_connections)
@property
def milliseconds_since_last_unsubscribe(self) -> float:
with self._connections_lock:
return current_time() - self._last_unsubscribe_time
[docs]
@_needs_document_lock
def with_document_locked[T](self, func: Callable[..., T], *args: Any, **kwargs: Any) -> T:
''' Asynchronously locks the document and runs the function with it locked.'''
return func(*args, **kwargs)
def _wrap_document_callback(self, callback: Callback) -> Callback:
if getattr(callback, "nolock", False):
@wraps(callback)
async def unlocked_callback(*args: Any, **kwargs: Any) -> Any:
result = await _run_in_executor(callback, *args, **kwargs)
if inspect.isawaitable(result):
await result
return None
return unlocked_callback
def locked_callback(*args: Any, **kwargs: Any) -> Any:
return self.with_document_locked(callback, *args, **kwargs)
return locked_callback
def _wrap_session_callback(self, callback: SessionCallback) -> SessionCallback:
wrapped = copy(callback)
wrapped._callback = self._wrap_document_callback(callback.callback)
return wrapped
def _document_patched(self, event: DocumentPatchedEvent) -> None:
may_suppress = event.setter is self
if self._pending_writes is None:
raise RuntimeError("_pending_writes should be non-None when we have a document lock, and we should have the lock when the document changes")
# TODO (havocp): our "change sync" protocol is flawed because if both
# sides change the same attribute at the same time, they will each end
# up with the state of the other and their final states will differ.
with self._connections_lock:
connections = tuple(
connection for connection in self._subscribed_connections
if not may_suppress or connection is not self._current_patch_connection
)
if connections:
self._pending_writes.append(_PendingPatch(event, connections[0].protocol, connections))
async def _run_in_executor[T](self, func: Callable[..., T], *args: Any) -> T:
if self._executor is not None:
return await self._executor.run(func, *args)
loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, partial(func, *args))
async def _send_pending_patches(self, pending: list[_PendingPatch]) -> None:
messages = await self._run_in_executor(_serialize_patches, pending)
for message, connections in messages:
for connection in connections:
await connection.send_message(message)
@_needs_document_lock_on_loop
async def _handle_pull(self, message: msg.pull_doc_req, connection: ServerConnection) -> None:
log.debug(f"Sending pull-doc-reply from session {self.id!r}")
async def send_reply() -> None:
reply = await self._run_in_executor(
_serialize_pull_reply,
connection.protocol,
message.header["msgid"],
self.document,
)
await connection.send_message(reply)
await _await_cancellation_safe(send_reply())
def _session_callback_added(self, event: SessionCallbackAdded) -> None:
wrapped = self._wrap_session_callback(event.callback)
self._callbacks.add_session_callback(wrapped)
def _session_callback_removed(self, event: SessionCallbackRemoved) -> None:
self._callbacks.remove_session_callback(event.callback)
[docs]
@classmethod
def pull(cls, message: msg.pull_doc_req, connection: ServerConnection) -> Awaitable[None]:
''' Handle a PULL-DOC, return a Future with work to be scheduled. '''
return connection.session._handle_pull(message, connection)
@_needs_document_lock_on_loop
async def _handle_push(self, message: msg.push_doc, connection: ServerConnection) -> msg.ok:
log.debug(f"pushing doc to session {self.id!r}")
await _run_in_executor(message.push_to_document, self.document)
return connection.ok(message)
[docs]
@classmethod
def push(cls, message: msg.push_doc, connection: ServerConnection) -> Awaitable[msg.ok]:
''' Handle a PUSH-DOC, return a Future with work to be scheduled. '''
return connection.session._handle_push(message, connection)
@_needs_document_lock_on_loop
async def _handle_patch(self, message: msg.patch_doc, connection: ServerConnection) -> msg.ok:
self._current_patch_connection = connection
try:
await _run_in_executor(message.apply_to_document, self.document, self)
finally:
self._current_patch_connection = None
return connection.ok(message)
[docs]
@classmethod
def patch(cls, message: msg.patch_doc, connection: ServerConnection) -> Awaitable[msg.ok]:
''' Handle a PATCH-DOC, return a Future with work to be scheduled. '''
return connection.session._handle_patch(message, connection)
[docs]
def notify_connection_lost(self) -> None:
''' Notify the document that the connection was lost. '''
event = ConnectionLost()
def notify() -> None:
invoke_with_curdoc(self.document,
lambda: self.document.callbacks.trigger_event(event))
assert self._loop is not None
loop = _asyncio_loop(self._loop)
async def notify_locked() -> None:
await self.with_document_locked(notify)
def schedule() -> None:
task = loop.create_task(notify_locked())
task.add_done_callback(_log_connection_lost_error)
loop.call_soon_threadsafe(schedule)
#-----------------------------------------------------------------------------
# Dev API
#-----------------------------------------------------------------------------
#-----------------------------------------------------------------------------
# Code
#-----------------------------------------------------------------------------