diff --git a/runtime/python_bridge.py b/runtime/python_bridge.py index 3edbd37..e82cc56 100644 --- a/runtime/python_bridge.py +++ b/runtime/python_bridge.py @@ -11,6 +11,8 @@ import uuid from pathlib import Path, PurePath +from safe_codec import SafeCodec, CodecError + # Ensure the working directory is importable so local modules can be resolved when # the bridge is launched as a script from a different directory. try: @@ -95,6 +97,16 @@ def get_codec_max_bytes(): # Why: parse once at startup to avoid per-response env lookups. CODEC_MAX_BYTES = get_codec_max_bytes() +# Why: use SafeCodec for final JSON encoding to reject NaN/Infinity and handle +# edge cases like numpy scalars. We use sys.maxsize for SafeCodec's internal limit +# to preserve the original "no limit unless TYWRAP_CODEC_MAX_BYTES is set" behavior. +# The explicit size check in encode_response() provides the specific error message +# mentioning the env var name, which is important for debugging. +_response_codec = SafeCodec( + allow_nan=False, + max_payload_bytes=sys.maxsize, +) + def get_request_max_bytes(): """ @@ -779,8 +791,13 @@ def encode_response(out): Serialize the response and enforce size limits. Why: keep payload size checks outside the main loop for clarity and lint compliance. + Uses SafeCodec to reject NaN/Infinity and handle edge cases like numpy scalars. """ - payload = json.dumps(out) + try: + payload = _response_codec.encode(out) + except CodecError as exc: + # Convert CodecError to ValueError for consistent error handling + raise ValueError(str(exc)) from exc payload_bytes = len(payload.encode('utf-8')) if CODEC_MAX_BYTES is not None and payload_bytes > CODEC_MAX_BYTES: raise PayloadTooLargeError(payload_bytes, CODEC_MAX_BYTES) diff --git a/test/adversarial_playground.test.ts b/test/adversarial_playground.test.ts index 1734c0f..14aebe1 100644 --- a/test/adversarial_playground.test.ts +++ b/test/adversarial_playground.test.ts @@ -290,7 +290,7 @@ describeAdversarial('Adversarial playground', () => { try { await expect(callAdversarial(bridge, 'return_nan_payload', [])).rejects.toThrow( - /Protocol error|Invalid JSON|JSON parse failed/ + /Protocol error|Invalid JSON|JSON parse failed|Cannot serialize NaN|NaN.*not allowed/ ); } finally { await bridge.dispose(); diff --git a/test/fixtures/python/adversarial_module.py b/test/fixtures/python/adversarial_module.py index af3a4d3..e60906a 100644 --- a/test/fixtures/python/adversarial_module.py +++ b/test/fixtures/python/adversarial_module.py @@ -41,8 +41,10 @@ def return_unserializable() -> Any: """Return a non-JSON-serializable value. Why: ensure serialization failures surface as explicit errors. + Note: sets are now serialized as lists, so we return a function which + cannot be JSON serialized. """ - return {1, 2, 3} + return lambda x: x def return_circular_reference() -> list: