chore: rename project coven → hack-house ⛧
Rebrand the Rust client crate (coven/ → hh/, package+binary "hack-house"), README, CLI strings, and branch (coven → hack-house). Gitea repo renamed cmd-chat → hack-house to match. Crypto/server logic unchanged; selftest + golden-vector test still green, binary is now `hack-house`. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
from sanic.models.server_types import ConnInfo, Signal
|
||||
from sanic.server.async_server import AsyncioServer
|
||||
from sanic.server.loop import try_use_uvloop
|
||||
from sanic.server.protocols.http_protocol import HttpProtocol
|
||||
from sanic.server.runners import serve
|
||||
|
||||
|
||||
__all__ = (
|
||||
"AsyncioServer",
|
||||
"ConnInfo",
|
||||
"HttpProtocol",
|
||||
"Signal",
|
||||
"serve",
|
||||
"try_use_uvloop",
|
||||
)
|
||||
@@ -0,0 +1,112 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sanic.exceptions import SanicException
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
class AsyncioServer:
|
||||
"""Wraps an asyncio server with functionality that might be useful to a user who needs to manage the server lifecycle manually.""" # noqa: E501
|
||||
|
||||
__slots__ = ("app", "connections", "loop", "serve_coro", "server")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app: Sanic,
|
||||
loop,
|
||||
serve_coro,
|
||||
connections,
|
||||
):
|
||||
# Note, Sanic already called "before_server_start" events
|
||||
# before this helper was even created. So we don't need it here.
|
||||
self.app = app
|
||||
self.connections = connections
|
||||
self.loop = loop
|
||||
self.serve_coro = serve_coro
|
||||
self.server = None
|
||||
|
||||
def startup(self):
|
||||
"""Trigger "startup" operations on the app"""
|
||||
return self.app._startup()
|
||||
|
||||
def before_start(self):
|
||||
"""Trigger "before_server_start" events"""
|
||||
return self._server_event("init", "before")
|
||||
|
||||
def after_start(self):
|
||||
"""Trigger "after_server_start" events"""
|
||||
return self._server_event("init", "after")
|
||||
|
||||
def before_stop(self):
|
||||
"""Trigger "before_server_stop" events"""
|
||||
return self._server_event("shutdown", "before")
|
||||
|
||||
def after_stop(self):
|
||||
"""Trigger "after_server_stop" events"""
|
||||
return self._server_event("shutdown", "after")
|
||||
|
||||
def is_serving(self) -> bool:
|
||||
"""Returns True if the server is running, False otherwise"""
|
||||
if self.server:
|
||||
return self.server.is_serving()
|
||||
return False
|
||||
|
||||
def wait_closed(self):
|
||||
"""Wait until the server is closed"""
|
||||
if self.server:
|
||||
return self.server.wait_closed()
|
||||
|
||||
def close(self):
|
||||
"""Close the server"""
|
||||
if self.server:
|
||||
self.server.close()
|
||||
coro = self.wait_closed()
|
||||
task = self.loop.create_task(coro)
|
||||
return task
|
||||
|
||||
def start_serving(self):
|
||||
"""Start serving requests"""
|
||||
return self._serve(self.server.start_serving)
|
||||
|
||||
def serve_forever(self):
|
||||
"""Serve requests until the server is stopped"""
|
||||
return self._serve(self.server.serve_forever)
|
||||
|
||||
def _serve(self, serve_func):
|
||||
if self.server:
|
||||
if not self.app.state.is_started:
|
||||
raise SanicException(
|
||||
"Cannot run Sanic server without first running "
|
||||
"await server.startup()"
|
||||
)
|
||||
|
||||
try:
|
||||
return serve_func()
|
||||
except AttributeError:
|
||||
name = serve_func.__name__
|
||||
raise NotImplementedError(
|
||||
f"server.{name} not available in this version "
|
||||
"of asyncio or uvloop."
|
||||
)
|
||||
|
||||
def _server_event(self, concern: str, action: str):
|
||||
if not self.app.state.is_started:
|
||||
raise SanicException(
|
||||
"Cannot dispatch server event without "
|
||||
"first running await server.startup()"
|
||||
)
|
||||
return self.app._server_event(concern, action, loop=self.loop)
|
||||
|
||||
def __await__(self):
|
||||
"""
|
||||
Starts the asyncio server, returns AsyncServerCoro
|
||||
"""
|
||||
task = self.loop.create_task(self.serve_coro)
|
||||
while not task.done():
|
||||
yield
|
||||
self.server = task.result()
|
||||
return self
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from inspect import isawaitable
|
||||
from typing import TYPE_CHECKING, Any, Callable
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic import Sanic
|
||||
|
||||
|
||||
def trigger_events(
|
||||
events: Iterable[Callable[..., Any]] | None,
|
||||
loop,
|
||||
app: Sanic | None = None,
|
||||
**kwargs,
|
||||
):
|
||||
"""Trigger event callbacks (functions or async)
|
||||
|
||||
Args:
|
||||
events (Optional[Iterable[Callable[..., Any]]]): [description]
|
||||
loop ([type]): [description]
|
||||
app (Optional[Sanic], optional): [description]. Defaults to None.
|
||||
"""
|
||||
if events:
|
||||
for event in events:
|
||||
try:
|
||||
result = event(**kwargs) if not app else event(app, **kwargs)
|
||||
except TypeError:
|
||||
result = (
|
||||
event(loop, **kwargs)
|
||||
if not app
|
||||
else event(app, loop, **kwargs)
|
||||
)
|
||||
if isawaitable(result):
|
||||
loop.run_until_complete(result)
|
||||
@@ -0,0 +1,31 @@
|
||||
# flake8: noqa: E501
|
||||
|
||||
import random
|
||||
import sys
|
||||
|
||||
|
||||
# fmt: off
|
||||
ascii_phrases = {
|
||||
'Farewell', 'Bye', 'See you later', 'Take care', 'So long', 'Adieu', 'Cheerio',
|
||||
'Goodbye', 'Adios', 'Au revoir', 'Arrivederci', 'Sayonara', 'Auf Wiedersehen',
|
||||
'Do svidaniya', 'Annyeong', 'Tot ziens', 'Ha det', 'Selamat tinggal',
|
||||
'Hasta luego', 'Nos vemos', 'Salut', 'Ciao', 'A presto',
|
||||
'Dag', 'Tot later', 'Vi ses', 'Sampai jumpa',
|
||||
}
|
||||
|
||||
non_ascii_phrases = {
|
||||
'Tschüss', 'Zài jiàn', 'Bāi bāi', 'Míngtiān jiàn', 'Adeus', 'Tchau', 'Até logo',
|
||||
'Hejdå', 'À bientôt', 'Bis später', 'Adjø',
|
||||
'じゃね', 'またね', '안녕히 계세요', '잘 가', 'שלום',
|
||||
'להתראות', 'مع السلامة', 'إلى اللقاء', 'وداعاً', 'अलविदा',
|
||||
'फिर मिलेंगे',
|
||||
}
|
||||
|
||||
all_phrases = ascii_phrases | non_ascii_phrases
|
||||
# fmt: on
|
||||
|
||||
|
||||
def get_goodbye() -> str: # pragma: no cover
|
||||
is_utf8 = sys.stdout.encoding.lower() == "utf-8"
|
||||
phrases = all_phrases if is_utf8 else ascii_phrases
|
||||
return random.choice(list(phrases)) # nosec: B311
|
||||
@@ -0,0 +1,64 @@
|
||||
import asyncio
|
||||
|
||||
from os import getenv
|
||||
|
||||
from sanic.compat import OS_IS_WINDOWS
|
||||
from sanic.log import error_logger
|
||||
from sanic.utils import str_to_bool
|
||||
|
||||
|
||||
def try_use_uvloop() -> None:
|
||||
"""Use uvloop instead of the default asyncio loop."""
|
||||
if OS_IS_WINDOWS:
|
||||
error_logger.warning(
|
||||
"You are trying to use uvloop, but uvloop is not compatible "
|
||||
"with your system. You can disable uvloop completely by setting "
|
||||
"the 'USE_UVLOOP' configuration value to false, or simply not "
|
||||
"defining it and letting Sanic handle it for you. Sanic will now "
|
||||
"continue to run using the default event loop."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
import uvloop # type: ignore
|
||||
except ImportError:
|
||||
error_logger.warning(
|
||||
"You are trying to use uvloop, but uvloop is not "
|
||||
"installed in your system. In order to use uvloop "
|
||||
"you must first install it. Otherwise, you can disable "
|
||||
"uvloop completely by setting the 'USE_UVLOOP' "
|
||||
"configuration value to false. Sanic will now continue "
|
||||
"to run with the default event loop."
|
||||
)
|
||||
return
|
||||
|
||||
uvloop_install_removed = str_to_bool(getenv("SANIC_NO_UVLOOP", "no"))
|
||||
if uvloop_install_removed:
|
||||
error_logger.info(
|
||||
"You are requesting to run Sanic using uvloop, but the "
|
||||
"install-time 'SANIC_NO_UVLOOP' environment variable (used to "
|
||||
"opt-out of installing uvloop with Sanic) is set to true. If "
|
||||
"you want to prevent Sanic from overriding the event loop policy "
|
||||
"during runtime, set the 'USE_UVLOOP' configuration value to "
|
||||
"false."
|
||||
)
|
||||
|
||||
if not isinstance(asyncio.get_event_loop_policy(), uvloop.EventLoopPolicy):
|
||||
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
|
||||
|
||||
|
||||
def try_windows_loop():
|
||||
"""Try to use the WindowsSelectorEventLoopPolicy instead of the default"""
|
||||
if not OS_IS_WINDOWS:
|
||||
error_logger.warning(
|
||||
"You are trying to use an event loop policy that is not "
|
||||
"compatible with your system. You can simply let Sanic handle "
|
||||
"selecting the best loop for you. Sanic will now continue to run "
|
||||
"using the default event loop."
|
||||
)
|
||||
return
|
||||
|
||||
if not isinstance(
|
||||
asyncio.get_event_loop_policy(), asyncio.WindowsSelectorEventLoopPolicy
|
||||
):
|
||||
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
|
||||
@@ -0,0 +1,260 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sanic.exceptions import RequestCancelled
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic.app import Sanic
|
||||
|
||||
import asyncio
|
||||
|
||||
from asyncio.transports import Transport
|
||||
from time import monotonic as current_time
|
||||
|
||||
from sanic.log import error_logger
|
||||
from sanic.models.server_types import ConnInfo, Signal
|
||||
|
||||
|
||||
class SanicProtocol(asyncio.Protocol):
|
||||
__slots__ = (
|
||||
"app",
|
||||
# event loop, connection
|
||||
"loop",
|
||||
"transport",
|
||||
"connections",
|
||||
"conn_info",
|
||||
"signal",
|
||||
"_can_write",
|
||||
"_time",
|
||||
"_task",
|
||||
"_unix",
|
||||
"_data_received",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
loop,
|
||||
app: Sanic,
|
||||
signal=None,
|
||||
connections=None,
|
||||
unix=None,
|
||||
**kwargs,
|
||||
):
|
||||
asyncio.set_event_loop(loop)
|
||||
self.loop = loop
|
||||
self.app: Sanic = app
|
||||
self.signal = signal or Signal()
|
||||
self.transport: Transport | None = None
|
||||
self.connections = connections if connections is not None else set()
|
||||
self.conn_info: ConnInfo | None = None
|
||||
self._can_write = asyncio.Event()
|
||||
self._can_write.set()
|
||||
self._unix = unix
|
||||
self._time = 0.0 # type: float
|
||||
self._task: asyncio.Task | None = None
|
||||
self._data_received = asyncio.Event()
|
||||
|
||||
@property
|
||||
def ctx(self):
|
||||
if self.conn_info is not None:
|
||||
return self.conn_info.ctx
|
||||
else:
|
||||
return None
|
||||
|
||||
async def send(self, data):
|
||||
"""
|
||||
Generic data write implementation with backpressure control.
|
||||
"""
|
||||
await self._can_write.wait()
|
||||
if self.transport.is_closing():
|
||||
raise RequestCancelled
|
||||
self.transport.write(data)
|
||||
self._time = current_time()
|
||||
|
||||
async def receive_more(self):
|
||||
"""
|
||||
Wait until more data is received into the Server protocol's buffer
|
||||
"""
|
||||
self.transport.resume_reading()
|
||||
self._data_received.clear()
|
||||
await self._data_received.wait()
|
||||
|
||||
def close(self, timeout: float | None = None):
|
||||
"""
|
||||
Attempt close the connection.
|
||||
"""
|
||||
if self.transport is None or self.transport.is_closing():
|
||||
# do not attempt to close again, already aborted or closing
|
||||
return
|
||||
|
||||
# Check if write is already paused _before_ close() is called.
|
||||
write_was_paused = not self._can_write.is_set()
|
||||
# Trigger the UVLoop Stream Transport Close routine
|
||||
# Causes a call to connection_lost where further cleanup occurs
|
||||
# Close may fully close the connection now, but if there is still
|
||||
# data in the libuv buffer, then close becomes an async operation
|
||||
self.transport.close()
|
||||
try:
|
||||
# Check write-buffer data left _after_ close is called.
|
||||
# in UVLoop, get the data in the libuv transport write-buffer
|
||||
data_left = self.transport.get_write_buffer_size()
|
||||
# Some asyncio implementations don't support get_write_buffer_size
|
||||
except (AttributeError, NotImplementedError):
|
||||
data_left = 0
|
||||
if write_was_paused or data_left > 0:
|
||||
# don't call resume_writing here, it gets called by the transport
|
||||
# to unpause the protocol when it is ready for more data
|
||||
|
||||
# Schedule the async close checker, to close the connection
|
||||
# after the transport is done, and clean everything up.
|
||||
if timeout is None:
|
||||
# This close timeout needs to be less than the graceful
|
||||
# shutdown timeout. The graceful shutdown _could_ be waiting
|
||||
# for this transport to close before shutting down the app.
|
||||
timeout = self.app.config.GRACEFUL_TCP_CLOSE_TIMEOUT
|
||||
# This is 5s by default.
|
||||
else:
|
||||
# Schedule the async close checker but with no timeout,
|
||||
# this will ensure abort() is called if required.
|
||||
if timeout is None:
|
||||
timeout = 0
|
||||
self.loop.call_soon(
|
||||
_async_protocol_transport_close,
|
||||
self,
|
||||
self.loop,
|
||||
timeout,
|
||||
)
|
||||
|
||||
def abort(self):
|
||||
"""
|
||||
Force close the connection.
|
||||
"""
|
||||
# Cause a call to connection_lost where further cleanup occurs
|
||||
if self.transport:
|
||||
self.transport.abort()
|
||||
self.transport = None
|
||||
|
||||
# asyncio.Protocol API Callbacks #
|
||||
# ------------------------------ #
|
||||
def connection_made(self, transport):
|
||||
"""
|
||||
Generic connection-made, with no connection_task, and no recv_buffer.
|
||||
Override this for protocol-specific connection implementations.
|
||||
"""
|
||||
try:
|
||||
transport.set_write_buffer_limits(low=16384, high=65536)
|
||||
self.connections.add(self)
|
||||
self.transport = transport
|
||||
self.conn_info = ConnInfo(self.transport, unix=self._unix)
|
||||
except Exception:
|
||||
error_logger.exception("protocol.connect_made")
|
||||
|
||||
def connection_lost(self, exc):
|
||||
"""
|
||||
This is a callback handler that is called from the asyncio
|
||||
transport layer implementation (eg, UVLoop's UVStreamTransport).
|
||||
It is scheduled to be called async after the transport has closed.
|
||||
When data is still in the send buffer, this call to connection_lost
|
||||
will be delayed until _after_ the buffer is finished being sent.
|
||||
|
||||
So we can use this callback as a confirmation callback
|
||||
that the async write-buffer transfer is finished.
|
||||
"""
|
||||
try:
|
||||
self.connections.discard(self)
|
||||
# unblock the send queue if it is paused,
|
||||
# this allows the route handler to see
|
||||
# the CancelledError exception
|
||||
self.resume_writing()
|
||||
self.conn_info.lost = True
|
||||
if self._task:
|
||||
self._task.cancel()
|
||||
except BaseException:
|
||||
error_logger.exception("protocol.connection_lost")
|
||||
|
||||
def pause_writing(self):
|
||||
self._can_write.clear()
|
||||
|
||||
def resume_writing(self):
|
||||
self._can_write.set()
|
||||
|
||||
def data_received(self, data: bytes):
|
||||
try:
|
||||
self._time = current_time()
|
||||
if not data:
|
||||
return self.close()
|
||||
|
||||
if self._data_received:
|
||||
self._data_received.set()
|
||||
except BaseException:
|
||||
error_logger.exception("protocol.data_received")
|
||||
|
||||
|
||||
def _async_protocol_transport_close(
|
||||
protocol: SanicProtocol,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
timeout: float,
|
||||
):
|
||||
"""
|
||||
This function is scheduled to be called after close() is called.
|
||||
It checks that the transport has shut down properly, or waits
|
||||
for any remaining data to be sent, and aborts after a timeout.
|
||||
This is required if the transport is closed while there is an async
|
||||
large async transport write operation in progress.
|
||||
This is observed when NGINX reverse-proxy is the client.
|
||||
"""
|
||||
if protocol.transport is None:
|
||||
# abort() is the only method that can make
|
||||
# protocol.transport be None, so abort was already called
|
||||
return
|
||||
# protocol.connection_lost does not set protocol.transport to None
|
||||
# so to detect it a different way with conninfo.lost
|
||||
elif protocol.conn_info is not None and protocol.conn_info.lost:
|
||||
# Terminus. Most connections finish the protocol here!
|
||||
# Connection_lost callback was executed already,
|
||||
# so transport did complete and close itself properly.
|
||||
# No need to call abort().
|
||||
|
||||
# This is the last part of cleanup to do
|
||||
# that is not done by connection_lost handler.
|
||||
# Ensure transport is cleaned up by GC.
|
||||
protocol.transport = None
|
||||
return
|
||||
elif not protocol.transport.is_closing():
|
||||
raise RuntimeError(
|
||||
"You must call transport.close() before "
|
||||
"protocol._async_transport_close() runs."
|
||||
)
|
||||
|
||||
write_is_paused = not protocol._can_write.is_set()
|
||||
try:
|
||||
# in UVLoop, get the data in the libuv write-buffer
|
||||
data_left = protocol.transport.get_write_buffer_size()
|
||||
# Some asyncio implementations don't support get_write_buffer_size
|
||||
except (AttributeError, NotImplementedError):
|
||||
data_left = 0
|
||||
if write_is_paused or data_left > 0:
|
||||
# don't need to call resume_writing here to unpause
|
||||
if timeout <= 0:
|
||||
# timeout is 0 or less, so we can simply abort now
|
||||
loop.call_soon(SanicProtocol.abort, protocol)
|
||||
else:
|
||||
next_check_interval = min(timeout, 0.1)
|
||||
next_check_timeout = timeout - next_check_interval
|
||||
loop.call_later(
|
||||
# Recurse back in after the timeout, to check again
|
||||
next_check_interval,
|
||||
# this next time with reduced timeout.
|
||||
_async_protocol_transport_close,
|
||||
protocol,
|
||||
loop,
|
||||
next_check_timeout,
|
||||
)
|
||||
else:
|
||||
# Not paused, and no data left in the buffer, but transport
|
||||
# is still open, connection_lost has not been called yet.
|
||||
# We can call abort() to fix that.
|
||||
loop.call_soon(SanicProtocol.abort, protocol)
|
||||
@@ -0,0 +1,360 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sanic.http.constants import HTTP
|
||||
from sanic.http.http3 import Http3
|
||||
from sanic.touchup.meta import TouchUpMeta
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic.app import Sanic
|
||||
|
||||
|
||||
from asyncio import CancelledError
|
||||
from time import monotonic as current_time
|
||||
|
||||
from sanic.exceptions import (
|
||||
RequestCancelled,
|
||||
RequestTimeout,
|
||||
ServiceUnavailable,
|
||||
)
|
||||
from sanic.http import Http, Stage
|
||||
from sanic.log import (
|
||||
Colors,
|
||||
access_logger,
|
||||
error_logger,
|
||||
logger,
|
||||
websockets_logger,
|
||||
)
|
||||
from sanic.models.server_types import ConnInfo
|
||||
from sanic.request import Request
|
||||
from sanic.server.protocols.base_protocol import SanicProtocol
|
||||
|
||||
|
||||
ConnectionProtocol = type("ConnectionProtocol", (), {})
|
||||
try:
|
||||
from aioquic.asyncio import QuicConnectionProtocol
|
||||
from aioquic.h3.connection import H3_ALPN, H3Connection
|
||||
from aioquic.quic.events import (
|
||||
DatagramFrameReceived,
|
||||
ProtocolNegotiated,
|
||||
QuicEvent,
|
||||
)
|
||||
|
||||
ConnectionProtocol = QuicConnectionProtocol
|
||||
except ModuleNotFoundError: # no cov
|
||||
...
|
||||
|
||||
|
||||
class HttpProtocolMixin:
|
||||
__slots__ = ()
|
||||
__version__: HTTP
|
||||
|
||||
def _setup_connection(self, *args, **kwargs):
|
||||
self._http = self.HTTP_CLASS(self, *args, **kwargs)
|
||||
self._time = current_time()
|
||||
try:
|
||||
self.check_timeouts()
|
||||
except AttributeError:
|
||||
...
|
||||
|
||||
def _setup(self):
|
||||
self.request: Request | None = None
|
||||
self.access_log = self.app.config.ACCESS_LOG
|
||||
self.request_handler = self.app.handle_request
|
||||
self.error_handler = self.app.error_handler
|
||||
self.request_timeout = self.app.config.REQUEST_TIMEOUT
|
||||
self.response_timeout = self.app.config.RESPONSE_TIMEOUT
|
||||
self.keep_alive_timeout = self.app.config.KEEP_ALIVE_TIMEOUT
|
||||
self.request_max_size = self.app.config.REQUEST_MAX_SIZE
|
||||
self.request_class = self.app.request_class or Request
|
||||
|
||||
@property
|
||||
def http(self):
|
||||
if not hasattr(self, "_http"):
|
||||
return None
|
||||
return self._http
|
||||
|
||||
@property
|
||||
def version(self):
|
||||
return self.__class__.__version__
|
||||
|
||||
|
||||
class HttpProtocol(HttpProtocolMixin, SanicProtocol, metaclass=TouchUpMeta):
|
||||
"""
|
||||
This class provides implements the HTTP 1.1 protocol on top of our
|
||||
Sanic Server transport
|
||||
"""
|
||||
|
||||
HTTP_CLASS = Http
|
||||
|
||||
__touchup__ = (
|
||||
"send",
|
||||
"connection_task",
|
||||
)
|
||||
__version__ = HTTP.VERSION_1
|
||||
__slots__ = (
|
||||
# request params
|
||||
"request",
|
||||
# request config
|
||||
"request_handler",
|
||||
"request_timeout",
|
||||
"response_timeout",
|
||||
"keep_alive_timeout",
|
||||
"request_max_size",
|
||||
"request_class",
|
||||
"error_handler",
|
||||
# enable or disable access log purpose
|
||||
"access_log",
|
||||
# connection management
|
||||
"state",
|
||||
"url",
|
||||
"_handler_task",
|
||||
"_http",
|
||||
"_exception",
|
||||
"recv_buffer",
|
||||
"_callback_check_timeouts",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
loop,
|
||||
app: Sanic,
|
||||
signal=None,
|
||||
connections=None,
|
||||
state=None,
|
||||
unix=None,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(
|
||||
loop=loop,
|
||||
app=app,
|
||||
signal=signal,
|
||||
connections=connections,
|
||||
unix=unix,
|
||||
)
|
||||
self.url = None
|
||||
self.state = state if state else {}
|
||||
self._setup()
|
||||
if "requests_count" not in self.state:
|
||||
self.state["requests_count"] = 0
|
||||
self._exception = None
|
||||
self._callback_check_timeouts = None
|
||||
|
||||
async def connection_task(self): # no cov
|
||||
"""
|
||||
Run a HTTP connection.
|
||||
|
||||
Timeouts and some additional error handling occur here, while most of
|
||||
everything else happens in class Http or in code called from there.
|
||||
"""
|
||||
try:
|
||||
self._setup_connection()
|
||||
await self.app.dispatch(
|
||||
"http.lifecycle.begin",
|
||||
inline=True,
|
||||
context={"conn_info": self.conn_info},
|
||||
)
|
||||
await self._http.http1()
|
||||
except CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
error_logger.exception("protocol.connection_task uncaught")
|
||||
finally:
|
||||
if (
|
||||
self.access_log
|
||||
and self._http
|
||||
and self.transport
|
||||
and not self._http.upgrade_websocket
|
||||
):
|
||||
self.log_disconnect()
|
||||
self._http = None
|
||||
self._task = None
|
||||
try:
|
||||
self.close()
|
||||
except BaseException:
|
||||
error_logger.exception("Closing failed")
|
||||
finally:
|
||||
await self.app.dispatch(
|
||||
"http.lifecycle.complete",
|
||||
inline=True,
|
||||
context={"conn_info": self.conn_info},
|
||||
)
|
||||
# Important to keep this Ellipsis here for the TouchUp module
|
||||
...
|
||||
|
||||
def log_disconnect(self):
|
||||
ip = self.transport.get_extra_info("peername")
|
||||
|
||||
req = self._http.request
|
||||
res = self._http.response
|
||||
extra = {
|
||||
"status": res.status if res else str(self._http.stage),
|
||||
"byte": "DISCONNECTED",
|
||||
"host": f"{id(self):X}"[-5:-1] + "unx",
|
||||
"request": "nil",
|
||||
"duration": "",
|
||||
}
|
||||
if req is not None:
|
||||
if ip := req.client_ip:
|
||||
extra["host"] = f"{ip}:{req.port}"
|
||||
extra["request"] = f"{req.method} {req.url}"
|
||||
access_logger.info("", extra=extra)
|
||||
|
||||
def check_timeouts(self):
|
||||
"""
|
||||
Runs itself periodically to enforce any expired timeouts.
|
||||
"""
|
||||
try:
|
||||
if not self._task:
|
||||
return
|
||||
duration = current_time() - self._time
|
||||
stage = self._http.stage
|
||||
if stage is Stage.IDLE and duration > self.keep_alive_timeout:
|
||||
logger.debug("KeepAlive Timeout. Closing connection.")
|
||||
elif stage is Stage.REQUEST and duration > self.request_timeout:
|
||||
logger.debug("Request Timeout. Closing connection.")
|
||||
self._http.exception = RequestTimeout("Request Timeout")
|
||||
elif stage is Stage.HANDLER and self._http.upgrade_websocket:
|
||||
websockets_logger.debug(
|
||||
"Handling websocket. Timeouts disabled."
|
||||
)
|
||||
return
|
||||
elif (
|
||||
stage in (Stage.HANDLER, Stage.RESPONSE, Stage.FAILED)
|
||||
and duration > self.response_timeout
|
||||
):
|
||||
logger.debug("Response Timeout. Closing connection.")
|
||||
self._http.exception = ServiceUnavailable("Response Timeout")
|
||||
else:
|
||||
interval = (
|
||||
min(
|
||||
self.keep_alive_timeout,
|
||||
self.request_timeout,
|
||||
self.response_timeout,
|
||||
)
|
||||
/ 2
|
||||
)
|
||||
_interval = max(0.1, interval)
|
||||
self._callback_check_timeouts = self.loop.call_later(
|
||||
_interval, self.check_timeouts
|
||||
)
|
||||
return
|
||||
if sys.version_info < (3, 14):
|
||||
self._task.cancel("Cancel connection task with a timeout")
|
||||
else:
|
||||
self._task.cancel()
|
||||
except Exception:
|
||||
error_logger.exception("protocol.check_timeouts")
|
||||
|
||||
def close(self, timeout: float | None = None):
|
||||
"""
|
||||
Requires to prevent checking timeouts for closed connections
|
||||
"""
|
||||
|
||||
if self._callback_check_timeouts:
|
||||
self._callback_check_timeouts.cancel()
|
||||
return super().close(timeout=timeout)
|
||||
|
||||
async def send(self, data): # no cov
|
||||
"""
|
||||
Writes HTTP data with backpressure control.
|
||||
"""
|
||||
await self._can_write.wait()
|
||||
if self.transport.is_closing():
|
||||
raise RequestCancelled
|
||||
await self.app.dispatch(
|
||||
"http.lifecycle.send",
|
||||
inline=True,
|
||||
context={"data": data},
|
||||
)
|
||||
self.transport.write(data)
|
||||
self._time = current_time()
|
||||
|
||||
def close_if_idle(self) -> bool:
|
||||
"""
|
||||
Close the connection if a request is not being sent or received
|
||||
|
||||
:return: boolean - True if closed, false if staying open
|
||||
"""
|
||||
if self.http is None or self.http.stage is Stage.IDLE:
|
||||
self.close()
|
||||
return True
|
||||
return False
|
||||
|
||||
# -------------------------------------------- #
|
||||
# Only asyncio.Protocol callbacks below this
|
||||
# -------------------------------------------- #
|
||||
|
||||
def connection_made(self, transport):
|
||||
"""
|
||||
HTTP-protocol-specific new connection handler
|
||||
"""
|
||||
try:
|
||||
# TODO: Benchmark to find suitable write buffer limits
|
||||
transport.set_write_buffer_limits(low=16384, high=65536)
|
||||
self.connections.add(self)
|
||||
self.transport = transport
|
||||
self._task = self.loop.create_task(self.connection_task())
|
||||
self.recv_buffer = bytearray()
|
||||
self.conn_info = ConnInfo(self.transport, unix=self._unix)
|
||||
except Exception:
|
||||
error_logger.exception("protocol.connect_made")
|
||||
|
||||
def data_received(self, data: bytes):
|
||||
try:
|
||||
self._time = current_time()
|
||||
if not data:
|
||||
return self.close()
|
||||
self.recv_buffer += data
|
||||
|
||||
if (
|
||||
len(self.recv_buffer) >= self.app.config.REQUEST_BUFFER_SIZE
|
||||
and self.transport
|
||||
):
|
||||
self.transport.pause_reading()
|
||||
|
||||
if self._data_received:
|
||||
self._data_received.set()
|
||||
except Exception:
|
||||
error_logger.exception("protocol.data_received")
|
||||
|
||||
|
||||
class Http3Protocol(HttpProtocolMixin, ConnectionProtocol): # type: ignore
|
||||
HTTP_CLASS = Http3
|
||||
__version__ = HTTP.VERSION_3
|
||||
|
||||
def __init__(self, *args, app: Sanic, **kwargs) -> None:
|
||||
self.app = app
|
||||
super().__init__(*args, **kwargs)
|
||||
self._setup()
|
||||
self._connection: H3Connection | None = None
|
||||
|
||||
def quic_event_received(self, event: QuicEvent) -> None:
|
||||
logger.debug(
|
||||
f"{Colors.BLUE}[quic_event_received]: "
|
||||
f"{Colors.PURPLE}{event}{Colors.END}",
|
||||
extra={"verbosity": 2},
|
||||
)
|
||||
if isinstance(event, ProtocolNegotiated):
|
||||
self._setup_connection(transmit=self.transmit)
|
||||
if event.alpn_protocol in H3_ALPN:
|
||||
self._connection = H3Connection(
|
||||
self._quic, enable_webtransport=True
|
||||
)
|
||||
elif isinstance(event, DatagramFrameReceived):
|
||||
if event.data == b"quack":
|
||||
self._quic.send_datagram_frame(b"quack-ack")
|
||||
|
||||
# pass event to the HTTP layer
|
||||
if self._connection is not None:
|
||||
for http_event in self._connection.handle_event(event):
|
||||
self._http.http_event_received(http_event)
|
||||
|
||||
@property
|
||||
def connection(self) -> H3Connection | None:
|
||||
return self._connection
|
||||
@@ -0,0 +1,230 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import cast
|
||||
|
||||
|
||||
try: # websockets >= 11.0
|
||||
from websockets.protocol import State # type: ignore
|
||||
from websockets.server import ServerProtocol # type: ignore
|
||||
except ImportError: # websockets < 11.0
|
||||
from websockets.connection import State
|
||||
from websockets.server import ServerConnection as ServerProtocol
|
||||
|
||||
from websockets import http11
|
||||
from websockets.datastructures import Headers as WSHeaders
|
||||
from websockets.typing import Subprotocol
|
||||
|
||||
from sanic.exceptions import SanicException
|
||||
from sanic.log import access_logger, websockets_logger
|
||||
from sanic.request import Request
|
||||
from sanic.server import HttpProtocol
|
||||
|
||||
from ..websockets.impl import WebsocketImplProtocol
|
||||
|
||||
|
||||
OPEN = State.OPEN
|
||||
CLOSING = State.CLOSING
|
||||
CLOSED = State.CLOSED
|
||||
|
||||
|
||||
class WebSocketProtocol(HttpProtocol):
|
||||
__slots__ = (
|
||||
"websocket",
|
||||
"websocket_timeout",
|
||||
"websocket_max_size",
|
||||
"websocket_ping_interval",
|
||||
"websocket_ping_timeout",
|
||||
"websocket_url",
|
||||
"websocket_peer",
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args,
|
||||
websocket_timeout: float = 10.0,
|
||||
websocket_max_size: int | None = None,
|
||||
websocket_ping_interval: float | None = 20.0,
|
||||
websocket_ping_timeout: float | None = 20.0,
|
||||
**kwargs,
|
||||
):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.websocket: WebsocketImplProtocol | None = None
|
||||
self.websocket_timeout = websocket_timeout
|
||||
self.websocket_max_size = websocket_max_size
|
||||
self.websocket_ping_interval = websocket_ping_interval
|
||||
self.websocket_ping_timeout = websocket_ping_timeout
|
||||
self.websocket_url: str | None = None
|
||||
self.websocket_peer: str | None = None
|
||||
|
||||
def connection_lost(self, exc):
|
||||
if self.websocket is not None:
|
||||
self.websocket.connection_lost(exc)
|
||||
super().connection_lost(exc)
|
||||
self.log_websocket("CLOSE")
|
||||
self.websocket_url = None
|
||||
self.websocket_peer = None
|
||||
|
||||
def data_received(self, data):
|
||||
if self.websocket is not None:
|
||||
self.websocket.data_received(data)
|
||||
else:
|
||||
# Pass it to HttpProtocol handler first
|
||||
# That will (hopefully) upgrade it to a websocket.
|
||||
super().data_received(data)
|
||||
|
||||
def eof_received(self) -> bool | None:
|
||||
if self.websocket is not None:
|
||||
return self.websocket.eof_received()
|
||||
else:
|
||||
return False
|
||||
|
||||
def close(self, timeout: float | None = None):
|
||||
# Called by HttpProtocol at the end of connection_task
|
||||
# If we've upgraded to websocket, we do our own closing
|
||||
if self.websocket is not None:
|
||||
# Note, we don't want to use websocket.close()
|
||||
# That is used for user's application code to send a
|
||||
# websocket close packet. This is different.
|
||||
self.websocket.end_connection(1001)
|
||||
else:
|
||||
super().close()
|
||||
|
||||
def close_if_idle(self):
|
||||
# Called by Sanic Server when shutting down
|
||||
# If we've upgraded to websocket, shut it down
|
||||
if self.websocket is not None:
|
||||
if self.websocket.ws_proto.state in (CLOSING, CLOSED):
|
||||
return True
|
||||
elif self.websocket.loop is not None:
|
||||
self.websocket.loop.create_task(self.websocket.close(1001))
|
||||
else:
|
||||
self.websocket.end_connection(1001)
|
||||
else:
|
||||
return super().close_if_idle()
|
||||
|
||||
@staticmethod
|
||||
def sanic_request_to_ws_request(request: Request):
|
||||
return http11.Request(
|
||||
path=request.path,
|
||||
headers=WSHeaders(request.headers),
|
||||
)
|
||||
|
||||
async def websocket_handshake(
|
||||
self, request, subprotocols: Sequence[str] | None = None
|
||||
):
|
||||
# let the websockets package do the handshake with the client
|
||||
try:
|
||||
if subprotocols is not None:
|
||||
# subprotocols can be a set or frozenset,
|
||||
# but ServerProtocol needs a list
|
||||
subprotocols = cast(
|
||||
Sequence[Subprotocol] | None,
|
||||
list(
|
||||
[
|
||||
Subprotocol(subprotocol)
|
||||
for subprotocol in subprotocols
|
||||
]
|
||||
),
|
||||
)
|
||||
ws_proto = ServerProtocol(
|
||||
max_size=self.websocket_max_size,
|
||||
subprotocols=subprotocols,
|
||||
state=OPEN,
|
||||
logger=websockets_logger,
|
||||
)
|
||||
resp = ws_proto.accept(self.sanic_request_to_ws_request(request))
|
||||
except Exception:
|
||||
msg = (
|
||||
"Failed to open a WebSocket connection.\n"
|
||||
"See server log for more information.\n"
|
||||
)
|
||||
raise SanicException(msg, status_code=500)
|
||||
if 100 <= resp.status_code <= 299:
|
||||
first_line = (
|
||||
f"HTTP/1.1 {resp.status_code} {resp.reason_phrase}\r\n"
|
||||
).encode()
|
||||
rbody = bytearray(first_line)
|
||||
rbody += (
|
||||
"".join([f"{k}: {v}\r\n" for k, v in resp.headers.items()])
|
||||
).encode()
|
||||
rbody += b"\r\n"
|
||||
if resp.body:
|
||||
rbody += resp.body
|
||||
rbody += b"\r\n\r\n"
|
||||
await super().send(rbody)
|
||||
else:
|
||||
raise SanicException(resp.body, resp.status_code)
|
||||
self.websocket = WebsocketImplProtocol(
|
||||
ws_proto,
|
||||
ping_interval=self.websocket_ping_interval,
|
||||
ping_timeout=self.websocket_ping_timeout,
|
||||
close_timeout=self.websocket_timeout,
|
||||
)
|
||||
loop = (
|
||||
request.transport.loop
|
||||
if hasattr(request, "transport")
|
||||
and hasattr(request.transport, "loop")
|
||||
else None
|
||||
)
|
||||
await self.websocket.connection_made(self, loop=loop)
|
||||
self.websocket_url = self._http.request.url
|
||||
self.websocket_peer = f"{id(self):X}"[-5:-1] + "unx"
|
||||
if ip := self._http.request.client_ip:
|
||||
self.websocket_peer = f"{ip}:{self._http.request.port}"
|
||||
self.log_websocket("OPEN")
|
||||
return self.websocket
|
||||
|
||||
def log_websocket(self, message):
|
||||
if not self.access_log or not self.websocket_url:
|
||||
return
|
||||
status = ""
|
||||
close = ""
|
||||
try:
|
||||
# Can we get some useful statistics?
|
||||
p = self.websocket.ws_proto
|
||||
state = p.state
|
||||
if state == CLOSED:
|
||||
codes = {
|
||||
1000: "NORMAL",
|
||||
1001: "GOING AWAY",
|
||||
1005: "NO STATUS",
|
||||
1006: "ABNORMAL",
|
||||
1011: "SERVER ERR",
|
||||
}
|
||||
if p.close_code == 1006:
|
||||
message = "CLOSE_ABN"
|
||||
scode = rcode = 1006 # Abnormal closure (disconnection)
|
||||
sdesc = rdesc = ""
|
||||
if p.close_sent:
|
||||
scode = p.close_sent.code
|
||||
sdesc = p.close_sent.reason
|
||||
if p.close_rcvd:
|
||||
rcode = p.close_rcvd.code
|
||||
rdesc = p.close_rcvd.reason
|
||||
# Use repr() to escape any control characters
|
||||
sdesc = repr(sdesc[:256]) if sdesc else codes.get(scode, "")
|
||||
rdesc = repr(rdesc[:256]) if rdesc else codes.get(rcode, "")
|
||||
if p.close_rcvd_then_sent or scode == 1006:
|
||||
status = rcode
|
||||
close = (
|
||||
f"{rdesc} from client"
|
||||
if scode in (rcode, 1006)
|
||||
else f"{rdesc} ▼▲ {scode} {sdesc}"
|
||||
)
|
||||
else:
|
||||
status = scode
|
||||
close = (
|
||||
f"{sdesc} from server"
|
||||
if rcode in (scode, 1006)
|
||||
else f"{sdesc} ▲▼ {rcode} {rdesc}"
|
||||
)
|
||||
|
||||
except AttributeError:
|
||||
...
|
||||
extra = {
|
||||
"status": status,
|
||||
"byte": close,
|
||||
"host": self.websocket_peer,
|
||||
"request": f" 🔌 {self.websocket_url}",
|
||||
"duration": "",
|
||||
}
|
||||
access_logger.info(message, extra=extra)
|
||||
@@ -0,0 +1,404 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ssl import SSLContext
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sanic.config import Config
|
||||
from sanic.exceptions import ServerError
|
||||
from sanic.http.constants import HTTP
|
||||
from sanic.http.tls import get_ssl_context
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from sanic.app import Sanic
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import socket
|
||||
|
||||
from functools import partial
|
||||
from signal import SIG_IGN, SIGINT, SIGTERM
|
||||
from signal import signal as signal_func
|
||||
|
||||
from sanic.application.ext import setup_ext
|
||||
from sanic.compat import OS_IS_WINDOWS, ctrlc_workaround_for_windows
|
||||
from sanic.http.http3 import SessionTicketStore, get_config
|
||||
from sanic.log import error_logger, server_logger
|
||||
from sanic.logging.setup import setup_logging
|
||||
from sanic.models.server_types import Signal
|
||||
from sanic.server.async_server import AsyncioServer
|
||||
from sanic.server.protocols.http_protocol import Http3Protocol, HttpProtocol
|
||||
from sanic.server.socket import bind_unix_socket, remove_unix_socket
|
||||
|
||||
|
||||
try:
|
||||
from aioquic.asyncio import serve as quic_serve
|
||||
|
||||
HTTP3_AVAILABLE = True
|
||||
except ModuleNotFoundError: # no cov
|
||||
HTTP3_AVAILABLE = False
|
||||
|
||||
|
||||
def serve(
|
||||
host,
|
||||
port,
|
||||
app: Sanic,
|
||||
ssl: SSLContext | None = None,
|
||||
sock: socket.socket | None = None,
|
||||
unix: str | None = None,
|
||||
reuse_port: bool = False,
|
||||
loop=None,
|
||||
protocol: type[asyncio.Protocol] = HttpProtocol,
|
||||
backlog: int = 100,
|
||||
register_sys_signals: bool = True,
|
||||
run_multiple: bool = False,
|
||||
run_async: bool = False,
|
||||
connections=None,
|
||||
signal=Signal(),
|
||||
state=None,
|
||||
asyncio_server_kwargs=None,
|
||||
version=HTTP.VERSION_1,
|
||||
):
|
||||
"""Start asynchronous HTTP Server on an individual process.
|
||||
|
||||
:param host: Address to host on
|
||||
:param port: Port to host on
|
||||
:param before_start: function to be executed before the server starts
|
||||
listening. Takes arguments `app` instance and `loop`
|
||||
:param after_start: function to be executed after the server starts
|
||||
listening. Takes arguments `app` instance and `loop`
|
||||
:param before_stop: function to be executed when a stop signal is
|
||||
received before it is respected. Takes arguments
|
||||
`app` instance and `loop`
|
||||
:param after_stop: function to be executed when a stop signal is
|
||||
received after it is respected. Takes arguments
|
||||
`app` instance and `loop`
|
||||
:param ssl: SSLContext
|
||||
:param sock: Socket for the server to accept connections from
|
||||
:param unix: Unix socket to listen on instead of TCP port
|
||||
:param reuse_port: `True` for multiple workers
|
||||
:param loop: asyncio compatible event loop
|
||||
:param run_async: bool: Do not create a new event loop for the server,
|
||||
and return an AsyncServer object rather than running it
|
||||
:param asyncio_server_kwargs: key-value args for asyncio/uvloop
|
||||
create_server method
|
||||
:return: Nothing
|
||||
|
||||
Args:
|
||||
host (str): Address to host on
|
||||
port (int): Port to host on
|
||||
app (Sanic): Sanic app instance
|
||||
ssl (Optional[SSLContext], optional): SSLContext. Defaults to `None`.
|
||||
sock (Optional[socket.socket], optional): Socket for the server to
|
||||
accept connections from. Defaults to `None`.
|
||||
unix (Optional[str], optional): Unix socket to listen on instead of
|
||||
TCP port. Defaults to `None`.
|
||||
reuse_port (bool, optional): `True` for multiple workers. Defaults
|
||||
to `False`.
|
||||
loop: asyncio compatible event loop. Defaults
|
||||
to `None`.
|
||||
protocol (Type[asyncio.Protocol], optional): Protocol to use. Defaults
|
||||
to `HttpProtocol`.
|
||||
backlog (int, optional): The maximum number of queued connections
|
||||
passed to socket.listen(). Defaults to `100`.
|
||||
register_sys_signals (bool, optional): Register SIGINT and SIGTERM.
|
||||
Defaults to `True`.
|
||||
run_multiple (bool, optional): Run multiple workers. Defaults
|
||||
to `False`.
|
||||
run_async (bool, optional): Return an AsyncServer object.
|
||||
Defaults to `False`.
|
||||
connections: Connections. Defaults to `None`.
|
||||
signal (Signal, optional): Signal. Defaults to `Signal()`.
|
||||
state: State. Defaults to `None`.
|
||||
asyncio_server_kwargs (Optional[Dict[str, Union[int, float]]], optional):
|
||||
key-value args for asyncio/uvloop create_server method. Defaults
|
||||
to `None`.
|
||||
version (str, optional): HTTP version. Defaults to `HTTP.VERSION_1`.
|
||||
|
||||
Raises:
|
||||
ServerError: Cannot run HTTP/3 server without aioquic installed.
|
||||
|
||||
Returns:
|
||||
AsyncioServer: AsyncioServer object if `run_async` is `True`.
|
||||
""" # noqa: E501
|
||||
if not run_async and not loop:
|
||||
# create new event_loop after fork
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
setup_logging(app.debug, app.config.NO_COLOR, app.config.LOG_EXTRA)
|
||||
|
||||
if app.debug:
|
||||
loop.set_debug(app.debug)
|
||||
|
||||
app.asgi = False
|
||||
|
||||
if version is HTTP.VERSION_3:
|
||||
return _serve_http_3(host, port, app, loop, ssl)
|
||||
return _serve_http_1(
|
||||
host,
|
||||
port,
|
||||
app,
|
||||
ssl,
|
||||
sock,
|
||||
unix,
|
||||
reuse_port,
|
||||
loop,
|
||||
protocol,
|
||||
backlog,
|
||||
register_sys_signals,
|
||||
run_multiple,
|
||||
run_async,
|
||||
connections,
|
||||
signal,
|
||||
state,
|
||||
asyncio_server_kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _setup_system_signals(
|
||||
app: Sanic,
|
||||
run_multiple: bool,
|
||||
register_sys_signals: bool,
|
||||
loop: asyncio.AbstractEventLoop,
|
||||
) -> None: # no cov
|
||||
signal_func(SIGINT, SIG_IGN)
|
||||
signal_func(SIGTERM, SIG_IGN)
|
||||
os.environ["SANIC_WORKER_PROCESS"] = "true"
|
||||
# Register signals for graceful termination
|
||||
if register_sys_signals:
|
||||
if OS_IS_WINDOWS:
|
||||
ctrlc_workaround_for_windows(app)
|
||||
else:
|
||||
for _signal in [SIGINT, SIGTERM]:
|
||||
loop.add_signal_handler(
|
||||
_signal, partial(app.stop, terminate=False)
|
||||
)
|
||||
|
||||
|
||||
def _run_shutdown_coro(loop, coro):
|
||||
"""Run a shutdown coroutine, handling the case where the loop was stopped.
|
||||
|
||||
When loop.stop() is called during run_forever(), asyncio sets an internal
|
||||
flag that causes the first run_until_complete() to fail. For standard
|
||||
asyncio, we clear the _stopping flag. For uvloop (which doesn't expose
|
||||
this flag), the first attempt may fail but subsequent attempts succeed.
|
||||
"""
|
||||
# Clear asyncio's stopped state if accessible
|
||||
if hasattr(loop, "_stopping"):
|
||||
loop._stopping = False
|
||||
|
||||
try:
|
||||
loop.run_until_complete(coro())
|
||||
except (RuntimeError, KeyboardInterrupt):
|
||||
# RuntimeError: loop was stopped (uvloop behavior)
|
||||
# KeyboardInterrupt: signal arrived during select (asyncio behavior)
|
||||
# Try once more - this handles uvloop's behavior where the first
|
||||
# run_until_complete after stop() fails but subsequent calls succeed.
|
||||
if hasattr(loop, "_stopping"):
|
||||
loop._stopping = False
|
||||
try:
|
||||
loop.run_until_complete(coro())
|
||||
except (RuntimeError, KeyboardInterrupt):
|
||||
# If it still fails, the loop is truly unusable
|
||||
pass
|
||||
|
||||
|
||||
def _run_server_forever(loop, before_stop, after_stop, cleanup, unix, pid):
|
||||
try:
|
||||
server_logger.info("Worker ready [%s]", pid)
|
||||
loop.run_forever()
|
||||
finally:
|
||||
server_logger.info("Stopping worker [%s]", pid)
|
||||
|
||||
for _signal in [SIGINT, SIGTERM]:
|
||||
try:
|
||||
loop.remove_signal_handler(_signal)
|
||||
except (NotImplementedError, OSError):
|
||||
pass
|
||||
|
||||
_run_shutdown_coro(loop, before_stop)
|
||||
|
||||
if cleanup:
|
||||
cleanup()
|
||||
|
||||
_run_shutdown_coro(loop, after_stop)
|
||||
|
||||
remove_unix_socket(unix)
|
||||
loop.close()
|
||||
server_logger.info("Worker complete [%s]", pid)
|
||||
|
||||
|
||||
def _serve_http_1(
|
||||
host,
|
||||
port,
|
||||
app,
|
||||
ssl,
|
||||
sock,
|
||||
unix,
|
||||
reuse_port,
|
||||
loop,
|
||||
protocol,
|
||||
backlog,
|
||||
register_sys_signals,
|
||||
run_multiple,
|
||||
run_async,
|
||||
connections,
|
||||
signal,
|
||||
state,
|
||||
asyncio_server_kwargs,
|
||||
):
|
||||
connections = connections if connections is not None else set()
|
||||
protocol_kwargs = _build_protocol_kwargs(protocol, app.config)
|
||||
server = partial(
|
||||
protocol,
|
||||
loop=loop,
|
||||
connections=connections,
|
||||
signal=signal,
|
||||
app=app,
|
||||
state=state,
|
||||
unix=unix,
|
||||
**protocol_kwargs,
|
||||
)
|
||||
asyncio_server_kwargs = (
|
||||
asyncio_server_kwargs if asyncio_server_kwargs else {}
|
||||
)
|
||||
if OS_IS_WINDOWS and sock:
|
||||
pid = os.getpid()
|
||||
sock = sock.share(pid)
|
||||
sock = socket.fromshare(sock)
|
||||
# UNIX sockets are always bound by us (to preserve semantics between modes)
|
||||
elif unix:
|
||||
sock = bind_unix_socket(unix, backlog=backlog)
|
||||
server_coroutine = loop.create_server(
|
||||
server,
|
||||
None if sock else host,
|
||||
None if sock else port,
|
||||
ssl=ssl,
|
||||
reuse_port=reuse_port,
|
||||
sock=sock,
|
||||
backlog=backlog,
|
||||
**asyncio_server_kwargs,
|
||||
)
|
||||
|
||||
setup_ext(app)
|
||||
if run_async:
|
||||
return AsyncioServer(
|
||||
app=app,
|
||||
loop=loop,
|
||||
serve_coro=server_coroutine,
|
||||
connections=connections,
|
||||
)
|
||||
|
||||
pid = os.getpid()
|
||||
server_logger.info("Starting worker [%s]", pid)
|
||||
loop.run_until_complete(app._startup())
|
||||
loop.run_until_complete(app._server_event("init", "before"))
|
||||
app.ack()
|
||||
|
||||
try:
|
||||
http_server = loop.run_until_complete(server_coroutine)
|
||||
except BaseException:
|
||||
error_logger.exception("Unable to start server", exc_info=True)
|
||||
return
|
||||
|
||||
def _cleanup():
|
||||
# Wait for event loop to finish and all connections to drain
|
||||
http_server.close()
|
||||
loop.run_until_complete(http_server.wait_closed())
|
||||
|
||||
# Complete all tasks on the loop
|
||||
signal.stopped = True
|
||||
for connection in connections:
|
||||
connection.close_if_idle()
|
||||
|
||||
# Gracefully shutdown timeout.
|
||||
# We should provide graceful_shutdown_timeout,
|
||||
# instead of letting connection hangs forever.
|
||||
# Let's roughly calcucate time.
|
||||
graceful = app.config.GRACEFUL_SHUTDOWN_TIMEOUT
|
||||
start_shutdown: float = 0
|
||||
while connections and (start_shutdown < graceful):
|
||||
loop.run_until_complete(asyncio.sleep(0.1))
|
||||
start_shutdown = start_shutdown + 0.1
|
||||
|
||||
app.shutdown_tasks(graceful - start_shutdown)
|
||||
|
||||
# Force close non-idle connection after waiting for
|
||||
# graceful_shutdown_timeout
|
||||
for conn in connections:
|
||||
if hasattr(conn, "websocket") and conn.websocket:
|
||||
conn.websocket.fail_connection(code=1001)
|
||||
else:
|
||||
conn.abort()
|
||||
|
||||
try:
|
||||
app.set_serving(False)
|
||||
except (BrokenPipeError, ConnectionResetError, EOFError):
|
||||
pass
|
||||
|
||||
_setup_system_signals(app, run_multiple, register_sys_signals, loop)
|
||||
loop.run_until_complete(app._server_event("init", "after"))
|
||||
app.set_serving(True)
|
||||
_run_server_forever(
|
||||
loop,
|
||||
partial(app._server_event, "shutdown", "before"),
|
||||
partial(app._server_event, "shutdown", "after"),
|
||||
_cleanup,
|
||||
unix,
|
||||
pid,
|
||||
)
|
||||
|
||||
|
||||
def _serve_http_3(
|
||||
host,
|
||||
port,
|
||||
app,
|
||||
loop,
|
||||
ssl,
|
||||
register_sys_signals: bool = True,
|
||||
run_multiple: bool = False,
|
||||
):
|
||||
if not HTTP3_AVAILABLE:
|
||||
raise ServerError(
|
||||
"Cannot run HTTP/3 server without aioquic installed. "
|
||||
)
|
||||
pid = os.getpid()
|
||||
server_logger.info("Starting worker [%s]", pid)
|
||||
protocol = partial(Http3Protocol, app=app)
|
||||
ticket_store = SessionTicketStore()
|
||||
ssl_context = get_ssl_context(app, ssl)
|
||||
config = get_config(app, ssl_context)
|
||||
coro = quic_serve(
|
||||
host,
|
||||
port,
|
||||
configuration=config,
|
||||
create_protocol=protocol,
|
||||
session_ticket_fetcher=ticket_store.pop,
|
||||
session_ticket_handler=ticket_store.add,
|
||||
)
|
||||
server = AsyncioServer(app, loop, coro, [])
|
||||
loop.run_until_complete(server.startup())
|
||||
loop.run_until_complete(server.before_start())
|
||||
app.ack()
|
||||
loop.run_until_complete(server)
|
||||
_setup_system_signals(app, run_multiple, register_sys_signals, loop)
|
||||
loop.run_until_complete(server.after_start())
|
||||
|
||||
# TODO: Create connection cleanup and graceful shutdown
|
||||
cleanup = None
|
||||
_run_server_forever(
|
||||
loop, server.before_stop, server.after_stop, cleanup, None, pid
|
||||
)
|
||||
|
||||
|
||||
def _build_protocol_kwargs(
|
||||
protocol: type[asyncio.Protocol], config: Config
|
||||
) -> dict[str, int | float]:
|
||||
if hasattr(protocol, "websocket_handshake"):
|
||||
return {
|
||||
"websocket_max_size": config.WEBSOCKET_MAX_SIZE,
|
||||
"websocket_ping_timeout": config.WEBSOCKET_PING_TIMEOUT,
|
||||
"websocket_ping_interval": config.WEBSOCKET_PING_INTERVAL,
|
||||
}
|
||||
return {}
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
|
||||
from ipaddress import ip_address
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sanic.http.constants import HTTP
|
||||
|
||||
|
||||
def bind_socket(host: str, port: int, *, backlog=100) -> socket.socket:
|
||||
"""Create TCP server socket.
|
||||
:param host: IPv4, IPv6 or hostname may be specified
|
||||
:param port: TCP port number
|
||||
:param backlog: Maximum number of connections to queue
|
||||
:return: socket.socket object
|
||||
"""
|
||||
location = (host, port)
|
||||
# socket.share, socket.fromshare
|
||||
try: # IP address: family must be specified for IPv6 at least
|
||||
ip = ip_address(host)
|
||||
host = str(ip)
|
||||
sock = socket.socket(
|
||||
socket.AF_INET6 if ip.version == 6 else socket.AF_INET
|
||||
)
|
||||
except ValueError: # Hostname, may become AF_INET or AF_INET6
|
||||
sock = socket.socket()
|
||||
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||
sock.bind(location)
|
||||
sock.listen(backlog)
|
||||
sock.set_inheritable(True)
|
||||
return sock
|
||||
|
||||
|
||||
def bind_unix_socket(
|
||||
path: Path | str, *, mode=0o666, backlog=100
|
||||
) -> socket.socket:
|
||||
"""Create unix socket.
|
||||
:param path: filesystem path
|
||||
:param backlog: Maximum number of connections to queue
|
||||
:return: socket.socket object
|
||||
"""
|
||||
|
||||
# Sanitise and pre-verify socket path
|
||||
path = Path(path)
|
||||
folder = path.parent
|
||||
if not folder.is_dir():
|
||||
raise FileNotFoundError(f"Socket folder does not exist: {folder}")
|
||||
try:
|
||||
if not stat.S_ISSOCK(path.lstat().st_mode):
|
||||
raise FileExistsError(f"Existing file is not a socket: {path}")
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
# Create new socket with a random temporary name
|
||||
tmp_path = path.with_name(f"{path.name}.{secrets.token_urlsafe()}")
|
||||
sock = socket.socket(socket.AF_UNIX)
|
||||
try:
|
||||
# Critical section begins (filename races)
|
||||
sock.bind(tmp_path.as_posix())
|
||||
try:
|
||||
tmp_path.chmod(mode)
|
||||
# Start listening before rename to avoid connection failures
|
||||
sock.listen(backlog)
|
||||
tmp_path.rename(path)
|
||||
except: # noqa: E722
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
finally:
|
||||
raise
|
||||
except: # noqa: E722
|
||||
try:
|
||||
sock.close()
|
||||
finally:
|
||||
raise
|
||||
return sock
|
||||
|
||||
|
||||
def remove_unix_socket(path: Path | str | None) -> None:
|
||||
"""Remove dead unix socket during server exit."""
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
path = Path(path)
|
||||
if stat.S_ISSOCK(path.lstat().st_mode):
|
||||
# Is it actually dead (doesn't belong to a new server instance)?
|
||||
with socket.socket(socket.AF_UNIX) as testsock:
|
||||
try:
|
||||
testsock.connect(path.as_posix())
|
||||
except ConnectionRefusedError:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def configure_socket(
|
||||
server_settings: dict[str, Any],
|
||||
) -> socket.SocketType | None:
|
||||
# Create a listening socket or use the one in settings
|
||||
if server_settings.get("version") is HTTP.VERSION_3:
|
||||
return None
|
||||
sock = server_settings.get("sock")
|
||||
unix = server_settings["unix"]
|
||||
backlog = server_settings["backlog"]
|
||||
if unix:
|
||||
unix = Path(unix).absolute()
|
||||
sock = bind_unix_socket(unix, backlog=backlog)
|
||||
server_settings["unix"] = unix
|
||||
if sock is None:
|
||||
sock = bind_socket(
|
||||
server_settings["host"],
|
||||
server_settings["port"],
|
||||
backlog=backlog,
|
||||
)
|
||||
sock.set_inheritable(True)
|
||||
server_settings["sock"] = sock
|
||||
server_settings["host"] = None
|
||||
server_settings["port"] = None
|
||||
return sock
|
||||
@@ -0,0 +1,82 @@
|
||||
from collections.abc import Awaitable, MutableMapping
|
||||
from typing import Any, Callable
|
||||
|
||||
from sanic.exceptions import InvalidUsage
|
||||
|
||||
|
||||
ASGIMessage = MutableMapping[str, Any]
|
||||
|
||||
|
||||
class WebSocketConnection:
|
||||
"""
|
||||
This is for ASGI Connections.
|
||||
It provides an interface similar to WebsocketProtocol, but
|
||||
sends/receives over an ASGI connection.
|
||||
"""
|
||||
|
||||
# TODO
|
||||
# - Implement ping/pong
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
send: Callable[[ASGIMessage], Awaitable[None]],
|
||||
receive: Callable[[], Awaitable[ASGIMessage]],
|
||||
subprotocols: list[str] | None = None,
|
||||
) -> None:
|
||||
self._send = send
|
||||
self._receive = receive
|
||||
self._subprotocols = subprotocols or []
|
||||
|
||||
async def send(self, data: str | bytes, *args, **kwargs) -> None:
|
||||
message: dict[str, str | bytes] = {"type": "websocket.send"}
|
||||
|
||||
if isinstance(data, bytes):
|
||||
message.update({"bytes": data})
|
||||
else:
|
||||
message.update({"text": str(data)})
|
||||
|
||||
await self._send(message)
|
||||
|
||||
async def recv(self, *args, **kwargs) -> str | bytes | None:
|
||||
message = await self._receive()
|
||||
|
||||
if message["type"] == "websocket.receive":
|
||||
try:
|
||||
return message["text"]
|
||||
except KeyError:
|
||||
try:
|
||||
return message["bytes"]
|
||||
except KeyError:
|
||||
raise InvalidUsage("Bad ASGI message received")
|
||||
elif message["type"] == "websocket.disconnect":
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
receive = recv
|
||||
|
||||
async def accept(self, subprotocols: list[str] | None = None) -> None:
|
||||
subprotocol = None
|
||||
if subprotocols:
|
||||
for subp in subprotocols:
|
||||
if subp in self.subprotocols:
|
||||
subprotocol = subp
|
||||
break
|
||||
|
||||
await self._send(
|
||||
{
|
||||
"type": "websocket.accept",
|
||||
"subprotocol": subprotocol,
|
||||
}
|
||||
)
|
||||
|
||||
async def close(self, code: int = 1000, reason: str = "") -> None:
|
||||
pass
|
||||
|
||||
@property
|
||||
def subprotocols(self):
|
||||
return self._subprotocols
|
||||
|
||||
@subprotocols.setter
|
||||
def subprotocols(self, subprotocols: list[str] | None = None):
|
||||
self._subprotocols = subprotocols or []
|
||||
@@ -0,0 +1,293 @@
|
||||
import asyncio
|
||||
import codecs
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from websockets.frames import Frame, Opcode
|
||||
from websockets.typing import Data
|
||||
|
||||
from sanic.exceptions import ServerError
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .impl import WebsocketImplProtocol
|
||||
|
||||
UTF8Decoder = codecs.getincrementaldecoder("utf-8")
|
||||
|
||||
|
||||
class WebsocketFrameAssembler:
|
||||
"""
|
||||
Assemble a message from frames.
|
||||
Code borrowed from aaugustin/websockets project:
|
||||
https://github.com/aaugustin/websockets/blob/6eb98dd8fa5b2c896b9f6be7e8d117708da82a39/src/websockets/sync/messages.py
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"protocol",
|
||||
"read_mutex",
|
||||
"write_mutex",
|
||||
"message_complete",
|
||||
"message_fetched",
|
||||
"get_in_progress",
|
||||
"decoder",
|
||||
"completed_queue",
|
||||
"chunks",
|
||||
"chunks_queue",
|
||||
"paused",
|
||||
"get_id",
|
||||
"put_id",
|
||||
)
|
||||
if TYPE_CHECKING:
|
||||
protocol: "WebsocketImplProtocol"
|
||||
read_mutex: asyncio.Lock
|
||||
write_mutex: asyncio.Lock
|
||||
message_complete: asyncio.Event
|
||||
message_fetched: asyncio.Event
|
||||
completed_queue: asyncio.Queue
|
||||
get_in_progress: bool
|
||||
decoder: codecs.IncrementalDecoder | None
|
||||
# For streaming chunks rather than messages:
|
||||
chunks: list[Data]
|
||||
chunks_queue: asyncio.Queue[Data | None] | None
|
||||
paused: bool
|
||||
|
||||
def __init__(self, protocol) -> None:
|
||||
self.protocol = protocol
|
||||
|
||||
self.read_mutex = asyncio.Lock()
|
||||
self.write_mutex = asyncio.Lock()
|
||||
|
||||
self.completed_queue = asyncio.Queue(maxsize=1) # type: asyncio.Queue[Data]
|
||||
|
||||
# put() sets this event to tell get() that a message can be fetched.
|
||||
self.message_complete = asyncio.Event()
|
||||
# get() sets this event to let put()
|
||||
self.message_fetched = asyncio.Event()
|
||||
|
||||
# This flag prevents concurrent calls to get() by user code.
|
||||
self.get_in_progress = False
|
||||
|
||||
# Decoder for text frames, None for binary frames.
|
||||
self.decoder = None
|
||||
|
||||
# Buffer data from frames belonging to the same message.
|
||||
self.chunks = []
|
||||
|
||||
# When switching from "buffering" to "streaming", we use a thread-safe
|
||||
# queue for transferring frames from the writing thread (library code)
|
||||
# to the reading thread (user code). We're buffering when chunks_queue
|
||||
# is None and streaming when it's a Queue. None is a sentinel
|
||||
# value marking the end of the stream, superseding message_complete.
|
||||
|
||||
# Stream data from frames belonging to the same message.
|
||||
self.chunks_queue = None
|
||||
|
||||
# Flag to indicate we've paused the protocol
|
||||
self.paused = False
|
||||
|
||||
async def get(self, timeout: float | None = None) -> Data | None:
|
||||
"""
|
||||
Read the next message.
|
||||
:meth:`get` returns a single :class:`str` or :class:`bytes`.
|
||||
If the :message was fragmented, :meth:`get` waits until the last frame
|
||||
is received, then it reassembles the message.
|
||||
If ``timeout`` is set and elapses before a complete message is
|
||||
received, :meth:`get` returns ``None``.
|
||||
"""
|
||||
completed: bool
|
||||
async with self.read_mutex:
|
||||
if timeout is not None and timeout <= 0:
|
||||
if not self.message_complete.is_set():
|
||||
return None
|
||||
if self.get_in_progress:
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is only here as a failsafe
|
||||
raise ServerError(
|
||||
"Called get() on Websocket frame assembler "
|
||||
"while asynchronous get is already in progress."
|
||||
)
|
||||
self.get_in_progress = True
|
||||
|
||||
# If the message_complete event isn't set yet, release the lock to
|
||||
# allow put() to run and eventually set it.
|
||||
# Locking with get_in_progress ensures only one task can get here.
|
||||
if timeout is None:
|
||||
completed = await self.message_complete.wait()
|
||||
elif timeout <= 0:
|
||||
completed = self.message_complete.is_set()
|
||||
else:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
self.message_complete.wait(), timeout=timeout
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
...
|
||||
finally:
|
||||
completed = self.message_complete.is_set()
|
||||
|
||||
# Unpause the transport, if its paused
|
||||
if self.paused:
|
||||
self.protocol.resume_frames()
|
||||
self.paused = False
|
||||
if not self.get_in_progress: # no cov
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is here as a failsafe
|
||||
raise ServerError(
|
||||
"State of Websocket frame assembler was modified while an "
|
||||
"asynchronous get was in progress."
|
||||
)
|
||||
self.get_in_progress = False
|
||||
|
||||
# Waiting for a complete message timed out.
|
||||
if not completed:
|
||||
return None
|
||||
if not self.message_complete.is_set():
|
||||
return None
|
||||
|
||||
self.message_complete.clear()
|
||||
|
||||
joiner: Data = b"" if self.decoder is None else ""
|
||||
# mypy cannot figure out that chunks have the proper type.
|
||||
message: Data = joiner.join(self.chunks) # type: ignore
|
||||
if self.message_fetched.is_set():
|
||||
# This should be guarded against with the read_mutex,
|
||||
# and get_in_progress check, this exception is here
|
||||
# as a failsafe
|
||||
raise ServerError(
|
||||
"Websocket get() found a message when "
|
||||
"state was already fetched."
|
||||
)
|
||||
self.message_fetched.set()
|
||||
self.chunks = []
|
||||
# this should already be None, but set it here for safety
|
||||
self.chunks_queue = None
|
||||
return message
|
||||
|
||||
async def get_iter(self) -> AsyncIterator[Data]:
|
||||
"""
|
||||
Stream the next message.
|
||||
Iterating the return value of :meth:`get_iter` yields a :class:`str`
|
||||
or :class:`bytes` for each frame in the message.
|
||||
"""
|
||||
async with self.read_mutex:
|
||||
if self.get_in_progress:
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is only here as a failsafe
|
||||
raise ServerError(
|
||||
"Called get_iter on Websocket frame assembler "
|
||||
"while asynchronous get is already in progress."
|
||||
)
|
||||
self.get_in_progress = True
|
||||
|
||||
chunks = self.chunks
|
||||
self.chunks = []
|
||||
self.chunks_queue = asyncio.Queue()
|
||||
|
||||
# Sending None in chunk_queue supersedes setting message_complete
|
||||
# when switching to "streaming". If message is already complete
|
||||
# when the switch happens, put() didn't send None, so we have to.
|
||||
if self.message_complete.is_set():
|
||||
await self.chunks_queue.put(None)
|
||||
|
||||
# Locking with get_in_progress ensures only one task can get here
|
||||
for c in chunks:
|
||||
yield c
|
||||
while True:
|
||||
chunk = await self.chunks_queue.get()
|
||||
if chunk is None:
|
||||
break
|
||||
yield chunk
|
||||
|
||||
# Unpause the transport, if its paused
|
||||
if self.paused:
|
||||
self.protocol.resume_frames()
|
||||
self.paused = False
|
||||
if not self.get_in_progress: # no cov
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is here as a failsafe
|
||||
raise ServerError(
|
||||
"State of Websocket frame assembler was modified while an "
|
||||
"asynchronous get was in progress."
|
||||
)
|
||||
self.get_in_progress = False
|
||||
if not self.message_complete.is_set(): # no cov
|
||||
# This should be guarded against with the read_mutex,
|
||||
# exception is here as a failsafe
|
||||
raise ServerError(
|
||||
"Websocket frame assembler chunks queue ended before "
|
||||
"message was complete."
|
||||
)
|
||||
self.message_complete.clear()
|
||||
if self.message_fetched.is_set(): # no cov
|
||||
# This should be guarded against with the read_mutex,
|
||||
# and get_in_progress check, this exception is
|
||||
# here as a failsafe
|
||||
raise ServerError(
|
||||
"Websocket get_iter() found a message when state was "
|
||||
"already fetched."
|
||||
)
|
||||
|
||||
self.message_fetched.set()
|
||||
# this should already be empty, but set it here for safety
|
||||
self.chunks = []
|
||||
self.chunks_queue = None
|
||||
|
||||
async def put(self, frame: Frame) -> None:
|
||||
"""
|
||||
Add ``frame`` to the next message.
|
||||
When ``frame`` is the final frame in a message, :meth:`put` waits
|
||||
until the message is fetched, either by calling :meth:`get` or by
|
||||
iterating the return value of :meth:`get_iter`.
|
||||
:meth:`put` assumes that the stream of frames respects the protocol.
|
||||
If it doesn't, the behavior is undefined.
|
||||
"""
|
||||
|
||||
async with self.write_mutex:
|
||||
if frame.opcode is Opcode.TEXT:
|
||||
self.decoder = UTF8Decoder(errors="strict")
|
||||
elif frame.opcode is Opcode.BINARY:
|
||||
self.decoder = None
|
||||
elif frame.opcode is Opcode.CONT:
|
||||
pass
|
||||
else:
|
||||
# Ignore control frames.
|
||||
return
|
||||
data: Data
|
||||
if self.decoder is not None:
|
||||
data = self.decoder.decode(frame.data, frame.fin)
|
||||
else:
|
||||
data = frame.data
|
||||
if self.chunks_queue is None:
|
||||
self.chunks.append(data)
|
||||
else:
|
||||
await self.chunks_queue.put(data)
|
||||
|
||||
if not frame.fin:
|
||||
return
|
||||
if not self.get_in_progress:
|
||||
# nobody is waiting for this frame, so try to pause subsequent
|
||||
# frames at the protocol level
|
||||
self.paused = self.protocol.pause_frames()
|
||||
# Message is complete. Wait until it's fetched to return.
|
||||
|
||||
if self.chunks_queue is not None:
|
||||
await self.chunks_queue.put(None)
|
||||
if self.message_complete.is_set():
|
||||
# This should be guarded against with the write_mutex
|
||||
raise ServerError(
|
||||
"Websocket put() got a new message when a message was "
|
||||
"already in its chamber."
|
||||
)
|
||||
self.message_complete.set() # Signal to get() it can serve the
|
||||
if self.message_fetched.is_set():
|
||||
# This should be guarded against with the write_mutex
|
||||
raise ServerError(
|
||||
"Websocket put() got a new message when the previous "
|
||||
"message was not yet fetched."
|
||||
)
|
||||
|
||||
# Allow get() to run and eventually set the event.
|
||||
await self.message_fetched.wait()
|
||||
self.message_fetched.clear()
|
||||
self.decoder = None
|
||||
@@ -0,0 +1,878 @@
|
||||
import asyncio
|
||||
import secrets
|
||||
|
||||
from collections.abc import AsyncIterator, Iterable, Mapping, Sequence
|
||||
|
||||
from websockets.exceptions import (
|
||||
ConnectionClosed,
|
||||
ConnectionClosedError,
|
||||
ConnectionClosedOK,
|
||||
)
|
||||
from websockets.frames import Frame, Opcode
|
||||
|
||||
|
||||
try: # websockets >= 11.0
|
||||
from websockets.protocol import Event, State # type: ignore
|
||||
from websockets.server import ServerProtocol # type: ignore
|
||||
except ImportError: # websockets < 11.0
|
||||
from websockets.connection import Event, State # type: ignore
|
||||
from websockets.server import ServerConnection as ServerProtocol
|
||||
|
||||
from websockets.typing import Data
|
||||
|
||||
from sanic.log import websockets_logger
|
||||
from sanic.server.protocols.base_protocol import SanicProtocol
|
||||
|
||||
from ...exceptions import ServerError, WebsocketClosed
|
||||
from .frame import WebsocketFrameAssembler
|
||||
|
||||
|
||||
OPEN = State.OPEN
|
||||
CLOSING = State.CLOSING
|
||||
CLOSED = State.CLOSED
|
||||
|
||||
|
||||
class WebsocketImplProtocol:
|
||||
ws_proto: ServerProtocol
|
||||
io_proto: SanicProtocol | None
|
||||
loop: asyncio.AbstractEventLoop | None
|
||||
max_queue: int
|
||||
close_timeout: float
|
||||
ping_interval: float | None
|
||||
ping_timeout: float | None
|
||||
assembler: WebsocketFrameAssembler
|
||||
# dict[bytes, asyncio.Future[None]]
|
||||
pings: dict[bytes, asyncio.Future]
|
||||
conn_mutex: asyncio.Lock
|
||||
recv_lock: asyncio.Lock
|
||||
recv_cancel: asyncio.Future | None
|
||||
process_event_mutex: asyncio.Lock
|
||||
can_pause: bool
|
||||
# asyncio.Future[None] | None
|
||||
data_finished_fut: asyncio.Future | None
|
||||
# asyncio.Future[None] | None
|
||||
pause_frame_fut: asyncio.Future | None
|
||||
# asyncio.Future[None] | None
|
||||
connection_lost_waiter: asyncio.Future | None
|
||||
keepalive_ping_task: asyncio.Task | None
|
||||
auto_closer_task: asyncio.Task | None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ws_proto,
|
||||
max_queue=None,
|
||||
ping_interval: float | None = 20,
|
||||
ping_timeout: float | None = 20,
|
||||
close_timeout: float = 10,
|
||||
loop=None,
|
||||
):
|
||||
self.ws_proto = ws_proto
|
||||
self.io_proto = None
|
||||
self.loop = None
|
||||
self.max_queue = max_queue
|
||||
self.close_timeout = close_timeout
|
||||
self.ping_interval = ping_interval
|
||||
self.ping_timeout = ping_timeout
|
||||
self.assembler = WebsocketFrameAssembler(self)
|
||||
self.pings = {}
|
||||
self.conn_mutex = asyncio.Lock()
|
||||
self.recv_lock = asyncio.Lock()
|
||||
self.recv_cancel = None
|
||||
self.process_event_mutex = asyncio.Lock()
|
||||
self.data_finished_fut = None
|
||||
self.can_pause = True
|
||||
self.pause_frame_fut = None
|
||||
self.keepalive_ping_task = None
|
||||
self.auto_closer_task = None
|
||||
self.connection_lost_waiter = None
|
||||
|
||||
@property
|
||||
def subprotocol(self):
|
||||
return self.ws_proto.subprotocol
|
||||
|
||||
def pause_frames(self):
|
||||
if not self.can_pause:
|
||||
return False
|
||||
if self.pause_frame_fut:
|
||||
websockets_logger.debug("Websocket connection already paused.")
|
||||
return False
|
||||
if (not self.loop) or (not self.io_proto):
|
||||
return False
|
||||
if self.io_proto.transport:
|
||||
self.io_proto.transport.pause_reading()
|
||||
self.pause_frame_fut = self.loop.create_future()
|
||||
websockets_logger.debug("Websocket connection paused.")
|
||||
return True
|
||||
|
||||
def resume_frames(self):
|
||||
if not self.pause_frame_fut:
|
||||
websockets_logger.debug("Websocket connection not paused.")
|
||||
return False
|
||||
if (not self.loop) or (not self.io_proto):
|
||||
websockets_logger.debug(
|
||||
"Websocket attempting to resume reading frames, "
|
||||
"but connection is gone."
|
||||
)
|
||||
return False
|
||||
if self.io_proto.transport:
|
||||
self.io_proto.transport.resume_reading()
|
||||
self.pause_frame_fut.set_result(None)
|
||||
self.pause_frame_fut = None
|
||||
websockets_logger.debug("Websocket connection unpaused.")
|
||||
return True
|
||||
|
||||
async def connection_made(
|
||||
self,
|
||||
io_proto: SanicProtocol,
|
||||
loop: asyncio.AbstractEventLoop | None = None,
|
||||
):
|
||||
if not loop:
|
||||
try:
|
||||
loop = getattr(io_proto, "loop")
|
||||
except AttributeError:
|
||||
loop = asyncio.get_event_loop()
|
||||
if not loop:
|
||||
# This catch is for mypy type checker
|
||||
# to assert loop is not None here.
|
||||
raise ServerError("Connection received with no asyncio loop.")
|
||||
if self.auto_closer_task:
|
||||
raise ServerError(
|
||||
"Cannot call connection_made more than once "
|
||||
"on a websocket connection."
|
||||
)
|
||||
self.loop = loop
|
||||
self.io_proto = io_proto
|
||||
self.connection_lost_waiter = self.loop.create_future()
|
||||
self.data_finished_fut = asyncio.shield(self.loop.create_future())
|
||||
|
||||
if self.ping_interval:
|
||||
self.keepalive_ping_task = asyncio.create_task(
|
||||
self.keepalive_ping()
|
||||
)
|
||||
self.auto_closer_task = asyncio.create_task(
|
||||
self.auto_close_connection()
|
||||
)
|
||||
|
||||
async def wait_for_connection_lost(self, timeout=None) -> bool:
|
||||
"""
|
||||
Wait until the TCP connection is closed or ``timeout`` elapses.
|
||||
If timeout is None, wait forever.
|
||||
Recommend you should pass in self.close_timeout as timeout
|
||||
|
||||
Return ``True`` if the connection is closed and ``False`` otherwise.
|
||||
|
||||
"""
|
||||
if not self.connection_lost_waiter:
|
||||
return False
|
||||
if self.connection_lost_waiter.done():
|
||||
return True
|
||||
else:
|
||||
try:
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(self.connection_lost_waiter), timeout
|
||||
)
|
||||
return True
|
||||
except asyncio.TimeoutError:
|
||||
# Re-check self.connection_lost_waiter.done() synchronously
|
||||
# because connection_lost() could run between the moment the
|
||||
# timeout occurs and the moment this coroutine resumes running
|
||||
return self.connection_lost_waiter.done()
|
||||
|
||||
async def process_events(self, events: Sequence[Event]) -> None:
|
||||
"""
|
||||
Process a list of incoming events.
|
||||
"""
|
||||
# Wrapped in a mutex lock, to prevent other incoming events
|
||||
# from processing at the same time
|
||||
async with self.process_event_mutex:
|
||||
for event in events:
|
||||
if not isinstance(event, Frame):
|
||||
# Event is not a frame. Ignore it.
|
||||
continue
|
||||
if event.opcode == Opcode.PONG:
|
||||
await self.process_pong(event)
|
||||
elif event.opcode == Opcode.CLOSE:
|
||||
if self.recv_cancel:
|
||||
self.recv_cancel.cancel()
|
||||
else:
|
||||
await self.assembler.put(event)
|
||||
|
||||
async def process_pong(self, frame: Frame) -> None:
|
||||
if frame.data in self.pings:
|
||||
# Acknowledge all pings up to the one matching this pong.
|
||||
ping_ids = []
|
||||
for ping_id, ping in self.pings.items():
|
||||
ping_ids.append(ping_id)
|
||||
if not ping.done():
|
||||
ping.set_result(None)
|
||||
if ping_id == frame.data:
|
||||
break
|
||||
else: # noqa
|
||||
raise ServerError("ping_id is not in self.pings")
|
||||
# Remove acknowledged pings from self.pings.
|
||||
for ping_id in ping_ids:
|
||||
del self.pings[ping_id]
|
||||
|
||||
async def keepalive_ping(self) -> None:
|
||||
"""
|
||||
Send a Ping frame and wait for a Pong frame at regular intervals.
|
||||
This coroutine exits when the connection terminates and one of the
|
||||
following happens:
|
||||
- :meth:`ping` raises :exc:`ConnectionClosed`, or
|
||||
- :meth:`auto_close_connection` cancels :attr:`keepalive_ping_task`.
|
||||
"""
|
||||
if self.ping_interval is None:
|
||||
return
|
||||
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(self.ping_interval)
|
||||
|
||||
# ping() raises CancelledError if the connection is closed,
|
||||
# when auto_close_connection() cancels keepalive_ping_task.
|
||||
|
||||
# ping() raises ConnectionClosed if the connection is lost,
|
||||
# when connection_lost() calls abort_pings().
|
||||
|
||||
ping_waiter = await self.ping()
|
||||
|
||||
if self.ping_timeout is not None:
|
||||
try:
|
||||
await asyncio.wait_for(ping_waiter, self.ping_timeout)
|
||||
except asyncio.TimeoutError:
|
||||
websockets_logger.warning(
|
||||
"Websocket timed out waiting for pong"
|
||||
)
|
||||
self.fail_connection(1011)
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
# It is expected for this task to be cancelled during during
|
||||
# normal operation, when the connection is closed.
|
||||
websockets_logger.debug(
|
||||
"Websocket keepalive ping task was cancelled."
|
||||
)
|
||||
except (ConnectionClosed, WebsocketClosed):
|
||||
websockets_logger.debug(
|
||||
"Websocket closed. Keepalive ping task exiting."
|
||||
)
|
||||
except Exception as e:
|
||||
websockets_logger.warning(
|
||||
"Unexpected exception in websocket keepalive ping task."
|
||||
)
|
||||
websockets_logger.debug(str(e))
|
||||
|
||||
def _force_disconnect(self) -> bool:
|
||||
"""
|
||||
Internal method used by end_connection and fail_connection
|
||||
only when the graceful auto-closer cannot be used
|
||||
"""
|
||||
if self.auto_closer_task and not self.auto_closer_task.done():
|
||||
self.auto_closer_task.cancel()
|
||||
if self.data_finished_fut and not self.data_finished_fut.done():
|
||||
self.data_finished_fut.cancel()
|
||||
self.data_finished_fut = None
|
||||
if self.keepalive_ping_task and not self.keepalive_ping_task.done():
|
||||
self.keepalive_ping_task.cancel()
|
||||
self.keepalive_ping_task = None
|
||||
if self.loop and self.io_proto and self.io_proto.transport:
|
||||
self.io_proto.transport.close()
|
||||
self.loop.call_later(
|
||||
self.close_timeout, self.io_proto.transport.abort
|
||||
)
|
||||
# We were never open, or already closed
|
||||
return True
|
||||
|
||||
def fail_connection(self, code: int = 1006, reason: str = "") -> bool:
|
||||
"""
|
||||
Fail the WebSocket Connection
|
||||
This requires:
|
||||
1. Stopping all processing of incoming data, which means cancelling
|
||||
pausing the underlying io protocol. The close code will be 1006
|
||||
unless a close frame was received earlier.
|
||||
2. Sending a close frame with an appropriate code if the opening
|
||||
handshake succeeded and the other side is likely to process it.
|
||||
3. Closing the connection. :meth:`auto_close_connection` takes care
|
||||
of this.
|
||||
(The specification describes these steps in the opposite order.)
|
||||
"""
|
||||
if self.io_proto and self.io_proto.transport:
|
||||
# Stop new data coming in
|
||||
# In Python Version 3.7: pause_reading is idempotent
|
||||
# ut can be called when the transport is already paused or closed
|
||||
self.io_proto.transport.pause_reading()
|
||||
|
||||
# Keeping fail_connection() synchronous guarantees it can't
|
||||
# get stuck and simplifies the implementation of the callers.
|
||||
# Not draining the write buffer is acceptable in this context.
|
||||
|
||||
# clear the send buffer
|
||||
_ = self.ws_proto.data_to_send()
|
||||
# If we're not already CLOSED or CLOSING, then send the close.
|
||||
if self.ws_proto.state is OPEN:
|
||||
if code in (1000, 1001):
|
||||
self.ws_proto.send_close(code, reason)
|
||||
else:
|
||||
self.ws_proto.fail(code, reason)
|
||||
try:
|
||||
data_to_send = self.ws_proto.data_to_send()
|
||||
while (
|
||||
len(data_to_send)
|
||||
and self.io_proto
|
||||
and self.io_proto.transport
|
||||
):
|
||||
frame_data = data_to_send.pop(0)
|
||||
self.io_proto.transport.write(frame_data)
|
||||
except Exception:
|
||||
# sending close frames may fail if the
|
||||
# transport closes during this period
|
||||
...
|
||||
if code == 1006:
|
||||
# Special case: 1006 consider the transport already closed
|
||||
self.ws_proto.state = CLOSED
|
||||
if self.data_finished_fut and not self.data_finished_fut.done():
|
||||
# We have a graceful auto-closer. Use it to close the connection.
|
||||
self.data_finished_fut.cancel()
|
||||
self.data_finished_fut = None
|
||||
if (not self.auto_closer_task) or self.auto_closer_task.done():
|
||||
return self._force_disconnect()
|
||||
return False
|
||||
|
||||
def end_connection(self, code=1000, reason=""):
|
||||
# This is like slightly more graceful form of fail_connection
|
||||
# Use this instead of close() when you need an immediate
|
||||
# close and cannot await websocket.close() handshake.
|
||||
|
||||
if code == 1006 or not self.io_proto or not self.io_proto.transport:
|
||||
return self.fail_connection(code, reason)
|
||||
|
||||
# Stop new data coming in
|
||||
# In Python Version 3.7: pause_reading is idempotent
|
||||
# i.e. it can be called when the transport is already paused or closed.
|
||||
self.io_proto.transport.pause_reading()
|
||||
if self.ws_proto.state == OPEN:
|
||||
data_to_send = self.ws_proto.data_to_send()
|
||||
self.ws_proto.send_close(code, reason)
|
||||
data_to_send.extend(self.ws_proto.data_to_send())
|
||||
try:
|
||||
while (
|
||||
len(data_to_send)
|
||||
and self.io_proto
|
||||
and self.io_proto.transport
|
||||
):
|
||||
frame_data = data_to_send.pop(0)
|
||||
self.io_proto.transport.write(frame_data)
|
||||
except Exception:
|
||||
# sending close frames may fail if the
|
||||
# transport closes during this period
|
||||
# But that doesn't matter at this point
|
||||
...
|
||||
if self.data_finished_fut and not self.data_finished_fut.done():
|
||||
# We have the ability to signal the auto-closer
|
||||
# try to trigger it to auto-close the connection
|
||||
self.data_finished_fut.cancel()
|
||||
self.data_finished_fut = None
|
||||
if (not self.auto_closer_task) or self.auto_closer_task.done():
|
||||
# Auto-closer is not running, do force disconnect
|
||||
return self._force_disconnect()
|
||||
return False
|
||||
|
||||
async def auto_close_connection(self) -> None:
|
||||
"""
|
||||
Close the WebSocket Connection
|
||||
When the opening handshake succeeds, :meth:`connection_open` starts
|
||||
this coroutine in a task. It waits for the data transfer phase to
|
||||
complete then it closes the TCP connection cleanly.
|
||||
When the opening handshake fails, :meth:`fail_connection` does the
|
||||
same. There's no data transfer phase in that case.
|
||||
"""
|
||||
try:
|
||||
# Wait for the data transfer phase to complete.
|
||||
if self.data_finished_fut:
|
||||
try:
|
||||
await self.data_finished_fut
|
||||
websockets_logger.debug(
|
||||
"Websocket task finished. Closing the connection."
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
# Cancelled error is called when data phase is cancelled
|
||||
# if an error occurred or the client closed the connection
|
||||
websockets_logger.debug(
|
||||
"Websocket handler cancelled. Closing the connection."
|
||||
)
|
||||
|
||||
# Cancel the keepalive ping task.
|
||||
if self.keepalive_ping_task:
|
||||
self.keepalive_ping_task.cancel()
|
||||
self.keepalive_ping_task = None
|
||||
|
||||
# Half-close the TCP connection if possible (when there's no TLS).
|
||||
if (
|
||||
self.io_proto
|
||||
and self.io_proto.transport
|
||||
and self.io_proto.transport.can_write_eof()
|
||||
):
|
||||
websockets_logger.debug(
|
||||
"Websocket half-closing TCP connection"
|
||||
)
|
||||
try:
|
||||
self.io_proto.transport.write_eof()
|
||||
except RuntimeError:
|
||||
...
|
||||
if self.connection_lost_waiter:
|
||||
if await self.wait_for_connection_lost(timeout=0):
|
||||
return
|
||||
except asyncio.CancelledError:
|
||||
...
|
||||
except BaseException:
|
||||
websockets_logger.exception("Error closing websocket connection")
|
||||
finally:
|
||||
# Does this still exist?
|
||||
if self.keepalive_ping_task:
|
||||
self.keepalive_ping_task.cancel()
|
||||
self.keepalive_ping_task = None
|
||||
# The try/finally ensures that the transport never remains open,
|
||||
# even if this coroutine is cancelled (for example).
|
||||
if (not self.io_proto) or (not self.io_proto.transport):
|
||||
# we were never open, or done. Can't do any finalization.
|
||||
return
|
||||
elif (
|
||||
self.connection_lost_waiter
|
||||
and self.connection_lost_waiter.done()
|
||||
):
|
||||
# connection confirmed closed already, proceed to abort waiter
|
||||
...
|
||||
elif self.io_proto.transport.is_closing():
|
||||
# Connection is already closing (due to half-close above)
|
||||
# proceed to abort waiter
|
||||
...
|
||||
else:
|
||||
self.io_proto.transport.close()
|
||||
if not self.connection_lost_waiter:
|
||||
# Our connection monitor task isn't running.
|
||||
try:
|
||||
await asyncio.sleep(self.close_timeout)
|
||||
except asyncio.CancelledError:
|
||||
...
|
||||
if self.io_proto and self.io_proto.transport:
|
||||
self.io_proto.transport.abort()
|
||||
else:
|
||||
if await self.wait_for_connection_lost(
|
||||
timeout=self.close_timeout
|
||||
):
|
||||
# Connection aborted before the timeout expired.
|
||||
return
|
||||
websockets_logger.warning(
|
||||
"Timeout waiting for TCP connection to close. Aborting"
|
||||
)
|
||||
if self.io_proto and self.io_proto.transport:
|
||||
self.io_proto.transport.abort()
|
||||
|
||||
def abort_pings(self) -> None:
|
||||
"""
|
||||
Raise ConnectionClosed in pending keepalive pings.
|
||||
They'll never receive a pong once the connection is closed.
|
||||
"""
|
||||
if self.ws_proto.state is not CLOSED:
|
||||
raise ServerError(
|
||||
"Webscoket about_pings should only be called "
|
||||
"after connection state is changed to CLOSED"
|
||||
)
|
||||
|
||||
for ping in self.pings.values():
|
||||
ping.set_exception(ConnectionClosedError(None, None))
|
||||
# If the exception is never retrieved, it will be logged when ping
|
||||
# is garbage-collected. This is confusing for users.
|
||||
# Given that ping is done (with an exception), canceling it does
|
||||
# nothing, but it prevents logging the exception.
|
||||
ping.cancel()
|
||||
|
||||
async def close(self, code: int = 1000, reason: str = "") -> None:
|
||||
"""
|
||||
Perform the closing handshake.
|
||||
This is a websocket-protocol level close.
|
||||
:meth:`close` waits for the other end to complete the handshake and
|
||||
for the TCP connection to terminate.
|
||||
:meth:`close` is idempotent: it doesn't do anything once the
|
||||
connection is closed.
|
||||
:param code: WebSocket close code
|
||||
:param reason: WebSocket close reason
|
||||
"""
|
||||
if code == 1006:
|
||||
self.fail_connection(code, reason)
|
||||
return
|
||||
async with self.conn_mutex:
|
||||
if self.ws_proto.state is OPEN:
|
||||
self.ws_proto.send_close(code, reason)
|
||||
data_to_send = self.ws_proto.data_to_send()
|
||||
await self.send_data(data_to_send)
|
||||
|
||||
async def recv(self, timeout: float | None = None) -> Data | None:
|
||||
"""
|
||||
Receive the next message.
|
||||
Return a :class:`str` for a text frame and :class:`bytes` for a binary
|
||||
frame.
|
||||
When the end of the message stream is reached, :meth:`recv` raises
|
||||
:exc:`~websockets.exceptions.ConnectionClosed`. Specifically, it
|
||||
raises :exc:`~websockets.exceptions.ConnectionClosedOK` after a normal
|
||||
connection closure and
|
||||
:exc:`~websockets.exceptions.ConnectionClosedError` after a protocol
|
||||
error or a network failure.
|
||||
If ``timeout`` is ``None``, block until a message is received. Else,
|
||||
if no message is received within ``timeout`` seconds, return ``None``.
|
||||
Set ``timeout`` to ``0`` to check if a message was already received.
|
||||
:raises ~websockets.exceptions.ConnectionClosed: when the
|
||||
connection is closed
|
||||
:raises asyncio.CancelledError: if the websocket closes while waiting
|
||||
:raises ServerError: if two tasks call :meth:`recv` or
|
||||
:meth:`recv_streaming` concurrently
|
||||
"""
|
||||
|
||||
if self.recv_lock.locked():
|
||||
raise ServerError(
|
||||
"cannot call recv while another task is "
|
||||
"already waiting for the next message"
|
||||
)
|
||||
await self.recv_lock.acquire()
|
||||
if self.ws_proto.state is CLOSED:
|
||||
self.recv_lock.release()
|
||||
raise WebsocketClosed(
|
||||
"Cannot receive from websocket interface after it is closed."
|
||||
)
|
||||
assembler_get: asyncio.Task | None = None
|
||||
try:
|
||||
self.recv_cancel = asyncio.Future()
|
||||
assembler_get = asyncio.create_task(self.assembler.get(timeout))
|
||||
tasks = (self.recv_cancel, assembler_get)
|
||||
done, pending = await asyncio.wait(
|
||||
tasks,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
done_task = next(iter(done))
|
||||
if done_task is self.recv_cancel:
|
||||
# recv was cancelled
|
||||
for p in pending:
|
||||
p.cancel()
|
||||
raise asyncio.CancelledError()
|
||||
else:
|
||||
self.recv_cancel.cancel()
|
||||
return done_task.result()
|
||||
except asyncio.CancelledError:
|
||||
# recv was cancelled
|
||||
if assembler_get:
|
||||
assembler_get.cancel()
|
||||
raise
|
||||
finally:
|
||||
self.recv_cancel = None
|
||||
self.recv_lock.release()
|
||||
|
||||
async def recv_burst(self, max_recv=256) -> Sequence[Data]:
|
||||
"""
|
||||
Receive the messages which have arrived since last checking.
|
||||
Return a :class:`list` containing :class:`str` for a text frame
|
||||
and :class:`bytes` for a binary frame.
|
||||
When the end of the message stream is reached, :meth:`recv_burst`
|
||||
raises :exc:`~websockets.exceptions.ConnectionClosed`. Specifically,
|
||||
it raises :exc:`~websockets.exceptions.ConnectionClosedOK` after a
|
||||
normal connection closure and
|
||||
:exc:`~websockets.exceptions.ConnectionClosedError` after a protocol
|
||||
error or a network failure.
|
||||
:raises ~websockets.exceptions.ConnectionClosed: when the
|
||||
connection is closed
|
||||
:raises ServerError: if two tasks call :meth:`recv_burst` or
|
||||
:meth:`recv_streaming` concurrently
|
||||
"""
|
||||
|
||||
if self.recv_lock.locked():
|
||||
raise ServerError(
|
||||
"cannot call recv_burst while another task is already waiting "
|
||||
"for the next message"
|
||||
)
|
||||
await self.recv_lock.acquire()
|
||||
if self.ws_proto.state is CLOSED:
|
||||
self.recv_lock.release()
|
||||
raise WebsocketClosed(
|
||||
"Cannot receive from websocket interface after it is closed."
|
||||
)
|
||||
messages = []
|
||||
assembler_get: asyncio.Task | None = None
|
||||
try:
|
||||
# Prevent pausing the transport when we're
|
||||
# receiving a burst of messages
|
||||
self.can_pause = False
|
||||
self.recv_cancel = asyncio.Future()
|
||||
while True:
|
||||
assembler_get = asyncio.create_task(self.assembler.get(0))
|
||||
tasks = (self.recv_cancel, assembler_get)
|
||||
done, pending = await asyncio.wait(
|
||||
tasks,
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
done_task = next(iter(done))
|
||||
if done_task is self.recv_cancel:
|
||||
# recv_burst was cancelled
|
||||
for p in pending:
|
||||
p.cancel()
|
||||
raise asyncio.CancelledError()
|
||||
m = done_task.result()
|
||||
if m is None:
|
||||
# None left in the burst. This is good!
|
||||
break
|
||||
messages.append(m)
|
||||
if len(messages) >= max_recv:
|
||||
# Too much data in the pipe. Hit our burst limit.
|
||||
break
|
||||
# Allow an eventloop iteration for the
|
||||
# next message to pass into the Assembler
|
||||
await asyncio.sleep(0)
|
||||
self.recv_cancel.cancel()
|
||||
except asyncio.CancelledError:
|
||||
# recv_burst was cancelled
|
||||
if assembler_get:
|
||||
assembler_get.cancel()
|
||||
raise
|
||||
finally:
|
||||
self.recv_cancel = None
|
||||
self.can_pause = True
|
||||
self.recv_lock.release()
|
||||
return messages
|
||||
|
||||
async def recv_streaming(self) -> AsyncIterator[Data]:
|
||||
"""
|
||||
Receive the next message frame by frame.
|
||||
Return an iterator of :class:`str` for a text frame and :class:`bytes`
|
||||
for a binary frame. The iterator should be exhausted, or else the
|
||||
connection will become unusable.
|
||||
With the exception of the return value, :meth:`recv_streaming` behaves
|
||||
like :meth:`recv`.
|
||||
"""
|
||||
if self.recv_lock.locked():
|
||||
raise ServerError(
|
||||
"Cannot call recv_streaming while another task "
|
||||
"is already waiting for the next message"
|
||||
)
|
||||
await self.recv_lock.acquire()
|
||||
if self.ws_proto.state is CLOSED:
|
||||
self.recv_lock.release()
|
||||
raise WebsocketClosed(
|
||||
"Cannot receive from websocket interface after it is closed."
|
||||
)
|
||||
try:
|
||||
cancelled = False
|
||||
self.recv_cancel = asyncio.Future()
|
||||
self.can_pause = False
|
||||
async for m in self.assembler.get_iter():
|
||||
if self.recv_cancel.done():
|
||||
cancelled = True
|
||||
break
|
||||
yield m
|
||||
if cancelled:
|
||||
raise asyncio.CancelledError()
|
||||
finally:
|
||||
self.can_pause = True
|
||||
self.recv_cancel = None
|
||||
self.recv_lock.release()
|
||||
|
||||
async def send(self, message: Data | Iterable[Data]) -> None:
|
||||
"""
|
||||
Send a message.
|
||||
A string (:class:`str`) is sent as a `Text frame`_. A bytestring or
|
||||
bytes-like object (:class:`bytes`, :class:`bytearray`, or
|
||||
:class:`memoryview`) is sent as a `Binary frame`_.
|
||||
.. _Text frame: https://tools.ietf.org/html/rfc6455#section-5.6
|
||||
.. _Binary frame: https://tools.ietf.org/html/rfc6455#section-5.6
|
||||
:meth:`send` also accepts an iterable of strings, bytestrings, or
|
||||
bytes-like objects. In that case the message is fragmented. Each item
|
||||
is treated as a message fragment and sent in its own frame. All items
|
||||
must be of the same type, or else :meth:`send` will raise a
|
||||
:exc:`TypeError` and the connection will be closed.
|
||||
:meth:`send` rejects dict-like objects because this is often an error.
|
||||
If you wish to send the keys of a dict-like object as fragments, call
|
||||
its :meth:`~dict.keys` method and pass the result to :meth:`send`.
|
||||
:raises TypeError: for unsupported inputs
|
||||
"""
|
||||
async with self.conn_mutex:
|
||||
if self.ws_proto.state in (CLOSED, CLOSING):
|
||||
raise WebsocketClosed(
|
||||
"Cannot write to websocket interface after it is closed."
|
||||
)
|
||||
if (not self.data_finished_fut) or self.data_finished_fut.done():
|
||||
raise ServerError(
|
||||
"Cannot write to websocket interface after it is finished."
|
||||
)
|
||||
|
||||
# Unfragmented message -- this case must be handled first because
|
||||
# strings and bytes-like objects are iterable.
|
||||
|
||||
if isinstance(message, str):
|
||||
self.ws_proto.send_text(message.encode("utf-8"))
|
||||
await self.send_data(self.ws_proto.data_to_send())
|
||||
|
||||
elif isinstance(message, (bytes, bytearray, memoryview)):
|
||||
self.ws_proto.send_binary(message)
|
||||
await self.send_data(self.ws_proto.data_to_send())
|
||||
|
||||
elif isinstance(message, Mapping):
|
||||
# Catch a common mistake -- passing a dict to send().
|
||||
raise TypeError("data is a dict-like object")
|
||||
|
||||
elif isinstance(message, Iterable):
|
||||
# Fragmented message -- regular iterator.
|
||||
raise NotImplementedError(
|
||||
"Fragmented websocket messages are not supported."
|
||||
)
|
||||
else:
|
||||
raise TypeError("Websocket data must be bytes, str.")
|
||||
|
||||
async def ping(self, data: Data | None = None) -> asyncio.Future:
|
||||
"""
|
||||
Send a ping.
|
||||
Return an :class:`~asyncio.Future` that will be resolved when the
|
||||
corresponding pong is received. You can ignore it if you don't intend
|
||||
to wait.
|
||||
A ping may serve as a keepalive or as a check that the remote endpoint
|
||||
received all messages up to this point::
|
||||
await pong_event = ws.ping()
|
||||
await pong_event # only if you want to wait for the pong
|
||||
By default, the ping contains four random bytes. This payload may be
|
||||
overridden with the optional ``data`` argument which must be a string
|
||||
(which will be encoded to UTF-8) or a bytes-like object.
|
||||
"""
|
||||
async with self.conn_mutex:
|
||||
if self.ws_proto.state in (CLOSED, CLOSING):
|
||||
raise WebsocketClosed(
|
||||
"Cannot send a ping when the websocket interface "
|
||||
"is closed."
|
||||
)
|
||||
if (not self.io_proto) or (not self.io_proto.loop):
|
||||
raise ServerError(
|
||||
"Cannot send a ping when the websocket has no I/O "
|
||||
"protocol attached."
|
||||
)
|
||||
if data is not None:
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
elif isinstance(data, (bytearray, memoryview)):
|
||||
data = bytes(data)
|
||||
|
||||
# Protect against duplicates if a payload is explicitly set.
|
||||
if data in self.pings:
|
||||
raise ValueError(
|
||||
"already waiting for a pong with the same data"
|
||||
)
|
||||
|
||||
# Generate a unique random payload otherwise.
|
||||
while data is None or data in self.pings:
|
||||
data = secrets.token_bytes(4)
|
||||
|
||||
self.pings[data] = self.io_proto.loop.create_future()
|
||||
|
||||
self.ws_proto.send_ping(data)
|
||||
await self.send_data(self.ws_proto.data_to_send())
|
||||
|
||||
return asyncio.shield(self.pings[data])
|
||||
|
||||
async def pong(self, data: Data = b"") -> None:
|
||||
"""
|
||||
Send a pong.
|
||||
An unsolicited pong may serve as a unidirectional heartbeat.
|
||||
The payload may be set with the optional ``data`` argument which must
|
||||
be a string (which will be encoded to UTF-8) or a bytes-like object.
|
||||
"""
|
||||
async with self.conn_mutex:
|
||||
if self.ws_proto.state in (CLOSED, CLOSING):
|
||||
# Cannot send pong after transport is shutting down
|
||||
return
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
elif isinstance(data, (bytearray, memoryview)):
|
||||
data = bytes(data)
|
||||
self.ws_proto.send_pong(data)
|
||||
await self.send_data(self.ws_proto.data_to_send())
|
||||
|
||||
async def send_data(self, data_to_send):
|
||||
for data in data_to_send:
|
||||
if data:
|
||||
await self.io_proto.send(data)
|
||||
else:
|
||||
# Send an EOF - We don't actually send it,
|
||||
# just trigger to autoclose the connection
|
||||
if (
|
||||
self.auto_closer_task
|
||||
and not self.auto_closer_task.done()
|
||||
and self.data_finished_fut
|
||||
and not self.data_finished_fut.done()
|
||||
):
|
||||
# Auto-close the connection
|
||||
self.data_finished_fut.set_result(None)
|
||||
else:
|
||||
# This will fail the connection appropriately
|
||||
SanicProtocol.close(self.io_proto, timeout=1.0)
|
||||
|
||||
async def async_data_received(self, data_to_send, events_to_process):
|
||||
if self.ws_proto.state in (OPEN, CLOSING) and len(data_to_send) > 0:
|
||||
# receiving data can generate data to send (eg, pong for a ping)
|
||||
# send connection.data_to_send()
|
||||
await self.send_data(data_to_send)
|
||||
if len(events_to_process) > 0:
|
||||
await self.process_events(events_to_process)
|
||||
|
||||
def data_received(self, data):
|
||||
self.ws_proto.receive_data(data)
|
||||
data_to_send = self.ws_proto.data_to_send()
|
||||
events_to_process = self.ws_proto.events_received()
|
||||
if len(data_to_send) > 0 or len(events_to_process) > 0:
|
||||
asyncio.create_task(
|
||||
self.async_data_received(data_to_send, events_to_process)
|
||||
)
|
||||
|
||||
async def async_eof_received(self, data_to_send, events_to_process):
|
||||
# receiving EOF can generate data to send
|
||||
# send connection.data_to_send()
|
||||
if self.ws_proto.state in (OPEN, CLOSING) and len(data_to_send) > 0:
|
||||
await self.send_data(data_to_send)
|
||||
if len(events_to_process) > 0:
|
||||
await self.process_events(events_to_process)
|
||||
if self.recv_cancel:
|
||||
self.recv_cancel.cancel()
|
||||
if (
|
||||
self.auto_closer_task
|
||||
and not self.auto_closer_task.done()
|
||||
and self.data_finished_fut
|
||||
and not self.data_finished_fut.done()
|
||||
):
|
||||
# Auto-close the connection
|
||||
self.data_finished_fut.set_result(None)
|
||||
# Cancel the running handler if its waiting
|
||||
else:
|
||||
# This will fail the connection appropriately
|
||||
SanicProtocol.close(self.io_proto, timeout=1.0)
|
||||
|
||||
def eof_received(self) -> bool | None:
|
||||
self.ws_proto.receive_eof()
|
||||
data_to_send = self.ws_proto.data_to_send()
|
||||
events_to_process = self.ws_proto.events_received()
|
||||
asyncio.create_task(
|
||||
self.async_eof_received(data_to_send, events_to_process)
|
||||
)
|
||||
return False
|
||||
|
||||
def connection_lost(self, exc):
|
||||
"""
|
||||
The WebSocket Connection is Closed.
|
||||
"""
|
||||
if not self.ws_proto.state == CLOSED:
|
||||
# signal to the websocket connection handler
|
||||
# we've lost the connection
|
||||
self.ws_proto.fail(code=1006)
|
||||
self.ws_proto.state = CLOSED
|
||||
|
||||
self.abort_pings()
|
||||
if self.connection_lost_waiter:
|
||||
self.connection_lost_waiter.set_result(None)
|
||||
|
||||
async def __aiter__(self):
|
||||
try:
|
||||
while True:
|
||||
yield await self.recv()
|
||||
except ConnectionClosedOK:
|
||||
return
|
||||
Reference in New Issue
Block a user