Skip to content

Commit bf7ba7e

Browse files
author
brett-bonner_infodesk
committed
fix(runtime): bound recursive producer traversal
1 parent ded2f62 commit bf7ba7e

3 files changed

Lines changed: 186 additions & 37 deletions

File tree

runtime/tywrap_bridge_core.py

Lines changed: 99 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@
5858
PROTOCOL = 'tywrap/1'
5959
PROTOCOL_VERSION = 1
6060
CODEC_VERSION = 1
61-
MAX_SERIALIZE_DEPTH = 2048
61+
MAX_SERIALIZE_DEPTH = 900
62+
MAX_SERIALIZE_NODES = 1_000_000
6263
_SERIALIZE_PATH_IDENTIFIER = re.compile(r'^[A-Za-z_$][\w$]*$', re.ASCII)
6364

6465

@@ -827,6 +828,14 @@ def _check_serialize_depth(depth, path):
827828
)
828829

829830

831+
def _check_serialize_nodes(nodes, path):
832+
if nodes > MAX_SERIALIZE_NODES:
833+
raise RuntimeError(
834+
f'Scientific envelope serialization maximum visited nodes '
835+
f'{MAX_SERIALIZE_NODES} exceeded at {path}'
836+
)
837+
838+
830839
def _serialize_scientific(obj, *, force_json_markers, torch_allow_copy, depth, path):
831840
"""Serialize a supported scientific value, or return _NO_SCIENTIFIC."""
832841
package = type(obj).__module__.split('.', 1)[0]
@@ -878,6 +887,31 @@ def _invalid_key_path(base, key):
878887
return f'{base}[{key!r}]'
879888

880889

