tigrcorn-protocols 0.3.16.dev5__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tigrcorn_protocols/__init__.py +1 -0
- tigrcorn_protocols/_compression.py +219 -0
- tigrcorn_protocols/connect.py +107 -0
- tigrcorn_protocols/content_coding.py +179 -0
- tigrcorn_protocols/custom/__init__.py +3 -0
- tigrcorn_protocols/custom/adapters.py +18 -0
- tigrcorn_protocols/custom/registry.py +15 -0
- tigrcorn_protocols/flow/__init__.py +1 -0
- tigrcorn_protocols/flow/backpressure.py +17 -0
- tigrcorn_protocols/flow/buffers.py +29 -0
- tigrcorn_protocols/flow/credits.py +21 -0
- tigrcorn_protocols/flow/keepalive.py +85 -0
- tigrcorn_protocols/flow/timeouts.py +17 -0
- tigrcorn_protocols/flow/watermarks.py +16 -0
- tigrcorn_protocols/http1/__init__.py +16 -0
- tigrcorn_protocols/http1/keepalive.py +21 -0
- tigrcorn_protocols/http1/parser.py +481 -0
- tigrcorn_protocols/http1/serializer.py +198 -0
- tigrcorn_protocols/http1/state.py +9 -0
- tigrcorn_protocols/http2/__init__.py +16 -0
- tigrcorn_protocols/http2/codec.py +266 -0
- tigrcorn_protocols/http2/flow.py +35 -0
- tigrcorn_protocols/http2/handler.py +1303 -0
- tigrcorn_protocols/http2/hpack.py +393 -0
- tigrcorn_protocols/http2/state.py +226 -0
- tigrcorn_protocols/http2/streams.py +76 -0
- tigrcorn_protocols/http2/websocket.py +360 -0
- tigrcorn_protocols/http3/__init__.py +82 -0
- tigrcorn_protocols/http3/codec.py +148 -0
- tigrcorn_protocols/http3/handler/__init__.py +3 -0
- tigrcorn_protocols/http3/handler/core.py +1823 -0
- tigrcorn_protocols/http3/handler/webtransport.py +184 -0
- tigrcorn_protocols/http3/handler.py +3 -0
- tigrcorn_protocols/http3/qpack.py +843 -0
- tigrcorn_protocols/http3/state.py +129 -0
- tigrcorn_protocols/http3/streams.py +657 -0
- tigrcorn_protocols/http3/websocket.py +360 -0
- tigrcorn_protocols/lifespan/__init__.py +3 -0
- tigrcorn_protocols/lifespan/driver.py +83 -0
- tigrcorn_protocols/py.typed +1 -0
- tigrcorn_protocols/rawframed/__init__.py +5 -0
- tigrcorn_protocols/rawframed/codec.py +18 -0
- tigrcorn_protocols/rawframed/frames.py +28 -0
- tigrcorn_protocols/rawframed/handler.py +72 -0
- tigrcorn_protocols/rawframed/state.py +9 -0
- tigrcorn_protocols/registry.py +22 -0
- tigrcorn_protocols/scheduler/__init__.py +17 -0
- tigrcorn_protocols/scheduler/cancellation.py +40 -0
- tigrcorn_protocols/scheduler/dispatch.py +27 -0
- tigrcorn_protocols/scheduler/fairness.py +21 -0
- tigrcorn_protocols/scheduler/policy.py +12 -0
- tigrcorn_protocols/scheduler/priorities.py +8 -0
- tigrcorn_protocols/scheduler/quotas.py +19 -0
- tigrcorn_protocols/scheduler/runtime.py +156 -0
- tigrcorn_protocols/scheduler/tasks.py +31 -0
- tigrcorn_protocols/sessions/__init__.py +1 -0
- tigrcorn_protocols/sessions/base.py +16 -0
- tigrcorn_protocols/sessions/connection.py +12 -0
- tigrcorn_protocols/sessions/limits.py +12 -0
- tigrcorn_protocols/sessions/manager.py +31 -0
- tigrcorn_protocols/sessions/metadata.py +10 -0
- tigrcorn_protocols/sessions/quic.py +14 -0
- tigrcorn_protocols/streams/__init__.py +1 -0
- tigrcorn_protocols/streams/base.py +13 -0
- tigrcorn_protocols/streams/ids.py +5 -0
- tigrcorn_protocols/streams/multiplex.py +6 -0
- tigrcorn_protocols/streams/registry.py +22 -0
- tigrcorn_protocols/streams/singleplex.py +6 -0
- tigrcorn_protocols/websocket/__init__.py +1 -0
- tigrcorn_protocols/websocket/codec.py +31 -0
- tigrcorn_protocols/websocket/extensions.py +324 -0
- tigrcorn_protocols/websocket/frames.py +174 -0
- tigrcorn_protocols/websocket/handler.py +462 -0
- tigrcorn_protocols/websocket/handshake.py +66 -0
- tigrcorn_protocols/websocket/state.py +10 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/METADATA +240 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/RECORD +80 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/WHEEL +5 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
- tigrcorn_protocols-0.3.16.dev5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from typing import Awaitable, Callable
|
|
6
|
+
|
|
7
|
+
from tigrcorn_protocols.flow.keepalive import KeepAlivePolicy, KeepAliveRuntime
|
|
8
|
+
from tigrcorn_observability.metrics import Metrics
|
|
9
|
+
|
|
10
|
+
from tigrcorn_asgi.events.websocket import websocket_connect, websocket_disconnect, websocket_receive_bytes, websocket_receive_text
|
|
11
|
+
from tigrcorn_asgi.receive import QueueReceive
|
|
12
|
+
from tigrcorn_asgi.scopes.websocket import build_websocket_scope
|
|
13
|
+
from tigrcorn_config.model import ServerConfig
|
|
14
|
+
from tigrcorn_core.errors import ProtocolError
|
|
15
|
+
from tigrcorn_protocols.http1.parser import ParsedRequest
|
|
16
|
+
from tigrcorn_protocols.websocket.codec import binary_frame, close_frame, pong_frame, text_frame
|
|
17
|
+
from tigrcorn_protocols.websocket.extensions import PerMessageDeflateRuntime, default_permessage_deflate_agreement, negotiate_permessage_deflate, parse_permessage_deflate_offers
|
|
18
|
+
from tigrcorn_protocols.websocket.frames import OP_BINARY, OP_CLOSE, OP_CONT, OP_PING, OP_PONG, OP_TEXT, decode_close_payload, parse_frame_bytes, serialize_frame
|
|
19
|
+
from tigrcorn_core.types import ASGIApp
|
|
20
|
+
from tigrcorn_core.utils.headers import get_header
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class H3WebSocketSession:
|
|
24
|
+
def __init__(
|
|
25
|
+
self,
|
|
26
|
+
*,
|
|
27
|
+
app: ASGIApp,
|
|
28
|
+
config: ServerConfig,
|
|
29
|
+
request: ParsedRequest,
|
|
30
|
+
client: tuple[str, int] | None,
|
|
31
|
+
server: tuple[str, int] | tuple[str, None] | None,
|
|
32
|
+
scheme: str,
|
|
33
|
+
send_headers: Callable[[int, list[tuple[bytes, bytes]], bool], Awaitable[None]],
|
|
34
|
+
send_data: Callable[[bytes, bool], Awaitable[None]],
|
|
35
|
+
metrics: Metrics | None = None,
|
|
36
|
+
on_close: Callable[[], None] | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
self.app = app
|
|
39
|
+
self.config = config
|
|
40
|
+
self.request = request
|
|
41
|
+
self.client = client
|
|
42
|
+
self.server = server
|
|
43
|
+
self.scheme = 'wss' if scheme == 'https' else 'ws'
|
|
44
|
+
self.send_headers = send_headers
|
|
45
|
+
self.send_data = send_data
|
|
46
|
+
self.metrics = metrics
|
|
47
|
+
self.on_close = on_close
|
|
48
|
+
self.receive = QueueReceive(max_size=self.config.websocket.max_queue)
|
|
49
|
+
self.task: asyncio.Task[None] | None = None
|
|
50
|
+
self.accepted = False
|
|
51
|
+
self.closed = False
|
|
52
|
+
self.http_denied = False
|
|
53
|
+
self.http_denial_status = 403
|
|
54
|
+
self.http_denial_headers: list[tuple[bytes, bytes]] = []
|
|
55
|
+
self.http_denial_started = False
|
|
56
|
+
self.subprotocols = build_websocket_scope(request, client=client, server=server, scheme=self.scheme, root_path=self.config.proxy.root_path, proxy=self.config.proxy)['subprotocols']
|
|
57
|
+
self.buffer = bytearray()
|
|
58
|
+
self.peer_end_stream_pending = False
|
|
59
|
+
self.fragmented_opcode: int | None = None
|
|
60
|
+
self.fragments: list[bytes] = []
|
|
61
|
+
self.current_message_size = 0
|
|
62
|
+
self.fragmented_compressed = False
|
|
63
|
+
self.permessage_deflate_offers = parse_permessage_deflate_offers(request.headers)
|
|
64
|
+
self.permessage_deflate_runtime: PerMessageDeflateRuntime | None = None
|
|
65
|
+
self.keepalive_policy = KeepAlivePolicy(
|
|
66
|
+
idle_timeout=self.config.http.idle_timeout,
|
|
67
|
+
ping_interval=self.config.websocket.ping_interval,
|
|
68
|
+
ping_timeout=self.config.websocket.ping_timeout,
|
|
69
|
+
)
|
|
70
|
+
self.keepalive = KeepAliveRuntime(self.keepalive_policy) if self.keepalive_policy.enabled else None
|
|
71
|
+
self.keepalive_task: asyncio.Task[None] | None = None
|
|
72
|
+
version = get_header(request.headers, b'sec-websocket-version')
|
|
73
|
+
if version != b'13':
|
|
74
|
+
raise ProtocolError('unsupported websocket version')
|
|
75
|
+
|
|
76
|
+
async def start(self) -> None:
|
|
77
|
+
scope = build_websocket_scope(self.request, client=self.client, server=self.server, scheme=self.scheme, root_path=self.config.proxy.root_path, proxy=self.config.proxy)
|
|
78
|
+
await self.receive.put(websocket_connect())
|
|
79
|
+
self.task = asyncio.create_task(self._run_app(scope), name=f'tigrcorn-h3-ws-{self.request.path}')
|
|
80
|
+
if self.keepalive is not None:
|
|
81
|
+
self.keepalive_task = asyncio.create_task(self._keepalive_loop(), name=f'tigrcorn-h3-ws-keepalive-{self.request.path}')
|
|
82
|
+
|
|
83
|
+
def _record_activity(self) -> None:
|
|
84
|
+
if self.keepalive is not None:
|
|
85
|
+
self.keepalive.record_activity()
|
|
86
|
+
|
|
87
|
+
def _notify_closed(self) -> None:
|
|
88
|
+
if self.on_close is not None:
|
|
89
|
+
callback = self.on_close
|
|
90
|
+
self.on_close = None
|
|
91
|
+
callback()
|
|
92
|
+
|
|
93
|
+
async def _keepalive_loop(self) -> None:
|
|
94
|
+
while not self.closed:
|
|
95
|
+
await asyncio.sleep(0.05)
|
|
96
|
+
if self.keepalive is None or self.closed:
|
|
97
|
+
return
|
|
98
|
+
if self.keepalive.ping_timed_out():
|
|
99
|
+
if self.metrics is not None:
|
|
100
|
+
self.metrics.websocket_ping_timeout()
|
|
101
|
+
if not self.closed:
|
|
102
|
+
await self.send_data(close_frame(1011, 'ping timeout'), True)
|
|
103
|
+
self.closed = True
|
|
104
|
+
self._notify_closed()
|
|
105
|
+
await self.receive.put(websocket_disconnect(1011, 'ping timeout'))
|
|
106
|
+
return
|
|
107
|
+
payload = self.keepalive.next_ping_payload()
|
|
108
|
+
if payload is None:
|
|
109
|
+
continue
|
|
110
|
+
if self.metrics is not None:
|
|
111
|
+
self.metrics.websocket_ping_sent()
|
|
112
|
+
await self.send_data(serialize_frame(OP_PING, payload), False)
|
|
113
|
+
|
|
114
|
+
async def _run_app(self, scope: dict) -> None:
|
|
115
|
+
try:
|
|
116
|
+
await self.app(scope, self.receive, self._send)
|
|
117
|
+
except Exception:
|
|
118
|
+
if self.accepted and not self.closed:
|
|
119
|
+
with suppress(Exception):
|
|
120
|
+
await self.send_data(close_frame(1011, 'internal error'), True)
|
|
121
|
+
raise
|
|
122
|
+
finally:
|
|
123
|
+
if self.http_denied and not self.closed:
|
|
124
|
+
if not self.http_denial_started:
|
|
125
|
+
await self.send_headers(self.http_denial_status, self.http_denial_headers, True)
|
|
126
|
+
self.http_denial_started = True
|
|
127
|
+
self.closed = True
|
|
128
|
+
elif not self.accepted and not self.closed:
|
|
129
|
+
await self.send_headers(403, [], True)
|
|
130
|
+
self.closed = True
|
|
131
|
+
elif self.accepted and not self.closed:
|
|
132
|
+
await self.send_data(close_frame(1000, ''), True)
|
|
133
|
+
self.closed = True
|
|
134
|
+
self._notify_closed()
|
|
135
|
+
if self.keepalive_task is not None:
|
|
136
|
+
self.keepalive_task.cancel()
|
|
137
|
+
with suppress(asyncio.CancelledError):
|
|
138
|
+
await self.keepalive_task
|
|
139
|
+
|
|
140
|
+
async def _send(self, message: dict) -> None:
|
|
141
|
+
typ = message['type']
|
|
142
|
+
if typ == 'websocket.accept':
|
|
143
|
+
if self.accepted or self.http_denied:
|
|
144
|
+
raise RuntimeError('websocket.accept sent more than once')
|
|
145
|
+
subprotocol = message.get('subprotocol')
|
|
146
|
+
if subprotocol is not None and subprotocol not in self.subprotocols:
|
|
147
|
+
raise RuntimeError('websocket.accept selected a subprotocol not offered by the client')
|
|
148
|
+
headers = [(bytes(k).lower(), bytes(v)) for k, v in message.get('headers', [])]
|
|
149
|
+
if self.config.websocket.compression != 'permessage-deflate':
|
|
150
|
+
headers = [(k, v) for k, v in headers if k != b'sec-websocket-extensions']
|
|
151
|
+
elif self.permessage_deflate_offers and get_header(headers, b'sec-websocket-extensions') is None:
|
|
152
|
+
default_agreement = default_permessage_deflate_agreement(self.permessage_deflate_offers)
|
|
153
|
+
if default_agreement is not None:
|
|
154
|
+
headers = headers + [(b'sec-websocket-extensions', default_agreement.as_header_value())]
|
|
155
|
+
response_headers = [(k, v) for k, v in headers if k not in {b'sec-websocket-extensions', b'sec-websocket-protocol'}]
|
|
156
|
+
agreement = negotiate_permessage_deflate(
|
|
157
|
+
request_headers=self.request.headers,
|
|
158
|
+
response_headers=headers,
|
|
159
|
+
)
|
|
160
|
+
if agreement is not None:
|
|
161
|
+
self.permessage_deflate_runtime = PerMessageDeflateRuntime(agreement)
|
|
162
|
+
response_headers.append((b'sec-websocket-extensions', agreement.as_header_value()))
|
|
163
|
+
if subprotocol is not None:
|
|
164
|
+
response_headers.append((b'sec-websocket-protocol', subprotocol.encode('ascii')))
|
|
165
|
+
await self.send_headers(200, response_headers, False)
|
|
166
|
+
self.accepted = True
|
|
167
|
+
self._record_activity()
|
|
168
|
+
if self.buffer or self.peer_end_stream_pending:
|
|
169
|
+
pending_end_stream = self.peer_end_stream_pending
|
|
170
|
+
self.peer_end_stream_pending = False
|
|
171
|
+
await self.feed_data(b'', end_stream=pending_end_stream)
|
|
172
|
+
return
|
|
173
|
+
if typ == 'websocket.send':
|
|
174
|
+
if not self.accepted:
|
|
175
|
+
raise RuntimeError('websocket.send before websocket.accept')
|
|
176
|
+
if self.closed:
|
|
177
|
+
return
|
|
178
|
+
text = message.get('text')
|
|
179
|
+
data = message.get('bytes')
|
|
180
|
+
if text is not None and data is not None:
|
|
181
|
+
raise RuntimeError('websocket.send cannot contain both text and bytes')
|
|
182
|
+
if text is not None:
|
|
183
|
+
payload = text.encode('utf-8')
|
|
184
|
+
if self.permessage_deflate_runtime is not None:
|
|
185
|
+
await self.send_data(serialize_frame(OP_TEXT, self.permessage_deflate_runtime.compress_message(payload), rsv1=True), False)
|
|
186
|
+
else:
|
|
187
|
+
await self.send_data(text_frame(text), False)
|
|
188
|
+
self._record_activity()
|
|
189
|
+
else:
|
|
190
|
+
raw = data or b''
|
|
191
|
+
if self.permessage_deflate_runtime is not None:
|
|
192
|
+
await self.send_data(binary_frame(self.permessage_deflate_runtime.compress_message(raw), rsv1=True), False)
|
|
193
|
+
else:
|
|
194
|
+
await self.send_data(binary_frame(raw), False)
|
|
195
|
+
self._record_activity()
|
|
196
|
+
return
|
|
197
|
+
if typ == 'websocket.close':
|
|
198
|
+
code = int(message.get('code', 1000))
|
|
199
|
+
reason = message.get('reason', '')
|
|
200
|
+
if not self.accepted:
|
|
201
|
+
self.http_denied = True
|
|
202
|
+
self.http_denial_status = 403
|
|
203
|
+
self.http_denial_headers = []
|
|
204
|
+
return
|
|
205
|
+
if not self.closed:
|
|
206
|
+
await self.send_data(close_frame(code, reason), True)
|
|
207
|
+
self.closed = True
|
|
208
|
+
self._notify_closed()
|
|
209
|
+
return
|
|
210
|
+
if typ == 'websocket.http.response.start':
|
|
211
|
+
if self.accepted:
|
|
212
|
+
raise RuntimeError('cannot deny websocket after accept')
|
|
213
|
+
self.http_denied = True
|
|
214
|
+
self.http_denial_status = int(message['status'])
|
|
215
|
+
self.http_denial_headers = list(message.get('headers', []))
|
|
216
|
+
return
|
|
217
|
+
if typ == 'websocket.http.response.body':
|
|
218
|
+
if not self.http_denied:
|
|
219
|
+
raise RuntimeError('websocket.http.response.body before denial start')
|
|
220
|
+
body = bytes(message.get('body', b''))
|
|
221
|
+
more = bool(message.get('more_body', False))
|
|
222
|
+
if not self.http_denial_started:
|
|
223
|
+
headers = list(self.http_denial_headers)
|
|
224
|
+
if not more:
|
|
225
|
+
headers.append((b'content-length', str(len(body)).encode('ascii')))
|
|
226
|
+
end_stream = (not body) and (not more)
|
|
227
|
+
await self.send_headers(self.http_denial_status, headers, end_stream)
|
|
228
|
+
self.http_denial_started = True
|
|
229
|
+
if end_stream:
|
|
230
|
+
self.closed = True
|
|
231
|
+
return
|
|
232
|
+
if body or not more:
|
|
233
|
+
await self.send_data(body, not more)
|
|
234
|
+
if not more:
|
|
235
|
+
self.closed = True
|
|
236
|
+
return
|
|
237
|
+
raise RuntimeError(f'unexpected websocket send message: {typ!r}')
|
|
238
|
+
|
|
239
|
+
def _frame_length(self, data: bytes) -> int | None:
|
|
240
|
+
if len(data) < 2:
|
|
241
|
+
return None
|
|
242
|
+
masked = bool(data[1] & 0x80)
|
|
243
|
+
length = data[1] & 0x7F
|
|
244
|
+
pos = 2
|
|
245
|
+
if length == 126:
|
|
246
|
+
if len(data) < pos + 2:
|
|
247
|
+
return None
|
|
248
|
+
length = int.from_bytes(data[pos:pos + 2], 'big')
|
|
249
|
+
pos += 2
|
|
250
|
+
elif length == 127:
|
|
251
|
+
if len(data) < pos + 8:
|
|
252
|
+
return None
|
|
253
|
+
length = int.from_bytes(data[pos:pos + 8], 'big')
|
|
254
|
+
pos += 8
|
|
255
|
+
if masked:
|
|
256
|
+
pos += 4
|
|
257
|
+
if len(data) < pos + length:
|
|
258
|
+
return None
|
|
259
|
+
return pos + length
|
|
260
|
+
|
|
261
|
+
def _inflate_if_needed(self, frame_payload: bytes, rsv1: bool) -> bytes:
|
|
262
|
+
if not rsv1:
|
|
263
|
+
return frame_payload
|
|
264
|
+
if self.permessage_deflate_runtime is None:
|
|
265
|
+
raise ProtocolError('RSV1 is not negotiated')
|
|
266
|
+
return self.permessage_deflate_runtime.decompress_message(frame_payload)
|
|
267
|
+
|
|
268
|
+
async def feed_data(self, data: bytes, *, end_stream: bool = False) -> None:
|
|
269
|
+
if self.closed:
|
|
270
|
+
return
|
|
271
|
+
self.buffer.extend(data)
|
|
272
|
+
if end_stream:
|
|
273
|
+
self.peer_end_stream_pending = True
|
|
274
|
+
if not self.accepted and not self.http_denied:
|
|
275
|
+
return
|
|
276
|
+
while self.buffer:
|
|
277
|
+
frame_len = self._frame_length(self.buffer)
|
|
278
|
+
if frame_len is None:
|
|
279
|
+
break
|
|
280
|
+
raw = bytes(self.buffer[:frame_len])
|
|
281
|
+
del self.buffer[:frame_len]
|
|
282
|
+
frame = parse_frame_bytes(
|
|
283
|
+
raw,
|
|
284
|
+
expect_masked=True,
|
|
285
|
+
max_payload_size=self.config.websocket_max_message_size,
|
|
286
|
+
allow_rsv1=self.permessage_deflate_runtime is not None,
|
|
287
|
+
)
|
|
288
|
+
self._record_activity()
|
|
289
|
+
if frame.opcode == OP_PING:
|
|
290
|
+
await self.send_data(pong_frame(frame.payload), False)
|
|
291
|
+
continue
|
|
292
|
+
if frame.opcode == OP_PONG:
|
|
293
|
+
if self.keepalive is not None:
|
|
294
|
+
self.keepalive.acknowledge_pong(frame.payload)
|
|
295
|
+
continue
|
|
296
|
+
if frame.opcode == OP_CLOSE:
|
|
297
|
+
code, reason = decode_close_payload(frame.payload)
|
|
298
|
+
if not self.closed:
|
|
299
|
+
await self.send_data(close_frame(code, reason), True)
|
|
300
|
+
self.closed = True
|
|
301
|
+
self._notify_closed()
|
|
302
|
+
await self.receive.put(websocket_disconnect(code, reason))
|
|
303
|
+
break
|
|
304
|
+
if frame.opcode in {OP_TEXT, OP_BINARY}:
|
|
305
|
+
if self.fragmented_opcode is not None:
|
|
306
|
+
raise ProtocolError('new data frame before fragmented message completion')
|
|
307
|
+
self.current_message_size = len(frame.payload)
|
|
308
|
+
if self.current_message_size > self.config.websocket_max_message_size:
|
|
309
|
+
raise ProtocolError('message too big')
|
|
310
|
+
if frame.fin:
|
|
311
|
+
payload = self._inflate_if_needed(frame.payload, frame.rsv1)
|
|
312
|
+
if frame.opcode == OP_TEXT:
|
|
313
|
+
await self.receive.put(websocket_receive_text(payload.decode('utf-8')))
|
|
314
|
+
else:
|
|
315
|
+
await self.receive.put(websocket_receive_bytes(payload))
|
|
316
|
+
self.current_message_size = 0
|
|
317
|
+
else:
|
|
318
|
+
self.fragmented_opcode = frame.opcode
|
|
319
|
+
self.fragmented_compressed = frame.rsv1
|
|
320
|
+
self.fragments = [frame.payload]
|
|
321
|
+
continue
|
|
322
|
+
if frame.opcode == OP_CONT:
|
|
323
|
+
if self.fragmented_opcode is None:
|
|
324
|
+
raise ProtocolError('unexpected continuation frame')
|
|
325
|
+
if frame.rsv1:
|
|
326
|
+
raise ProtocolError('RSV1 is only valid on the first frame of a compressed message')
|
|
327
|
+
self.current_message_size += len(frame.payload)
|
|
328
|
+
if self.current_message_size > self.config.websocket_max_message_size:
|
|
329
|
+
raise ProtocolError('message too big')
|
|
330
|
+
self.fragments.append(frame.payload)
|
|
331
|
+
if frame.fin:
|
|
332
|
+
message = b''.join(self.fragments)
|
|
333
|
+
if self.fragmented_compressed:
|
|
334
|
+
message = self._inflate_if_needed(message, True)
|
|
335
|
+
opcode = self.fragmented_opcode
|
|
336
|
+
self.fragmented_opcode = None
|
|
337
|
+
self.fragmented_compressed = False
|
|
338
|
+
self.fragments = []
|
|
339
|
+
self.current_message_size = 0
|
|
340
|
+
if opcode == OP_TEXT:
|
|
341
|
+
await self.receive.put(websocket_receive_text(message.decode('utf-8')))
|
|
342
|
+
else:
|
|
343
|
+
await self.receive.put(websocket_receive_bytes(message))
|
|
344
|
+
continue
|
|
345
|
+
raise ProtocolError('unsupported websocket opcode')
|
|
346
|
+
if self.peer_end_stream_pending and not self.closed:
|
|
347
|
+
self.peer_end_stream_pending = False
|
|
348
|
+
self.closed = True
|
|
349
|
+
self._notify_closed()
|
|
350
|
+
await self.receive.put(websocket_disconnect(1000, ''))
|
|
351
|
+
|
|
352
|
+
async def abort(self) -> None:
|
|
353
|
+
if not self.closed:
|
|
354
|
+
self.closed = True
|
|
355
|
+
self._notify_closed()
|
|
356
|
+
await self.receive.put(websocket_disconnect(1006, ''))
|
|
357
|
+
if self.task is not None:
|
|
358
|
+
self.task.cancel()
|
|
359
|
+
with suppress(asyncio.CancelledError):
|
|
360
|
+
await self.task
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from tigrcorn_asgi.events.lifespan import lifespan_shutdown, lifespan_startup
|
|
7
|
+
from tigrcorn_asgi.receive import LifespanReceive
|
|
8
|
+
from tigrcorn_asgi.scopes.lifespan import build_lifespan_scope
|
|
9
|
+
from tigrcorn_asgi.send import LifespanSend
|
|
10
|
+
from tigrcorn_core.errors import TigrCornError
|
|
11
|
+
from tigrcorn_core.types import ASGIApp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LifespanError(TigrCornError):
|
|
15
|
+
pass
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class LifespanManager:
|
|
20
|
+
app: ASGIApp
|
|
21
|
+
mode: str = "auto"
|
|
22
|
+
timeout: float = 10.0
|
|
23
|
+
started: bool = False
|
|
24
|
+
_receive: LifespanReceive | None = None
|
|
25
|
+
_send: LifespanSend | None = None
|
|
26
|
+
_task: asyncio.Task | None = None
|
|
27
|
+
|
|
28
|
+
async def startup(self) -> None:
|
|
29
|
+
if self.mode == "off":
|
|
30
|
+
return
|
|
31
|
+
receive = LifespanReceive()
|
|
32
|
+
send = LifespanSend()
|
|
33
|
+
scope = build_lifespan_scope()
|
|
34
|
+
|
|
35
|
+
async def runner() -> None:
|
|
36
|
+
await self.app(scope, receive, send)
|
|
37
|
+
|
|
38
|
+
task = asyncio.create_task(runner(), name="tigrcorn-lifespan")
|
|
39
|
+
self._receive = receive
|
|
40
|
+
self._send = send
|
|
41
|
+
self._task = task
|
|
42
|
+
await receive.put(lifespan_startup())
|
|
43
|
+
try:
|
|
44
|
+
message = await asyncio.wait_for(send.get(), timeout=self.timeout)
|
|
45
|
+
except Exception as exc:
|
|
46
|
+
task.cancel()
|
|
47
|
+
if self.mode == "auto":
|
|
48
|
+
self._task = None
|
|
49
|
+
self._receive = None
|
|
50
|
+
self._send = None
|
|
51
|
+
return
|
|
52
|
+
raise LifespanError("lifespan startup did not complete") from exc
|
|
53
|
+
|
|
54
|
+
if message["type"] == "lifespan.startup.complete":
|
|
55
|
+
self.started = True
|
|
56
|
+
return
|
|
57
|
+
if message["type"] == "lifespan.startup.failed":
|
|
58
|
+
task.cancel()
|
|
59
|
+
raise LifespanError(str(message.get("message", "lifespan startup failed")))
|
|
60
|
+
if self.mode == "auto":
|
|
61
|
+
task.cancel()
|
|
62
|
+
self._task = None
|
|
63
|
+
self._receive = None
|
|
64
|
+
self._send = None
|
|
65
|
+
return
|
|
66
|
+
raise LifespanError(f"unexpected lifespan startup message: {message!r}")
|
|
67
|
+
|
|
68
|
+
async def shutdown(self) -> None:
|
|
69
|
+
if self.mode == "off":
|
|
70
|
+
return
|
|
71
|
+
if not self.started:
|
|
72
|
+
if self._task is not None:
|
|
73
|
+
self._task.cancel()
|
|
74
|
+
return
|
|
75
|
+
assert self._receive is not None and self._send is not None
|
|
76
|
+
await self._receive.put(lifespan_shutdown())
|
|
77
|
+
message = await asyncio.wait_for(self._send.get(), timeout=self.timeout)
|
|
78
|
+
if message["type"] == "lifespan.shutdown.failed":
|
|
79
|
+
raise LifespanError(str(message.get("message", "lifespan shutdown failed")))
|
|
80
|
+
if message["type"] != "lifespan.shutdown.complete":
|
|
81
|
+
raise LifespanError(f"unexpected lifespan shutdown message: {message!r}")
|
|
82
|
+
if self._task is not None:
|
|
83
|
+
await asyncio.wait({self._task}, timeout=self.timeout)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
from .codec import RawFrame, encode_frame, read_frame, try_decode_frame
|
|
2
|
+
from .handler import RawFramedApplicationHandler
|
|
3
|
+
from .state import RawFramedState
|
|
4
|
+
|
|
5
|
+
__all__ = ['RawFrame', 'encode_frame', 'read_frame', 'try_decode_frame', 'RawFramedState', 'RawFramedApplicationHandler']
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from tigrcorn_core.errors import ProtocolError
|
|
4
|
+
from tigrcorn_protocols.rawframed.frames import RawFrame, encode_frame, try_decode_frame
|
|
5
|
+
from tigrcorn_core.types import StreamReaderLike
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def read_frame(reader: StreamReaderLike, *, max_frame_size: int = 16 * 1024 * 1024) -> RawFrame:
|
|
9
|
+
import struct
|
|
10
|
+
|
|
11
|
+
prefix = await reader.readexactly(4)
|
|
12
|
+
size = struct.unpack("!I", prefix)[0]
|
|
13
|
+
if size > max_frame_size:
|
|
14
|
+
raise ProtocolError("raw frame exceeds configured max_frame_size")
|
|
15
|
+
return RawFrame(await reader.readexactly(size))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
__all__ = ["RawFrame", "encode_frame", "read_frame", "try_decode_frame"]
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import struct
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(slots=True)
|
|
8
|
+
class RawFrame:
|
|
9
|
+
payload: bytes
|
|
10
|
+
|
|
11
|
+
@property
|
|
12
|
+
def length(self) -> int:
|
|
13
|
+
return len(self.payload)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def encode_frame(payload: bytes) -> bytes:
|
|
17
|
+
return struct.pack("!I", len(payload)) + payload
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def try_decode_frame(buffer: bytearray) -> RawFrame | None:
|
|
21
|
+
if len(buffer) < 4:
|
|
22
|
+
return None
|
|
23
|
+
size = struct.unpack("!I", buffer[:4])[0]
|
|
24
|
+
if len(buffer) < 4 + size:
|
|
25
|
+
return None
|
|
26
|
+
payload = bytes(buffer[4 : 4 + size])
|
|
27
|
+
del buffer[: 4 + size]
|
|
28
|
+
return RawFrame(payload)
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from typing import Protocol
|
|
5
|
+
|
|
6
|
+
from tigrcorn_asgi.events.custom import stream_receive
|
|
7
|
+
from tigrcorn_asgi.receive import QueueReceive
|
|
8
|
+
from tigrcorn_asgi.scopes.custom import build_custom_scope
|
|
9
|
+
from tigrcorn_config.model import ListenerConfig, ServerConfig
|
|
10
|
+
from tigrcorn_observability.logging import AccessLogger
|
|
11
|
+
from tigrcorn_protocols.custom.adapters import adapt_scope
|
|
12
|
+
from tigrcorn_protocols.rawframed.frames import encode_frame, try_decode_frame
|
|
13
|
+
from tigrcorn_protocols.rawframed.state import RawFramedState
|
|
14
|
+
from tigrcorn_core.types import ASGIApp
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _Writable(Protocol):
|
|
18
|
+
def write(self, data: bytes) -> int: ...
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(slots=True)
|
|
22
|
+
class _RawAppSend:
|
|
23
|
+
connection: _Writable
|
|
24
|
+
outbound_frames: int = 0
|
|
25
|
+
|
|
26
|
+
async def __call__(self, message: dict) -> None:
|
|
27
|
+
typ = message.get('type')
|
|
28
|
+
if typ != 'tigrcorn.stream.send':
|
|
29
|
+
raise RuntimeError(f'unexpected raw framed send event: {typ!r}')
|
|
30
|
+
payload = bytes(message.get('data', b''))
|
|
31
|
+
self.connection.write(encode_frame(payload))
|
|
32
|
+
self.outbound_frames += 1
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(slots=True)
|
|
36
|
+
class RawFramedApplicationHandler:
|
|
37
|
+
app: ASGIApp
|
|
38
|
+
config: ServerConfig
|
|
39
|
+
listener: ListenerConfig
|
|
40
|
+
access_logger: AccessLogger
|
|
41
|
+
buffers: dict[int, bytearray] = field(default_factory=dict)
|
|
42
|
+
states: dict[int, RawFramedState] = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
async def feed_bytes(self, connection: _Writable, data: bytes, *, path: str | None = None) -> int:
|
|
45
|
+
key = id(connection)
|
|
46
|
+
buffer = self.buffers.setdefault(key, bytearray())
|
|
47
|
+
state = self.states.setdefault(key, RawFramedState())
|
|
48
|
+
buffer.extend(data)
|
|
49
|
+
handled = 0
|
|
50
|
+
while True:
|
|
51
|
+
frame = try_decode_frame(buffer)
|
|
52
|
+
if frame is None:
|
|
53
|
+
return handled
|
|
54
|
+
state.frames_received += 1
|
|
55
|
+
handled += 1
|
|
56
|
+
await self._dispatch_frame(connection, frame.payload, state, path=path)
|
|
57
|
+
|
|
58
|
+
async def _dispatch_frame(self, connection: _Writable, payload: bytes, state: RawFramedState, *, path: str | None = None) -> None:
|
|
59
|
+
scope = adapt_scope(
|
|
60
|
+
build_custom_scope(
|
|
61
|
+
'tigrcorn.rawframed',
|
|
62
|
+
scheme=self.listener.scheme or 'tigrcorn+raw',
|
|
63
|
+
path=path or self.listener.path or '',
|
|
64
|
+
headers=[],
|
|
65
|
+
extensions={'tigrcorn.custom': {'transport': self.listener.kind}},
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
receive = QueueReceive()
|
|
69
|
+
await receive.put(stream_receive(payload, more_data=False))
|
|
70
|
+
send = _RawAppSend(connection=connection)
|
|
71
|
+
await self.app(scope, receive, send)
|
|
72
|
+
state.frames_sent += send.outbound_frames
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(slots=True)
|
|
7
|
+
class ProtocolDescriptor:
|
|
8
|
+
name: str
|
|
9
|
+
multiplexed: bool = False
|
|
10
|
+
asgi_scope_types: tuple[str, ...] = ()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
BUILTIN_PROTOCOLS = {
|
|
14
|
+
"http1": ProtocolDescriptor(name="http1", multiplexed=False, asgi_scope_types=("http",)),
|
|
15
|
+
"http2": ProtocolDescriptor(name="http2", multiplexed=True, asgi_scope_types=("http",)),
|
|
16
|
+
"http3": ProtocolDescriptor(name="http3", multiplexed=True, asgi_scope_types=("http",)),
|
|
17
|
+
"quic": ProtocolDescriptor(name="quic", multiplexed=True, asgi_scope_types=("tigrcorn.quic",)),
|
|
18
|
+
"websocket": ProtocolDescriptor(name="websocket", multiplexed=False, asgi_scope_types=("websocket",)),
|
|
19
|
+
"lifespan": ProtocolDescriptor(name="lifespan", multiplexed=False, asgi_scope_types=("lifespan",)),
|
|
20
|
+
"rawframed": ProtocolDescriptor(name="rawframed", multiplexed=False, asgi_scope_types=("tigrcorn.rawframed",)),
|
|
21
|
+
"custom": ProtocolDescriptor(name="custom", multiplexed=False, asgi_scope_types=("tigrcorn.stream",)),
|
|
22
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Scheduling policy and runtime components."""
|
|
2
|
+
|
|
3
|
+
from .dispatch import TaskDispatcher
|
|
4
|
+
from .policy import SchedulerPolicy
|
|
5
|
+
from .quotas import Quotas
|
|
6
|
+
from .runtime import ConnectionLease, ProductionScheduler, WorkLease
|
|
7
|
+
from .tasks import TaskSet
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'ConnectionLease',
|
|
11
|
+
'ProductionScheduler',
|
|
12
|
+
'WorkLease',
|
|
13
|
+
'Quotas',
|
|
14
|
+
'SchedulerPolicy',
|
|
15
|
+
'TaskDispatcher',
|
|
16
|
+
'TaskSet',
|
|
17
|
+
]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Iterable
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(slots=True)
|
|
10
|
+
class CancellationResult:
|
|
11
|
+
completed: int
|
|
12
|
+
pending: int
|
|
13
|
+
timed_out: bool
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def cancel(task: asyncio.Task | None) -> None:
|
|
17
|
+
if task is None:
|
|
18
|
+
return
|
|
19
|
+
task.cancel()
|
|
20
|
+
with suppress(asyncio.CancelledError):
|
|
21
|
+
await task
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
async def cancel_many(tasks: Iterable[asyncio.Task]) -> None:
|
|
25
|
+
for task in tasks:
|
|
26
|
+
task.cancel()
|
|
27
|
+
for task in tasks:
|
|
28
|
+
with suppress(asyncio.CancelledError):
|
|
29
|
+
await task
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def cancel_many_bounded(tasks: Iterable[asyncio.Task], *, timeout: float) -> CancellationResult:
|
|
33
|
+
task_list = list(tasks)
|
|
34
|
+
for task in task_list:
|
|
35
|
+
task.cancel()
|
|
36
|
+
done, pending = await asyncio.wait(task_list, timeout=timeout)
|
|
37
|
+
for task in done:
|
|
38
|
+
with suppress(asyncio.CancelledError):
|
|
39
|
+
task.result()
|
|
40
|
+
return CancellationResult(completed=len(done), pending=len(pending), timed_out=bool(pending))
|