tywrap 0.7.0 → 0.9.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 (165) hide show
  1. package/README.md +36 -11
  2. package/SECURITY.md +39 -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/emit-call.d.ts.map +1 -1
  11. package/dist/core/emit-call.js +1 -1
  12. package/dist/core/emit-call.js.map +1 -1
  13. package/dist/core/generator.d.ts +23 -4
  14. package/dist/core/generator.d.ts.map +1 -1
  15. package/dist/core/generator.js +243 -170
  16. package/dist/core/generator.js.map +1 -1
  17. package/dist/core/mapper.d.ts +3 -2
  18. package/dist/core/mapper.d.ts.map +1 -1
  19. package/dist/core/mapper.js +5 -5
  20. package/dist/core/mapper.js.map +1 -1
  21. package/dist/dev.d.ts.map +1 -1
  22. package/dist/dev.js +1 -3
  23. package/dist/dev.js.map +1 -1
  24. package/dist/index.d.ts +7 -5
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/runtime/base-bridge.d.ts +3 -7
  29. package/dist/runtime/base-bridge.d.ts.map +1 -1
  30. package/dist/runtime/base-bridge.js +4 -17
  31. package/dist/runtime/base-bridge.js.map +1 -1
  32. package/dist/runtime/bounded-context.d.ts +3 -22
  33. package/dist/runtime/bounded-context.d.ts.map +1 -1
  34. package/dist/runtime/bounded-context.js +13 -53
  35. package/dist/runtime/bounded-context.js.map +1 -1
  36. package/dist/runtime/bridge-codec.d.ts +1 -1
  37. package/dist/runtime/bridge-codec.d.ts.map +1 -1
  38. package/dist/runtime/bridge-codec.js +78 -55
  39. package/dist/runtime/bridge-codec.js.map +1 -1
  40. package/dist/runtime/errors.d.ts +16 -0
  41. package/dist/runtime/errors.d.ts.map +1 -1
  42. package/dist/runtime/errors.js +17 -0
  43. package/dist/runtime/errors.js.map +1 -1
  44. package/dist/runtime/frame-codec.d.ts +111 -0
  45. package/dist/runtime/frame-codec.d.ts.map +1 -0
  46. package/dist/runtime/frame-codec.js +352 -0
  47. package/dist/runtime/frame-codec.js.map +1 -0
  48. package/dist/runtime/http-transport.d.ts +1 -1
  49. package/dist/runtime/http-transport.d.ts.map +1 -1
  50. package/dist/runtime/http-transport.js +1 -1
  51. package/dist/runtime/http-transport.js.map +1 -1
  52. package/dist/runtime/index.d.ts +2 -1
  53. package/dist/runtime/index.d.ts.map +1 -1
  54. package/dist/runtime/index.js +2 -1
  55. package/dist/runtime/index.js.map +1 -1
  56. package/dist/runtime/node.d.ts +7 -23
  57. package/dist/runtime/node.d.ts.map +1 -1
  58. package/dist/runtime/node.js +5 -78
  59. package/dist/runtime/node.js.map +1 -1
  60. package/dist/runtime/pooled-transport.d.ts +120 -59
  61. package/dist/runtime/pooled-transport.d.ts.map +1 -1
  62. package/dist/runtime/pooled-transport.js +345 -78
  63. package/dist/runtime/pooled-transport.js.map +1 -1
  64. package/dist/runtime/pyodide-bootstrap-core.generated.d.ts.map +1 -1
  65. package/dist/runtime/pyodide-bootstrap-core.generated.js +1 -1
  66. package/dist/runtime/pyodide-bootstrap-core.generated.js.map +1 -1
  67. package/dist/runtime/pyodide-transport.d.ts +1 -1
  68. package/dist/runtime/pyodide-transport.d.ts.map +1 -1
  69. package/dist/runtime/pyodide-transport.js +2 -3
  70. package/dist/runtime/pyodide-transport.js.map +1 -1
  71. package/dist/runtime/rpc-client.d.ts +7 -36
  72. package/dist/runtime/rpc-client.d.ts.map +1 -1
  73. package/dist/runtime/rpc-client.js +37 -72
  74. package/dist/runtime/rpc-client.js.map +1 -1
  75. package/dist/runtime/subprocess-transport.d.ts +134 -8
  76. package/dist/runtime/subprocess-transport.d.ts.map +1 -1
  77. package/dist/runtime/subprocess-transport.js +490 -65
  78. package/dist/runtime/subprocess-transport.js.map +1 -1
  79. package/dist/runtime/timed-out-request-tracker.d.ts +2 -1
  80. package/dist/runtime/timed-out-request-tracker.d.ts.map +1 -1
  81. package/dist/runtime/transport.d.ts +90 -18
  82. package/dist/runtime/transport.d.ts.map +1 -1
  83. package/dist/runtime/transport.js +21 -1
  84. package/dist/runtime/transport.js.map +1 -1
  85. package/dist/runtime/validators.d.ts +49 -0
  86. package/dist/runtime/validators.d.ts.map +1 -1
  87. package/dist/runtime/validators.js +152 -0
  88. package/dist/runtime/validators.js.map +1 -1
  89. package/dist/types/index.d.ts +14 -15
  90. package/dist/types/index.d.ts.map +1 -1
  91. package/dist/tywrap.d.ts +3 -2
  92. package/dist/tywrap.d.ts.map +1 -1
  93. package/dist/tywrap.js +140 -21
  94. package/dist/tywrap.js.map +1 -1
  95. package/dist/utils/cache.d.ts +3 -16
  96. package/dist/utils/cache.d.ts.map +1 -1
  97. package/dist/utils/codec.d.ts +1 -0
  98. package/dist/utils/codec.d.ts.map +1 -1
  99. package/dist/utils/codec.js +165 -17
  100. package/dist/utils/codec.js.map +1 -1
  101. package/dist/utils/ir-cache.d.ts +2 -1
  102. package/dist/utils/ir-cache.d.ts.map +1 -1
  103. package/dist/utils/runtime.d.ts +0 -29
  104. package/dist/utils/runtime.d.ts.map +1 -1
  105. package/dist/utils/runtime.js +16 -107
  106. package/dist/utils/runtime.js.map +1 -1
  107. package/dist/version.js +1 -1
  108. package/package.json +7 -6
  109. package/runtime/frame_codec.py +430 -0
  110. package/runtime/python_bridge.py +213 -54
  111. package/runtime/tywrap_bridge_core.py +217 -147
  112. package/src/config/index.ts +11 -0
  113. package/src/core/annotation-parser.ts +9 -5
  114. package/src/core/emit-call.ts +1 -7
  115. package/src/core/generator.ts +315 -205
  116. package/src/core/mapper.ts +8 -8
  117. package/src/dev.ts +1 -3
  118. package/src/index.ts +7 -3
  119. package/src/runtime/base-bridge.ts +5 -30
  120. package/src/runtime/bounded-context.ts +12 -67
  121. package/src/runtime/bridge-codec.ts +94 -65
  122. package/src/runtime/errors.ts +21 -0
  123. package/src/runtime/frame-codec.ts +469 -0
  124. package/src/runtime/http-transport.ts +6 -1
  125. package/src/runtime/index.ts +7 -6
  126. package/src/runtime/node.ts +17 -104
  127. package/src/runtime/pooled-transport.ts +424 -90
  128. package/src/runtime/pyodide-bootstrap-core.generated.ts +1 -1
  129. package/src/runtime/pyodide-transport.ts +7 -3
  130. package/src/runtime/rpc-client.ts +58 -93
  131. package/src/runtime/subprocess-transport.ts +585 -80
  132. package/src/runtime/timed-out-request-tracker.ts +1 -1
  133. package/src/runtime/transport.ts +112 -22
  134. package/src/runtime/validators.ts +204 -0
  135. package/src/types/index.ts +21 -35
  136. package/src/tywrap.ts +157 -30
  137. package/src/utils/cache.ts +3 -3
  138. package/src/utils/codec.ts +205 -16
  139. package/src/utils/ir-cache.ts +1 -1
  140. package/src/utils/runtime.ts +17 -128
  141. package/src/version.ts +1 -1
  142. package/dist/core/discovery.d.ts +0 -103
  143. package/dist/core/discovery.d.ts.map +0 -1
  144. package/dist/core/discovery.js +0 -380
  145. package/dist/core/discovery.js.map +0 -1
  146. package/dist/core/validation.d.ts +0 -102
  147. package/dist/core/validation.d.ts.map +0 -1
  148. package/dist/core/validation.js +0 -490
  149. package/dist/core/validation.js.map +0 -1
  150. package/dist/runtime/base.d.ts +0 -22
  151. package/dist/runtime/base.d.ts.map +0 -1
  152. package/dist/runtime/base.js +0 -23
  153. package/dist/runtime/base.js.map +0 -1
  154. package/dist/runtime/transport-pool.d.ts +0 -196
  155. package/dist/runtime/transport-pool.d.ts.map +0 -1
  156. package/dist/runtime/transport-pool.js +0 -418
  157. package/dist/runtime/transport-pool.js.map +0 -1
  158. package/runtime/__pycache__/_tywrap_member_fixtures.cpython-311.pyc +0 -0
  159. package/runtime/__pycache__/safe_codec.cpython-311.pyc +0 -0
  160. package/runtime/__pycache__/tywrap_bridge_core.cpython-311.pyc +0 -0
  161. package/runtime/safe_codec.py +0 -352
  162. package/src/core/discovery.ts +0 -477
  163. package/src/core/validation.ts +0 -729
  164. package/src/runtime/base.ts +0 -24
  165. 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,9 +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
46
+ from frame_codec import FrameError, Reassembler, encode_frames
43
47
 
44
48
  # Re-export the shared protocol/serialization surface so existing importers of
45
49
  # python_bridge keep working after the extraction (codex-flagged: runtime/ ships).
@@ -47,8 +51,9 @@ from tywrap_bridge_core import ( # noqa: F401
47
51
  PROTOCOL,
48
52
  PROTOCOL_VERSION,
49
53
  CODEC_VERSION,
54
+ BridgeCodec,
55
+ CodecError,
50
56
  ProtocolError,
51
- InstanceHandleError,
52
57
  ImportNotAllowedError,
53
58
  AttributeNotAllowedError,
54
59
  import_allowed_module,
@@ -89,8 +94,6 @@ except (OSError, ValueError, TypeError, AttributeError) as exc:
89
94
  except (OSError, ValueError):
90
95
  pass
91
96
 
92
- instances = {}
93
-
94
97
  FALLBACK_JSON = os.environ.get('TYWRAP_CODEC_FALLBACK', '').lower() == 'json'
95
98
  TORCH_ALLOW_COPY = os.environ.get('TYWRAP_TORCH_ALLOW_COPY', '').lower() in ('1', 'true', 'yes')
96
99
  BRIDGE_NAME = 'python-subprocess'
@@ -221,6 +224,24 @@ def get_request_max_bytes():
221
224
  REQUEST_MAX_BYTES = get_request_max_bytes()
222
225
 
223
226
 
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
239
+
240
+
241
+ MAX_FRAME_BYTES = get_transport_frame_bytes()
242
+
243
+
244
+
224
245
  def serialize(obj):
225
246
  """
226
247
  Backward-compatible result serializer (subprocess identity).
@@ -262,7 +283,6 @@ def handle_meta():
262
283
  real pid and bridge='python-subprocess'.
263
284
  """
264
285
  return core.build_meta(
265
- instances,
266
286
  bridge=BRIDGE_NAME,
267
287
  pid=os.getpid(),
268
288
  python_version=sys.version.split()[0],
@@ -270,7 +290,7 @@ def handle_meta():
270
290
  )
271
291
 
272
292
 
273
- def dispatch_request(msg):
293
+ def dispatch_request(msg, *, has_envelope_markers=True):
274
294
  """
275
295
  Dispatch a validated request to the correct handler (subprocess identity).
276
296
 
@@ -280,7 +300,6 @@ def dispatch_request(msg):
280
300
  """
281
301
  out = core.dispatch_request(
282
302
  msg,
283
- instances,
284
303
  bridge=BRIDGE_NAME,
285
304
  pid=os.getpid(),
286
305
  force_json_markers=FALLBACK_JSON,
@@ -289,6 +308,7 @@ def dispatch_request(msg):
289
308
  torch_allow_copy=TORCH_ALLOW_COPY,
290
309
  allowed_modules=ALLOWED_MODULES,
291
310
  allow_private_attrs=ALLOW_PRIVATE_ATTRS,
311
+ has_envelope_markers=has_envelope_markers,
292
312
  )
293
313
  return out['id'], out['result']
294
314
 
@@ -305,10 +325,11 @@ def encode_response(out):
305
325
  except CodecError as exc:
306
326
  # Convert CodecError to ValueError for consistent error handling
307
327
  raise ValueError(str(exc)) from exc
308
- payload_bytes = len(payload.encode('utf-8'))
328
+ payload_utf8 = payload.encode('utf-8')
329
+ payload_bytes = len(payload_utf8)
309
330
  if CODEC_MAX_BYTES is not None and payload_bytes > CODEC_MAX_BYTES:
310
331
  raise PayloadTooLargeError(payload_bytes, CODEC_MAX_BYTES)
311
- return payload
332
+ return payload, payload_utf8
312
333
 
313
334
 
314
335
  def write_payload(payload: str) -> bool:
@@ -326,55 +347,193 @@ def write_payload(payload: str) -> bool:
326
347
  return False
327
348
 
328
349
 
329
- def main():
330
- for line in sys.stdin:
331
- line = line.strip()
332
- if not line:
333
- continue
334
- mid = None
335
- out = None
350
+ def write_response(payload: str, payload_utf8: bytes, response_id) -> bool:
351
+ """
352
+ Write a fully-encoded JSONL response, fragmenting it into ``tywrap-frame/1``
353
+ frames when the payload exceeds the fixed per-frame ceiling.
354
+
355
+ Why: a single response can exceed the JS-side JSONL line ceiling
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.
361
+
362
+ Frames require an integer correlation id. A response with a non-integer id
363
+ (only reachable on a malformed request whose error envelope carries id=None)
364
+ is never large enough to chunk, so it is always written as a single line.
365
+
366
+ Returns False if the parent's stdin/our stdout closed mid-write (BrokenPipe),
367
+ so the caller can exit the loop cleanly.
368
+ """
369
+ payload_bytes = len(payload_utf8)
370
+ if payload_bytes <= MAX_FRAME_BYTES:
371
+ return write_payload(payload)
372
+
373
+ if not isinstance(response_id, int) or isinstance(response_id, bool):
374
+ # Cannot correlate frames without an integer id; emit as one line. This
375
+ # only happens for tiny malformed-request error envelopes (id=None),
376
+ # which never exceed a sane frame ceiling.
377
+ return write_payload(payload)
378
+
379
+ frames = encode_frames(
380
+ payload,
381
+ id=response_id,
382
+ stream='response',
383
+ max_frame_bytes=MAX_FRAME_BYTES,
384
+ total_bytes=payload_bytes,
385
+ )
386
+ for frame in frames:
387
+ # One frame per JSONL line; flush per frame so the pipe backpressures
388
+ # and the TS reader can interleave reassembly with the write.
389
+ if not write_payload(_response_codec.encode(frame)):
390
+ return False
391
+ return True
392
+
393
+
394
+ # Reassembles `tywrap-frame/1` REQUEST frames (W5) back into a single logical
395
+ # request line. Framing is always accepted and restricted to the 'request'
396
+ # stream. No reassembly-bytes cap here: the request size is
397
+ # already bounded by the TS codec's maxPayloadBytes on the sending side, and
398
+ # TYWRAP_REQUEST_MAX_BYTES is enforced on the complete payload after reassembly
399
+ # (process_request_line), preserving the W5 post-reassembly semantics.
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)
413
+
414
+
415
+ def _try_parse_frame_line(line):
416
+ """
417
+ Return a frame dict if ``line`` is a ``tywrap-frame/1`` envelope, else None.
418
+
419
+ Why: a request frame is a JSON object carrying ``__tywrap_frame__``. We only
420
+ treat a line as a frame when it parses as such an
421
+ object; anything else (including invalid JSON) falls through to the normal
422
+ single-line request path, which reports the JSON error exactly as before.
423
+ Structural validity (protocol, seq/total ranges, etc.) is enforced by the
424
+ Reassembler, not here.
425
+ """
426
+ try:
427
+ parsed = json.loads(line)
428
+ except json.JSONDecodeError:
429
+ return None
430
+ if isinstance(parsed, dict) and '__tywrap_frame__' in parsed:
431
+ return parsed
432
+ return None
433
+
434
+
435
+ def process_request_line(line):
436
+ """
437
+ Process one complete logical request line and write its response.
438
+
439
+ ``line`` is the full logical JSON request: either a single line read from
440
+ stdin, or the payload reassembled from ``tywrap-frame/1`` request frames.
441
+ TYWRAP_REQUEST_MAX_BYTES is enforced on this complete payload (so for a
442
+ chunked request the limit applies to the REASSEMBLED size, not per frame).
443
+
444
+ Returns True to keep the loop running, or False if the parent's stdin / our
445
+ stdout closed mid-write (BrokenPipe), so main() can exit cleanly.
446
+ """
447
+ mid = None
448
+ out = None
449
+ try:
450
+ if REQUEST_MAX_BYTES is not None:
451
+ payload_bytes = len(line.encode('utf-8'))
452
+ if payload_bytes > REQUEST_MAX_BYTES:
453
+ raise RequestTooLargeError(payload_bytes, REQUEST_MAX_BYTES)
454
+ msg = json.loads(line)
455
+ if isinstance(msg, dict):
456
+ req_id = msg.get('id')
457
+ if isinstance(req_id, int):
458
+ # Why: preserve request ids even when handlers raise.
459
+ mid = req_id
336
460
  try:
