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.
Files changed (80) hide show
  1. tigrcorn_protocols/__init__.py +1 -0
  2. tigrcorn_protocols/_compression.py +219 -0
  3. tigrcorn_protocols/connect.py +107 -0
  4. tigrcorn_protocols/content_coding.py +179 -0
  5. tigrcorn_protocols/custom/__init__.py +3 -0
  6. tigrcorn_protocols/custom/adapters.py +18 -0
  7. tigrcorn_protocols/custom/registry.py +15 -0
  8. tigrcorn_protocols/flow/__init__.py +1 -0
  9. tigrcorn_protocols/flow/backpressure.py +17 -0
  10. tigrcorn_protocols/flow/buffers.py +29 -0
  11. tigrcorn_protocols/flow/credits.py +21 -0
  12. tigrcorn_protocols/flow/keepalive.py +85 -0
  13. tigrcorn_protocols/flow/timeouts.py +17 -0
  14. tigrcorn_protocols/flow/watermarks.py +16 -0
  15. tigrcorn_protocols/http1/__init__.py +16 -0
  16. tigrcorn_protocols/http1/keepalive.py +21 -0
  17. tigrcorn_protocols/http1/parser.py +481 -0
  18. tigrcorn_protocols/http1/serializer.py +198 -0
  19. tigrcorn_protocols/http1/state.py +9 -0
  20. tigrcorn_protocols/http2/__init__.py +16 -0
  21. tigrcorn_protocols/http2/codec.py +266 -0
  22. tigrcorn_protocols/http2/flow.py +35 -0
  23. tigrcorn_protocols/http2/handler.py +1303 -0
  24. tigrcorn_protocols/http2/hpack.py +393 -0
  25. tigrcorn_protocols/http2/state.py +226 -0
  26. tigrcorn_protocols/http2/streams.py +76 -0
  27. tigrcorn_protocols/http2/websocket.py +360 -0
  28. tigrcorn_protocols/http3/__init__.py +82 -0
  29. tigrcorn_protocols/http3/codec.py +148 -0
  30. tigrcorn_protocols/http3/handler/__init__.py +3 -0
  31. tigrcorn_protocols/http3/handler/core.py +1823 -0
  32. tigrcorn_protocols/http3/handler/webtransport.py +184 -0
  33. tigrcorn_protocols/http3/handler.py +3 -0
  34. tigrcorn_protocols/http3/qpack.py +843 -0
  35. tigrcorn_protocols/http3/state.py +129 -0
  36. tigrcorn_protocols/http3/streams.py +657 -0
  37. tigrcorn_protocols/http3/websocket.py +360 -0
  38. tigrcorn_protocols/lifespan/__init__.py +3 -0
  39. tigrcorn_protocols/lifespan/driver.py +83 -0
  40. tigrcorn_protocols/py.typed +1 -0
  41. tigrcorn_protocols/rawframed/__init__.py +5 -0
  42. tigrcorn_protocols/rawframed/codec.py +18 -0
  43. tigrcorn_protocols/rawframed/frames.py +28 -0
  44. tigrcorn_protocols/rawframed/handler.py +72 -0
  45. tigrcorn_protocols/rawframed/state.py +9 -0
  46. tigrcorn_protocols/registry.py +22 -0
  47. tigrcorn_protocols/scheduler/__init__.py +17 -0
  48. tigrcorn_protocols/scheduler/cancellation.py +40 -0
  49. tigrcorn_protocols/scheduler/dispatch.py +27 -0
  50. tigrcorn_protocols/scheduler/fairness.py +21 -0
  51. tigrcorn_protocols/scheduler/policy.py +12 -0
  52. tigrcorn_protocols/scheduler/priorities.py +8 -0
  53. tigrcorn_protocols/scheduler/quotas.py +19 -0
  54. tigrcorn_protocols/scheduler/runtime.py +156 -0
  55. tigrcorn_protocols/scheduler/tasks.py +31 -0
  56. tigrcorn_protocols/sessions/__init__.py +1 -0
  57. tigrcorn_protocols/sessions/base.py +16 -0
  58. tigrcorn_protocols/sessions/connection.py +12 -0
  59. tigrcorn_protocols/sessions/limits.py +12 -0
  60. tigrcorn_protocols/sessions/manager.py +31 -0
  61. tigrcorn_protocols/sessions/metadata.py +10 -0
  62. tigrcorn_protocols/sessions/quic.py +14 -0
  63. tigrcorn_protocols/streams/__init__.py +1 -0
  64. tigrcorn_protocols/streams/base.py +13 -0
  65. tigrcorn_protocols/streams/ids.py +5 -0
  66. tigrcorn_protocols/streams/multiplex.py +6 -0
  67. tigrcorn_protocols/streams/registry.py +22 -0
  68. tigrcorn_protocols/streams/singleplex.py +6 -0
  69. tigrcorn_protocols/websocket/__init__.py +1 -0
  70. tigrcorn_protocols/websocket/codec.py +31 -0
  71. tigrcorn_protocols/websocket/extensions.py +324 -0
  72. tigrcorn_protocols/websocket/frames.py +174 -0
  73. tigrcorn_protocols/websocket/handler.py +462 -0
  74. tigrcorn_protocols/websocket/handshake.py +66 -0
  75. tigrcorn_protocols/websocket/state.py +10 -0
  76. tigrcorn_protocols-0.3.16.dev5.dist-info/METADATA +240 -0
  77. tigrcorn_protocols-0.3.16.dev5.dist-info/RECORD +80 -0
  78. tigrcorn_protocols-0.3.16.dev5.dist-info/WHEEL +5 -0
  79. tigrcorn_protocols-0.3.16.dev5.dist-info/licenses/LICENSE +163 -0
  80. 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,3 @@
1
+ from .driver import LifespanManager
2
+
3
+ __all__ = ["LifespanManager"]
@@ -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,9 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+
5
+
6
+ @dataclass(slots=True)
7
+ class RawFramedState:
8
+ frames_received: int = 0
9
+ frames_sent: int = 0
@@ -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))