tywrap 0.8.0 → 0.10.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.
Files changed (159) hide show
  1. package/README.md +42 -22
  2. package/SECURITY.md +38 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/index.js +8 -0
  5. package/dist/config/index.js.map +1 -1
  6. package/dist/core/annotation-parser.d.ts +2 -1
  7. package/dist/core/annotation-parser.d.ts.map +1 -1
  8. package/dist/core/annotation-parser.js +6 -3
  9. package/dist/core/annotation-parser.js.map +1 -1
  10. package/dist/core/generator.d.ts +23 -4
  11. package/dist/core/generator.d.ts.map +1 -1
  12. package/dist/core/generator.js +254 -170
  13. package/dist/core/generator.js.map +1 -1
  14. package/dist/core/mapper.d.ts +3 -2
  15. package/dist/core/mapper.d.ts.map +1 -1
  16. package/dist/core/mapper.js +5 -5
  17. package/dist/core/mapper.js.map +1 -1
  18. package/dist/index.d.ts +7 -5
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +6 -4
  21. package/dist/index.js.map +1 -1
  22. package/dist/runtime/base-bridge.d.ts +3 -7
  23. package/dist/runtime/base-bridge.d.ts.map +1 -1
  24. package/dist/runtime/base-bridge.js +4 -17
  25. package/dist/runtime/base-bridge.js.map +1 -1
  26. package/dist/runtime/bounded-context.d.ts +3 -22
  27. package/dist/runtime/bounded-context.d.ts.map +1 -1
  28. package/dist/runtime/bounded-context.js +13 -53
  29. package/dist/runtime/bounded-context.js.map +1 -1
  30. package/dist/runtime/bridge-codec.d.ts +3 -3
  31. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  32. package/dist/runtime/bridge-codec.js +129 -62
  33. package/dist/runtime/bridge-codec.js.map +1 -1
  34. package/dist/runtime/errors.d.ts +16 -0
  35. package/dist/runtime/errors.d.ts.map +1 -1
  36. package/dist/runtime/errors.js +17 -0
  37. package/dist/runtime/errors.js.map +1 -1
  38. package/dist/runtime/http-transport.d.ts +1 -1
  39. package/dist/runtime/http-transport.d.ts.map +1 -1
  40. package/dist/runtime/http-transport.js +1 -1
  41. package/dist/runtime/http-transport.js.map +1 -1
  42. package/dist/runtime/index.d.ts +1 -0
  43. package/dist/runtime/index.d.ts.map +1 -1
  44. package/dist/runtime/index.js +1 -0
  45. package/dist/runtime/index.js.map +1 -1
  46. package/dist/runtime/node.d.ts +7 -36
  47. package/dist/runtime/node.d.ts.map +1 -1
  48. package/dist/runtime/node.js +2 -80
  49. package/dist/runtime/node.js.map +1 -1
  50. package/dist/runtime/pooled-transport.d.ts +120 -59
  51. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  52. package/dist/runtime/pooled-transport.js +345 -78
  53. package/dist/runtime/pooled-transport.js.map +1 -1
  54. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  55. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  56. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  57. package/dist/runtime/pyodide-transport.d.ts +1 -1
  58. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  59. package/dist/runtime/pyodide-transport.js +2 -3
  60. package/dist/runtime/pyodide-transport.js.map +1 -1
  61. package/dist/runtime/rpc-client.d.ts +7 -36
  62. package/dist/runtime/rpc-client.d.ts.map +1 -1
  63. package/dist/runtime/rpc-client.js +20 -102
  64. package/dist/runtime/rpc-client.js.map +1 -1
  65. package/dist/runtime/subprocess-transport.d.ts +30 -69
  66. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  67. package/dist/runtime/subprocess-transport.js +179 -236
  68. package/dist/runtime/subprocess-transport.js.map +1 -1
  69. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  70. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  71. package/dist/runtime/transport.d.ts +9 -19
  72. package/dist/runtime/transport.d.ts.map +1 -1
  73. package/dist/runtime/transport.js +1 -1
  74. package/dist/runtime/transport.js.map +1 -1
  75. package/dist/runtime/validators.d.ts +50 -0
  76. package/dist/runtime/validators.d.ts.map +1 -1
  77. package/dist/runtime/validators.js +154 -0
  78. package/dist/runtime/validators.js.map +1 -1
  79. package/dist/types/index.d.ts +14 -39
  80. package/dist/types/index.d.ts.map +1 -1
  81. package/dist/tywrap.d.ts +3 -2
  82. package/dist/tywrap.d.ts.map +1 -1
  83. package/dist/tywrap.js +140 -13
  84. package/dist/tywrap.js.map +1 -1
  85. package/dist/utils/cache.d.ts +3 -16
  86. package/dist/utils/cache.d.ts.map +1 -1
  87. package/dist/utils/codec.d.ts +17 -0
  88. package/dist/utils/codec.d.ts.map +1 -1
  89. package/dist/utils/codec.js +500 -85
  90. package/dist/utils/codec.js.map +1 -1
  91. package/dist/utils/ir-cache.d.ts +2 -1
  92. package/dist/utils/ir-cache.d.ts.map +1 -1
  93. package/dist/utils/runtime.d.ts +0 -29
  94. package/dist/utils/runtime.d.ts.map +1 -1
  95. package/dist/utils/runtime.js +16 -107
  96. package/dist/utils/runtime.js.map +1 -1
  97. package/dist/version.d.ts.map +1 -1
  98. package/dist/version.js +1 -1
  99. package/dist/version.js.map +1 -1
  100. package/package.json +7 -6
  101. package/runtime/frame_codec.py +7 -1
  102. package/runtime/python_bridge.py +85 -125
  103. package/runtime/tywrap_bridge_core.py +515 -187
  104. package/src/config/index.ts +11 -0
  105. package/src/core/annotation-parser.ts +7 -4
  106. package/src/core/generator.ts +330 -205
  107. package/src/core/mapper.ts +8 -8
  108. package/src/index.ts +7 -4
  109. package/src/runtime/base-bridge.ts +5 -30
  110. package/src/runtime/bounded-context.ts +12 -67
  111. package/src/runtime/bridge-codec.ts +158 -74
  112. package/src/runtime/errors.ts +21 -0
  113. package/src/runtime/http-transport.ts +6 -1
  114. package/src/runtime/index.ts +6 -0
  115. package/src/runtime/node.ts +9 -120
  116. package/src/runtime/pooled-transport.ts +424 -90
  117. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  118. package/src/runtime/pyodide-transport.ts +7 -3
  119. package/src/runtime/rpc-client.ts +37 -139
  120. package/src/runtime/subprocess-transport.ts +210 -285
  121. package/src/runtime/timed-out-request-tracker.ts +1 -1
  122. package/src/runtime/transport.ts +15 -23
  123. package/src/runtime/validators.ts +212 -0
  124. package/src/types/index.ts +21 -60
  125. package/src/tywrap.ts +157 -22
  126. package/src/utils/cache.ts +3 -3
  127. package/src/utils/codec.ts +749 -112
  128. package/src/utils/ir-cache.ts +1 -1
  129. package/src/utils/runtime.ts +17 -128
  130. package/src/version.ts +1 -1
  131. package/dist/core/discovery.d.ts +0 -103
  132. package/dist/core/discovery.d.ts.map +0 -1
  133. package/dist/core/discovery.js +0 -380
  134. package/dist/core/discovery.js.map +0 -1
  135. package/dist/core/validation.d.ts +0 -102
  136. package/dist/core/validation.d.ts.map +0 -1
  137. package/dist/core/validation.js +0 -490
  138. package/dist/core/validation.js.map +0 -1
  139. package/dist/runtime/base.d.ts +0 -22
  140. package/dist/runtime/base.d.ts.map +0 -1
  141. package/dist/runtime/base.js +0 -23
  142. package/dist/runtime/base.js.map +0 -1
  143. package/dist/runtime/transport-pool.d.ts +0 -196
  144. package/dist/runtime/transport-pool.d.ts.map +0 -1
  145. package/dist/runtime/transport-pool.js +0 -418
  146. package/dist/runtime/transport-pool.js.map +0 -1
  147. package/runtime/__pycache__/_tywrap_conformance_chunking_fixtures.cpython-311.pyc +0 -0
  148. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  149. package/runtime/__pycache__/_tywrap_w4_chunking_fixture.cpython-311.pyc +0 -0
  150. package/runtime/__pycache__/_tywrap_w5_request_chunking_fixture.cpython-311.pyc +0 -0
  151. package/runtime/__pycache__/_tywrap_w6_pool_chunking_fixture.cpython-311.pyc +0 -0
  152. package/runtime/__pycache__/frame_codec.cpython-311.pyc +0 -0
  153. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  154. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  155. package/runtime/safe_codec.py +0 -352
  156. package/src/core/discovery.ts +0 -477
  157. package/src/core/validation.ts +0 -729
  158. package/src/runtime/base.ts +0 -24
  159. package/src/runtime/transport-pool.ts +0 -538