337
- if REQUEST_MAX_BYTES is not None:
338
- payload_bytes = len(line.encode('utf-8'))
339
- if payload_bytes > REQUEST_MAX_BYTES:
340
- raise RequestTooLargeError(payload_bytes, REQUEST_MAX_BYTES)
341
- msg = json.loads(line)
342
- if isinstance(msg, dict):
343
- req_id = msg.get('id')
344
- if isinstance(req_id, int):
345
- # Why: preserve request ids even when handlers raise.
346
- mid = req_id
347
- try:
348
- mid, result = dispatch_request(msg)
349
- out = {'id': mid, 'protocol': PROTOCOL, 'result': result}
350
- except ProtocolError as e:
351
- emit_protocol_diagnostic(str(e))
352
- out = build_error_payload(mid, e, include_traceback=False)
353
- except Exception as e: # noqa: BLE001
354
- # Why: ensure any handler error becomes a protocol-compliant response.
355
- out = build_error_payload(mid, e, include_traceback=True)
356
- except RequestTooLargeError as e:
461
+ has_envelope_markers = '__tywrap' in line or '__type__' in line
462
+ mid, result = dispatch_request(msg, has_envelope_markers=has_envelope_markers)
463
+ out = {'id': mid, 'protocol': PROTOCOL, 'result': result}
464
+ except ProtocolError as e:
357
465
  emit_protocol_diagnostic(str(e))
