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,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)
|
||||
Reference in New Issue
Block a user