@@ -1,9 +1,14 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- Reference tywrap Python bridge server (subprocess + HTTP transports).
3
+ Reference tywrap Python subprocess bridge.
4
+
5
+ This module implements only the stdin/stdout subprocess loop for the Node/Bun/
6
+ Deno transport. `HttpBridge` connects to an HTTP server provided by the
7
+ operator; tywrap does not ship that server or an HTTP transport in this module.
8
+ The operator is responsible for securing any network exposure.
4
9
 
5
10
  This module owns the I/O concerns and process identity for the Node/Bun/Deno
6
- subprocess transport and the HTTP transport:
11
+ subprocess transport:
7
12
  - the stdin/stdout JSONL request/response loop (main())
8
13
  - env-var size guards (TYWRAP_CODEC_MAX_BYTES / TYWRAP_REQUEST_MAX_BYTES)
9
14
  - TYWRAP_CODEC_FALLBACK=json marker mode and TYWRAP_TORCH_ALLOW_COPY
@@ -14,7 +19,7 @@ subprocess transport and the HTTP transport:
14
19
  the explicit byte-size limit error message)
15
20
 
16
21
  SECURITY / TRUST MODEL: the bridge imports the requested module and getattrs the
17
- requested function/class/method, so call/instantiate/call_method are an arbitrary
22
+ requested function/class method, so call is an arbitrary
18
23
  import+getattr+call surface. Two guards (implemented in tywrap_bridge_core) bound
