tywrap 0.7.0 → 0.8.0
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.
- package/README.md +15 -5
- package/dist/core/annotation-parser.d.ts.map +1 -1
- package/dist/core/annotation-parser.js.map +1 -1
- package/dist/core/emit-call.d.ts.map +1 -1
- package/dist/core/emit-call.js +1 -1
- package/dist/core/emit-call.js.map +1 -1
- package/dist/dev.d.ts.map +1 -1
- package/dist/dev.js +1 -3
- package/dist/dev.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/frame-codec.d.ts +111 -0
- package/dist/runtime/frame-codec.d.ts.map +1 -0
- package/dist/runtime/frame-codec.js +352 -0
- package/dist/runtime/frame-codec.js.map +1 -0
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/runtime/index.js +1 -1
- package/dist/runtime/index.js.map +1 -1
- package/dist/runtime/node.d.ts +13 -0
- package/dist/runtime/node.d.ts.map +1 -1
- package/dist/runtime/node.js +5 -0
- package/dist/runtime/node.js.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
- package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
- package/dist/runtime/rpc-client.d.ts.map +1 -1
- package/dist/runtime/rpc-client.js +53 -6
- package/dist/runtime/rpc-client.js.map +1 -1
- package/dist/runtime/subprocess-transport.d.ts +172 -7
- package/dist/runtime/subprocess-transport.d.ts.map +1 -1
- package/dist/runtime/subprocess-transport.js +513 -31
- package/dist/runtime/subprocess-transport.js.map +1 -1
- package/dist/runtime/transport.d.ts +85 -3
- package/dist/runtime/transport.d.ts.map +1 -1
- package/dist/runtime/transport.js +20 -0
- package/dist/runtime/transport.js.map +1 -1
- package/dist/types/index.d.ts +24 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/tywrap.js +1 -9
- package/dist/tywrap.js.map +1 -1
- package/dist/utils/codec.d.ts.map +1 -1
- package/dist/utils/codec.js +152 -4
- package/dist/utils/codec.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
- package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
- package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
- package/runtime/frame_codec.py +424 -0
- package/runtime/python_bridge.py +241 -42
- package/runtime/tywrap_bridge_core.py +97 -10
- package/src/core/annotation-parser.ts +2 -1
- package/src/core/emit-call.ts +1 -7
- package/src/dev.ts +1 -3
- package/src/index.ts +1 -0
- package/src/runtime/frame-codec.ts +469 -0
- package/src/runtime/index.ts +1 -6
- package/src/runtime/node.ts +25 -1
- package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
- package/src/runtime/rpc-client.ts +74 -7
- package/src/runtime/subprocess-transport.ts +615 -35
- package/src/runtime/transport.ts +101 -3
- package/src/types/index.ts +25 -0
- package/src/tywrap.ts +1 -9
- package/src/utils/codec.ts +184 -3
- package/src/version.ts +1 -1
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pure frame codec + reassembler for the ``tywrap-frame/1`` framing protocol.
|
|
3
|
+
|
|
4
|
+
This is the Python mirror of ``src/runtime/frame-codec.ts``. It fragments one
|
|
5
|
+
complete logical JSON message into chunk frames and reassembles a stream of
|
|
6
|
+
frames back into the original string. It performs NO I/O and reads NO env vars
|
|
7
|
+
(PURITY, matching ``tywrap_bridge_core``): the read/write loop in
|
|
8
|
+
``python_bridge.py`` (W4/W5) wires these functions onto stdin/stdout.
|
|
9
|
+
|
|
10
|
+
Encoding is ``utf8-slice`` (plan decision #6, docs/transport-framing.md): the
|
|
11
|
+
logical payload is already valid-UTF-8 JSON, so each frame's ``data`` is a raw
|
|
12
|
+
substring split on a UTF-8 codepoint boundary at or before ``max_frame_bytes``
|
|
13
|
+
UTF-8 bytes. Reassembly is plain concatenation -- no base64, no ~33% inflation.
|
|
14
|
+
A frame's ``data`` MUST NOT split a multi-byte UTF-8 sequence; ``encode_frames``
|
|
15
|
+
guarantees this by snapping every boundary back to the nearest codepoint
|
|
16
|
+
boundary.
|
|
17
|
+
|
|
18
|
+
The two implementations MUST agree byte-for-byte on the wire (see
|
|
19
|
+
test/python/test_frame_codec.py and test/frame-codec.test.ts for the
|
|
20
|
+
cross-language parity vectors).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Any, Dict, List, Optional
|
|
26
|
+
|
|
27
|
+
# Framing protocol identifier; mirrors FRAME_PROTOCOL_ID in
|
|
28
|
+
# src/runtime/transport.ts. Any other value on the wire is rejected.
|
|
29
|
+
FRAME_PROTOCOL_ID = 'tywrap-frame/1'
|
|
30
|
+
|
|
31
|
+
# Numeric framing-protocol version, derived from the trailing number so the two
|
|
32
|
+
# cannot drift (same pattern as PROTOCOL_VERSION in tywrap_bridge_core).
|
|
33
|
+
FRAME_PROTOCOL_VERSION = int(FRAME_PROTOCOL_ID.split('/')[1])
|
|
34
|
+
|
|
35
|
+
# The single per-frame encoding tywrap emits/accepts in 0.8.0.
|
|
36
|
+
FRAME_ENCODING = 'utf8-slice'
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class FrameError(Exception):
|
|
40
|
+
"""A framing-protocol violation (malformed/duplicate/inconsistent frame).
|
|
41
|
+
|
|
42
|
+
Carries a stable ``code`` mirroring the BridgeProtocolError codes on the TS
|
|
43
|
+
side so callers can branch on the failure class.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
def __init__(self, message: str, *, code: str) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.code = code
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _utf8_bytes_of_codepoint(code_point: int) -> int:
|
|
52
|
+
"""UTF-8 byte length of a single Unicode codepoint."""
|
|
53
|
+
if code_point <= 0x7F:
|
|
54
|
+
return 1
|
|
55
|
+
if code_point <= 0x7FF:
|
|
56
|
+
return 2
|
|
57
|
+
if code_point <= 0xFFFF:
|
|
58
|
+
return 3
|
|
59
|
+
return 4
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def utf8_byte_length(value: str) -> int:
|
|
63
|
+
"""Exact UTF-8 byte length of a string."""
|
|
64
|
+
return len(value.encode('utf-8'))
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def encode_frames(
|
|
68
|
+
logical_json: str,
|
|
69
|
+
*,
|
|
70
|
+
id: int,
|
|
71
|
+
stream: str,
|
|
72
|
+
max_frame_bytes: int,
|
|
73
|
+
) -> List[Dict[str, Any]]:
|
|
74
|
+
"""Fragment a complete logical JSON message into ``tywrap-frame/1`` frames.
|
|
75
|
+
|
|
76
|
+
Splits ``logical_json`` on UTF-8 codepoint boundaries so each frame's
|
|
77
|
+
``data`` is at most ``max_frame_bytes`` UTF-8 bytes and never splits a
|
|
78
|
+
multi-byte sequence. ``totalBytes`` is the exact UTF-8 byte length of the
|
|
79
|
+
full message; ``total`` is the resulting frame count; ``seq`` is zero-based
|
|
80
|
+
and dense.
|
|
81
|
+
|
|
82
|
+
An empty ``logical_json`` still produces exactly one (empty) frame so the
|
|
83
|
+
receiver always sees ``total >= 1`` and a well-formed stream.
|
|
84
|
+
|
|
85
|
+
:raises FrameError: if ``max_frame_bytes`` is not an int >= 4, or ``stream``
|
|
86
|
+
is not ``"request"``/``"response"``.
|
|
87
|
+
"""
|
|
88
|
+
if not isinstance(max_frame_bytes, int) or isinstance(max_frame_bytes, bool) or max_frame_bytes < 4:
|
|
89
|
+
raise FrameError(
|
|
90
|
+
f'encode_frames: max_frame_bytes must be an int >= 4 (got {max_frame_bytes!r})',
|
|
91
|
+
code='FRAME_BAD_MAX_BYTES',
|
|
92
|
+
)
|
|
93
|
+
if stream not in ('request', 'response'):
|
|
94
|
+
raise FrameError(
|
|
95
|
+
f'encode_frames: stream must be "request" or "response" (got {stream!r})',
|
|
96
|
+
code='FRAME_MALFORMED',
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
total_bytes = utf8_byte_length(logical_json)
|
|
100
|
+
|
|
101
|
+
# Walk codepoints, accumulating UTF-8 bytes into the current slice until the
|
|
102
|
+
# next codepoint would exceed max_frame_bytes; that boundary is, by
|
|
103
|
+
# construction, a codepoint boundary so no multi-byte sequence is split.
|
|
104
|
+
slices: List[str] = []
|
|
105
|
+
current_chars: List[str] = []
|
|
106
|
+
current_bytes = 0
|
|
107
|
+
for ch in logical_json:
|
|
108
|
+
ch_bytes = _utf8_bytes_of_codepoint(ord(ch))
|
|
109
|
+
if current_bytes + ch_bytes > max_frame_bytes and current_chars:
|
|
110
|
+
slices.append(''.join(current_chars))
|
|
111
|
+
current_chars = []
|
|
112
|
+
current_bytes = 0
|
|
113
|
+
current_chars.append(ch)
|
|
114
|
+
current_bytes += ch_bytes
|
|
115
|
+
# Always emit a final slice (covers the empty-string case: one empty frame).
|
|
116
|
+
slices.append(''.join(current_chars))
|
|
117
|
+
|
|
118
|
+
total = len(slices)
|
|
119
|
+
return [
|
|
120
|
+
{
|
|
121
|
+
'__tywrap_frame__': 'chunk',
|
|
122
|
+
'frameProtocol': FRAME_PROTOCOL_ID,
|
|
123
|
+
'stream': stream,
|
|
124
|
+
'id': id,
|
|
125
|
+
'seq': seq,
|
|
126
|
+
'total': total,
|
|
127
|
+
'totalBytes': total_bytes,
|
|
128
|
+
'encoding': FRAME_ENCODING,
|
|
129
|
+
'data': data,
|
|
130
|
+
}
|
|
131
|
+
for seq, data in enumerate(slices)
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_chunk_frame(value: Any) -> Dict[str, Any]:
|
|
136
|
+
"""Validate that ``value`` is a structurally well-formed data frame.
|
|
137
|
+
|
|
138
|
+
Returns the frame dict, or raises :class:`FrameError`. Only
|
|
139
|
+
``__tywrap_frame__ == "chunk"`` frames flow through reassembly; ``"error"``
|
|
140
|
+
frames are a transport-layer concern handled above this module.
|
|
141
|
+
"""
|
|
142
|
+
if not isinstance(value, dict):
|
|
143
|
+
raise FrameError('frame: expected an object', code='FRAME_MALFORMED')
|
|
144
|
+
|
|
145
|
+
if value.get('__tywrap_frame__') != 'chunk':
|
|
146
|
+
raise FrameError(
|
|
147
|
+
f'frame: __tywrap_frame__ must be "chunk" (got {value.get("__tywrap_frame__")!r})',
|
|
148
|
+
code='FRAME_MALFORMED',
|
|
149
|
+
)
|
|
150
|
+
if value.get('frameProtocol') != FRAME_PROTOCOL_ID:
|
|
151
|
+
raise FrameError(
|
|
152
|
+
f'frame: unknown frameProtocol {value.get("frameProtocol")!r} '
|
|
153
|
+
f'(expected {FRAME_PROTOCOL_ID})',
|
|
154
|
+
code='FRAME_UNKNOWN_PROTOCOL',
|
|
155
|
+
)
|
|
156
|
+
if value.get('stream') not in ('request', 'response'):
|
|
157
|
+
raise FrameError(
|
|
158
|
+
f'frame: stream must be "request" or "response" (got {value.get("stream")!r})',
|
|
159
|
+
code='FRAME_MALFORMED',
|
|
160
|
+
)
|
|
161
|
+
if value.get('encoding') != FRAME_ENCODING:
|
|
162
|
+
# utf8-base64 is reserved in the schema but never emitted/accepted here.
|
|
163
|
+
raise FrameError(
|
|
164
|
+
f'frame: unsupported encoding {value.get("encoding")!r} '
|
|
165
|
+
f'(only "utf8-slice" in 0.8.0)',
|
|
166
|
+
code='FRAME_MALFORMED',
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
frame_id = value.get('id')
|
|
170
|
+
if not _is_int(frame_id):
|
|
171
|
+
raise FrameError(f'frame: id must be an integer (got {frame_id!r})', code='FRAME_MALFORMED')
|
|
172
|
+
|
|
173
|
+
seq = value.get('seq')
|
|
174
|
+
if not _is_int(seq) or seq < 0:
|
|
175
|
+
raise FrameError(
|
|
176
|
+
f'frame: seq must be a non-negative integer (got {seq!r})',
|
|
177
|
+
code='FRAME_MALFORMED',
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
total = value.get('total')
|
|
181
|
+
if not _is_int(total) or total < 1:
|
|
182
|
+
raise FrameError(
|
|
183
|
+
f'frame: total must be an integer >= 1 (got {total!r})',
|
|
184
|
+
code='FRAME_MALFORMED',
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
total_bytes = value.get('totalBytes')
|
|
188
|
+
if not _is_int(total_bytes) or total_bytes < 0:
|
|
189
|
+
raise FrameError(
|
|
190
|
+
f'frame: totalBytes must be a non-negative integer (got {total_bytes!r})',
|
|
191
|
+
code='FRAME_MALFORMED',
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
data = value.get('data')
|
|
195
|
+
if not isinstance(data, str):
|
|
196
|
+
raise FrameError(
|
|
197
|
+
f'frame: data must be a string (got {type(data).__name__})',
|
|
198
|
+
code='FRAME_MALFORMED',
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if seq >= total:
|
|
202
|
+
raise FrameError(
|
|
203
|
+
f'frame: seq {seq} out of range for total {total}',
|
|
204
|
+
code='FRAME_MALFORMED',
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
return value
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _is_int(value: Any) -> bool:
|
|
211
|
+
"""True for a real integer (rejecting bool, which is an int subclass)."""
|
|
212
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
# Defensive bounds on per-id reassembly state — mirror of src/runtime/frame-codec.ts.
|
|
216
|
+
# The peer is the local Python bridge, but a buggy/corrupt bridge must not grow
|
|
217
|
+
# memory without limit: cap concurrent mid-reassembly ids (fail loud past the cap)
|
|
218
|
+
# and FIFO-bound the timed-out-id discard set so a long-lived process whose
|
|
219
|
+
# timed-out streams never see their final frame does not leak markers forever.
|
|
220
|
+
MAX_CONCURRENT_STREAMS = 1024
|
|
221
|
+
MAX_DISCARDED_IDS = 4096
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
class Reassembler:
|
|
225
|
+
"""Accumulate ``tywrap-frame/1`` frames by ``id`` and reconstruct the string.
|
|
226
|
+
|
|
227
|
+
Mirror of the TypeScript ``Reassembler`` class. A single instance handles
|
|
228
|
+
many concurrent ids. Validation is enforced on every :meth:`accept`:
|
|
229
|
+
|
|
230
|
+
- matching ``FRAME_PROTOCOL_ID`` on every frame;
|
|
231
|
+
- consistent ``total`` / ``totalBytes`` across all frames of an id;
|
|
232
|
+
- no duplicate ``seq``;
|
|
233
|
+
- on completion, exactly ``total`` frames covering ``[0, total)``;
|
|
234
|
+
- the concatenated payload's UTF-8 byte length equals ``totalBytes`` exactly;
|
|
235
|
+
- the concatenated payload is valid UTF-8.
|
|
236
|
+
|
|
237
|
+
Timed-out ids: the transport marks an id timed out via :meth:`discard`.
|
|
238
|
+
Every subsequent frame for that id is dropped (returning ``None``) until its
|
|
239
|
+
final frame arrives, at which point the id is forgotten so the slot can be
|
|
240
|
+
reused. This prevents late multi-frame responses from desyncing the stream.
|
|
241
|
+
"""
|
|
242
|
+
|
|
243
|
+
def __init__(
|
|
244
|
+
self,
|
|
245
|
+
max_reassembly_bytes: Optional[int] = None,
|
|
246
|
+
expected_stream: Optional[str] = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
# id -> {'total', 'totalBytes', 'bytesSoFar', 'slices': {seq: data}}
|
|
249
|
+
self._streams: Dict[int, Dict[str, Any]] = {}
|
|
250
|
+
# Insertion-ordered set-as-dict so the oldest marker can be FIFO-evicted
|
|
251
|
+
# once the discard set exceeds MAX_DISCARDED_IDS.
|
|
252
|
+
self._discarded: Dict[int, None] = {}
|
|
253
|
+
# Fail loud past this many UTF-8 bytes per stream (declared OR
|
|
254
|
+
# accumulated) so a huge payload cannot be buffered to OOM; None = no cap.
|
|
255
|
+
self._max_reassembly_bytes = max_reassembly_bytes
|
|
256
|
+
# If set, every frame must carry this stream direction (defense-in-depth).
|
|
257
|
+
self._expected_stream = expected_stream
|
|
258
|
+
|
|
259
|
+
def accept(self, raw_frame: Any) -> Optional[str]:
|
|
260
|
+
"""Feed one frame.
|
|
261
|
+
|
|
262
|
+
Returns the fully reassembled logical string when this frame completes
|
|
263
|
+
the stream for its id, ``None`` if more frames are still needed (or the
|
|
264
|
+
frame was dropped because its id is timed out).
|
|
265
|
+
|
|
266
|
+
:raises FrameError: on any framing violation.
|
|
267
|
+
"""
|
|
268
|
+
frame = parse_chunk_frame(raw_frame)
|
|
269
|
+
frame_id = frame['id']
|
|
270
|
+
seq = frame['seq']
|
|
271
|
+
total = frame['total']
|
|
272
|
+
total_bytes = frame['totalBytes']
|
|
273
|
+
data = frame['data']
|
|
274
|
+
stream = frame['stream']
|
|
275
|
+
|
|
276
|
+
if self._expected_stream is not None and stream != self._expected_stream:
|
|
277
|
+
raise FrameError(
|
|
278
|
+
f"frame: unexpected stream '{stream}' for id {frame_id} "
|
|
279
|
+
f"(this reassembler handles '{self._expected_stream}')",
|
|
280
|
+
code='FRAME_WRONG_STREAM',
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# Late-frame discard: drop frames for a timed-out id; forget the id once
|
|
284
|
+
# its declared final frame has been seen so the stream stays aligned and
|
|
285
|
+
# the id can be reused.
|
|
286
|
+
if frame_id in self._discarded:
|
|
287
|
+
if seq == total - 1:
|
|
288
|
+
self._discarded.pop(frame_id, None)
|
|
289
|
+
return None
|
|
290
|
+
|
|
291
|
+
state = self._streams.get(frame_id)
|
|
292
|
+
if state is None:
|
|
293
|
+
if len(self._streams) >= MAX_CONCURRENT_STREAMS:
|
|
294
|
+
raise FrameError(
|
|
295
|
+
f'frame: too many concurrent reassembly streams '
|
|
296
|
+
f'(>= {MAX_CONCURRENT_STREAMS}); refusing to buffer id {frame_id}',
|
|
297
|
+
code='FRAME_TOO_MANY_STREAMS',
|
|
298
|
+
)
|
|
299
|
+
if (
|
|
300
|
+
self._max_reassembly_bytes is not None
|
|
301
|
+
and total_bytes > self._max_reassembly_bytes
|
|
302
|
+
):
|
|
303
|
+
raise FrameError(
|
|
304
|
+
f'frame: declared payload {total_bytes} bytes exceeds max '
|
|
305
|
+
f'reassembly {self._max_reassembly_bytes} bytes for id {frame_id}',
|
|
306
|
+
code='FRAME_PAYLOAD_TOO_LARGE',
|
|
307
|
+
)
|
|
308
|
+
state = {
|
|
309
|
+
'total': total,
|
|
310
|
+
'totalBytes': total_bytes,
|
|
311
|
+
'bytesSoFar': 0,
|
|
312
|
+
'slices': {},
|
|
313
|
+
}
|
|
314
|
+
self._streams[frame_id] = state
|
|
315
|
+
else:
|
|
316
|
+
if state['total'] != total:
|
|
317
|
+
del self._streams[frame_id]
|
|
318
|
+
raise FrameError(
|
|
319
|
+
f'frame: total mismatch for id {frame_id} '
|
|
320
|
+
f'(saw {state["total"]}, frame says {total})',
|
|
321
|
+
code='FRAME_INCONSISTENT',
|
|
322
|
+
)
|
|
323
|
+
if state['totalBytes'] != total_bytes:
|
|
324
|
+
del self._streams[frame_id]
|
|
325
|
+
raise FrameError(
|
|
326
|
+
f'frame: totalBytes mismatch for id {frame_id} '
|
|
327
|
+
f'(saw {state["totalBytes"]}, frame says {total_bytes})',
|
|
328
|
+
code='FRAME_INCONSISTENT',
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
slices: Dict[int, str] = state['slices']
|
|
332
|
+
if seq in slices:
|
|
333
|
+
del self._streams[frame_id]
|
|
334
|
+
raise FrameError(
|
|
335
|
+
f'frame: duplicate seq {seq} for id {frame_id}',
|
|
336
|
+
code='FRAME_DUPLICATE_SEQ',
|
|
337
|
+
)
|
|
338
|
+
slices[seq] = data
|
|
339
|
+
|
|
340
|
+
# Running memory bound (mirrors the declared-size check above): a peer
|
|
341
|
+
# that under-declares totalBytes then overshoots is caught before the
|
|
342
|
+
# full payload is buffered.
|
|
343
|
+
state['bytesSoFar'] += utf8_byte_length(data)
|
|
344
|
+
if (
|
|
345
|
+
self._max_reassembly_bytes is not None
|
|
346
|
+
and state['bytesSoFar'] > self._max_reassembly_bytes
|
|
347
|
+
):
|
|
348
|
+
del self._streams[frame_id]
|
|
349
|
+
raise FrameError(
|
|
350
|
+
f'frame: accumulated payload exceeds max reassembly '
|
|
351
|
+
f'{self._max_reassembly_bytes} bytes for id {frame_id}',
|
|
352
|
+
code='FRAME_PAYLOAD_TOO_LARGE',
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
if len(slices) < total:
|
|
356
|
+
return None
|
|
357
|
+
|
|
358
|
+
# All `total` frames present; the dense [0, total) range is guaranteed
|
|
359
|
+
# because each seq is in range, unique, and there are exactly `total` of
|
|
360
|
+
# them. Concatenate in seq order.
|
|
361
|
+
del self._streams[frame_id]
|
|
362
|
+
parts: List[str] = []
|
|
363
|
+
for i in range(total):
|
|
364
|
+
if i not in slices:
|
|
365
|
+
# Unreachable given the count + uniqueness + range invariants,
|
|
366
|
+
# but kept explicit rather than a silent gap.
|
|
367
|
+
raise FrameError(
|
|
368
|
+
f'frame: missing seq {i} for id {frame_id}',
|
|
369
|
+
code='FRAME_SEQ_GAP',
|
|
370
|
+
)
|
|
371
|
+
parts.append(slices[i])
|
|
372
|
+
payload = ''.join(parts)
|
|
373
|
+
|
|
374
|
+
actual_bytes = utf8_byte_length(payload)
|
|
375
|
+
if actual_bytes != total_bytes:
|
|
376
|
+
raise FrameError(
|
|
377
|
+
f'frame: reassembled byte length {actual_bytes} != declared '
|
|
378
|
+
f'totalBytes {total_bytes} for id {frame_id}',
|
|
379
|
+
code='FRAME_BYTES_MISMATCH',
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
# Strict UTF-8 validation. With utf8-slice the concatenation cannot
|
|
383
|
+
# introduce invalid sequences (each slice is whole codepoints), but the
|
|
384
|
+
# spec requires the check explicitly.
|
|
385
|
+
try:
|
|
386
|
+
payload.encode('utf-8').decode('utf-8')
|
|
387
|
+
except UnicodeError as exc: # pragma: no cover - defensive
|
|
388
|
+
raise FrameError(
|
|
389
|
+
f'frame: reassembled payload is not valid UTF-8 for id {frame_id}',
|
|
390
|
+
code='FRAME_INVALID_UTF8',
|
|
391
|
+
) from exc
|
|
392
|
+
|
|
393
|
+
return payload
|
|
394
|
+
|
|
395
|
+
def discard(self, frame_id: int) -> None:
|
|
396
|
+
"""Mark an id as timed out / aborted.
|
|
397
|
+
|
|
398
|
+
Drops any partial state immediately and discards every subsequent frame
|
|
399
|
+
for this id until its declared final frame arrives. Idempotent.
|
|
400
|
+
"""
|
|
401
|
+
self._streams.pop(frame_id, None)
|
|
402
|
+
self._discarded[frame_id] = None
|
|
403
|
+
# Bound the discard set (FIFO): a timed-out id whose declared final frame
|
|
404
|
+
# never arrives would otherwise linger forever.
|
|
405
|
+
if len(self._discarded) > MAX_DISCARDED_IDS:
|
|
406
|
+
oldest = next(iter(self._discarded))
|
|
407
|
+
self._discarded.pop(oldest, None)
|
|
408
|
+
|
|
409
|
+
def is_pending(self, frame_id: int) -> bool:
|
|
410
|
+
"""Whether any frame for ``frame_id`` is still being accumulated."""
|
|
411
|
+
return frame_id in self._streams
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def pending_count(self) -> int:
|
|
415
|
+
"""Number of ids currently mid-reassembly (for diagnostics/tests)."""
|
|
416
|
+
return len(self._streams)
|
|
417
|
+
|
|
418
|
+
@property
|
|
419
|
+
def discarded_count(self) -> int:
|
|
420
|
+
"""Number of timed-out ids whose late frames are still being dropped.
|
|
421
|
+
|
|
422
|
+
FIFO-bounded by ``MAX_DISCARDED_IDS`` (for diagnostics/tests).
|
|
423
|
+
"""
|
|
424
|
+
return len(self._discarded)
|