358
466
  out = build_error_payload(mid, e, include_traceback=False)
359
- except json.JSONDecodeError as e:
360
- emit_protocol_diagnostic(f'Invalid JSON: {e}')
361
- out = build_error_payload(mid, e, include_traceback=False)
362
467
  except Exception as e: # noqa: BLE001
363
- # Why: catch malformed input without breaking the JSONL protocol.
364
- out = build_error_payload(mid, e, include_traceback=False)
468
+ # Why: ensure any handler error becomes a protocol-compliant response.
469
+ out = build_error_payload(mid, e, include_traceback=True)
470
+ except RequestTooLargeError as e:
471
+ emit_protocol_diagnostic(str(e))
472
+ out = build_error_payload(mid, e, include_traceback=False)
473
+ except json.JSONDecodeError as e:
474
+ emit_protocol_diagnostic(f'Invalid JSON: {e}')
475
+ out = build_error_payload(mid, e, include_traceback=False)
476
+ except Exception as e: # noqa: BLE001
477
+ # Why: catch malformed input without breaking the JSONL protocol.
478
+ out = build_error_payload(mid, e, include_traceback=False)
365
479
 
480
+ try:
481
+ payload, payload_utf8 = encode_response(out)
482
+ # Correlate frames by the response id when chunking; out always
483
+ # carries the request id (or None for a malformed-request envelope).
484
+ response_id = out.get('id') if isinstance(out, dict) else None
485
+ if not write_response(payload, payload_utf8, response_id):
486
+ return False
487
+ except Exception as e: # noqa: BLE001
488
+ # Why: fallback error keeps responses well-formed even if serialization fails.
489
+ err_out = build_error_payload(mid, e, include_traceback=False)
366
490
  try:
367
- payload = encode_response(out)
368
- if not write_payload(payload):
369
- return
370
- except Exception as e: # noqa: BLE001
371
- # Why: fallback error keeps responses well-formed even if serialization fails.
372
- err_out = build_error_payload(mid, e, include_traceback=False)
491
+ # Error envelopes are tiny; write as a single line regardless of
492
+ # chunking so a serialization failure never recurses into framing.
493
+ if not write_payload(json.dumps(err_out)):
494
+ return False
495
+ except Exception:
496
+ return False
497
+ return True
498
+
499
+
500
+ def main():
501
+ for line in sys.stdin:
502
+ line = line.strip()
503
+ if not line:
504
+ continue
505
+
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.
373
515
  try:
374
- if not write_payload(json.dumps(err_out)):
375
- return
376
- except Exception:
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.
527
+ continue
528
+ if not process_request_line(reassembled):
377
529
  return
530
+ continue
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()
535
+ if not process_request_line(line):
536
+ return
378
537
 
379
538
 
380
539
  if __name__ == '__main__':