19
24
  that surface:
20
25
  * TYWRAP_ALLOWED_MODULES (comma/space separated): when set, only those modules
@@ -37,10 +42,8 @@ import json
37
42
  import os
38
43
  import importlib # noqa: F401 (re-exported for compat / used by handlers via core)
39
44
 
40
- from safe_codec import BridgeCodec, CodecError
41
-
42
45
  import tywrap_bridge_core as core
43
- from frame_codec import FRAME_PROTOCOL_ID, FrameError, Reassembler, encode_frames
46
+ from frame_codec import FrameError, Reassembler, encode_frames
44
47
 
45
48
  # Re-export the shared protocol/serialization surface so existing importers of
46
49
  # python_bridge keep working after the extraction (codex-flagged: runtime/ ships).
@@ -48,8 +51,9 @@ from tywrap_bridge_core import ( # noqa: F401
48
51
  PROTOCOL,
49
52
  PROTOCOL_VERSION,
50
53
  CODEC_VERSION,
54
+ BridgeCodec,
55
+ CodecError,
51
56
  ProtocolError,
52
- InstanceHandleError,
53
57
  ImportNotAllowedError,
54
58
  AttributeNotAllowedError,
55
59
  import_allowed_module,
@@ -90,8 +94,6 @@ except (OSError, ValueError, TypeError, AttributeError) as exc:
90
94
  except (OSError, ValueError):
91
95
  pass
92
96
 
93
- instances = {}
94
-
95
97
  FALLBACK_JSON = os.environ.get('TYWRAP_CODEC_FALLBACK', '').lower() == 'json'
96
98
  TORCH_ALLOW_COPY = os.environ.get('TYWRAP_TORCH_ALLOW_COPY', '').lower() in ('1', 'true', 'yes')
97
99
  BRIDGE_NAME = 'python-subprocess'
@@ -222,70 +224,22 @@ def get_request_max_bytes():
222
224
  REQUEST_MAX_BYTES = get_request_max_bytes()
223
225
 
224
226
 
225
- class TransportFrameBytesParseError(CodecConfigError):
226
- """Invalid TYWRAP_TRANSPORT_MAX_FRAME_BYTES value."""
227
-
228
- def __init__(self) -> None:
229
- super().__init__('TYWRAP_TRANSPORT_MAX_FRAME_BYTES must be a positive integer byte count')
230
-
227
+ def get_transport_frame_bytes():
228
+ """Read the private frame ceiling argument supplied by SubprocessTransport."""
229
+ if "--tywrap-max-frame-bytes" not in sys.argv:
230
+ return 100 * 1024 * 1024
231
+ try:
232
+ index = sys.argv.index("--tywrap-max-frame-bytes")
233
+ value = int(sys.argv[index + 1])
234
+ except (ValueError, IndexError) as exc:
235
+ raise CodecConfigError("--tywrap-max-frame-bytes must be a positive integer") from exc
236
+ if value <= 0:
237
+ raise CodecConfigError("--tywrap-max-frame-bytes must be a positive integer")
238
+ return value
231
239
 
232
- def negotiate_chunking():
233
- """
234
- Resolve the chunked-transport (``tywrap-frame/1``) negotiation from env.
235
240
 
236
- The subprocess transport spawns this bridge with three env vars
237
- (see docs/transport-framing.md):
241
+ MAX_FRAME_BYTES = get_transport_frame_bytes()
238
242
 
239
- * ``TYWRAP_TRANSPORT_CHUNKING=1`` -- enable framing
240
- * ``TYWRAP_TRANSPORT_FRAME_PROTOCOL`` -- must equal ``tywrap-frame/1``
241
- * ``TYWRAP_TRANSPORT_MAX_FRAME_BYTES`` -- the JS-side JSONL line ceiling
242
-
243
- Returns ``(enabled, max_frame_bytes)``. Chunking is enabled ONLY when all
244
- three agree: the flag is truthy, the advertised frame protocol matches the
245
- one this bridge implements, and the max-frame size is a positive integer.
246
- A mismatched frame protocol (a future framing version this bridge does not
247
- speak) leaves chunking disabled -- the bridge then advertises
248
- ``supportsChunking: false`` and oversize responses fail LOUD (no silent
249
- single-frame fallback), exactly as an old un-negotiated bridge would.
250
-
251
- :raises TransportFrameBytesParseError: if the flag/protocol are set to enable
252
- chunking but the max-frame-bytes value is not a positive integer.
253
- """
254
- flag = os.environ.get('TYWRAP_TRANSPORT_CHUNKING', '').lower() in ('1', 'true', 'yes')
255
- if not flag:
256
- return False, None
257
- frame_protocol = os.environ.get('TYWRAP_TRANSPORT_FRAME_PROTOCOL', '')
258
- if frame_protocol != FRAME_PROTOCOL_ID:
259
- # A framing protocol this bridge does not implement: stay single-frame.
260
- return False, None
261
- raw = os.environ.get('TYWRAP_TRANSPORT_MAX_FRAME_BYTES', '')
262
- raw = str(raw).strip()
263
- try:
264
- value = int(raw)
265
- except Exception as exc:
266
- raise TransportFrameBytesParseError() from exc
267
- if value <= 0:
268
- raise TransportFrameBytesParseError()
269
- return True, value
270
-
271
-
272
- # Why: parse once at startup. CHUNKING_ENABLED gates the chunked write path and
273
- # the transport block advertised in meta; MAX_FRAME_BYTES is the negotiated
274
- # per-frame UTF-8 byte ceiling.
275
- CHUNKING_ENABLED, MAX_FRAME_BYTES = negotiate_chunking()
276
-
277
- # The transport negotiation block echoed back in the `meta` response so the JS
278
- # side learns this bridge can reassemble chunked frames. None => omitted (an old
279
- # bridge / un-negotiated process is indistinguishable on the wire).
280
- TRANSPORT_INFO = (
281
- {
282
- 'frameProtocol': FRAME_PROTOCOL_ID,
283
- 'supportsChunking': True,
284
- 'maxFrameBytes': MAX_FRAME_BYTES,
285
- }
286
- if CHUNKING_ENABLED
287
- else None
288
- )
289
243
 
290
244
 
291
245
  def serialize(obj):
@@ -329,16 +283,14 @@ def handle_meta():
329
283
  real pid and bridge='python-subprocess'.
330
284
  """
331
285
  return core.build_meta(
332
- instances,
333
286
  bridge=BRIDGE_NAME,
334
287
  pid=os.getpid(),
335
288
  python_version=sys.version.split()[0],
336
289
  codec_fallback='json' if FALLBACK_JSON else 'none',
337
- transport_info=TRANSPORT_INFO,
338
290
  )
339
291
 
340
292
 
341
- def dispatch_request(msg):
293
+ def dispatch_request(msg, *, has_envelope_markers=True):
342
294
  """
343
295
  Dispatch a validated request to the correct handler (subprocess identity).
344
296
 
@@ -348,7 +300,6 @@ def dispatch_request(msg):
348
300
  """
349
301
  out = core.dispatch_request(
350
302
  msg,
351
- instances,
352
303
  bridge=BRIDGE_NAME,
353
304
  pid=os.getpid(),
354
305
  force_json_markers=FALLBACK_JSON,
@@ -357,7 +308,7 @@ def dispatch_request(msg):
357
308
  torch_allow_copy=TORCH_ALLOW_COPY,
358
309
  allowed_modules=ALLOWED_MODULES,
359
310
  allow_private_attrs=ALLOW_PRIVATE_ATTRS,
360
- transport_info=TRANSPORT_INFO,
311
+ has_envelope_markers=has_envelope_markers,
361
312
  )
362
313
  return out['id'], out['result']
363
314
 
@@ -374,10 +325,11 @@ def encode_response(out):
374
325
  except CodecError as exc:
375
326
  # Convert CodecError to ValueError for consistent error handling
376
327
  raise ValueError(str(exc)) from exc
377
- payload_bytes = len(payload.encode('utf-8'))
328
+ payload_utf8 = payload.encode('utf-8')
329
+ payload_bytes = len(payload_utf8)
378
330
  if CODEC_MAX_BYTES is not None and payload_bytes > CODEC_MAX_BYTES:
379
331
  raise PayloadTooLargeError(payload_bytes, CODEC_MAX_BYTES)
380
- return payload
332
+ return payload, payload_utf8
381
333
 
382
334
 
383
335
  def write_payload(payload: str) -> bool:
@@ -395,22 +347,17 @@ def write_payload(payload: str) -> bool:
395
347
  return False
396
348
 
397
349
 
398
- def write_response(payload: str, response_id) -> bool:
350
+ def write_response(payload: str, payload_utf8: bytes, response_id) -> bool:
399
351
  """
400
352
  Write a fully-encoded JSONL response, fragmenting it into ``tywrap-frame/1``
401
- frames when chunking is negotiated and the payload exceeds the per-frame
402
- ceiling.
353
+ frames when the payload exceeds the fixed per-frame ceiling.
403
354
 
404
355
  Why: a single response can exceed the JS-side JSONL line ceiling
405
- (``maxLineLength``). When the subprocess transport negotiated chunking (the
406
- three ``TYWRAP_TRANSPORT_*`` env vars), an oversize response is split into
407
- frames, each written as its own JSONL line and flushed one at a time so the
408
- OS pipe provides backpressure. The TS reassembler (W3) rebuilds the single
409
- logical response before the codec ever sees it. Small responses (or any
410
- response when chunking was not negotiated) keep going out as one JSONL line
411
- exactly as before. There is NO silent single-frame fallback for an oversize
412
- response on an un-negotiated bridge: it is written whole and the JS line
413
- ceiling rejects it LOUD, by design.
356
+ (``maxLineLength``). The packaged subprocess transport always enables
357
+ framing, so an oversize response is split into frames, each written as its
358
+ own JSONL line and flushed one at a time so the OS pipe provides
359
+ backpressure. The TS reassembler rebuilds the logical response before the
360
+ codec sees it. Small responses keep the single-line fast path.
414
361
 
415
362
  Frames require an integer correlation id. A response with a non-integer id
416
363
  (only reachable on a malformed request whose error envelope carries id=None)
@@ -419,10 +366,7 @@ def write_response(payload: str, response_id) -> bool:
419
366
  Returns False if the parent's stdin/our stdout closed mid-write (BrokenPipe),
420
367
  so the caller can exit the loop cleanly.
421
368
  """
422
- if not CHUNKING_ENABLED or MAX_FRAME_BYTES is None:
423
- return write_payload(payload)
424
-
425
- payload_bytes = len(payload.encode('utf-8'))
369
+ payload_bytes = len(payload_utf8)
426
370
  if payload_bytes <= MAX_FRAME_BYTES:
427
371
  return write_payload(payload)
428
372
 
@@ -437,6 +381,7 @@ def write_response(payload: str, response_id) -> bool:
437
381
  id=response_id,
438
382
  stream='response',
439
383
  max_frame_bytes=MAX_FRAME_BYTES,
384
+ total_bytes=payload_bytes,
440
385
  )
441
386
  for frame in frames:
442
387
  # One frame per JSONL line; flush per frame so the pipe backpressures
@@ -447,15 +392,24 @@ def write_response(payload: str, response_id) -> bool:
447
392
 
448
393
 
449
394
  # Reassembles `tywrap-frame/1` REQUEST frames (W5) back into a single logical
450
- # request line. Created only when chunking is negotiated; None means no request
451
- # framing is expected and every line is a normal single-line request. Restricted
452
- # to the 'request' stream. No reassembly-bytes cap here: the request size is
395
+ # request line. Framing is always accepted and restricted to the 'request'
396
+ # stream. No reassembly-bytes cap here: the request size is
453
397
  # already bounded by the TS codec's maxPayloadBytes on the sending side, and
454
398
  # TYWRAP_REQUEST_MAX_BYTES is enforced on the complete payload after reassembly
455
399
  # (process_request_line), preserving the W5 post-reassembly semantics.
456
- _request_reassembler = (
457
- Reassembler(expected_stream='request') if CHUNKING_ENABLED else None
458
- )
400
+ _request_reassembler = Reassembler(expected_stream='request')
401
+
402
+
403
+ def _accept_request_frame(frame):
404
+ """Accept one request frame, abandoning a different older serialized burst."""
405
+ frame_id = frame.get('id')
406
+ if frame.get('seq') == 0 and not _request_reassembler.is_pending(frame_id):
407
+ # The stdin loop is deliberately blocking/serial: an abandoned partial
408
+ # buffer persists while the worker is idle, then is freed by the next
409
+ # logical request (or by process restart). A background TTL timer would
410
+ # add concurrency solely to reclaim memory during an otherwise idle loop.
411
+ _request_reassembler.clear_pending()
412
+ return _request_reassembler.accept(frame)
459
413
 
460
414
 
461
415
  def _try_parse_frame_line(line):
@@ -463,7 +417,7 @@ def _try_parse_frame_line(line):
463
417
  Return a frame dict if ``line`` is a ``tywrap-frame/1`` envelope, else None.
464
418
 
465
419
  Why: a request frame is a JSON object carrying ``__tywrap_frame__``. We only
466
- treat a line as a frame when chunking was negotiated AND it parses as such an
420
+ treat a line as a frame when it parses as such an
467
421
  object; anything else (including invalid JSON) falls through to the normal
468
422
  single-line request path, which reports the JSON error exactly as before.
469
423
  Structural validity (protocol, seq/total ranges, etc.) is enforced by the
@@ -504,7 +458,8 @@ def process_request_line(line):
504
458
  # Why: preserve request ids even when handlers raise.
505
459
  mid = req_id
506
460
  try:
507
- mid, result = dispatch_request(msg)
461
+ has_envelope_markers = '__tywrap' in line or '__type__' in line
462
+ mid, result = dispatch_request(msg, has_envelope_markers=has_envelope_markers)
508
463
  out = {'id': mid, 'protocol': PROTOCOL, 'result': result}
509
464
  except ProtocolError as e:
510
465
  emit_protocol_diagnostic(str(e))
@@ -523,11 +478,11 @@ def process_request_line(line):
523
478
  out = build_error_payload(mid, e, include_traceback=False)
524
479
 
525
480
  try:
526
- payload = encode_response(out)
481
+ payload, payload_utf8 = encode_response(out)
527
482
  # Correlate frames by the response id when chunking; out always
528
483
  # carries the request id (or None for a malformed-request envelope).
529
484
  response_id = out.get('id') if isinstance(out, dict) else None
530
- if not write_response(payload, response_id):
485
+ if not write_response(payload, payload_utf8, response_id):
531
486
  return False
532
487
  except Exception as e: # noqa: BLE001
533
488
  # Why: fallback error keeps responses well-formed even if serialization fails.
@@ -548,30 +503,35 @@ def main():
548
503
  if not line:
549
504
  continue
550
505
 
551
- # W5: when chunking is negotiated, a line may be a `tywrap-frame/1`
552
- # REQUEST frame. Reassemble per id; only the completed logical request is
553
- # handed to process_request_line (which then enforces the request-size
554
- # guard on the reassembled payload). Non-frame lines are normal requests.
555
- if _request_reassembler is not None:
556
- frame = _try_parse_frame_line(line)
557
- if frame is not None:
558
- try:
559
- reassembled = _request_reassembler.accept(frame)
560
- except FrameError as exc:
561
- # A framing-protocol violation desyncs the request stream and
562
- # there is no correlatable response to write (the id may be
563
- # malformed). Fail LOUD on stderr and stop the loop so the
564
- # transport restarts the bridge rather than silently
565
- # mis-parsing subsequent frames.
566
- emit_protocol_diagnostic(f'Request frame error: {exc}')
567
- return
568
- if reassembled is None:
569
- # More frames needed for this id; await the rest.
570
- continue
571
- if not process_request_line(reassembled):
572
- return
506
+ # A line may be a `tywrap-frame/1` REQUEST frame. Reassemble per id; only
507
+ # the completed logical request is handed to process_request_line (which
508
+ # then enforces the request-size guard on the reassembled payload).
509
+ # Non-frame lines are normal requests.
510
+ frame = _try_parse_frame_line(line)
511
+ if frame is not None:
512
+ # JS serializes request frame bursts. A seq=0 frame for a different
513
+ # id proves any older partial stream was abandoned; a duplicate
514
+ # seq=0 for the same id must still fail loud in the reassembler.
515
+ try:
516
+ reassembled = _accept_request_frame(frame)
517
+ except FrameError as exc:
518
+ # A framing-protocol violation desyncs the request stream and
519
+ # there is no correlatable response to write (the id may be
520
+ # malformed). Fail LOUD on stderr and stop the loop so the
521
+ # transport restarts the bridge rather than silently
522
+ # mis-parsing subsequent frames.
523
+ emit_protocol_diagnostic(f'Request frame error: {exc}')
524
+ return
525
+ if reassembled is None:
526
+ # More frames needed for this id; await the rest.
573
527
  continue
528
+ if not process_request_line(reassembled):
529
+ return
530
+ continue
574
531
 
532
+ # Same idle-retention tradeoff as _accept_request_frame: a partial burst
533
+ # is retained only until the next request or process restart.
534
+ _request_reassembler.clear_pending()
575
535
  if not process_request_line(line):
576
536
  return
577
537