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,393 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from typing import Iterable
5
+
6
+ from tigrcorn_core.errors import ProtocolError
7
+ from tigrcorn_protocols._compression import (
8
+ decode_prefixed_integer,
9
+ decode_prefixed_string,
10
+ encode_prefixed_integer,
11
+ encode_prefixed_string,
12
+ )
13
+
14
+ _STATIC_TABLE: list[tuple[bytes, bytes]] = [
15
+ (b":authority", b""),
16
+ (b":method", b"GET"),
17
+ (b":method", b"POST"),
18
+ (b":path", b"/"),
19
+ (b":path", b"/index.html"),
20
+ (b":scheme", b"http"),
21
+ (b":scheme", b"https"),
22
+ (b":status", b"200"),
23
+ (b":status", b"204"),
24
+ (b":status", b"206"),
25
+ (b":status", b"304"),
26
+ (b":status", b"400"),
27
+ (b":status", b"404"),
28
+ (b":status", b"500"),
29
+ (b"accept-charset", b""),
30
+ (b"accept-encoding", b"gzip, deflate"),
31
+ (b"accept-language", b""),
32
+ (b"accept-ranges", b""),
33
+ (b"accept", b""),
34
+ (b"access-control-allow-origin", b""),
35
+ (b"age", b""),
36
+ (b"allow", b""),
37
+ (b"authorization", b""),
38
+ (b"cache-control", b""),
39
+ (b"content-disposition", b""),
40
+ (b"content-encoding", b""),
41
+ (b"content-language", b""),
42
+ (b"content-length", b""),
43
+ (b"content-location", b""),
44
+ (b"content-range", b""),
45
+ (b"content-type", b""),
46
+ (b"cookie", b""),
47
+ (b"date", b""),
48
+ (b"etag", b""),
49
+ (b"expect", b""),
50
+ (b"expires", b""),
51
+ (b"from", b""),
52
+ (b"host", b""),
53
+ (b"if-match", b""),
54
+ (b"if-modified-since", b""),
55
+ (b"if-none-match", b""),
56
+ (b"if-range", b""),
57
+ (b"if-unmodified-since", b""),
58
+ (b"last-modified", b""),
59
+ (b"link", b""),
60
+ (b"location", b""),
61
+ (b"max-forwards", b""),
62
+ (b"proxy-authenticate", b""),
63
+ (b"proxy-authorization", b""),
64
+ (b"range", b""),
65
+ (b"referer", b""),
66
+ (b"refresh", b""),
67
+ (b"retry-after", b""),
68
+ (b"server", b""),
69
+ (b"set-cookie", b""),
70
+ (b"strict-transport-security", b""),
71
+ (b"transfer-encoding", b""),
72
+ (b"user-agent", b""),
73
+ (b"vary", b""),
74
+ (b"via", b""),
75
+ (b"www-authenticate", b""),
76
+ ]
77
+
78
+ STATIC_TABLE: list[tuple[bytes, bytes] | None] = [None, *_STATIC_TABLE]
79
+ STATIC_TABLE_LENGTH = len(STATIC_TABLE) - 1
80
+ STATIC_INDEX = {entry: idx for idx, entry in enumerate(STATIC_TABLE) if idx and entry is not None}
81
+ STATIC_NAME_INDEX: dict[bytes, int] = {}
82
+ for idx, entry in enumerate(STATIC_TABLE):
83
+ if not idx or entry is None:
84
+ continue
85
+ name, _value = entry
86
+ if name not in STATIC_NAME_INDEX:
87
+ STATIC_NAME_INDEX[name] = idx
88
+
89
+ SENSITIVE_HEADERS = {
90
+ b"authorization",
91
+ b"cookie",
92
+ b"proxy-authorization",
93
+ b"set-cookie",
94
+ }
95
+
96
+
97
+ # Public helpers retained for existing callers.
98
+ def encode_integer(value: int, prefix_bits: int, prefix_mask: int = 0) -> bytes:
99
+ return encode_prefixed_integer(value, prefix_bits, prefix_mask)
100
+
101
+
102
+
103
+ def decode_integer(
104
+ data: bytes,
105
+ offset: int,
106
+ prefix_bits: int,
107
+ *,
108
+ max_octets: int | None = None,
109
+ max_value: int | None = None,
110
+ ) -> tuple[int, int]:
111
+ return decode_prefixed_integer(data, offset, prefix_bits, max_octets=max_octets, max_value=max_value)
112
+
113
+
114
+
115
+ def encode_string(data: bytes, *, huffman: bool = True) -> bytes:
116
+ return encode_prefixed_string(data, 8, 0x00, huffman=huffman)
117
+
118
+
119
+
120
+ def decode_string(
121
+ data: bytes,
122
+ offset: int,
123
+ *,
124
+ max_length: int | None = None,
125
+ max_decoded_length: int | None = None,
126
+ max_integer_octets: int | None = None,
127
+ ) -> tuple[bytes, int]:
128
+ return decode_prefixed_string(
129
+ data,
130
+ offset,
131
+ 8,
132
+ max_length=max_length,
133
+ max_decoded_length=max_decoded_length,
134
+ max_integer_octets=max_integer_octets,
135
+ )
136
+
137
+
138
+ @dataclass(slots=True)
139
+ class DynamicTableEntry:
140
+ name: bytes
141
+ value: bytes
142
+
143
+ @property
144
+ def size(self) -> int:
145
+ return len(self.name) + len(self.value) + 32
146
+
147
+
148
+ @dataclass(slots=True)
149
+ class HPACKDynamicTable:
150
+ max_size: int = 4096
151
+ entries: list[DynamicTableEntry] = field(default_factory=list)
152
+ size: int = 0
153
+
154
+ def update_max_size(self, max_size: int) -> None:
155
+ if max_size < 0:
156
+ raise ProtocolError("HPACK dynamic table size must be non-negative")
157
+ self.max_size = max_size
158
+ self._evict_to_limit(0)
159
+
160
+ def _evict_to_limit(self, incoming_size: int) -> None:
161
+ while self.size + incoming_size > self.max_size and self.entries:
162
+ evicted = self.entries.pop()
163
+ self.size -= evicted.size
164
+
165
+ def insert(self, name: bytes, value: bytes) -> None:
166
+ entry = DynamicTableEntry(name=name, value=value)
167
+ if entry.size > self.max_size:
168
+ self.entries.clear()
169
+ self.size = 0
170
+ return
171
+ self._evict_to_limit(entry.size)
172
+ self.entries.insert(0, entry)
173
+ self.size += entry.size
174
+
175
+ def lookup(self, index: int) -> tuple[bytes, bytes]:
176
+ if index <= 0:
177
+ raise ProtocolError(f"invalid HPACK index: {index}")
178
+ if index <= STATIC_TABLE_LENGTH:
179
+ entry = STATIC_TABLE[index]
180
+ if entry is None:
181
+ raise ProtocolError(f"unknown HPACK static index: {index}")
182
+ return entry
183
+ dynamic_index = index - STATIC_TABLE_LENGTH - 1
184
+ if dynamic_index < 0 or dynamic_index >= len(self.entries):
185
+ raise ProtocolError(f"unknown HPACK dynamic index: {index}")
186
+ entry = self.entries[dynamic_index]
187
+ return entry.name, entry.value
188
+
189
+ def lookup_exact(self, name: bytes, value: bytes) -> int | None:
190
+ exact_static = STATIC_INDEX.get((name, value))
191
+ if exact_static is not None:
192
+ return exact_static
193
+ for offset, entry in enumerate(self.entries, start=STATIC_TABLE_LENGTH + 1):
194
+ if entry.name == name and entry.value == value:
195
+ return offset
196
+ return None
197
+
198
+ def lookup_name(self, name: bytes) -> int:
199
+ for offset, entry in enumerate(self.entries, start=STATIC_TABLE_LENGTH + 1):
200
+ if entry.name == name:
201
+ return offset
202
+ return STATIC_NAME_INDEX.get(name, 0)
203
+
204
+
205
+ class HPACKEncoder:
206
+ def __init__(
207
+ self,
208
+ *,
209
+ max_table_size: int = 4096,
210
+ use_huffman: bool = True,
211
+ sensitive_headers: set[bytes] | None = None,
212
+ ) -> None:
213
+ self.dynamic_table = HPACKDynamicTable(max_size=max_table_size)
214
+ self.use_huffman = use_huffman
215
+ self.sensitive_headers = set(SENSITIVE_HEADERS if sensitive_headers is None else sensitive_headers)
216
+ self._pending_table_size_updates: list[int] = []
217
+
218
+ def set_max_table_size(self, value: int) -> None:
219
+ self.dynamic_table.update_max_size(value)
220
+ self._pending_table_size_updates.append(value)
221
+
222
+ def _encode_indexed(self, index: int) -> bytes:
223
+ return encode_integer(index, 7, 0x80)
224
+
225
+ def _encode_literal(self, name: bytes, value: bytes, *, prefix_mask: int, prefix_bits: int, index: bool) -> bytes:
226
+ name_index = self.dynamic_table.lookup_name(name)
227
+ raw = bytearray(encode_integer(name_index, prefix_bits, prefix_mask))
228
+ if name_index == 0:
229
+ raw.extend(encode_string(name, huffman=self.use_huffman))
230
+ raw.extend(encode_string(value, huffman=self.use_huffman))
231
+ if index:
232
+ self.dynamic_table.insert(name, value)
233
+ return bytes(raw)
234
+
235
+ def _should_index(self, name: bytes, value: bytes) -> bool:
236
+ if name in self.sensitive_headers:
237
+ return False
238
+ return self.dynamic_table.max_size > 0 and len(name) + len(value) + 32 <= self.dynamic_table.max_size
239
+
240
+ def encode_header(self, name: bytes, value: bytes) -> bytes:
241
+ exact = self.dynamic_table.lookup_exact(name, value)
242
+ if exact is not None:
243
+ return self._encode_indexed(exact)
244
+ if self._should_index(name, value):
245
+ return self._encode_literal(name, value, prefix_mask=0x40, prefix_bits=6, index=True)
246
+ prefix_mask = 0x10 if name in self.sensitive_headers else 0x00
247
+ return self._encode_literal(name, value, prefix_mask=prefix_mask, prefix_bits=4, index=False)
248
+
249
+ def encode_header_block(self, headers: Iterable[tuple[bytes, bytes]]) -> bytes:
250
+ raw = bytearray()
251
+ for value in self._pending_table_size_updates:
252
+ raw.extend(encode_integer(value, 5, 0x20))
253
+ self._pending_table_size_updates.clear()
254
+ for name, value in headers:
255
+ raw.extend(self.encode_header(name, value))
256
+ return bytes(raw)
257
+
258
+
259
+ class HPACKDecoder:
260
+ def __init__(
261
+ self,
262
+ *,
263
+ max_table_size: int = 4096,
264
+ max_header_list_size: int | None = 65536,
265
+ max_header_block_size: int = 65536,
266
+ max_header_count: int = 256,
267
+ max_string_length: int = 65536,
268
+ max_integer_octets: int = 8,
269
+ ) -> None:
270
+ self.dynamic_table = HPACKDynamicTable(max_size=max_table_size)
271
+ self.max_allowed_table_size = max_table_size
272
+ self.max_header_list_size = max_header_list_size
273
+ self.max_header_block_size = max_header_block_size
274
+ self.max_header_count = max_header_count
275
+ self.max_string_length = max_string_length
276
+ self.max_integer_octets = max_integer_octets
277
+
278
+ def set_max_allowed_table_size(self, value: int) -> None:
279
+ if value < 0:
280
+ raise ProtocolError("HPACK table size limit must be non-negative")
281
+ self.max_allowed_table_size = value
282
+ if self.dynamic_table.max_size > value:
283
+ self.dynamic_table.update_max_size(value)
284
+
285
+ def set_max_header_list_size(self, value: int | None) -> None:
286
+ if value is not None and value < 0:
287
+ raise ProtocolError("HPACK header list size limit must be non-negative")
288
+ self.max_header_list_size = value
289
+
290
+ def _resolve_name(self, name_index: int, data: bytes, offset: int) -> tuple[bytes, int]:
291
+ if name_index == 0:
292
+ return decode_string(
293
+ data,
294
+ offset,
295
+ max_length=self.max_string_length,
296
+ max_decoded_length=self.max_string_length,
297
+ max_integer_octets=self.max_integer_octets,
298
+ )
299
+ name, _value = self.dynamic_table.lookup(name_index)
300
+ return name, offset
301
+
302
+ def _append_header(
303
+ self,
304
+ headers: list[tuple[bytes, bytes]],
305
+ header: tuple[bytes, bytes],
306
+ running_size: int,
307
+ ) -> int:
308
+ if len(headers) >= self.max_header_count:
309
+ raise ProtocolError("HPACK header count exceeds configured maximum")
310
+ new_size = running_size + len(header[0]) + len(header[1]) + 32
311
+ if self.max_header_list_size is not None and new_size > self.max_header_list_size:
312
+ raise ProtocolError("HPACK header list exceeds configured maximum")
313
+ headers.append(header)
314
+ return new_size
315
+
316
+ def decode_header_block(self, block: bytes) -> list[tuple[bytes, bytes]]:
317
+ if len(block) > self.max_header_block_size:
318
+ raise ProtocolError("HPACK header block exceeds configured maximum")
319
+ headers: list[tuple[bytes, bytes]] = []
320
+ offset = 0
321
+ header_size = 0
322
+ saw_header_representation = False
323
+ while offset < len(block):
324
+ first = block[offset]
325
+ if first & 0x80:
326
+ saw_header_representation = True
327
+ index, offset = decode_integer(block, offset, 7, max_octets=self.max_integer_octets)
328
+ header = self.dynamic_table.lookup(index)
329
+ header_size = self._append_header(headers, header, header_size)
330
+ continue
331
+ if first & 0x40:
332
+ saw_header_representation = True
333
+ name_index, offset = decode_integer(block, offset, 6, max_octets=self.max_integer_octets)
334
+ name, offset = self._resolve_name(name_index, block, offset)
335
+ value, offset = decode_string(
336
+ block,
337
+ offset,
338
+ max_length=self.max_string_length,
339
+ max_decoded_length=self.max_string_length,
340
+ max_integer_octets=self.max_integer_octets,
341
+ )
342
+ header_size = self._append_header(headers, (name, value), header_size)
343
+ self.dynamic_table.insert(name, value)
344
+ continue
345
+ if first & 0x20:
346
+ if saw_header_representation:
347
+ raise ProtocolError("HPACK dynamic table size update must appear at the start of a header block")
348
+ size, offset = decode_integer(
349
+ block,
350
+ offset,
351
+ 5,
352
+ max_octets=self.max_integer_octets,
353
+ max_value=self.max_allowed_table_size,
354
+ )
355
+ if size > self.max_allowed_table_size:
356
+ raise ProtocolError("HPACK dynamic table size update exceeds allowed maximum")
357
+ self.dynamic_table.update_max_size(size)
358
+ continue
359
+ saw_header_representation = True
360
+ name_index, offset = decode_integer(block, offset, 4, max_octets=self.max_integer_octets)
361
+ name, offset = self._resolve_name(name_index, block, offset)
362
+ value, offset = decode_string(
363
+ block,
364
+ offset,
365
+ max_length=self.max_string_length,
366
+ max_decoded_length=self.max_string_length,
367
+ max_integer_octets=self.max_integer_octets,
368
+ )
369
+ header_size = self._append_header(headers, (name, value), header_size)
370
+ return headers
371
+
372
+
373
+ # Stateless wrappers used by standalone tests and utilities.
374
+ def encode_header(name: bytes, value: bytes) -> bytes:
375
+ return HPACKEncoder(max_table_size=0).encode_header(name, value)
376
+
377
+
378
+
379
+ def encode_header_block(headers: Iterable[tuple[bytes, bytes]]) -> bytes:
380
+ return HPACKEncoder().encode_header_block(headers)
381
+
382
+
383
+
384
+ def decode_header_block(
385
+ block: bytes,
386
+ *,
387
+ max_header_list_size: int | None = 65536,
388
+ max_header_block_size: int = 65536,
389
+ ) -> list[tuple[bytes, bytes]]:
390
+ return HPACKDecoder(
391
+ max_header_list_size=max_header_list_size,
392
+ max_header_block_size=max_header_block_size,
393
+ ).decode_header_block(block)
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from enum import Enum
5
+
6
+ from tigrcorn_core.errors import ProtocolError
7
+ from tigrcorn_protocols.http2.codec import DEFAULT_SETTINGS, SETTING_ENABLE_PUSH, SETTING_INITIAL_WINDOW_SIZE, SETTING_MAX_CONCURRENT_STREAMS, SETTING_MAX_FRAME_SIZE, SETTING_MAX_HEADER_LIST_SIZE
8
+
9
+ MAX_FLOW_WINDOW = 0x7FFFFFFF
10
+
11
+
12
+ class H2StreamLifecycle(str, Enum):
13
+ IDLE = "idle"
14
+ RESERVED_LOCAL = "reserved-local"
15
+ RESERVED_REMOTE = "reserved-remote"
16
+ OPEN = "open"
17
+ HALF_CLOSED_LOCAL = "half-closed-local"
18
+ HALF_CLOSED_REMOTE = "half-closed-remote"
19
+ CLOSED = "closed"
20
+
21
+
22
+ @dataclass(slots=True)
23
+ class FlowWindow:
24
+ available: int
25
+
26
+ def consume(self, amount: int) -> None:
27
+ if amount < 0:
28
+ raise ValueError("amount must be non-negative")
29
+ self.available -= amount
30
+
31
+ def increase(self, amount: int) -> None:
32
+ if amount < 0:
33
+ raise ValueError("amount must be non-negative")
34
+ if self.available > MAX_FLOW_WINDOW - amount:
35
+ raise ProtocolError("HTTP/2 flow-control window overflow")
36
+ self.available += amount
37
+
38
+ def adjust(self, delta: int) -> None:
39
+ if delta == 0:
40
+ return
41
+ if delta > 0 and self.available > MAX_FLOW_WINDOW - delta:
42
+ raise ProtocolError("HTTP/2 flow-control window overflow")
43
+ self.available += delta
44
+
45
+
46
+ @dataclass(slots=True)
47
+ class H2StreamState:
48
+ stream_id: int
49
+ headers: list[tuple[bytes, bytes]] = field(default_factory=list)
50
+ trailers: list[tuple[bytes, bytes]] = field(default_factory=list)
51
+ body_parts: list[bytes] = field(default_factory=list)
52
+ header_fragments: list[bytes] = field(default_factory=list)
53
+ headers_complete: bool = False
54
+ trailers_complete: bool = False
55
+ awaiting_continuation: bool = False
56
+ end_stream_received: bool = False
57
+ dispatched: bool = False
58
+ closed: bool = False
59
+ websocket_session: object | None = None
60
+ connect_tunnel: object | None = None
61
+ send_window: FlowWindow = field(default_factory=lambda: FlowWindow(DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]))
62
+ receive_window: FlowWindow = field(default_factory=lambda: FlowWindow(DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]))
63
+ receive_window_target: int = DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]
64
+ receive_consumed_since_update: int = 0
65
+ buffered_body_size: int = 0
66
+ header_block_bytes: int = 0
67
+ current_header_block_is_trailers: bool = False
68
+ opened: bool = False
69
+ local_closed: bool = False
70
+ reserved_local: bool = False
71
+ reserved_remote: bool = False
72
+ reset_received: bool = False
73
+ reset_sent: bool = False
74
+ lifecycle: H2StreamLifecycle = H2StreamLifecycle.IDLE
75
+
76
+ @property
77
+ def body(self) -> bytes:
78
+ return b"".join(self.body_parts)
79
+
80
+ @property
81
+ def remote_closed(self) -> bool:
82
+ return self.end_stream_received
83
+
84
+ def _sync_lifecycle(self) -> None:
85
+ if self.reset_received or self.reset_sent or (self.local_closed and self.end_stream_received):
86
+ self.lifecycle = H2StreamLifecycle.CLOSED
87
+ elif self.reserved_local:
88
+ self.lifecycle = H2StreamLifecycle.RESERVED_LOCAL
89
+ elif self.reserved_remote:
90
+ self.lifecycle = H2StreamLifecycle.RESERVED_REMOTE
91
+ elif not self.opened:
92
+ self.lifecycle = H2StreamLifecycle.IDLE
93
+ elif self.local_closed:
94
+ self.lifecycle = H2StreamLifecycle.HALF_CLOSED_LOCAL
95
+ elif self.end_stream_received:
96
+ self.lifecycle = H2StreamLifecycle.HALF_CLOSED_REMOTE
97
+ else:
98
+ self.lifecycle = H2StreamLifecycle.OPEN
99
+ self.closed = self.lifecycle is H2StreamLifecycle.CLOSED
100
+
101
+ def open_remote(self, *, end_stream: bool = False) -> None:
102
+ if self.opened and self.lifecycle is not H2StreamLifecycle.IDLE:
103
+ raise ProtocolError("HTTP/2 stream is already open")
104
+ self.opened = True
105
+ self.end_stream_received = end_stream
106
+ self._sync_lifecycle()
107
+
108
+ def reserve_local(self) -> None:
109
+ self.reserved_local = True
110
+ self.opened = False
111
+ self.local_closed = False
112
+ self.end_stream_received = False
113
+ self._sync_lifecycle()
114
+
115
+ def open_local_reserved(self, *, end_stream: bool = False) -> None:
116
+ if not self.reserved_local:
117
+ raise ProtocolError("HTTP/2 local stream is not reserved")
118
+ self.reserved_local = False
119
+ self.opened = True
120
+ self.end_stream_received = True
121
+ self.local_closed = end_stream
122
+ self._sync_lifecycle()
123
+
124
+ def receive_end_stream(self) -> None:
125
+ self.end_stream_received = True
126
+ self._sync_lifecycle()
127
+
128
+ def send_end_stream(self) -> None:
129
+ self.local_closed = True
130
+ self._sync_lifecycle()
131
+
132
+ def mark_reset_received(self) -> None:
133
+ self.reset_received = True
134
+ self.local_closed = True
135
+ self.end_stream_received = True
136
+ self._sync_lifecycle()
137
+
138
+ def mark_reset_sent(self) -> None:
139
+ self.reset_sent = True
140
+ self.local_closed = True
141
+ self.end_stream_received = True
142
+ self._sync_lifecycle()
143
+
144
+ def append_body(self, payload: bytes) -> None:
145
+ if payload:
146
+ self.body_parts.append(payload)
147
+ self.buffered_body_size += len(payload)
148
+
149
+
150
+ @dataclass(slots=True)
151
+ class H2ConnectionState:
152
+ local_settings: dict[int, int] = field(default_factory=lambda: dict(DEFAULT_SETTINGS))
153
+ remote_settings: dict[int, int] = field(default_factory=lambda: {**DEFAULT_SETTINGS, SETTING_ENABLE_PUSH: 1})
154
+ connection_send_window: FlowWindow = field(default_factory=lambda: FlowWindow(DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]))
155
+ connection_receive_window: FlowWindow = field(default_factory=lambda: FlowWindow(DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]))
156
+ connection_receive_window_target: int = DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE]
157
+ connection_receive_consumed_since_update: int = 0
158
+ preface_seen: bool = False
159
+ remote_settings_seen: bool = False
160
+ shutdown: bool = False
161
+ last_stream_id: int = 0
162
+ highest_remote_stream_id: int = 0
163
+ peer_goaway_received: bool = False
164
+ peer_last_stream_id: int | None = None
165
+ local_goaway_sent: bool = False
166
+ local_goaway_last_stream_id: int | None = None
167
+ next_local_stream_id: int = 2
168
+
169
+ @property
170
+ def max_frame_size(self) -> int:
171
+ return self.remote_settings.get(SETTING_MAX_FRAME_SIZE, DEFAULT_SETTINGS[SETTING_MAX_FRAME_SIZE])
172
+
173
+ @property
174
+ def initial_window_size(self) -> int:
175
+ return self.remote_settings.get(SETTING_INITIAL_WINDOW_SIZE, DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE])
176
+
177
+ @property
178
+ def local_initial_window_size(self) -> int:
179
+ return self.local_settings.get(SETTING_INITIAL_WINDOW_SIZE, DEFAULT_SETTINGS[SETTING_INITIAL_WINDOW_SIZE])
180
+
181
+ @property
182
+ def max_concurrent_streams(self) -> int:
183
+ return self.local_settings.get(SETTING_MAX_CONCURRENT_STREAMS, DEFAULT_SETTINGS[SETTING_MAX_CONCURRENT_STREAMS])
184
+
185
+ @property
186
+ def max_header_list_size(self) -> int:
187
+ return self.local_settings.get(SETTING_MAX_HEADER_LIST_SIZE, DEFAULT_SETTINGS[SETTING_MAX_HEADER_LIST_SIZE])
188
+
189
+ @property
190
+ def client_allows_push(self) -> bool:
191
+ return self.remote_settings.get(SETTING_ENABLE_PUSH, 1) != 0
192
+
193
+
194
+ H2_STREAM_TRANSITION_TABLE: tuple[dict[str, object], ...] = (
195
+ {'from': 'idle', 'event': 'remote headers', 'to': 'open', 'notes': 'peer opens the stream without END_STREAM'},
196
+ {'from': 'idle', 'event': 'remote headers + END_STREAM', 'to': 'half-closed-remote', 'notes': 'request headers end the peer send side immediately'},
197
+ {'from': 'idle', 'event': 'reserve_local', 'to': 'reserved-local', 'notes': 'local PUSH_PROMISE reservation state'},
198
+ {'from': 'reserved-local', 'event': 'local headers', 'to': 'half-closed-remote', 'notes': 'reserved local stream becomes locally open and remotely closed'},
199
+ {'from': 'reserved-local', 'event': 'local headers + END_STREAM', 'to': 'closed', 'notes': 'reserved local stream can close immediately when local side ends'},
200
+ {'from': 'open', 'event': 'receive_end_stream', 'to': 'half-closed-remote', 'notes': 'peer closed its send side'},
201
+ {'from': 'open', 'event': 'send_end_stream', 'to': 'half-closed-local', 'notes': 'local side closed while peer may still send'},
202
+ {'from': 'half-closed-remote', 'event': 'send_end_stream', 'to': 'closed', 'notes': 'stream fully closed after local END_STREAM'},
203
+ {'from': 'half-closed-local', 'event': 'receive_end_stream', 'to': 'closed', 'notes': 'stream fully closed after peer END_STREAM'},
204
+ {'from': 'open|half-closed-local|half-closed-remote|reserved-local|reserved-remote', 'event': 'reset sent/received', 'to': 'closed', 'notes': 'RST_STREAM transitions to closed regardless of prior active lifecycle'},
205
+ )
206
+
207
+ H2_CONNECTION_RULE_TABLE: tuple[dict[str, object], ...] = (
208
+ {'rule': 'first-frame-after-preface-is-settings', 'source': 'handler', 'notes': 'peer frame sequence starts with SETTINGS'},
209
+ {'rule': 'continuation-sequences-are-exclusive', 'source': 'handler', 'notes': 'no interleaved frames are permitted while CONTINUATION is pending'},
210
+ {'rule': 'priority-self-dependency-forbidden', 'source': 'handler', 'notes': 'PRIORITY cannot depend on its own stream'},
211
+ {'rule': 'client-push-promise-forbidden', 'source': 'handler', 'notes': 'server rejects PUSH_PROMISE received from the client'},
212
+ {'rule': 'max-concurrent-streams-enforced', 'source': 'handler/state', 'notes': 'new streams are rejected beyond advertised local limits'},
213
+ {'rule': 'goaway-last-stream-id-monotonic', 'source': 'handler', 'notes': 'peer GOAWAY last_stream_id must not increase'},
214
+ {'rule': 'new-stream-after-goaway-forbidden', 'source': 'handler', 'notes': 'new remotely initiated streams are rejected after peer GOAWAY'},
215
+ {'rule': 'flow-control-window-overflow-forbidden', 'source': 'state', 'notes': 'flow-control windows cannot overflow 2^31-1 or go negative under DATA'},
216
+ {'rule': 'window-update-on-closed-stream-ignored', 'source': 'handler', 'notes': 'closed-stream WINDOW_UPDATE does not reopen or mutate stream state'},
217
+ )
218
+
219
+
220
+ def h2_stream_transition_table() -> tuple[dict[str, object], ...]:
221
+ return tuple(dict(entry) for entry in H2_STREAM_TRANSITION_TABLE)
222
+
223
+
224
+
225
+ def h2_connection_rule_table() -> tuple[dict[str, object], ...]:
226
+ return tuple(dict(entry) for entry in H2_CONNECTION_RULE_TABLE)
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+
5
+ from tigrcorn_core.errors import ProtocolError
6
+ from tigrcorn_protocols.http2.state import FlowWindow, H2StreamState
7
+
8
+
9
+ @dataclass(slots=True)
10
+ class H2StreamRegistry:
11
+ streams: dict[int, H2StreamState] = field(default_factory=dict)
12
+ closed_stream_ids: set[int] = field(default_factory=set)
13
+
14
+ def get(self, stream_id: int) -> H2StreamState:
15
+ return self.streams.setdefault(stream_id, H2StreamState(stream_id=stream_id))
16
+
17
+ def find(self, stream_id: int) -> H2StreamState | None:
18
+ return self.streams.get(stream_id)
19
+
20
+ def activate_remote(self, stream_id: int, *, send_window: int, receive_window: int) -> H2StreamState:
21
+ if stream_id in self.closed_stream_ids:
22
+ raise ProtocolError("HTTP/2 closed stream cannot be reopened")
23
+ state = self.streams.get(stream_id)
24
+ if state is None:
25
+ state = H2StreamState(
26
+ stream_id=stream_id,
27
+ send_window=FlowWindow(send_window),
28
+ receive_window=FlowWindow(receive_window),
29
+ receive_window_target=receive_window,
30
+ )
31
+ self.streams[stream_id] = state
32
+ else:
33
+ state.send_window = FlowWindow(send_window)
34
+ state.receive_window = FlowWindow(receive_window)
35
+ state.receive_window_target = receive_window
36
+ return state
37
+
38
+ def reserve_local(self, stream_id: int, *, send_window: int, receive_window: int) -> H2StreamState:
39
+ if stream_id in self.closed_stream_ids:
40
+ raise ProtocolError("HTTP/2 closed stream cannot be reopened")
41
+ if stream_id in self.streams:
42
+ raise ProtocolError("HTTP/2 local stream is already active")
43
+ state = H2StreamState(
44
+ stream_id=stream_id,
45
+ send_window=FlowWindow(send_window),
46
+ receive_window=FlowWindow(receive_window),
47
+ receive_window_target=receive_window,
48
+ )
49
+ state.reserve_local()
50
+ self.streams[stream_id] = state
51
+ return state
52
+
53
+ def close(self, stream_id: int) -> None:
54
+ state = self.streams.get(stream_id)
55
+ if state is not None:
56
+ state.local_closed = True
57
+ state.end_stream_received = True
58
+ state._sync_lifecycle()
59
+ self.streams.pop(stream_id, None)
60
+ self.closed_stream_ids.add(stream_id)
61
+
62
+ def apply_window_delta(self, delta: int) -> None:
63
+ for state in self.streams.values():
64
+ state.send_window.adjust(delta)
65
+
66
+ def active_ids(self) -> list[int]:
67
+ return sorted(self.streams)
68
+
69
+ def active_remote_stream_count(self) -> int:
70
+ return sum(1 for stream_id, state in self.streams.items() if stream_id % 2 == 1 and not state.closed)
71
+
72
+ def active_local_stream_count(self) -> int:
73
+ return sum(1 for stream_id, state in self.streams.items() if stream_id % 2 == 0 and not state.closed)
74
+
75
+ def is_closed(self, stream_id: int) -> bool:
76
+ return stream_id in self.closed_stream_ids