890+
def _needs_serialize_visit(value):
891+
"""Return whether value needs container or scientific traversal work."""
892+
if type(value) in (type(None), bool, int, float, str):
893+
return False
894+
if type(value) in (dict, list, tuple):
895+
return True
896+
package = type(value).__module__.split('.', 1)[0]
897+
if package in ('numpy', 'pandas', 'scipy', 'torch'):
898+
return True
899+
return 'sklearn.base' in sys.modules and is_sklearn_estimator(value)
900+
901+
902+
def _serialize_leaf(value):
903+
"""Apply non-container conversions without allocating a traversal frame."""
904+
if type(value) in (type(None), bool, int, float, str):
905+
return value
906+
pydantic_value = serialize_pydantic(value)
907+
if pydantic_value is not _NO_PYDANTIC:
908+
return pydantic_value
909+
stdlib_value = serialize_stdlib(value)
910+
if stdlib_value is not None:
911+
return stdlib_value
912+
return value
913+
914+
881915
def serialize(obj, *, force_json_markers, torch_allow_copy=False):
882916
"""
883917
Top-level result serializer.
@@ -893,16 +927,51 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
893927
root = [None]
894928
active_ids = set()
895929
stack = [('visit', obj, 0, 'result', root, 0)]
930+
visited_nodes = 0
896931

897932
# Repeated aliases have value semantics and are intentionally serialized twice.
898933
while stack:
899-
action, current, depth, path, parent, key = stack.pop()
900-
if action == 'exit':
901-
active_ids.remove(id(current))
934+
frame = stack.pop()
935+
action = frame[0]
936+
if action == 'dict':
937+
_, current, depth, path, parent, key, output, iterator = frame
938+
try:
939+
item_key, item = next(iterator)
940+
except StopIteration:
941+
active_ids.remove(id(current))
942+
parent[key] = output
943+
continue
944+
stack.append(frame)
945+
if not (isinstance(item_key, (str, int, float, bool)) or item_key is None):
946+
invalid_path = _invalid_key_path(path, item_key)
947+
raise TypeError(
948+
f'keys must be str, int, float, bool or None, not '
949+
f'{type(item_key).__name__} at {invalid_path}'
950+
)
951+
child_key = next(iter(json.loads(json.dumps({item_key: None}))))
952+
child_path = _serialize_path(path, child_key)
953+
if _needs_serialize_visit(item):
954+
stack.append(('visit', item, depth + 1, child_path, output, item_key))
955+
else:
956+
output[item_key] = _serialize_leaf(item)
902957
continue
903-
if action == 'tuple':
904-
parent[key] = tuple(current)
958+
if action == 'sequence':
959+
_, current, depth, path, parent, key, output, index = frame
960+
if index == len(output):
961+
active_ids.remove(id(current))
962+
parent[key] = output if type(current) is list else tuple(output)
963+
continue
964+
stack.append(('sequence', current, depth, path, parent, key, output, index + 1))
965+
item = current[index]
966+
if _needs_serialize_visit(item):
967+
stack.append(
968+
('visit', item, depth + 1, _serialize_path(path, index), output, index)
969+
)
970+
else:
971+
output[index] = _serialize_leaf(item)
905972
continue
973+
974+
_, current, depth, path, parent, key = frame
906975
try:
907976
scientific = _serialize_scientific(
908977
current,
@@ -916,49 +985,48 @@ def serialize(obj, *, force_json_markers, torch_allow_copy=False):
916985
raise
917986
raise RuntimeError(f'Scientific value serialization failed at {path}: {exc}') from exc
918987
if scientific is not _NO_SCIENTIFIC:
988+
# Recognized envelopes are terminal containers to the JS decoder.
989+
visited_nodes += 1
990+
_check_serialize_nodes(visited_nodes, path)
991+
if scientific.get('__tywrap__') == 'torch.tensor':
992+
nested_path = _serialize_path(path, 'value')
993+
try:
994+
visited_nodes += 1
995+
_check_serialize_nodes(visited_nodes, nested_path)
996+
except Exception as exc:
997+
if path == 'result':
998+
raise
999+
raise RuntimeError(
1000+
f'Scientific value serialization failed at {path}: {exc}'
1001+
) from exc
9191002
parent[key] = scientific
9201003
continue
9211004

9221005
container_type = type(current)
9231006
if container_type in (dict, list, tuple):
9241007
_check_serialize_depth(depth, path)
1008+
visited_nodes += 1
1009+
_check_serialize_nodes(visited_nodes, path)
9251010
current_id = id(current)
9261011
if current_id in active_ids:
9271012
raise RuntimeError(f'Circular reference detected at {path}')
9281013
active_ids.add(current_id)
929-
stack.append(('exit', current, depth, path, parent, key))
9301014

9311015
if container_type is dict:
9321016
output = {}
9331017
parent[key] = output
934-
items = list(current.items())
935-
for item_key, item in reversed(items):
936-
if not (
937-
isinstance(item_key, (str, int, float, bool)) or item_key is None
938-
):
939-
invalid_path = _invalid_key_path(path, item_key)
940-
raise TypeError(
941-
f'keys must be str, int, float, bool or None, not '
942-
f'{type(item_key).__name__} at {invalid_path}'
943-
)
944-
child_key = next(iter(json.loads(json.dumps({item_key: None}))))
945-
stack.append(
946-
('visit', item, depth + 1, _serialize_path(path, child_key), output, item_key)
947-
)
1018+
stack.append(
1019+
('dict', current, depth, path, parent, key, output, iter(current.items()))
1020+
)
9481021
continue
9491022

9501023
output = [None] * len(current)
9511024
if container_type is list:
9521025
parent[key] = output
953-
else:
954-
stack.append(('tuple', output, depth, path, parent, key))
955-
for index in range(len(current) - 1, -1, -1):
956-
stack.append(
957-
('visit', current[index], depth + 1, _serialize_path(path, index), output, index)
958-
)
1026+
stack.append(('sequence', current, depth, path, parent, key, output, 0))
9591027
continue
9601028

961-
parent[key] = current
1029+
parent[key] = _serialize_leaf(current)
9621030

9631031
return root[0]
9641032

@@ -1082,12 +1150,10 @@ def encode_value(value, *, allow_nan):
10821150
# ("...not JSON compliant: nan"), but 3.10/3.11 emit only the canonical
10831151
# "Out of range float values are not JSON compliant". Match that phrase too
10841152
# so the typed error message is stable across versions.
1153+
nonfinite_token = re.search(r'(^|[^a-z])(nan|inf|infinity)([^a-z]|$)', error_msg)
10851154
if (
1086-
'nan' in error_msg
1087-
or 'infinity' in error_msg
1088-
or 'inf' in error_msg
1089-
or 'out of range float' in error_msg
1090-
):
1155+
not allow_nan and 'out of range float values are not json compliant' in error_msg
1156+
) or nonfinite_token:
10911157
raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON') from exc
10921158
raise CodecError(f'JSON encoding failed: {exc}') from exc
10931159
except TypeError as exc:

src/runtime/pyodide-bootstrap-core.generated.ts

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

test/python/test_bridge_core.py

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import json
66
import subprocess
77
import sys
8+
import tracemalloc
89
from pathlib import Path
910

1011
import pytest
@@ -14,9 +15,12 @@
1415

1516
sys.path.insert(0, str(RUNTIME_DIR))
1617

18+
import tywrap_bridge_core as bridge_core # noqa: E402
1719
from tywrap_bridge_core import ( # noqa: E402
1820
MAX_SERIALIZE_DEPTH,
21+
MAX_SERIALIZE_NODES,
1922
PROTOCOL,
23+
CodecError,
2024
ProtocolError,
2125
deserialize,
2226
dispatch_request,
@@ -98,7 +102,7 @@ def test_cycle_rejection_names_the_nested_path() -> None:
98102
serialize(value, force_json_markers=True)
99103

100104

101-
def test_depth_bound_accepts_2048_containers_and_rejects_2049() -> None:
105+
def test_full_encode_accepts_depth_bound_and_rejects_next_container() -> None:
102106
def nested(depth: int) -> dict[str, object]:
103107
root: dict[str, object] = {}
104108
cursor = root
@@ -108,7 +112,11 @@ def nested(depth: int) -> dict[str, object]:
108112
cursor = child
109113
return root
110114

111-
serialize(nested(MAX_SERIALIZE_DEPTH), force_json_markers=True)
115+
encoded = encode_value(
116+
serialize(nested(MAX_SERIALIZE_DEPTH), force_json_markers=True),
117+
allow_nan=False,
118+
)
119+
assert encoded.startswith('{"next":')
112120
path = 'result' + '.next' * (MAX_SERIALIZE_DEPTH + 1)
113121
with pytest.raises(RuntimeError) as exc_info:
114122
serialize(nested(MAX_SERIALIZE_DEPTH + 1), force_json_markers=True)
@@ -130,6 +138,69 @@ def test_primitive_leaf_does_not_consume_depth_budget() -> None:
130138
serialize(root, force_json_markers=True)
131139

132140

141+
def test_wide_primitive_list_does_not_consume_node_budget(
142+
monkeypatch: pytest.MonkeyPatch,
143+
) -> None:
144+
monkeypatch.setattr(bridge_core, 'MAX_SERIALIZE_NODES', 1)
145+
value = list(range(250_000))
146+
147+
tracemalloc.start()
148+
try:
149+
result = serialize(value, force_json_markers=True)
150+
_, peak = tracemalloc.get_traced_memory()
151+
finally:
152+
tracemalloc.stop()
153+
154+
assert result == value
155+
assert peak < 12 * 1024 * 1024
156+
157+
158+
def test_container_node_bound_names_first_excess_path(
159+
monkeypatch: pytest.MonkeyPatch,
160+
) -> None:
161+
assert MAX_SERIALIZE_NODES == 1_000_000
162+
monkeypatch.setattr(bridge_core, 'MAX_SERIALIZE_NODES', 3)
163+
164+
with pytest.raises(RuntimeError) as exc_info:
165+
serialize([[], [], []], force_json_markers=True)
166+
167+
assert str(exc_info.value) == (
168+
'Scientific envelope serialization maximum visited nodes '
169+
'3 exceeded at result[2]'
170+
)
171+
172+
173+
def test_scientific_envelopes_consume_container_node_budget(
174+
monkeypatch: pytest.MonkeyPatch,
175+
) -> None:
176+
np = pytest.importorskip('numpy')
177+
monkeypatch.setattr(bridge_core, 'MAX_SERIALIZE_NODES', 3)
178+
179+
with pytest.raises(RuntimeError) as exc_info:
180+
serialize([np.array([1]), np.array([2]), np.array([3])], force_json_markers=True)
181+
182+
assert str(exc_info.value) == (
183+
'Scientific envelope serialization maximum visited nodes '
184+
'3 exceeded at result[2]'
185+
)
186+
187+
188+
def test_torch_nested_ndarray_envelope_consumes_node_budget(
189+
monkeypatch: pytest.MonkeyPatch,
190+
) -> None:
191+
torch = pytest.importorskip('torch')
192+
monkeypatch.setattr(bridge_core, 'MAX_SERIALIZE_NODES', 2)
193+
194+
with pytest.raises(RuntimeError) as exc_info:
195+
serialize([torch.tensor([1])], force_json_markers=True)
196+
197+
assert str(exc_info.value) == (
198+
'Scientific value serialization failed at result[0]: '
199+
'Scientific envelope serialization maximum visited nodes '
200+
'2 exceeded at result[0].value'
201+
)
202+
203+
133204
def test_scientific_leaf_consumes_depth_budget() -> None:
134205
np = pytest.importorskip('numpy')
135206

@@ -183,7 +254,7 @@ def test_depth_rejection_precedes_scientific_codec_rejection() -> None:
183254
with pytest.raises(RuntimeError) as exc_info:
184255
serialize(root, force_json_markers=True)
185256

186-
assert 'maximum depth 2048 exceeded' in str(exc_info.value)
257+
assert f'maximum depth {MAX_SERIALIZE_DEPTH} exceeded' in str(exc_info.value)
187258
assert 'object dtype' not in str(exc_info.value)
188259

189260

@@ -211,6 +282,18 @@ def test_nested_set_uses_the_same_default_encoding_as_a_root_set() -> None:
211282
assert nested_json == f'{{"outer": {{"values": {root_json}}}}}'
212283

213284

285+
def test_model_dump_information_error_preserves_origin_behavior() -> None:
286+
class BrokenModel:
287+
def model_dump(self, **_kwargs: object) -> object:
288+
raise ValueError('invalid information')
289+
290+
with pytest.raises(RuntimeError, match='^model_dump failed: invalid information$'):
291+
serialize(BrokenModel(), force_json_markers=True)
292+
293+
with pytest.raises(CodecError, match='^JSON encoding failed: invalid information$'):
294+
encode_value(BrokenModel(), allow_nan=False)
295+
296+
214297
def test_rejected_scientific_dtype_keeps_error_and_adds_path() -> None:
215298
np = pytest.importorskip('numpy')
216299

0 commit comments

Comments
 (0)