From 0486e7b4b5ec7f02b94bd47e4fbcf3802114fb03 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 00:37:57 -0700 Subject: [PATCH 1/7] feat(runtime): await Python coroutines in Node and Pyodide --- docs/maintainers/architecture.md | 3 + docs/maintainers/coroutines.md | 47 ++ runtime/tywrap_bridge_core.py | 117 ++++- src/runtime/pooled-transport.ts | 11 + .../pyodide-bootstrap-core.generated.ts | 2 +- src/runtime/pyodide-transport.ts | 172 +++++-- src/runtime/rpc-client.ts | 33 +- src/runtime/subprocess-transport.ts | 89 ++-- test/fixtures/python/async_module.py | 10 + test/menagerie/manifest.ts | 10 +- test/runtime_conformance.test.ts | 21 +- test/runtime_coroutines.test.ts | 448 ++++++++++++++++++ 12 files changed, 875 insertions(+), 88 deletions(-) create mode 100644 docs/maintainers/coroutines.md create mode 100644 test/runtime_coroutines.test.ts diff --git a/docs/maintainers/architecture.md b/docs/maintainers/architecture.md index fac5902d..89d12489 100644 --- a/docs/maintainers/architecture.md +++ b/docs/maintainers/architecture.md @@ -49,6 +49,9 @@ not edit it directly. Regenerate it from `runtime/tywrap_bridge_core.py` with the repository script. Pyodide uses JSON codec envelopes because pyarrow is not available in WASM. +The bridge awaits Python call results under the event loop owned by each +backend. See [coroutine calls](./coroutines.md) for timeout and disposal rules. + `HttpBridge` sends the same RPC and codec concepts to a remote Python service. The transport owns HTTP concerns while the codec and return validator preserve the client-side invariants. diff --git a/docs/maintainers/coroutines.md b/docs/maintainers/coroutines.md new file mode 100644 index 00000000..b28c5c8b --- /dev/null +++ b/docs/maintainers/coroutines.md @@ -0,0 +1,47 @@ +# Coroutine calls + +A `call` request returns one result or one error envelope. The bridge awaits a +Python awaitable before it serializes the result. It rejects generators and async +generators. It does not expose tasks, streams, retries, or cached results. + +## Node subprocess + +The Python JSONL server handles requests in order. A synchronous call runs +directly. For an awaitable result, `handle_call()` wraps it in a coroutine and +uses `asyncio.run()`. Python 3.10 through 3.12 create and close one event loop +for that call. Pending background tasks do not survive the call. + +The call must return an awaitable that can run on this new loop. A Future bound +to another loop fails in the normal Python error envelope. A synchronous +function that needs a running loop before it returns must create that loop +itself or use an `async def` function. + +After a request reaches stdin, a timeout or abort retires that Python process. +The transport rejects other requests on the same process and starts a new +process for later calls. Replacement runs the configured worker warmup. Python +state held only in the retired process is lost. A request that times out before +its first write does not retire the process. + +Disposal kills the process and rejects pending requests. The caller receives +one timeout, abort, or disposal error. A retired process cannot send a late +response into a replacement process. A timeout does not cancel Python code in +place; process retirement stops code that does not cooperate with cancellation. + +## Pyodide + +Pyodide owns its [WebLoop](https://pyodide.org/en/0.28.1/usage/api/python-api/webloop.html). +The bootstrap registers a task by request ID before it returns the task to +JavaScript. The transport awaits the task and destroys its Pyodide proxies +after the task settles. Timeout, abort, and disposal call `Task.cancel()` for +that request. The task map removes settled and cancelled tasks. + +Cancellation is cooperative. Python receives `CancelledError` at an await. +The caller receives one timeout, abort, or disposal error. The transport +consumes a late task rejection without sending another response. + +CPU-bound Python on the browser thread can block both the JavaScript timer +and the Pyodide event loop. This transport cannot stop it at a deadline. +Pyodide documents [interrupt buffers](https://pyodide.org/en/0.28.1/usage/api/js-api.html#pyodide.setInterruptBuffer) +for web workers. The bridge does not install an interrupt buffer or terminate +a worker. Host applications must manage their own worker lifecycle if they +need that limit. diff --git a/runtime/tywrap_bridge_core.py b/runtime/tywrap_bridge_core.py index 4a6f62ac..ac04e0e4 100644 --- a/runtime/tywrap_bridge_core.py +++ b/runtime/tywrap_bridge_core.py @@ -1447,11 +1447,9 @@ def coerce_dict(value, key): return value -def handle_call( +def _invoke_call( params, *, - force_json_markers, - torch_allow_copy, allowed_modules, allow_private_attrs, has_envelope_markers, @@ -1465,7 +1463,72 @@ def handle_call( # calls, which the generated wrapper routes through call() rather than an # instance handle. resolve_allowed_attr_path guards each segment. func = resolve_allowed_attr_path(mod, function_name, allow_private_attrs=allow_private_attrs) - res = func(*args, **kwargs) + return func(*args, **kwargs) + + +def _reject_generator(value): + import inspect + + if inspect.isgenerator(value): + raise TypeError('Generator results are not supported by tywrap RPC') + if inspect.isasyncgen(value): + raise TypeError('Async generator results are not supported by tywrap RPC') + + +async def _await_result(value): + return await value + + +def handle_call( + params, + *, + force_json_markers, + torch_allow_copy, + allowed_modules, + allow_private_attrs, + has_envelope_markers, +): + """Run one call. The serial subprocess owns a fresh loop for awaitables.""" + import asyncio + import inspect + + res = _invoke_call( + params, + allowed_modules=allowed_modules, + allow_private_attrs=allow_private_attrs, + has_envelope_markers=has_envelope_markers, + ) + _reject_generator(res) + if inspect.isawaitable(res): + # asyncio.run requires a coroutine, while user code may return any + # awaitable. It closes the per-call loop on Python 3.10 through 3.12. + res = asyncio.run(_await_result(res)) + _reject_generator(res) + return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy) + + +async def handle_call_async( + params, + *, + force_json_markers, + torch_allow_copy, + allowed_modules, + allow_private_attrs, + has_envelope_markers, +): + """Run one call on the caller's loop, which Pyodide owns.""" + import inspect + + res = _invoke_call( + params, + allowed_modules=allowed_modules, + allow_private_attrs=allow_private_attrs, + has_envelope_markers=has_envelope_markers, + ) + _reject_generator(res) + if inspect.isawaitable(res): + res = await res + _reject_generator(res) return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy) @@ -1568,6 +1631,52 @@ def dispatch_request( return {'id': mid, 'protocol': PROTOCOL, 'result': result} +async def dispatch_request_async( + msg, + *, + bridge, + pid, + force_json_markers, + allow_nan=False, + python_version=None, + torch_allow_copy=False, + arrow_available_override=None, + allowed_modules=None, + allow_private_attrs=False, + has_envelope_markers=True, +): + """Dispatch on the caller's event loop and return one response envelope.""" + mid = require_protocol(msg) + method = msg.get('method') + if not isinstance(method, str): + raise ProtocolError('Missing method') + params = coerce_dict(msg.get('params'), 'params') + if method == 'call': + result = await handle_call_async( + params, + force_json_markers=force_json_markers, + torch_allow_copy=torch_allow_copy, + allowed_modules=allowed_modules, + allow_private_attrs=allow_private_attrs, + has_envelope_markers=has_envelope_markers, + ) + elif method == 'meta': + if python_version is None: + import sys + python_version = sys.version.split()[0] + codec_fallback = 'json' if force_json_markers else 'none' + result = build_meta( + bridge=bridge, + pid=pid, + python_version=python_version, + codec_fallback=codec_fallback, + arrow_available_override=arrow_available_override, + ) + else: + raise ProtocolError(f'Unknown method: {method}') + return {'id': mid, 'protocol': PROTOCOL, 'result': result} + + def build_error_payload(mid, exc, *, include_traceback): """ Build a protocol error response. Protocol/validation errors omit traceback; diff --git a/src/runtime/pooled-transport.ts b/src/runtime/pooled-transport.ts index 4e6ccfc9..c17ac6f6 100644 --- a/src/runtime/pooled-transport.ts +++ b/src/runtime/pooled-transport.ts @@ -329,6 +329,12 @@ export class PooledTransport extends DisposableBase implements Transport { } throw error; } finally { + // A subprocess may retire its process after this lease reached stdin. + // Remove it before another waiter can acquire the old generation. + if (!workerRemoved && this.requiresReplacement(worker)) { + this.removeWorker(worker); + workerRemoved = true; + } // Only release if worker wasn't removed due to fatal error if (!workerRemoved) { this.release(worker); @@ -362,6 +368,11 @@ export class PooledTransport extends DisposableBase implements Transport { return false; } + private requiresReplacement(worker: TransportLease): boolean { + const transport = worker.transport as Transport & { readonly requiresReplacement?: boolean }; + return transport.requiresReplacement === true; + } + /** * Remove a worker from the pool. * diff --git a/src/runtime/pyodide-bootstrap-core.generated.ts b/src/runtime/pyodide-bootstrap-core.generated.ts index 59dc856f..534e5425 100644 --- a/src/runtime/pyodide-bootstrap-core.generated.ts +++ b/src/runtime/pyodide-bootstrap-core.generated.ts @@ -9,4 +9,4 @@ * Regenerate with: node scripts/generate-pyodide-bootstrap.mjs */ -export const PYODIDE_BRIDGE_CORE_SOURCE: string = "\"\"\"\nShared tywrap bridge core: protocol dispatch + value (de)serialization.\n\nThis module is the SINGLE source of truth for the \"tywrap/1\" server-side\nprotocol. It is imported by:\n\n - runtime/python_bridge.py (the Node/Bun/Deno subprocess server and the HTTP\n server), which owns I/O concerns: the stdin/stdout JSONL loop, env-var size\n guards, the real OS pid, bridge='python-subprocess', and the final BridgeCodec\n encode wrapper.\n\n - the in-WASM Pyodide server (src/runtime/pyodide-transport.ts). Pyodide cannot read\n this file from disk, so it is shipped as a build-time-generated TypeScript\n string constant (src/runtime/pyodide-bootstrap-core.generated.ts) produced by\n scripts/generate-pyodide-bootstrap.mjs and exec'd into a module registered in\n sys.modules. A conformance drift guard (test/runtime_conformance.test.ts)\n asserts the generated constant stays byte-identical to this file.\n\nCROSS-LANGUAGE CONTRACT (Python <-> the TypeScript decoder in src/utils/codec.ts\nand the request encoder in src/runtime/bridge-codec.ts):\n\n * Every value-type \"marker\" envelope carries {'__tywrap__': ,\n 'codecVersion': 1, 'encoding': ...}. The 6 markers are: ndarray, dataframe,\n series, scipy.sparse, torch.tensor, sklearn.estimator.\n * bytes round-trip both ways via base64 envelopes (see _deserialize_bytes_*\n and the bytes branch of default_encoder).\n * NaN/Infinity are rejected (the JS client cannot parse the non-standard tokens\n that allow_nan=True would emit).\n\nPURITY: This module depends only on the standard library plus LAZY optional\nimports (numpy/pandas/scipy/torch/sklearn/pyarrow are each imported inside the\nfunction that needs them). It performs no stdin/stdout I/O and reads no env vars,\nso it runs unchanged under CPython-in-WASM (Pyodide).\n\nforce_json_markers: a *parameter* threaded through every serializer (including\nthe nested torch.tensor -> ndarray call). When True, ndarray/dataframe/series are\nforced down their JSON path regardless of pyarrow availability. Pyodide passes\nTrue (Arrow is unavailable in WASM); the subprocess server passes the boolean\nderived from TYWRAP_CODEC_FALLBACK=json so that \"Node in json-fallback mode\" and\n\"Pyodide\" produce byte-identical marker envelopes.\n\"\"\"\n\nimport base64\nimport datetime as dt\nimport decimal\nimport importlib\nimport importlib.util\nimport json\nimport math\nimport re\nimport sys\nimport traceback\nimport uuid\nfrom pathlib import Path, PurePath\n\n# Protocol constants. These MUST match src/runtime/protocol.ts (PROTOCOL_ID,\n# TYWRAP_PROTOCOL_VERSION) and the codec version baked into marker envelopes.\nPROTOCOL = 'tywrap/1'\nPROTOCOL_VERSION = 1\nCODEC_VERSION = 1\nMAX_SERIALIZE_DEPTH = 900\nMAX_SERIALIZE_NODES = 1_000_000\nJS_SAFE_INTEGER_MAX = 2**53 - 1\n_SERIALIZE_PATH_IDENTIFIER = re.compile(r'^[A-Za-z_$][\\w$]*$', re.ASCII)\n\n\nclass ProtocolError(Exception):\n \"\"\"Raised for malformed requests (bad protocol/id/method/params).\"\"\"\n\n\nclass ImportNotAllowedError(PermissionError):\n \"\"\"Raised when a requested module import is not on the active allowlist.\"\"\"\n\n def __init__(self, module_name):\n super().__init__(\n f'Import of module {module_name!r} is not permitted by the tywrap bridge '\n 'allowlist; add it to TYWRAP_ALLOWED_MODULES (subprocess) or the '\n 'allowed_modules parameter to enable it'\n )\n\n\nclass AttributeNotAllowedError(PermissionError):\n \"\"\"Raised when access to a private/dunder attribute is denied by policy.\"\"\"\n\n def __init__(self, attr_name):\n super().__init__(\n f'Access to attribute {attr_name!r} is not permitted by the tywrap bridge: '\n 'underscore-prefixed (private/dunder) attributes are blocked to prevent '\n 'sandbox-escape via attributes like __globals__/__subclasses__/__builtins__; '\n 'set TYWRAP_ALLOW_PRIVATE_ATTRS=1 (subprocess) or pass allow_private_attrs=True '\n 'to override'\n )\n\n\n# =============================================================================\n# IMPORT / ATTRIBUTE ALLOWLIST (trust boundary enforcement)\n# =============================================================================\n#\n# The bridge dispatches call requests by importing the requested module and\n# getattr-ing the requested function/class method. That is an\n# arbitrary import+getattr+call surface, so two complementary guards live here.\n# Both are PURE (no env reads) so the rules behave identically under the\n# subprocess server and the in-WASM Pyodide server; the subprocess server derives\n# the parameters from env vars (TYWRAP_ALLOWED_MODULES / TYWRAP_ALLOW_PRIVATE_ATTRS)\n# and threads them in, exactly like force_json_markers / torch_allow_copy.\n#\n# 1. MODULE ALLOWLIST (opt-in, default = allow all):\n# allowed_modules=None means \"no restriction\" so existing configurations keep\n# working unchanged. When a caller supplies a set, only those modules (plus the\n# stdlib the bridge itself needs to serialize results, see _BRIDGE_REQUIRED_MODULES)\n# may be imported; submodules of an allowed module are permitted (e.g. allowing\n# 'scipy' also allows 'scipy.sparse'). A non-allowlisted import fails LOUDLY with\n# ImportNotAllowedError rather than silently importing.\n#\n# 2. PRIVATE-ATTRIBUTE BLOCK (default ON):\n# getattr of any name starting with '_' (single-underscore private OR dunder) is\n# rejected. This blocks the classic escape chain (obj.__class__.__subclasses__()\n# /__globals__/__builtins__/__import__) without depending on the module allowlist.\n# tywrap-generated wrappers never reference underscore-prefixed names (the IR\n# analyzer skips them), so this does not regress generated code. Set\n# allow_private_attrs=True to restore unrestricted getattr for trusted callers.\n\n# Stdlib modules the bridge's own serialization/handlers may need to import even\n# when a caller-supplied allowlist is active. Optional codec deps (numpy, pandas,\n# scipy, torch, sklearn, pyarrow) are intentionally NOT here: if a caller restricts\n# modules, they must opt those in explicitly. These names cover only what the\n# bridge core itself imports.\n_BRIDGE_REQUIRED_MODULES = frozenset(\n {\n 'base64',\n 'datetime',\n 'decimal',\n 'importlib',\n 'json',\n 'math',\n 'sys',\n 'traceback',\n 'uuid',\n 'pathlib',\n }\n)\n\n\ndef _top_level_package(module_name):\n \"\"\"Return the top-level package of a dotted module name ('a.b.c' -> 'a').\"\"\"\n return module_name.split('.', 1)[0]\n\n\ndef _is_module_allowed(module_name, allowed_modules):\n \"\"\"\n Return True when module_name may be imported under the active policy.\n\n allowed_modules=None disables enforcement (allow all). Otherwise a module is\n allowed when it (or its top-level package) is explicitly listed, or it is one\n of the stdlib modules the bridge itself requires.\n \"\"\"\n if allowed_modules is None:\n return True\n if module_name in allowed_modules or module_name in _BRIDGE_REQUIRED_MODULES:\n return True\n top = _top_level_package(module_name)\n return top in allowed_modules or top in _BRIDGE_REQUIRED_MODULES\n\n\ndef import_allowed_module(module_name, allowed_modules):\n \"\"\"\n Import module_name only if permitted by the allowlist, else raise loudly.\n\n This is the single chokepoint every handler routes module imports through.\n \"\"\"\n if not _is_module_allowed(module_name, allowed_modules):\n raise ImportNotAllowedError(module_name)\n return importlib.import_module(module_name)\n\n\ndef get_allowed_attr(obj, attr_name, *, allow_private_attrs):\n \"\"\"\n getattr(obj, attr_name) with the private/dunder block applied.\n\n Rejects any underscore-prefixed name unless allow_private_attrs is True. This\n is the single chokepoint every handler routes attribute access through.\n \"\"\"\n if not allow_private_attrs and attr_name.startswith('_'):\n raise AttributeNotAllowedError(attr_name)\n return getattr(obj, attr_name)\n\n\ndef resolve_allowed_attr_path(root, dotted_name, *, allow_private_attrs):\n \"\"\"\n Resolve a possibly-dotted attribute path from root, applying the\n private/dunder getattr guard to EVERY segment.\n\n A single segment (the common case, e.g. a module-level function) behaves\n exactly like get_allowed_attr. Dotted names exist because @classmethod and\n @staticmethod are invoked through their owning class: the generated wrapper\n emits call(module, 'Class.method', ...), so the bridge must walk\n module -> Class -> method. Guarding each segment means 'Class._secret' or\n '_Hidden.method' are rejected exactly as a direct private getattr would be —\n the dotted path opens no access the single-getattr path did not already.\n \"\"\"\n obj = root\n for segment in dotted_name.split('.'):\n obj = get_allowed_attr(obj, segment, allow_private_attrs=allow_private_attrs)\n return obj\n\n\nclass CodecError(Exception):\n \"\"\"Raised when value encoding fails (e.g. NaN/Infinity not allowed).\"\"\"\n\n\n# =============================================================================\n# REQUEST-SIDE DESERIALIZATION (bytes envelopes -> Python bytes)\n# =============================================================================\n\n_NO_DESERIALIZE = object()\n_ERR_BYTES_MISSING_B64 = 'Invalid bytes envelope: missing b64'\n_ERR_BYTES_MISSING_DATA = 'Invalid bytes envelope: missing data'\n_ERR_BYTES_INVALID_BASE64 = 'Invalid bytes envelope: invalid base64'\n\n\ndef _deserialize_bytes_envelope(value):\n \"\"\"\n Decode base64-encoded bytes envelopes from JS into Python bytes.\n\n Supported shapes:\n - { \"__tywrap_bytes__\": true, \"b64\": \"...\" } (JS BridgeCodec.encodeRequest)\n - { \"__type__\": \"bytes\", \"encoding\": \"base64\", \"data\": \"...\" } (legacy/compat)\n\n Why: TS BridgeCodec encodes Uint8Array/ArrayBuffer as base64 objects, but\n Python handlers expect real bytes/bytearray to preserve behavior (e.g., len()).\n \"\"\"\n if not isinstance(value, dict):\n return _NO_DESERIALIZE\n\n if value.get('__tywrap_bytes__') is True:\n b64 = value.get('b64')\n if not isinstance(b64, str):\n raise ProtocolError(_ERR_BYTES_MISSING_B64)\n try:\n return base64.b64decode(b64, validate=True)\n except Exception as exc:\n raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc\n\n if value.get('__type__') == 'bytes' and value.get('encoding') == 'base64':\n data = value.get('data')\n if not isinstance(data, str):\n raise ProtocolError(_ERR_BYTES_MISSING_DATA)\n try:\n return base64.b64decode(data, validate=True)\n except Exception as exc:\n raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc\n\n return _NO_DESERIALIZE\n\n\ndef deserialize(value, *, has_envelope_markers=True):\n \"\"\"\n Recursively deserialize request values into Python-native types.\n\n Why: requests are JSON-only; we need a small set of explicit decoders\n (currently bytes) to restore Python semantics at the boundary.\n \"\"\"\n if not has_envelope_markers:\n return value\n decoded = _deserialize_bytes_envelope(value)\n if decoded is not _NO_DESERIALIZE:\n return decoded\n\n if isinstance(value, list):\n return [deserialize(item, has_envelope_markers=True) for item in value]\n if isinstance(value, dict):\n # Preserve dict shape while decoding nested values.\n return {k: deserialize(v, has_envelope_markers=True) for k, v in value.items()}\n return value\n\n\n# =============================================================================\n# CAPABILITY DETECTION (lazy, best-effort)\n# =============================================================================\n\ndef arrow_available():\n \"\"\"Return True when pyarrow can be imported.\"\"\"\n try:\n import pyarrow # noqa: F401\n except (ImportError, OSError):\n return False\n return True\n\n\ndef module_available(module_name):\n \"\"\"\n Lightweight feature detection for optional codec dependencies via find_spec.\n\n Why: exposes availability in bridge metadata without importing heavy modules.\n \"\"\"\n try:\n return importlib.util.find_spec(module_name) is not None\n except (ImportError, AttributeError, TypeError, ValueError):\n return False\n\n\ndef is_numpy_array(obj):\n \"\"\"Return whether obj is an ndarray without importing NumPy.\"\"\"\n np = sys.modules.get('numpy')\n if np is None:\n return False\n return isinstance(obj, np.ndarray)\n\n\ndef is_pandas_dataframe(obj):\n \"\"\"Return whether obj is a DataFrame without importing pandas.\"\"\"\n pd = sys.modules.get('pandas')\n if pd is None:\n return False\n return isinstance(obj, pd.DataFrame)\n\n\ndef is_pandas_series(obj):\n \"\"\"Return whether obj is a Series without importing pandas.\"\"\"\n pd = sys.modules.get('pandas')\n if pd is None:\n return False\n return isinstance(obj, pd.Series)\n\n\ndef is_scipy_sparse(obj):\n \"\"\"Return whether obj is sparse without importing SciPy.\"\"\"\n sp = sys.modules.get('scipy.sparse')\n if sp is None:\n return False\n try:\n return sp.issparse(obj)\n except Exception:\n return False\n\n\ndef is_torch_tensor(obj):\n \"\"\"Return whether obj is a Tensor without importing PyTorch.\"\"\"\n torch = sys.modules.get('torch')\n if torch is None:\n return False\n try:\n return torch.is_tensor(obj)\n except Exception:\n return False\n\n\ndef is_sklearn_estimator(obj):\n \"\"\"Return whether obj is an estimator without importing scikit-learn.\"\"\"\n sklearn_base = sys.modules.get('sklearn.base')\n if sklearn_base is None:\n return False\n return isinstance(obj, sklearn_base.BaseEstimator)\n\n\n# =============================================================================\n# MARKER SERIALIZERS (6 __tywrap__ value types)\n# =============================================================================\n#\n# Each serializer accepts force_json_markers. When True, the Arrow path is never\n# taken (used by Pyodide and by the subprocess server in TYWRAP_CODEC_FALLBACK=json\n# mode). The JSON fallback envelopes are byte-identical across both callers, which\n# is what the conformance suite asserts.\n\ndef serialize_ndarray(obj, *, force_json_markers):\n \"\"\"\n Encode a NumPy ndarray. Arrow IPC (compact, lossless) by default; JSON when\n force_json_markers is set or pyarrow is unavailable in fallback mode.\n\n Note: pa.array() only handles 1D arrays; multi-dimensional arrays are\n flattened with shape metadata for JS-side reconstruction. See\n https://github.com/apache/arrow-js/issues/115\n \"\"\"\n if force_json_markers:\n return serialize_ndarray_json(obj)\n try:\n import pyarrow as pa # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for ndarray; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n original_shape = list(obj.shape) if hasattr(obj, 'shape') else None\n flat = obj.flatten() if hasattr(obj, 'ndim') and obj.ndim != 1 else obj\n arr = pa.array(flat)\n table = pa.Table.from_arrays([arr], names=['value'])\n sink = pa.BufferOutputStream()\n with pa.ipc.new_stream(sink, table.schema) as writer:\n writer.write_table(table)\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'ndarray',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n 'shape': original_shape,\n 'dtype': str(obj.dtype) if hasattr(obj, 'dtype') else None,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for ndarray') from exc\n\n\ndef serialize_ndarray_json(obj):\n \"\"\"JSON fallback for ndarray values that JavaScript can represent safely.\"\"\"\n dtype = obj.dtype\n dtype_label = str(dtype)\n\n if not dtype.isnative:\n raise RuntimeError(\n f'JSON ndarray encoding does not support big-endian dtype={dtype_label}; '\n \"convert to native byte order with a.byteswap().view(a.dtype.newbyteorder('='))\"\n )\n\n if dtype.kind in ('i', 'u'):\n # JSON numbers become JavaScript Number values. Scan only on this explicit\n # fallback path and reject before tolist() can silently round an integer.\n if obj.size and (\n (obj < -JS_SAFE_INTEGER_MAX).any() or (obj > JS_SAFE_INTEGER_MAX).any()\n ):\n raise RuntimeError(\n f'JSON ndarray encoding cannot safely represent dtype={dtype_label} values '\n 'outside the JavaScript safe integer range; use Arrow encoding or '\n \"cast/encode explicitly (e.g. .astype('float64') or str)\"\n )\n elif dtype.kind == 'M':\n raise RuntimeError(\n f'JSON ndarray encoding does not support dtype={dtype_label}; use Arrow encoding '\n \"or convert explicitly (e.g. .astype('datetime64[ms]').astype(str) or int with \"\n 'declared unit)'\n )\n elif dtype.kind == 'm':\n raise RuntimeError(\n f'JSON ndarray encoding does not support dtype={dtype_label}; use Arrow encoding '\n \"or convert explicitly (e.g. .astype('timedelta64[ms]').astype(str) or int with \"\n 'declared unit)'\n )\n elif dtype.kind == 'V':\n if dtype.fields is not None:\n raise RuntimeError(\n f'JSON ndarray encoding does not support structured dtype={dtype_label}; '\n 'encode each named field explicitly (e.g. as a plain JSON object)'\n )\n raise RuntimeError(\n f'JSON ndarray encoding does not support void dtype={dtype_label}; convert the '\n \"raw bytes explicitly (e.g. .view('uint8'))\"\n )\n elif dtype.kind == 'O':\n raise RuntimeError(\n f'JSON ndarray encoding does not support object dtype={dtype_label}; cast to a '\n 'concrete numeric dtype or encode elements explicitly as plain JSON'\n )\n elif dtype.kind == 'S':\n raise RuntimeError(\n f'JSON ndarray encoding does not support byte-string dtype={dtype_label}; '\n 'decode elements and return a plain JSON list explicitly'\n )\n elif dtype.kind == 'U':\n raise RuntimeError(\n f'JSON ndarray encoding does not support unicode dtype={dtype_label}; convert '\n 'explicitly to plain JSON strings with .tolist()'\n )\n elif dtype.kind == 'c':\n raise RuntimeError(\n f'JSON ndarray encoding does not support complex dtype={dtype_label}; encode '\n '.real and .imag arrays explicitly'\n )\n elif dtype.kind == 'f' and dtype.itemsize > 8:\n raise RuntimeError(\n f'JSON ndarray encoding does not support float dtype={dtype_label} wider than '\n \"64 bits; cast explicitly (e.g. .astype('float64')) or use Arrow encoding\"\n )\n elif dtype.kind not in ('b', 'f'):\n raise RuntimeError(\n f'JSON ndarray encoding does not support dtype={dtype_label}; cast to bool, '\n 'integer, or float dtype, or encode values explicitly as plain JSON'\n )\n\n try:\n data = obj.tolist()\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for ndarray') from exc\n return {\n '__tywrap__': 'ndarray',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n 'shape': getattr(obj, 'shape', None),\n 'dtype': dtype.name,\n }\n\n\ndef serialize_dataframe(obj, *, force_json_markers):\n \"\"\"\n Encode a pandas DataFrame. Feather/Arrow-IPC (uncompressed, so apache-arrow\n in JS can read it) by default; JSON when force_json_markers is set.\n \"\"\"\n if force_json_markers:\n return serialize_dataframe_json(obj)\n try:\n import pyarrow as pa # type: ignore\n import pyarrow.feather as feather # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for pandas.DataFrame; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n table = pa.Table.from_pandas(obj) # type: ignore\n sink = pa.BufferOutputStream()\n feather.write_feather(table, sink, compression='uncompressed')\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'dataframe',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for pandas.DataFrame') from exc\n\n\ndef serialize_dataframe_json(obj):\n \"\"\"JSON fallback for DataFrame values that JavaScript can represent safely.\"\"\"\n import pandas as pd # type: ignore\n\n _validate_pandas_json_index(obj.index, pd, 'DataFrame')\n json_columns = [_json_object_key(column) for column in obj.columns]\n supported_json_columns = [column for column in json_columns if column is not None]\n if not obj.columns.is_unique or len(set(supported_json_columns)) != len(\n supported_json_columns\n ):\n raise RuntimeError(\n 'JSON pandas.DataFrame encoding requires column labels to remain unique after '\n 'JSON object-key coercion; rename columns or make them distinct before applying '\n '.columns.astype(str)'\n )\n for column, dtype in obj.dtypes.items():\n if isinstance(dtype, pd.CategoricalDtype):\n raise RuntimeError(\n f'JSON pandas.DataFrame encoding does not support categorical dtype in '\n f'column {column!r}; use Arrow encoding or convert explicitly '\n \"(e.g. .astype(str))\"\n )\n try:\n data = (\n [{} for _ in range(len(obj.index))]\n if len(obj.columns) == 0\n else obj.to_dict(orient='records')\n )\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc\n for row_number, row in enumerate(data):\n for column, value in row.items():\n row[column] = _normalize_pandas_json_scalar(\n value,\n f'DataFrame cell at row {row_number}, column {column!r}',\n pd,\n )\n return {\n '__tywrap__': 'dataframe',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n }\n\n\ndef serialize_series(obj, *, force_json_markers, path='result'):\n \"\"\"\n Encode a pandas Series as a single-column Arrow Table stream (the JS decoder\n contract is \"table-like\"); JSON when force_json_markers is set.\n \"\"\"\n if force_json_markers:\n return serialize_series_json(obj, path=path)\n name = getattr(obj, 'name', None)\n _check_terminal_json_integers(\n encode_value(name, allow_nan=False), _serialize_path(path, 'name')\n )\n try:\n import pyarrow as pa # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for pandas.Series; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n arr = pa.Array.from_pandas(obj) # type: ignore\n table = pa.Table.from_arrays([arr], names=['value'])\n sink = pa.BufferOutputStream()\n with pa.ipc.new_stream(sink, table.schema) as writer:\n writer.write_table(table)\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'series',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n 'name': name,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for pandas.Series') from exc\n\n\ndef serialize_series_json(obj, *, path='result'):\n \"\"\"JSON fallback for Series values that JavaScript can represent safely.\"\"\"\n import pandas as pd # type: ignore\n\n _validate_pandas_json_index(obj.index, pd, 'Series')\n if isinstance(obj.dtype, pd.CategoricalDtype):\n raise RuntimeError(\n 'JSON pandas.Series encoding does not support categorical dtype; use Arrow '\n \"encoding or convert explicitly (e.g. .astype(str))\"\n )\n try:\n data = obj.to_list() # type: ignore\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.Series') from exc\n data = [\n _normalize_pandas_json_scalar(value, f'Series value at position {position}', pd)\n for position, value in enumerate(data)\n ]\n name = getattr(obj, 'name', None)\n _check_terminal_json_integers(\n encode_value(name, allow_nan=False), _serialize_path(path, 'name')\n )\n return {\n '__tywrap__': 'series',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n 'name': name,\n }\n\n\ndef _validate_pandas_json_index(index, pd, container_name):\n \"\"\"Reject index metadata that records/list-oriented JSON would discard.\"\"\"\n if isinstance(index, pd.MultiIndex):\n raise RuntimeError(\n f'JSON pandas.{container_name} encoding does not support MultiIndex; use Arrow '\n 'encoding or flatten the index explicitly with .reset_index()'\n )\n if not (\n isinstance(index, pd.RangeIndex)\n and index.start == 0\n and index.step == 1\n and index.stop == len(index)\n and index.name is None\n ):\n raise RuntimeError(\n f'JSON pandas.{container_name} encoding requires an unnamed RangeIndex starting '\n 'at 0 with step 1; use Arrow encoding or normalize explicitly with '\n '.reset_index(drop=True)'\n )\n\n\ndef _json_object_key(value):\n \"\"\"Return json.dumps' object-key spelling, or None for unsupported keys.\"\"\"\n try:\n encoded = json.dumps({value: None})\n except (TypeError, ValueError):\n return None\n return next(iter(json.loads(encoded)))\n\n\ndef _normalize_pandas_json_scalar(value, location, pd):\n \"\"\"Normalize pandas nulls and reject values outside the plain JSON domain.\"\"\"\n np = sys.modules.get('numpy')\n if np is not None and isinstance(value, np.generic):\n value = value.item()\n if value is None or value is pd.NA or value is pd.NaT:\n return None\n if type(value) is bool:\n return value\n if type(value) is int:\n if value < -JS_SAFE_INTEGER_MAX or value > JS_SAFE_INTEGER_MAX:\n raise RuntimeError(\n f'JSON pandas encoding cannot safely represent {location} integer values '\n 'outside the JavaScript safe integer range; use Arrow encoding or '\n \"cast/encode explicitly (e.g. .astype('float64') or str)\"\n )\n return value\n if type(value) is float:\n if not math.isfinite(value):\n raise RuntimeError(\n f'JSON pandas encoding cannot represent non-finite {location} float values '\n '(NaN or Infinity); use .fillna(...) for intentional missing values or '\n 'Arrow encoding'\n )\n return value\n if type(value) is str:\n return value\n raise RuntimeError(\n f'JSON pandas encoding does not support {location} value of type '\n f'{type(value).__name__}; use Arrow encoding or convert explicitly '\n '(e.g. .astype(str))'\n )\n\n\ndef serialize_sparse_matrix(obj, *, path='result'):\n \"\"\"\n Serialize scipy sparse matrices into structured JSON envelopes (json-only;\n there is no Arrow path). Preserves sparsity; rejects unsupported formats and\n complex dtypes explicitly.\n \"\"\"\n try:\n fmt = obj.getformat()\n except Exception as exc:\n raise RuntimeError('Failed to inspect scipy sparse matrix format') from exc\n\n if fmt not in ('csr', 'csc', 'coo'):\n raise RuntimeError(\n f'Unsupported scipy sparse format: {fmt}; only csr/csc/coo are supported. '\n 'Convert explicitly (e.g. matrix.tocsr()) before returning'\n )\n\n dtype = None\n try:\n dtype = str(obj.dtype)\n except Exception:\n dtype = None\n if getattr(obj.dtype, 'kind', None) == 'c':\n raise RuntimeError(\n 'Complex scipy sparse matrices are not supported by the JSON codec; '\n 'split into real/imag components explicitly before returning'\n )\n if getattr(obj.dtype, 'kind', None) in ('i', 'u') and obj.data.size and (\n (obj.data < -JS_SAFE_INTEGER_MAX).any()\n or (obj.data > JS_SAFE_INTEGER_MAX).any()\n ):\n raise RuntimeError(\n 'JSON scipy sparse encoding cannot safely represent data outside the '\n 'JavaScript safe integer range; cast or encode the values explicitly'\n )\n\n for index, dimension in enumerate(obj.shape):\n if int.__int__(dimension) > JS_SAFE_INTEGER_MAX:\n raise RuntimeError(\n f'Unsafe Python integer at {_serialize_path(_serialize_path(path, \"shape\"), index)}: '\n f'{dimension} is outside the JavaScript safe integer range'\n )\n index_fields = ('indices', 'indptr') if fmt in ('csr', 'csc') else ('row', 'col')\n for field in index_fields:\n values = getattr(obj, field)\n if values.size and (\n (values < -JS_SAFE_INTEGER_MAX).any()\n or (values > JS_SAFE_INTEGER_MAX).any()\n ):\n raise RuntimeError(\n f'JSON scipy sparse encoding cannot safely represent {field} at '\n f'{_serialize_path(path, field)} outside the JavaScript safe integer range'\n )\n\n if fmt in ('csr', 'csc'):\n data = obj.data.tolist()\n indices = obj.indices.tolist()\n indptr = obj.indptr.tolist()\n return {\n '__tywrap__': 'scipy.sparse',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'format': fmt,\n 'shape': list(obj.shape),\n 'data': data,\n 'indices': indices,\n 'indptr': indptr,\n 'dtype': dtype,\n }\n\n # coo\n data = obj.data.tolist()\n row = obj.row.tolist()\n col = obj.col.tolist()\n return {\n '__tywrap__': 'scipy.sparse',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'format': fmt,\n 'shape': list(obj.shape),\n 'data': data,\n 'row': row,\n 'col': col,\n 'dtype': dtype,\n }\n\n\ndef serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):\n \"\"\"\n Serialize torch.Tensor values via the nested ndarray envelope. CPU-only by\n default; device/copy behavior is explicit. force_json_markers is threaded\n into the nested ndarray serialization so Pyodide gets a JSON ndarray value.\n\n Rejection order is significant: the categorical rejections (sparse / quantized\n / meta / complex) are checked BEFORE the device/contiguous opt-in branch so\n they fail with a clear, specific message and are NOT bypassable by\n TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the device transfer and\n contiguous copy, never an unrepresentable layout/dtype.\n \"\"\"\n import torch # already importable: is_torch_tensor() gated the dispatch\n\n tensor = obj.detach()\n source_device = None\n source_dtype = None\n\n # Sparse tensors (COO/CSR/CSC/BSR/BSC -> any non-strided layout) have no dense\n # numpy representation without a densify step, which is not the round-trip this\n # envelope promises. Reject explicitly rather than emitting a misleading\n # \"not contiguous\" error or silently densifying.\n layout = getattr(tensor, 'layout', None)\n if getattr(tensor, 'is_sparse', False) or (\n layout is not None and layout != torch.strided\n ):\n raise RuntimeError(\n f'Torch sparse tensors are not supported (layout={layout}); '\n 'convert to a dense CPU tensor explicitly (e.g. tensor.to_dense()) before returning'\n )\n\n # Quantized tensors carry a qscheme/scale/zero_point that numpy() cannot\n # represent; .numpy() raises an opaque \"unsupported ScalarType\" deep in torch.\n # Reject up front with an actionable message.\n if getattr(tensor, 'is_quantized', False):\n raise RuntimeError(\n 'Torch quantized tensors are not supported; dequantize explicitly '\n '(e.g. tensor.dequantize()) before returning'\n )\n\n # Meta tensors have shape/dtype but NO storage; copying to CPU yields garbage,\n # so this is never a lossy-but-honest transfer the opt-in could authorize.\n if getattr(tensor, 'is_meta', False) or (\n getattr(tensor, 'device', None) is not None and tensor.device.type == 'meta'\n ):\n raise RuntimeError(\n 'Torch meta tensors carry no data and cannot be serialized; '\n 'materialize the tensor on a real device before returning'\n )\n\n # Complex tensors round-trip to numpy complex arrays, which are not\n # JSON-serializable and have no codec envelope. Reject explicitly instead of\n # emitting Python complex tuples that the JS decoder cannot parse.\n if torch.is_complex(tensor):\n raise RuntimeError(\n f'Torch complex tensors are not supported (dtype={tensor.dtype}); '\n 'split into real/imag components explicitly before returning'\n )\n\n if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':\n if not torch_allow_copy:\n raise RuntimeError(\n 'Torch tensor is on a non-CPU device; set TYWRAP_TORCH_ALLOW_COPY=1 to allow CPU transfer'\n )\n source_device = str(tensor.device)\n tensor = tensor.to('cpu')\n if hasattr(tensor, 'is_contiguous') and not tensor.is_contiguous():\n if not torch_allow_copy:\n raise RuntimeError(\n 'Torch tensor is not contiguous; set TYWRAP_TORCH_ALLOW_COPY=1 to allow contiguous copy'\n )\n tensor = tensor.contiguous()\n if tensor.dtype == torch.bfloat16:\n source_dtype = str(tensor.dtype)\n tensor = tensor.float()\n try:\n arr = tensor.numpy()\n except Exception as exc:\n raise RuntimeError('Failed to convert torch.Tensor to numpy') from exc\n\n envelope = {\n '__tywrap__': 'torch.tensor',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'ndarray',\n 'value': serialize_ndarray(arr, force_json_markers=force_json_markers),\n 'shape': list(tensor.shape),\n 'dtype': str(tensor.dtype),\n 'device': str(tensor.device),\n }\n if source_dtype is not None:\n envelope['sourceDtype'] = source_dtype\n if source_device is not None:\n envelope['sourceDevice'] = source_device\n return envelope\n\n\ndef serialize_sklearn_estimator(obj, *, path):\n \"\"\"Serialize sklearn estimators as metadata only (json-only); no pickling.\"\"\"\n try:\n import sklearn # noqa: F401\n except Exception as exc:\n raise RuntimeError('scikit-learn is not available') from exc\n\n params = obj.get_params(deep=False)\n\n # Metadata-only: NEVER pickle/joblib. Every param value must be plain JSON\n # (no callables, nested estimators, numpy arrays, or other objects). Probe\n # each value individually so the error names the offending param instead of\n # failing opaquely on the whole dict. allow_nan=False also rejects NaN/Inf\n # params here for parity with the response codec.\n for key, value in params.items():\n try:\n encoded = json.dumps(value, allow_nan=False)\n param_path = _serialize_path(_serialize_path(path, 'params'), str(key))\n _check_terminal_json_integers(encoded, param_path)\n except RuntimeError:\n raise\n except (TypeError, ValueError) as exc:\n raise RuntimeError(\n f'scikit-learn estimator param {key!r} is not JSON-serializable '\n f'(got {type(value).__name__}); estimators are serialized as metadata only '\n '(no pickle/joblib), so every param must be a plain JSON value. '\n 'Sanitize or drop the param before returning'\n ) from exc\n\n return {\n '__tywrap__': 'sklearn.estimator',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'className': obj.__class__.__name__,\n 'module': obj.__class__.__module__,\n 'version': getattr(sklearn, '__version__', None),\n 'params': params,\n }\n\n\ndef _check_terminal_json_integers(encoded, path):\n \"\"\"Check integer tokens in a small JSON metadata value before emission.\"\"\"\n stack = [(json.loads(encoded), path)]\n while stack:\n current, current_path = stack.pop()\n if type(current) is int:\n _serialize_leaf(current, current_path)\n elif isinstance(current, dict):\n stack.extend(\n (item, _serialize_path(current_path, key))\n for key, item in current.items()\n )\n elif isinstance(current, list):\n stack.extend(\n (item, _serialize_path(current_path, index))\n for index, item in enumerate(current)\n )\n\n\n_NO_PYDANTIC = object()\n\n\ndef serialize_pydantic(obj):\n \"\"\"\n Serialize Pydantic v2 models via model_dump(by_alias=True, mode='json')\n without importing Pydantic. Returns _NO_PYDANTIC when obj is not a model.\n \"\"\"\n model_dump = getattr(obj, 'model_dump', None)\n if not callable(model_dump):\n return _NO_PYDANTIC\n try:\n try:\n return model_dump(by_alias=True, mode='json')\n except TypeError:\n # Older Pydantic versions may not support `mode=...`.\n return model_dump(by_alias=True)\n except Exception as exc:\n raise RuntimeError(f'model_dump failed: {exc}') from exc\n\n\ndef serialize_stdlib(obj):\n \"\"\"Coerce common stdlib scalar types to JSON-safe forms; None otherwise.\"\"\"\n if isinstance(obj, dt.datetime):\n return obj.isoformat()\n if isinstance(obj, dt.date):\n return obj.isoformat()\n if isinstance(obj, dt.time):\n return obj.isoformat()\n if isinstance(obj, dt.timedelta):\n return obj.total_seconds()\n if isinstance(obj, decimal.Decimal):\n return str(obj)\n if isinstance(obj, uuid.UUID):\n return str(obj)\n if isinstance(obj, (Path, PurePath)):\n return str(obj)\n return None\n\n\n_NO_SCIENTIFIC = object()\n\n\ndef _check_serialize_depth(depth, path):\n if depth > MAX_SERIALIZE_DEPTH:\n raise RuntimeError(\n f'Scientific envelope serialization maximum depth '\n f'{MAX_SERIALIZE_DEPTH} exceeded at {path}'\n )\n\n\ndef _check_serialize_nodes(nodes, path):\n if nodes > MAX_SERIALIZE_NODES:\n raise RuntimeError(\n f'Scientific envelope serialization maximum visited nodes '\n f'{MAX_SERIALIZE_NODES} exceeded at {path}'\n )\n\n\ndef _serialize_scientific(obj, *, force_json_markers, torch_allow_copy, depth, path):\n \"\"\"Serialize a supported scientific value, or return _NO_SCIENTIFIC.\"\"\"\n package = type(obj).__module__.split('.', 1)[0]\n\n if package == 'numpy' and 'numpy' in sys.modules:\n if is_numpy_array(obj):\n _check_serialize_depth(depth, path)\n return serialize_ndarray(obj, force_json_markers=force_json_markers)\n elif package == 'pandas' and 'pandas' in sys.modules:\n if is_pandas_dataframe(obj):\n _check_serialize_depth(depth, path)\n return serialize_dataframe(obj, force_json_markers=force_json_markers)\n if is_pandas_series(obj):\n _check_serialize_depth(depth, path)\n return serialize_series(obj, force_json_markers=force_json_markers, path=path)\n elif package == 'scipy' and 'scipy.sparse' in sys.modules:\n if is_scipy_sparse(obj):\n _check_serialize_depth(depth, path)\n return serialize_sparse_matrix(obj, path=path)\n elif package == 'torch' and 'torch' in sys.modules:\n if is_torch_tensor(obj):\n _check_serialize_depth(depth, path)\n _check_serialize_depth(depth + 1, _serialize_path(path, 'value'))\n return serialize_torch_tensor(\n obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy\n )\n elif 'sklearn.base' in sys.modules and is_sklearn_estimator(obj):\n # No package gate here, unlike the branches above: subclassing\n # BaseEstimator is sklearn's documented extension point, so user-defined\n # estimators live outside the 'sklearn' package and must still get the\n # estimator serializer (and its param-naming errors).\n _check_serialize_depth(depth, path)\n return serialize_sklearn_estimator(obj, path=path)\n\n return _NO_SCIENTIFIC\n\n\ndef _serialize_path(base, key):\n \"\"\"Build a decoder-compatible JSONPath-like result path.\"\"\"\n if isinstance(key, int):\n return f'{base}[{key}]'\n if _SERIALIZE_PATH_IDENTIFIER.fullmatch(key):\n return f'{base}.{key}'\n return f'{base}[{json.dumps(key, ensure_ascii=False)}]'\n\n\ndef _invalid_key_path(base, key):\n \"\"\"Name a dict key that cannot be represented by JSON.\"\"\"\n return f'{base}[{key!r}]'\n\n\ndef _needs_serialize_visit(value):\n \"\"\"Return whether value needs container or scientific traversal work.\"\"\"\n if type(value) in (type(None), bool, int, float, str):\n return False\n if isinstance(value, (dict, list, tuple, set, frozenset)):\n return True\n package = type(value).__module__.split('.', 1)[0]\n if package in ('numpy', 'pandas', 'scipy', 'torch'):\n return True\n return callable(getattr(value, 'model_dump', None)) or (\n 'sklearn.base' in sys.modules and is_sklearn_estimator(value)\n )\n\n\ndef _serialize_leaf(value, path):\n \"\"\"Apply non-container conversions without allocating a traversal frame.\"\"\"\n integer_value = int.__int__(value) if isinstance(value, int) and not isinstance(value, bool) else None\n if integer_value is not None and (\n integer_value < -JS_SAFE_INTEGER_MAX or integer_value > JS_SAFE_INTEGER_MAX\n ):\n raise RuntimeError(\n f'Unsafe Python integer at {path}: {integer_value} is outside the JavaScript safe '\n 'integer range; return an explicit string or use an Arrow integer column'\n )\n if type(value) in (type(None), bool, int, float, str):\n return value\n np = sys.modules.get('numpy')\n if np is not None and isinstance(value, np.generic):\n extracted = value.item()\n if isinstance(extracted, np.generic):\n raise TypeError(\n f'NumPy scalar at {path} did not convert to a JSON value; '\n 'cast it explicitly before returning'\n )\n return _serialize_leaf(extracted, path)\n pydantic_value = serialize_pydantic(value)\n if pydantic_value is not _NO_PYDANTIC:\n return pydantic_value\n if isinstance(value, (set, frozenset)):\n return list(value)\n stdlib_value = serialize_stdlib(value)\n if stdlib_value is not None:\n return stdlib_value\n return value\n\n\ndef serialize(obj, *, force_json_markers, torch_allow_copy=False):\n \"\"\"\n Top-level result serializer.\n\n Scientific codecs are type-first and only inspect packages that the value can\n belong to. A value from an optional package implies that package is already in\n sys.modules, so these checks never cold-import the scientific stack. The\n package dispatch deliberately precedes the JSON-native fast path: e.g. a\n package-defined subclass of dict still receives its relevant codec check.\n Every other value is left untouched so the shared JSON encoder applies the\n exact same default conversion at the root and at any nested depth.\n \"\"\"\n root = [None]\n active_ids = set()\n stack = [('visit', obj, 0, 'result', root, 0)]\n visited_nodes = 0\n\n # Repeated aliases have value semantics and are intentionally serialized twice.\n while stack:\n frame = stack.pop()\n action = frame[0]\n if action == 'release-model':\n active_ids.remove(frame[1])\n continue\n if action == 'dict':\n _, current, depth, path, parent, key, output, iterator = frame\n try:\n item_key, item = next(iterator)\n except StopIteration:\n active_ids.remove(id(current))\n parent[key] = output\n continue\n stack.append(frame)\n if not (isinstance(item_key, (str, int, float, bool)) or item_key is None):\n invalid_path = _invalid_key_path(path, item_key)\n raise TypeError(\n f'keys must be str, int, float, bool or None, not '\n f'{type(item_key).__name__} at {invalid_path}'\n )\n child_key = _json_object_key(item_key)\n child_path = _serialize_path(path, child_key)\n if _needs_serialize_visit(item):\n stack.append(('visit', item, depth + 1, child_path, output, item_key))\n else:\n output[item_key] = _serialize_leaf(item, child_path)\n continue\n if action == 'sequence':\n _, current, depth, path, parent, key, output, index = frame\n if index == len(output):\n active_ids.remove(id(current))\n parent[key] = output if isinstance(current, list) else tuple(output)\n continue\n stack.append(('sequence', current, depth, path, parent, key, output, index + 1))\n item = current[index]\n child_path = _serialize_path(path, index)\n if _needs_serialize_visit(item):\n stack.append(\n ('visit', item, depth + 1, child_path, output, index)\n )\n else:\n output[index] = _serialize_leaf(item, child_path)\n continue\n\n _, current, depth, path, parent, key = frame\n try:\n scientific = _serialize_scientific(\n current,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n depth=depth,\n path=path,\n )\n except Exception as exc:\n if path == 'result' and 'safe integer range' not in str(exc):\n raise\n raise RuntimeError(f'Scientific value serialization failed at {path}: {exc}') from exc\n if scientific is not _NO_SCIENTIFIC:\n # Recognized envelopes are terminal containers to the JS decoder.\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, path)\n if scientific.get('__tywrap__') == 'torch.tensor':\n nested_path = _serialize_path(path, 'value')\n try:\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, nested_path)\n except Exception as exc:\n if path == 'result':\n raise\n raise RuntimeError(\n f'Scientific value serialization failed at {path}: {exc}'\n ) from exc\n parent[key] = scientific\n continue\n\n if callable(getattr(current, 'model_dump', None)):\n _check_serialize_depth(depth, path)\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, path)\n current_id = id(current)\n if current_id in active_ids:\n raise RuntimeError(f'Circular model conversion detected at {path}')\n active_ids.add(current_id)\n normalized = _serialize_leaf(current, path)\n if normalized is current:\n raise RuntimeError(f'Circular model conversion detected at {path}')\n stack.append(('release-model', current_id))\n if _needs_serialize_visit(normalized):\n stack.append(('visit', normalized, depth + 1, path, parent, key))\n else:\n parent[key] = _serialize_leaf(normalized, path)\n continue\n\n if isinstance(current, (dict, list, tuple)):\n _check_serialize_depth(depth, path)\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, path)\n current_id = id(current)\n if current_id in active_ids:\n raise RuntimeError(f'Circular reference detected at {path}')\n active_ids.add(current_id)\n\n if isinstance(current, dict):\n output = {}\n parent[key] = output\n stack.append(\n ('dict', current, depth, path, parent, key, output, iter(current.items()))\n )\n continue\n\n output = [None] * len(current)\n if isinstance(current, list):\n parent[key] = output\n stack.append(('sequence', current, depth, path, parent, key, output, 0))\n continue\n\n normalized = _serialize_leaf(current, path)\n if normalized is not current and _needs_serialize_visit(normalized):\n stack.append(('visit', normalized, depth, path, parent, key))\n elif normalized is not current:\n parent[key] = _serialize_leaf(normalized, path)\n else:\n parent[key] = normalized\n\n return root[0]\n\n\n# =============================================================================\n# JSON CODEC: value handling and size-limited encode/decode\n# =============================================================================\n#\n# This is the single Python implementation used by both the subprocess bridge and\n# the embedded Pyodide core. BridgeCodec adds payload-size enforcement around the\n# shared encoder; encode_value is the unbounded form used by the in-memory bridge.\n\ndef _is_nan_or_inf(value):\n if not isinstance(value, (int, float)):\n return False\n try:\n return math.isnan(value) or math.isinf(value)\n except (TypeError, ValueError):\n return False\n\n\ndef _is_numpy_scalar(obj):\n try:\n import numpy as np\n except ImportError:\n return False\n return isinstance(obj, (np.generic, np.ndarray)) and obj.ndim == 0\n\n\ndef _is_pandas_scalar(obj):\n try:\n import pandas as pd\n except ImportError:\n return False\n return isinstance(obj, (pd.Timestamp, pd.Timedelta, type(pd.NaT)))\n\n\ndef make_default_encoder(*, allow_nan):\n \"\"\"\n Build a json.dumps default= encoder matching BridgeCodec's value handling.\n\n Raises CodecError for NaN/Infinity extracted from numpy scalars (json.dumps\n itself rejects top-level/nested NaN/Infinity floats when allow_nan=False).\n \"\"\"\n\n def default_encoder(obj):\n # numpy/pandas scalars first (need .item() extraction).\n if _is_numpy_scalar(obj):\n extracted = obj.item()\n if not allow_nan and _is_nan_or_inf(extracted):\n raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON')\n return extracted\n\n if _is_pandas_scalar(obj):\n try:\n import pandas as pd\n except ImportError:\n pass\n else:\n if obj is pd.NaT or (hasattr(pd, 'isna') and pd.isna(obj)):\n return None\n if isinstance(obj, pd.Timestamp):\n return obj.isoformat()\n if isinstance(obj, pd.Timedelta):\n return obj.total_seconds()\n\n stdlib_value = serialize_stdlib(obj)\n if stdlib_value is not None:\n return stdlib_value\n\n if isinstance(obj, (bytes, bytearray)):\n return {\n '__type__': 'bytes',\n 'encoding': 'base64',\n 'data': base64.b64encode(obj).decode('ascii'),\n }\n\n model_dump = getattr(obj, 'model_dump', None)\n if callable(model_dump):\n try:\n return model_dump(by_alias=True, mode='json')\n except TypeError:\n return model_dump(by_alias=True)\n\n if isinstance(obj, (set, frozenset)):\n return list(obj)\n\n if isinstance(obj, complex):\n raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')\n\n raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')\n\n return default_encoder\n\n\ndef encode_value(value, *, allow_nan):\n \"\"\"\n JSON-encode a fully-serialized response value, applying the BridgeCodec-equivalent\n default encoder and rejecting NaN/Infinity when allow_nan is False.\n\n Raises CodecError (wrapping the json.dumps ValueError) on NaN/Infinity, matching\n BridgeCodec's \"Cannot serialize NaN...\" wording so error parity holds.\n \"\"\"\n try:\n return json.dumps(value, default=make_default_encoder(allow_nan=allow_nan), allow_nan=allow_nan)\n except ValueError as exc:\n error_msg = str(exc).lower()\n # json.dumps(allow_nan=False) rejects NaN/Infinity with a ValueError whose\n # wording is Python-version dependent: 3.12+ appends the offending value\n # (\"...not JSON compliant: nan\"), but 3.10/3.11 emit only the canonical\n # \"Out of range float values are not JSON compliant\". Match that phrase too\n # so the typed error message is stable across versions.\n nonfinite_token = re.search(r'(^|[^a-z])(nan|inf|infinity)([^a-z]|$)', error_msg)\n if (\n not allow_nan and 'out of range float values are not json compliant' in error_msg\n ) or nonfinite_token:\n raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON') from exc\n raise CodecError(f'JSON encoding failed: {exc}') from exc\n except TypeError as exc:\n raise CodecError(f'JSON encoding failed: {exc}') from exc\n\n\nclass BridgeCodec:\n \"\"\"Safe JSON codec with explicit value handling and payload-size limits.\"\"\"\n\n def __init__(self, allow_nan=False, max_payload_bytes=10 * 1024 * 1024):\n self.allow_nan = allow_nan\n self.max_payload_bytes = max_payload_bytes\n self._encoder = make_default_encoder(allow_nan=allow_nan)\n\n def encode(self, value):\n result = encode_value(value, allow_nan=self.allow_nan)\n if len(result.encode('utf-8')) > self.max_payload_bytes:\n raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')\n return result\n\n def decode(self, payload):\n if len(payload.encode('utf-8')) > self.max_payload_bytes:\n raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')\n try:\n return json.loads(payload)\n except json.JSONDecodeError as exc:\n raise CodecError(f'JSON decoding failed: {exc}') from exc\n\n def _default_encoder(self, obj):\n \"\"\"Compatibility hook for callers that used the codec's JSON encoder.\"\"\"\n return self._encoder(obj)\n\n\n_default_codec = None\n\n\ndef get_default_codec():\n \"\"\"Return the lazily-created default BridgeCodec instance.\"\"\"\n global _default_codec\n if _default_codec is None:\n _default_codec = BridgeCodec()\n return _default_codec\n\n\ndef encode(value, *, allow_nan=False):\n \"\"\"Encode a value with the default codec settings.\"\"\"\n if allow_nan:\n return BridgeCodec(allow_nan=True).encode(value)\n return get_default_codec().encode(value)\n\n\ndef decode(payload):\n \"\"\"Decode a JSON payload with the default codec settings.\"\"\"\n return get_default_codec().decode(payload)\n\n\n# =============================================================================\n# REQUEST VALIDATION + HANDLERS + DISPATCH\n# =============================================================================\n\ndef require_protocol(msg):\n if not isinstance(msg, dict):\n raise ProtocolError('Invalid request payload')\n proto = msg.get('protocol')\n if proto != PROTOCOL:\n raise ProtocolError(f'Invalid protocol: {proto}')\n mid = msg.get('id')\n if not isinstance(mid, int):\n raise ProtocolError(f'Invalid request id: {mid}')\n return mid\n\n\ndef require_str(params, key):\n value = params.get(key)\n if not isinstance(value, str) or not value:\n raise ProtocolError(f'Missing {key}')\n return value\n\n\ndef coerce_list(value, key):\n if value is None:\n return []\n if not isinstance(value, list):\n raise ProtocolError(f'Invalid {key}')\n return value\n\n\ndef coerce_dict(value, key):\n if value is None:\n return {}\n if not isinstance(value, dict):\n raise ProtocolError(f'Invalid {key}')\n return value\n\n\ndef handle_call(\n params,\n *,\n force_json_markers,\n torch_allow_copy,\n allowed_modules,\n allow_private_attrs,\n has_envelope_markers,\n):\n module_name = require_str(params, 'module')\n function_name = require_str(params, 'functionName')\n args = deserialize(coerce_list(params.get('args'), 'args'), has_envelope_markers=has_envelope_markers)\n kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'), has_envelope_markers=has_envelope_markers)\n mod = import_allowed_module(module_name, allowed_modules)\n # function_name may be dotted ('Class.method') for @classmethod/@staticmethod\n # calls, which the generated wrapper routes through call() rather than an\n # instance handle. resolve_allowed_attr_path guards each segment.\n func = resolve_allowed_attr_path(mod, function_name, allow_private_attrs=allow_private_attrs)\n res = func(*args, **kwargs)\n return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)\n\n\ndef build_meta(\n *,\n bridge,\n pid,\n python_version,\n codec_fallback,\n arrow_available_override=None,\n):\n \"\"\"\n Build the bridge metadata payload.\n\n Field order here is part of the wire contract (the JS validator and the\n documented BridgeInfo shape). Callers supply the backend-specific identity:\n the subprocess server passes bridge='python-subprocess' and a real pid; the\n Pyodide server passes bridge='pyodide' and pid=None.\n\n arrow_available_override: when not None, report this value for arrowAvailable\n instead of probing pyarrow. The Pyodide server forces markers to JSON\n unconditionally, so it advertises arrowAvailable=False regardless of whether\n pyarrow happens to be importable in the WASM environment.\n \"\"\"\n arrow = arrow_available() if arrow_available_override is None else arrow_available_override\n meta = {\n 'protocol': PROTOCOL,\n 'protocolVersion': PROTOCOL_VERSION,\n 'bridge': bridge,\n 'pythonVersion': python_version,\n 'pid': pid,\n 'codecFallback': codec_fallback,\n 'arrowAvailable': arrow,\n 'scipyAvailable': module_available('scipy'),\n 'torchAvailable': module_available('torch'),\n 'sklearnAvailable': module_available('sklearn'),\n 'instances': 0,\n }\n return meta\n\n\ndef dispatch_request(\n msg,\n *,\n bridge,\n pid,\n force_json_markers,\n allow_nan=False,\n python_version=None,\n torch_allow_copy=False,\n arrow_available_override=None,\n allowed_modules=None,\n allow_private_attrs=False,\n has_envelope_markers=True,\n):\n \"\"\"\n Validate and route a request, returning the fully-serialized response dict\n ({'id', 'protocol', 'result'}). Raises ProtocolError for malformed requests\n and propagates handler exceptions to the caller, which is responsible for\n building the error envelope (so it controls traceback inclusion).\n\n allow_nan is accepted for signature symmetry; NaN rejection happens during\n the final encode_value() call, which the caller performs.\n\n allowed_modules: None (default) disables the import allowlist so existing\n behavior is preserved. Supplying a set restricts call imports to\n those modules (plus the stdlib the bridge itself needs) and raises\n ImportNotAllowedError otherwise. allow_private_attrs=False (default) blocks\n getattr of underscore-prefixed names; True restores unrestricted access. See\n the IMPORT / ATTRIBUTE ALLOWLIST section above for the full trust model.\n \"\"\"\n mid = require_protocol(msg)\n method = msg.get('method')\n if not isinstance(method, str):\n raise ProtocolError('Missing method')\n params = coerce_dict(msg.get('params'), 'params')\n if method == 'call':\n result = handle_call(\n params,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n allowed_modules=allowed_modules,\n allow_private_attrs=allow_private_attrs,\n has_envelope_markers=has_envelope_markers,\n )\n elif method == 'meta':\n if python_version is None:\n import sys\n python_version = sys.version.split()[0]\n codec_fallback = 'json' if force_json_markers else 'none'\n result = build_meta(\n bridge=bridge,\n pid=pid,\n python_version=python_version,\n codec_fallback=codec_fallback,\n arrow_available_override=arrow_available_override,\n )\n else:\n raise ProtocolError(f'Unknown method: {method}')\n return {'id': mid, 'protocol': PROTOCOL, 'result': result}\n\n\ndef build_error_payload(mid, exc, *, include_traceback):\n \"\"\"\n Build a protocol error response. Protocol/validation errors omit traceback;\n handler errors include it. Field order matches the reference server.\n \"\"\"\n error = {'type': type(exc).__name__, 'message': str(exc)}\n if include_traceback:\n error['traceback'] = traceback.format_exc()\n return {\n 'id': mid if mid is not None else -1,\n 'protocol': PROTOCOL,\n 'error': error,\n }\n"; +export const PYODIDE_BRIDGE_CORE_SOURCE: string = "\"\"\"\nShared tywrap bridge core: protocol dispatch + value (de)serialization.\n\nThis module is the SINGLE source of truth for the \"tywrap/1\" server-side\nprotocol. It is imported by:\n\n - runtime/python_bridge.py (the Node/Bun/Deno subprocess server and the HTTP\n server), which owns I/O concerns: the stdin/stdout JSONL loop, env-var size\n guards, the real OS pid, bridge='python-subprocess', and the final BridgeCodec\n encode wrapper.\n\n - the in-WASM Pyodide server (src/runtime/pyodide-transport.ts). Pyodide cannot read\n this file from disk, so it is shipped as a build-time-generated TypeScript\n string constant (src/runtime/pyodide-bootstrap-core.generated.ts) produced by\n scripts/generate-pyodide-bootstrap.mjs and exec'd into a module registered in\n sys.modules. A conformance drift guard (test/runtime_conformance.test.ts)\n asserts the generated constant stays byte-identical to this file.\n\nCROSS-LANGUAGE CONTRACT (Python <-> the TypeScript decoder in src/utils/codec.ts\nand the request encoder in src/runtime/bridge-codec.ts):\n\n * Every value-type \"marker\" envelope carries {'__tywrap__': ,\n 'codecVersion': 1, 'encoding': ...}. The 6 markers are: ndarray, dataframe,\n series, scipy.sparse, torch.tensor, sklearn.estimator.\n * bytes round-trip both ways via base64 envelopes (see _deserialize_bytes_*\n and the bytes branch of default_encoder).\n * NaN/Infinity are rejected (the JS client cannot parse the non-standard tokens\n that allow_nan=True would emit).\n\nPURITY: This module depends only on the standard library plus LAZY optional\nimports (numpy/pandas/scipy/torch/sklearn/pyarrow are each imported inside the\nfunction that needs them). It performs no stdin/stdout I/O and reads no env vars,\nso it runs unchanged under CPython-in-WASM (Pyodide).\n\nforce_json_markers: a *parameter* threaded through every serializer (including\nthe nested torch.tensor -> ndarray call). When True, ndarray/dataframe/series are\nforced down their JSON path regardless of pyarrow availability. Pyodide passes\nTrue (Arrow is unavailable in WASM); the subprocess server passes the boolean\nderived from TYWRAP_CODEC_FALLBACK=json so that \"Node in json-fallback mode\" and\n\"Pyodide\" produce byte-identical marker envelopes.\n\"\"\"\n\nimport base64\nimport datetime as dt\nimport decimal\nimport importlib\nimport importlib.util\nimport json\nimport math\nimport re\nimport sys\nimport traceback\nimport uuid\nfrom pathlib import Path, PurePath\n\n# Protocol constants. These MUST match src/runtime/protocol.ts (PROTOCOL_ID,\n# TYWRAP_PROTOCOL_VERSION) and the codec version baked into marker envelopes.\nPROTOCOL = 'tywrap/1'\nPROTOCOL_VERSION = 1\nCODEC_VERSION = 1\nMAX_SERIALIZE_DEPTH = 900\nMAX_SERIALIZE_NODES = 1_000_000\nJS_SAFE_INTEGER_MAX = 2**53 - 1\n_SERIALIZE_PATH_IDENTIFIER = re.compile(r'^[A-Za-z_$][\\w$]*$', re.ASCII)\n\n\nclass ProtocolError(Exception):\n \"\"\"Raised for malformed requests (bad protocol/id/method/params).\"\"\"\n\n\nclass ImportNotAllowedError(PermissionError):\n \"\"\"Raised when a requested module import is not on the active allowlist.\"\"\"\n\n def __init__(self, module_name):\n super().__init__(\n f'Import of module {module_name!r} is not permitted by the tywrap bridge '\n 'allowlist; add it to TYWRAP_ALLOWED_MODULES (subprocess) or the '\n 'allowed_modules parameter to enable it'\n )\n\n\nclass AttributeNotAllowedError(PermissionError):\n \"\"\"Raised when access to a private/dunder attribute is denied by policy.\"\"\"\n\n def __init__(self, attr_name):\n super().__init__(\n f'Access to attribute {attr_name!r} is not permitted by the tywrap bridge: '\n 'underscore-prefixed (private/dunder) attributes are blocked to prevent '\n 'sandbox-escape via attributes like __globals__/__subclasses__/__builtins__; '\n 'set TYWRAP_ALLOW_PRIVATE_ATTRS=1 (subprocess) or pass allow_private_attrs=True '\n 'to override'\n )\n\n\n# =============================================================================\n# IMPORT / ATTRIBUTE ALLOWLIST (trust boundary enforcement)\n# =============================================================================\n#\n# The bridge dispatches call requests by importing the requested module and\n# getattr-ing the requested function/class method. That is an\n# arbitrary import+getattr+call surface, so two complementary guards live here.\n# Both are PURE (no env reads) so the rules behave identically under the\n# subprocess server and the in-WASM Pyodide server; the subprocess server derives\n# the parameters from env vars (TYWRAP_ALLOWED_MODULES / TYWRAP_ALLOW_PRIVATE_ATTRS)\n# and threads them in, exactly like force_json_markers / torch_allow_copy.\n#\n# 1. MODULE ALLOWLIST (opt-in, default = allow all):\n# allowed_modules=None means \"no restriction\" so existing configurations keep\n# working unchanged. When a caller supplies a set, only those modules (plus the\n# stdlib the bridge itself needs to serialize results, see _BRIDGE_REQUIRED_MODULES)\n# may be imported; submodules of an allowed module are permitted (e.g. allowing\n# 'scipy' also allows 'scipy.sparse'). A non-allowlisted import fails LOUDLY with\n# ImportNotAllowedError rather than silently importing.\n#\n# 2. PRIVATE-ATTRIBUTE BLOCK (default ON):\n# getattr of any name starting with '_' (single-underscore private OR dunder) is\n# rejected. This blocks the classic escape chain (obj.__class__.__subclasses__()\n# /__globals__/__builtins__/__import__) without depending on the module allowlist.\n# tywrap-generated wrappers never reference underscore-prefixed names (the IR\n# analyzer skips them), so this does not regress generated code. Set\n# allow_private_attrs=True to restore unrestricted getattr for trusted callers.\n\n# Stdlib modules the bridge's own serialization/handlers may need to import even\n# when a caller-supplied allowlist is active. Optional codec deps (numpy, pandas,\n# scipy, torch, sklearn, pyarrow) are intentionally NOT here: if a caller restricts\n# modules, they must opt those in explicitly. These names cover only what the\n# bridge core itself imports.\n_BRIDGE_REQUIRED_MODULES = frozenset(\n {\n 'base64',\n 'datetime',\n 'decimal',\n 'importlib',\n 'json',\n 'math',\n 'sys',\n 'traceback',\n 'uuid',\n 'pathlib',\n }\n)\n\n\ndef _top_level_package(module_name):\n \"\"\"Return the top-level package of a dotted module name ('a.b.c' -> 'a').\"\"\"\n return module_name.split('.', 1)[0]\n\n\ndef _is_module_allowed(module_name, allowed_modules):\n \"\"\"\n Return True when module_name may be imported under the active policy.\n\n allowed_modules=None disables enforcement (allow all). Otherwise a module is\n allowed when it (or its top-level package) is explicitly listed, or it is one\n of the stdlib modules the bridge itself requires.\n \"\"\"\n if allowed_modules is None:\n return True\n if module_name in allowed_modules or module_name in _BRIDGE_REQUIRED_MODULES:\n return True\n top = _top_level_package(module_name)\n return top in allowed_modules or top in _BRIDGE_REQUIRED_MODULES\n\n\ndef import_allowed_module(module_name, allowed_modules):\n \"\"\"\n Import module_name only if permitted by the allowlist, else raise loudly.\n\n This is the single chokepoint every handler routes module imports through.\n \"\"\"\n if not _is_module_allowed(module_name, allowed_modules):\n raise ImportNotAllowedError(module_name)\n return importlib.import_module(module_name)\n\n\ndef get_allowed_attr(obj, attr_name, *, allow_private_attrs):\n \"\"\"\n getattr(obj, attr_name) with the private/dunder block applied.\n\n Rejects any underscore-prefixed name unless allow_private_attrs is True. This\n is the single chokepoint every handler routes attribute access through.\n \"\"\"\n if not allow_private_attrs and attr_name.startswith('_'):\n raise AttributeNotAllowedError(attr_name)\n return getattr(obj, attr_name)\n\n\ndef resolve_allowed_attr_path(root, dotted_name, *, allow_private_attrs):\n \"\"\"\n Resolve a possibly-dotted attribute path from root, applying the\n private/dunder getattr guard to EVERY segment.\n\n A single segment (the common case, e.g. a module-level function) behaves\n exactly like get_allowed_attr. Dotted names exist because @classmethod and\n @staticmethod are invoked through their owning class: the generated wrapper\n emits call(module, 'Class.method', ...), so the bridge must walk\n module -> Class -> method. Guarding each segment means 'Class._secret' or\n '_Hidden.method' are rejected exactly as a direct private getattr would be —\n the dotted path opens no access the single-getattr path did not already.\n \"\"\"\n obj = root\n for segment in dotted_name.split('.'):\n obj = get_allowed_attr(obj, segment, allow_private_attrs=allow_private_attrs)\n return obj\n\n\nclass CodecError(Exception):\n \"\"\"Raised when value encoding fails (e.g. NaN/Infinity not allowed).\"\"\"\n\n\n# =============================================================================\n# REQUEST-SIDE DESERIALIZATION (bytes envelopes -> Python bytes)\n# =============================================================================\n\n_NO_DESERIALIZE = object()\n_ERR_BYTES_MISSING_B64 = 'Invalid bytes envelope: missing b64'\n_ERR_BYTES_MISSING_DATA = 'Invalid bytes envelope: missing data'\n_ERR_BYTES_INVALID_BASE64 = 'Invalid bytes envelope: invalid base64'\n\n\ndef _deserialize_bytes_envelope(value):\n \"\"\"\n Decode base64-encoded bytes envelopes from JS into Python bytes.\n\n Supported shapes:\n - { \"__tywrap_bytes__\": true, \"b64\": \"...\" } (JS BridgeCodec.encodeRequest)\n - { \"__type__\": \"bytes\", \"encoding\": \"base64\", \"data\": \"...\" } (legacy/compat)\n\n Why: TS BridgeCodec encodes Uint8Array/ArrayBuffer as base64 objects, but\n Python handlers expect real bytes/bytearray to preserve behavior (e.g., len()).\n \"\"\"\n if not isinstance(value, dict):\n return _NO_DESERIALIZE\n\n if value.get('__tywrap_bytes__') is True:\n b64 = value.get('b64')\n if not isinstance(b64, str):\n raise ProtocolError(_ERR_BYTES_MISSING_B64)\n try:\n return base64.b64decode(b64, validate=True)\n except Exception as exc:\n raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc\n\n if value.get('__type__') == 'bytes' and value.get('encoding') == 'base64':\n data = value.get('data')\n if not isinstance(data, str):\n raise ProtocolError(_ERR_BYTES_MISSING_DATA)\n try:\n return base64.b64decode(data, validate=True)\n except Exception as exc:\n raise ProtocolError(_ERR_BYTES_INVALID_BASE64) from exc\n\n return _NO_DESERIALIZE\n\n\ndef deserialize(value, *, has_envelope_markers=True):\n \"\"\"\n Recursively deserialize request values into Python-native types.\n\n Why: requests are JSON-only; we need a small set of explicit decoders\n (currently bytes) to restore Python semantics at the boundary.\n \"\"\"\n if not has_envelope_markers:\n return value\n decoded = _deserialize_bytes_envelope(value)\n if decoded is not _NO_DESERIALIZE:\n return decoded\n\n if isinstance(value, list):\n return [deserialize(item, has_envelope_markers=True) for item in value]\n if isinstance(value, dict):\n # Preserve dict shape while decoding nested values.\n return {k: deserialize(v, has_envelope_markers=True) for k, v in value.items()}\n return value\n\n\n# =============================================================================\n# CAPABILITY DETECTION (lazy, best-effort)\n# =============================================================================\n\ndef arrow_available():\n \"\"\"Return True when pyarrow can be imported.\"\"\"\n try:\n import pyarrow # noqa: F401\n except (ImportError, OSError):\n return False\n return True\n\n\ndef module_available(module_name):\n \"\"\"\n Lightweight feature detection for optional codec dependencies via find_spec.\n\n Why: exposes availability in bridge metadata without importing heavy modules.\n \"\"\"\n try:\n return importlib.util.find_spec(module_name) is not None\n except (ImportError, AttributeError, TypeError, ValueError):\n return False\n\n\ndef is_numpy_array(obj):\n \"\"\"Return whether obj is an ndarray without importing NumPy.\"\"\"\n np = sys.modules.get('numpy')\n if np is None:\n return False\n return isinstance(obj, np.ndarray)\n\n\ndef is_pandas_dataframe(obj):\n \"\"\"Return whether obj is a DataFrame without importing pandas.\"\"\"\n pd = sys.modules.get('pandas')\n if pd is None:\n return False\n return isinstance(obj, pd.DataFrame)\n\n\ndef is_pandas_series(obj):\n \"\"\"Return whether obj is a Series without importing pandas.\"\"\"\n pd = sys.modules.get('pandas')\n if pd is None:\n return False\n return isinstance(obj, pd.Series)\n\n\ndef is_scipy_sparse(obj):\n \"\"\"Return whether obj is sparse without importing SciPy.\"\"\"\n sp = sys.modules.get('scipy.sparse')\n if sp is None:\n return False\n try:\n return sp.issparse(obj)\n except Exception:\n return False\n\n\ndef is_torch_tensor(obj):\n \"\"\"Return whether obj is a Tensor without importing PyTorch.\"\"\"\n torch = sys.modules.get('torch')\n if torch is None:\n return False\n try:\n return torch.is_tensor(obj)\n except Exception:\n return False\n\n\ndef is_sklearn_estimator(obj):\n \"\"\"Return whether obj is an estimator without importing scikit-learn.\"\"\"\n sklearn_base = sys.modules.get('sklearn.base')\n if sklearn_base is None:\n return False\n return isinstance(obj, sklearn_base.BaseEstimator)\n\n\n# =============================================================================\n# MARKER SERIALIZERS (6 __tywrap__ value types)\n# =============================================================================\n#\n# Each serializer accepts force_json_markers. When True, the Arrow path is never\n# taken (used by Pyodide and by the subprocess server in TYWRAP_CODEC_FALLBACK=json\n# mode). The JSON fallback envelopes are byte-identical across both callers, which\n# is what the conformance suite asserts.\n\ndef serialize_ndarray(obj, *, force_json_markers):\n \"\"\"\n Encode a NumPy ndarray. Arrow IPC (compact, lossless) by default; JSON when\n force_json_markers is set or pyarrow is unavailable in fallback mode.\n\n Note: pa.array() only handles 1D arrays; multi-dimensional arrays are\n flattened with shape metadata for JS-side reconstruction. See\n https://github.com/apache/arrow-js/issues/115\n \"\"\"\n if force_json_markers:\n return serialize_ndarray_json(obj)\n try:\n import pyarrow as pa # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for ndarray; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n original_shape = list(obj.shape) if hasattr(obj, 'shape') else None\n flat = obj.flatten() if hasattr(obj, 'ndim') and obj.ndim != 1 else obj\n arr = pa.array(flat)\n table = pa.Table.from_arrays([arr], names=['value'])\n sink = pa.BufferOutputStream()\n with pa.ipc.new_stream(sink, table.schema) as writer:\n writer.write_table(table)\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'ndarray',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n 'shape': original_shape,\n 'dtype': str(obj.dtype) if hasattr(obj, 'dtype') else None,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for ndarray') from exc\n\n\ndef serialize_ndarray_json(obj):\n \"\"\"JSON fallback for ndarray values that JavaScript can represent safely.\"\"\"\n dtype = obj.dtype\n dtype_label = str(dtype)\n\n if not dtype.isnative:\n raise RuntimeError(\n f'JSON ndarray encoding does not support big-endian dtype={dtype_label}; '\n \"convert to native byte order with a.byteswap().view(a.dtype.newbyteorder('='))\"\n )\n\n if dtype.kind in ('i', 'u'):\n # JSON numbers become JavaScript Number values. Scan only on this explicit\n # fallback path and reject before tolist() can silently round an integer.\n if obj.size and (\n (obj < -JS_SAFE_INTEGER_MAX).any() or (obj > JS_SAFE_INTEGER_MAX).any()\n ):\n raise RuntimeError(\n f'JSON ndarray encoding cannot safely represent dtype={dtype_label} values '\n 'outside the JavaScript safe integer range; use Arrow encoding or '\n \"cast/encode explicitly (e.g. .astype('float64') or str)\"\n )\n elif dtype.kind == 'M':\n raise RuntimeError(\n f'JSON ndarray encoding does not support dtype={dtype_label}; use Arrow encoding '\n \"or convert explicitly (e.g. .astype('datetime64[ms]').astype(str) or int with \"\n 'declared unit)'\n )\n elif dtype.kind == 'm':\n raise RuntimeError(\n f'JSON ndarray encoding does not support dtype={dtype_label}; use Arrow encoding '\n \"or convert explicitly (e.g. .astype('timedelta64[ms]').astype(str) or int with \"\n 'declared unit)'\n )\n elif dtype.kind == 'V':\n if dtype.fields is not None:\n raise RuntimeError(\n f'JSON ndarray encoding does not support structured dtype={dtype_label}; '\n 'encode each named field explicitly (e.g. as a plain JSON object)'\n )\n raise RuntimeError(\n f'JSON ndarray encoding does not support void dtype={dtype_label}; convert the '\n \"raw bytes explicitly (e.g. .view('uint8'))\"\n )\n elif dtype.kind == 'O':\n raise RuntimeError(\n f'JSON ndarray encoding does not support object dtype={dtype_label}; cast to a '\n 'concrete numeric dtype or encode elements explicitly as plain JSON'\n )\n elif dtype.kind == 'S':\n raise RuntimeError(\n f'JSON ndarray encoding does not support byte-string dtype={dtype_label}; '\n 'decode elements and return a plain JSON list explicitly'\n )\n elif dtype.kind == 'U':\n raise RuntimeError(\n f'JSON ndarray encoding does not support unicode dtype={dtype_label}; convert '\n 'explicitly to plain JSON strings with .tolist()'\n )\n elif dtype.kind == 'c':\n raise RuntimeError(\n f'JSON ndarray encoding does not support complex dtype={dtype_label}; encode '\n '.real and .imag arrays explicitly'\n )\n elif dtype.kind == 'f' and dtype.itemsize > 8:\n raise RuntimeError(\n f'JSON ndarray encoding does not support float dtype={dtype_label} wider than '\n \"64 bits; cast explicitly (e.g. .astype('float64')) or use Arrow encoding\"\n )\n elif dtype.kind not in ('b', 'f'):\n raise RuntimeError(\n f'JSON ndarray encoding does not support dtype={dtype_label}; cast to bool, '\n 'integer, or float dtype, or encode values explicitly as plain JSON'\n )\n\n try:\n data = obj.tolist()\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for ndarray') from exc\n return {\n '__tywrap__': 'ndarray',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n 'shape': getattr(obj, 'shape', None),\n 'dtype': dtype.name,\n }\n\n\ndef serialize_dataframe(obj, *, force_json_markers):\n \"\"\"\n Encode a pandas DataFrame. Feather/Arrow-IPC (uncompressed, so apache-arrow\n in JS can read it) by default; JSON when force_json_markers is set.\n \"\"\"\n if force_json_markers:\n return serialize_dataframe_json(obj)\n try:\n import pyarrow as pa # type: ignore\n import pyarrow.feather as feather # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for pandas.DataFrame; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n table = pa.Table.from_pandas(obj) # type: ignore\n sink = pa.BufferOutputStream()\n feather.write_feather(table, sink, compression='uncompressed')\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'dataframe',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for pandas.DataFrame') from exc\n\n\ndef serialize_dataframe_json(obj):\n \"\"\"JSON fallback for DataFrame values that JavaScript can represent safely.\"\"\"\n import pandas as pd # type: ignore\n\n _validate_pandas_json_index(obj.index, pd, 'DataFrame')\n json_columns = [_json_object_key(column) for column in obj.columns]\n supported_json_columns = [column for column in json_columns if column is not None]\n if not obj.columns.is_unique or len(set(supported_json_columns)) != len(\n supported_json_columns\n ):\n raise RuntimeError(\n 'JSON pandas.DataFrame encoding requires column labels to remain unique after '\n 'JSON object-key coercion; rename columns or make them distinct before applying '\n '.columns.astype(str)'\n )\n for column, dtype in obj.dtypes.items():\n if isinstance(dtype, pd.CategoricalDtype):\n raise RuntimeError(\n f'JSON pandas.DataFrame encoding does not support categorical dtype in '\n f'column {column!r}; use Arrow encoding or convert explicitly '\n \"(e.g. .astype(str))\"\n )\n try:\n data = (\n [{} for _ in range(len(obj.index))]\n if len(obj.columns) == 0\n else obj.to_dict(orient='records')\n )\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.DataFrame') from exc\n for row_number, row in enumerate(data):\n for column, value in row.items():\n row[column] = _normalize_pandas_json_scalar(\n value,\n f'DataFrame cell at row {row_number}, column {column!r}',\n pd,\n )\n return {\n '__tywrap__': 'dataframe',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n }\n\n\ndef serialize_series(obj, *, force_json_markers, path='result'):\n \"\"\"\n Encode a pandas Series as a single-column Arrow Table stream (the JS decoder\n contract is \"table-like\"); JSON when force_json_markers is set.\n \"\"\"\n if force_json_markers:\n return serialize_series_json(obj, path=path)\n name = getattr(obj, 'name', None)\n _check_terminal_json_integers(\n encode_value(name, allow_nan=False), _serialize_path(path, 'name')\n )\n try:\n import pyarrow as pa # type: ignore\n except Exception as exc:\n raise RuntimeError(\n 'Arrow encoding unavailable for pandas.Series; install pyarrow or set TYWRAP_CODEC_FALLBACK=json to enable JSON fallback'\n ) from exc\n try:\n arr = pa.Array.from_pandas(obj) # type: ignore\n table = pa.Table.from_arrays([arr], names=['value'])\n sink = pa.BufferOutputStream()\n with pa.ipc.new_stream(sink, table.schema) as writer:\n writer.write_table(table)\n buf = sink.getvalue()\n b64 = base64.b64encode(buf.to_pybytes()).decode('ascii')\n return {\n '__tywrap__': 'series',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'arrow',\n 'b64': b64,\n 'name': name,\n }\n except Exception as exc:\n raise RuntimeError('Arrow encoding failed for pandas.Series') from exc\n\n\ndef serialize_series_json(obj, *, path='result'):\n \"\"\"JSON fallback for Series values that JavaScript can represent safely.\"\"\"\n import pandas as pd # type: ignore\n\n _validate_pandas_json_index(obj.index, pd, 'Series')\n if isinstance(obj.dtype, pd.CategoricalDtype):\n raise RuntimeError(\n 'JSON pandas.Series encoding does not support categorical dtype; use Arrow '\n \"encoding or convert explicitly (e.g. .astype(str))\"\n )\n try:\n data = obj.to_list() # type: ignore\n except Exception as exc:\n raise RuntimeError('JSON fallback failed for pandas.Series') from exc\n data = [\n _normalize_pandas_json_scalar(value, f'Series value at position {position}', pd)\n for position, value in enumerate(data)\n ]\n name = getattr(obj, 'name', None)\n _check_terminal_json_integers(\n encode_value(name, allow_nan=False), _serialize_path(path, 'name')\n )\n return {\n '__tywrap__': 'series',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'data': data,\n 'name': name,\n }\n\n\ndef _validate_pandas_json_index(index, pd, container_name):\n \"\"\"Reject index metadata that records/list-oriented JSON would discard.\"\"\"\n if isinstance(index, pd.MultiIndex):\n raise RuntimeError(\n f'JSON pandas.{container_name} encoding does not support MultiIndex; use Arrow '\n 'encoding or flatten the index explicitly with .reset_index()'\n )\n if not (\n isinstance(index, pd.RangeIndex)\n and index.start == 0\n and index.step == 1\n and index.stop == len(index)\n and index.name is None\n ):\n raise RuntimeError(\n f'JSON pandas.{container_name} encoding requires an unnamed RangeIndex starting '\n 'at 0 with step 1; use Arrow encoding or normalize explicitly with '\n '.reset_index(drop=True)'\n )\n\n\ndef _json_object_key(value):\n \"\"\"Return json.dumps' object-key spelling, or None for unsupported keys.\"\"\"\n try:\n encoded = json.dumps({value: None})\n except (TypeError, ValueError):\n return None\n return next(iter(json.loads(encoded)))\n\n\ndef _normalize_pandas_json_scalar(value, location, pd):\n \"\"\"Normalize pandas nulls and reject values outside the plain JSON domain.\"\"\"\n np = sys.modules.get('numpy')\n if np is not None and isinstance(value, np.generic):\n value = value.item()\n if value is None or value is pd.NA or value is pd.NaT:\n return None\n if type(value) is bool:\n return value\n if type(value) is int:\n if value < -JS_SAFE_INTEGER_MAX or value > JS_SAFE_INTEGER_MAX:\n raise RuntimeError(\n f'JSON pandas encoding cannot safely represent {location} integer values '\n 'outside the JavaScript safe integer range; use Arrow encoding or '\n \"cast/encode explicitly (e.g. .astype('float64') or str)\"\n )\n return value\n if type(value) is float:\n if not math.isfinite(value):\n raise RuntimeError(\n f'JSON pandas encoding cannot represent non-finite {location} float values '\n '(NaN or Infinity); use .fillna(...) for intentional missing values or '\n 'Arrow encoding'\n )\n return value\n if type(value) is str:\n return value\n raise RuntimeError(\n f'JSON pandas encoding does not support {location} value of type '\n f'{type(value).__name__}; use Arrow encoding or convert explicitly '\n '(e.g. .astype(str))'\n )\n\n\ndef serialize_sparse_matrix(obj, *, path='result'):\n \"\"\"\n Serialize scipy sparse matrices into structured JSON envelopes (json-only;\n there is no Arrow path). Preserves sparsity; rejects unsupported formats and\n complex dtypes explicitly.\n \"\"\"\n try:\n fmt = obj.getformat()\n except Exception as exc:\n raise RuntimeError('Failed to inspect scipy sparse matrix format') from exc\n\n if fmt not in ('csr', 'csc', 'coo'):\n raise RuntimeError(\n f'Unsupported scipy sparse format: {fmt}; only csr/csc/coo are supported. '\n 'Convert explicitly (e.g. matrix.tocsr()) before returning'\n )\n\n dtype = None\n try:\n dtype = str(obj.dtype)\n except Exception:\n dtype = None\n if getattr(obj.dtype, 'kind', None) == 'c':\n raise RuntimeError(\n 'Complex scipy sparse matrices are not supported by the JSON codec; '\n 'split into real/imag components explicitly before returning'\n )\n if getattr(obj.dtype, 'kind', None) in ('i', 'u') and obj.data.size and (\n (obj.data < -JS_SAFE_INTEGER_MAX).any()\n or (obj.data > JS_SAFE_INTEGER_MAX).any()\n ):\n raise RuntimeError(\n 'JSON scipy sparse encoding cannot safely represent data outside the '\n 'JavaScript safe integer range; cast or encode the values explicitly'\n )\n\n for index, dimension in enumerate(obj.shape):\n if int.__int__(dimension) > JS_SAFE_INTEGER_MAX:\n raise RuntimeError(\n f'Unsafe Python integer at {_serialize_path(_serialize_path(path, \"shape\"), index)}: '\n f'{dimension} is outside the JavaScript safe integer range'\n )\n index_fields = ('indices', 'indptr') if fmt in ('csr', 'csc') else ('row', 'col')\n for field in index_fields:\n values = getattr(obj, field)\n if values.size and (\n (values < -JS_SAFE_INTEGER_MAX).any()\n or (values > JS_SAFE_INTEGER_MAX).any()\n ):\n raise RuntimeError(\n f'JSON scipy sparse encoding cannot safely represent {field} at '\n f'{_serialize_path(path, field)} outside the JavaScript safe integer range'\n )\n\n if fmt in ('csr', 'csc'):\n data = obj.data.tolist()\n indices = obj.indices.tolist()\n indptr = obj.indptr.tolist()\n return {\n '__tywrap__': 'scipy.sparse',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'format': fmt,\n 'shape': list(obj.shape),\n 'data': data,\n 'indices': indices,\n 'indptr': indptr,\n 'dtype': dtype,\n }\n\n # coo\n data = obj.data.tolist()\n row = obj.row.tolist()\n col = obj.col.tolist()\n return {\n '__tywrap__': 'scipy.sparse',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'format': fmt,\n 'shape': list(obj.shape),\n 'data': data,\n 'row': row,\n 'col': col,\n 'dtype': dtype,\n }\n\n\ndef serialize_torch_tensor(obj, *, force_json_markers, torch_allow_copy=False):\n \"\"\"\n Serialize torch.Tensor values via the nested ndarray envelope. CPU-only by\n default; device/copy behavior is explicit. force_json_markers is threaded\n into the nested ndarray serialization so Pyodide gets a JSON ndarray value.\n\n Rejection order is significant: the categorical rejections (sparse / quantized\n / meta / complex) are checked BEFORE the device/contiguous opt-in branch so\n they fail with a clear, specific message and are NOT bypassable by\n TYWRAP_TORCH_ALLOW_COPY. The opt-in only governs the device transfer and\n contiguous copy, never an unrepresentable layout/dtype.\n \"\"\"\n import torch # already importable: is_torch_tensor() gated the dispatch\n\n tensor = obj.detach()\n source_device = None\n source_dtype = None\n\n # Sparse tensors (COO/CSR/CSC/BSR/BSC -> any non-strided layout) have no dense\n # numpy representation without a densify step, which is not the round-trip this\n # envelope promises. Reject explicitly rather than emitting a misleading\n # \"not contiguous\" error or silently densifying.\n layout = getattr(tensor, 'layout', None)\n if getattr(tensor, 'is_sparse', False) or (\n layout is not None and layout != torch.strided\n ):\n raise RuntimeError(\n f'Torch sparse tensors are not supported (layout={layout}); '\n 'convert to a dense CPU tensor explicitly (e.g. tensor.to_dense()) before returning'\n )\n\n # Quantized tensors carry a qscheme/scale/zero_point that numpy() cannot\n # represent; .numpy() raises an opaque \"unsupported ScalarType\" deep in torch.\n # Reject up front with an actionable message.\n if getattr(tensor, 'is_quantized', False):\n raise RuntimeError(\n 'Torch quantized tensors are not supported; dequantize explicitly '\n '(e.g. tensor.dequantize()) before returning'\n )\n\n # Meta tensors have shape/dtype but NO storage; copying to CPU yields garbage,\n # so this is never a lossy-but-honest transfer the opt-in could authorize.\n if getattr(tensor, 'is_meta', False) or (\n getattr(tensor, 'device', None) is not None and tensor.device.type == 'meta'\n ):\n raise RuntimeError(\n 'Torch meta tensors carry no data and cannot be serialized; '\n 'materialize the tensor on a real device before returning'\n )\n\n # Complex tensors round-trip to numpy complex arrays, which are not\n # JSON-serializable and have no codec envelope. Reject explicitly instead of\n # emitting Python complex tuples that the JS decoder cannot parse.\n if torch.is_complex(tensor):\n raise RuntimeError(\n f'Torch complex tensors are not supported (dtype={tensor.dtype}); '\n 'split into real/imag components explicitly before returning'\n )\n\n if getattr(tensor, 'device', None) is not None and tensor.device.type != 'cpu':\n if not torch_allow_copy:\n raise RuntimeError(\n 'Torch tensor is on a non-CPU device; set TYWRAP_TORCH_ALLOW_COPY=1 to allow CPU transfer'\n )\n source_device = str(tensor.device)\n tensor = tensor.to('cpu')\n if hasattr(tensor, 'is_contiguous') and not tensor.is_contiguous():\n if not torch_allow_copy:\n raise RuntimeError(\n 'Torch tensor is not contiguous; set TYWRAP_TORCH_ALLOW_COPY=1 to allow contiguous copy'\n )\n tensor = tensor.contiguous()\n if tensor.dtype == torch.bfloat16:\n source_dtype = str(tensor.dtype)\n tensor = tensor.float()\n try:\n arr = tensor.numpy()\n except Exception as exc:\n raise RuntimeError('Failed to convert torch.Tensor to numpy') from exc\n\n envelope = {\n '__tywrap__': 'torch.tensor',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'ndarray',\n 'value': serialize_ndarray(arr, force_json_markers=force_json_markers),\n 'shape': list(tensor.shape),\n 'dtype': str(tensor.dtype),\n 'device': str(tensor.device),\n }\n if source_dtype is not None:\n envelope['sourceDtype'] = source_dtype\n if source_device is not None:\n envelope['sourceDevice'] = source_device\n return envelope\n\n\ndef serialize_sklearn_estimator(obj, *, path):\n \"\"\"Serialize sklearn estimators as metadata only (json-only); no pickling.\"\"\"\n try:\n import sklearn # noqa: F401\n except Exception as exc:\n raise RuntimeError('scikit-learn is not available') from exc\n\n params = obj.get_params(deep=False)\n\n # Metadata-only: NEVER pickle/joblib. Every param value must be plain JSON\n # (no callables, nested estimators, numpy arrays, or other objects). Probe\n # each value individually so the error names the offending param instead of\n # failing opaquely on the whole dict. allow_nan=False also rejects NaN/Inf\n # params here for parity with the response codec.\n for key, value in params.items():\n try:\n encoded = json.dumps(value, allow_nan=False)\n param_path = _serialize_path(_serialize_path(path, 'params'), str(key))\n _check_terminal_json_integers(encoded, param_path)\n except RuntimeError:\n raise\n except (TypeError, ValueError) as exc:\n raise RuntimeError(\n f'scikit-learn estimator param {key!r} is not JSON-serializable '\n f'(got {type(value).__name__}); estimators are serialized as metadata only '\n '(no pickle/joblib), so every param must be a plain JSON value. '\n 'Sanitize or drop the param before returning'\n ) from exc\n\n return {\n '__tywrap__': 'sklearn.estimator',\n 'codecVersion': CODEC_VERSION,\n 'encoding': 'json',\n 'className': obj.__class__.__name__,\n 'module': obj.__class__.__module__,\n 'version': getattr(sklearn, '__version__', None),\n 'params': params,\n }\n\n\ndef _check_terminal_json_integers(encoded, path):\n \"\"\"Check integer tokens in a small JSON metadata value before emission.\"\"\"\n stack = [(json.loads(encoded), path)]\n while stack:\n current, current_path = stack.pop()\n if type(current) is int:\n _serialize_leaf(current, current_path)\n elif isinstance(current, dict):\n stack.extend(\n (item, _serialize_path(current_path, key))\n for key, item in current.items()\n )\n elif isinstance(current, list):\n stack.extend(\n (item, _serialize_path(current_path, index))\n for index, item in enumerate(current)\n )\n\n\n_NO_PYDANTIC = object()\n\n\ndef serialize_pydantic(obj):\n \"\"\"\n Serialize Pydantic v2 models via model_dump(by_alias=True, mode='json')\n without importing Pydantic. Returns _NO_PYDANTIC when obj is not a model.\n \"\"\"\n model_dump = getattr(obj, 'model_dump', None)\n if not callable(model_dump):\n return _NO_PYDANTIC\n try:\n try:\n return model_dump(by_alias=True, mode='json')\n except TypeError:\n # Older Pydantic versions may not support `mode=...`.\n return model_dump(by_alias=True)\n except Exception as exc:\n raise RuntimeError(f'model_dump failed: {exc}') from exc\n\n\ndef serialize_stdlib(obj):\n \"\"\"Coerce common stdlib scalar types to JSON-safe forms; None otherwise.\"\"\"\n if isinstance(obj, dt.datetime):\n return obj.isoformat()\n if isinstance(obj, dt.date):\n return obj.isoformat()\n if isinstance(obj, dt.time):\n return obj.isoformat()\n if isinstance(obj, dt.timedelta):\n return obj.total_seconds()\n if isinstance(obj, decimal.Decimal):\n return str(obj)\n if isinstance(obj, uuid.UUID):\n return str(obj)\n if isinstance(obj, (Path, PurePath)):\n return str(obj)\n return None\n\n\n_NO_SCIENTIFIC = object()\n\n\ndef _check_serialize_depth(depth, path):\n if depth > MAX_SERIALIZE_DEPTH:\n raise RuntimeError(\n f'Scientific envelope serialization maximum depth '\n f'{MAX_SERIALIZE_DEPTH} exceeded at {path}'\n )\n\n\ndef _check_serialize_nodes(nodes, path):\n if nodes > MAX_SERIALIZE_NODES:\n raise RuntimeError(\n f'Scientific envelope serialization maximum visited nodes '\n f'{MAX_SERIALIZE_NODES} exceeded at {path}'\n )\n\n\ndef _serialize_scientific(obj, *, force_json_markers, torch_allow_copy, depth, path):\n \"\"\"Serialize a supported scientific value, or return _NO_SCIENTIFIC.\"\"\"\n package = type(obj).__module__.split('.', 1)[0]\n\n if package == 'numpy' and 'numpy' in sys.modules:\n if is_numpy_array(obj):\n _check_serialize_depth(depth, path)\n return serialize_ndarray(obj, force_json_markers=force_json_markers)\n elif package == 'pandas' and 'pandas' in sys.modules:\n if is_pandas_dataframe(obj):\n _check_serialize_depth(depth, path)\n return serialize_dataframe(obj, force_json_markers=force_json_markers)\n if is_pandas_series(obj):\n _check_serialize_depth(depth, path)\n return serialize_series(obj, force_json_markers=force_json_markers, path=path)\n elif package == 'scipy' and 'scipy.sparse' in sys.modules:\n if is_scipy_sparse(obj):\n _check_serialize_depth(depth, path)\n return serialize_sparse_matrix(obj, path=path)\n elif package == 'torch' and 'torch' in sys.modules:\n if is_torch_tensor(obj):\n _check_serialize_depth(depth, path)\n _check_serialize_depth(depth + 1, _serialize_path(path, 'value'))\n return serialize_torch_tensor(\n obj, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy\n )\n elif 'sklearn.base' in sys.modules and is_sklearn_estimator(obj):\n # No package gate here, unlike the branches above: subclassing\n # BaseEstimator is sklearn's documented extension point, so user-defined\n # estimators live outside the 'sklearn' package and must still get the\n # estimator serializer (and its param-naming errors).\n _check_serialize_depth(depth, path)\n return serialize_sklearn_estimator(obj, path=path)\n\n return _NO_SCIENTIFIC\n\n\ndef _serialize_path(base, key):\n \"\"\"Build a decoder-compatible JSONPath-like result path.\"\"\"\n if isinstance(key, int):\n return f'{base}[{key}]'\n if _SERIALIZE_PATH_IDENTIFIER.fullmatch(key):\n return f'{base}.{key}'\n return f'{base}[{json.dumps(key, ensure_ascii=False)}]'\n\n\ndef _invalid_key_path(base, key):\n \"\"\"Name a dict key that cannot be represented by JSON.\"\"\"\n return f'{base}[{key!r}]'\n\n\ndef _needs_serialize_visit(value):\n \"\"\"Return whether value needs container or scientific traversal work.\"\"\"\n if type(value) in (type(None), bool, int, float, str):\n return False\n if isinstance(value, (dict, list, tuple, set, frozenset)):\n return True\n package = type(value).__module__.split('.', 1)[0]\n if package in ('numpy', 'pandas', 'scipy', 'torch'):\n return True\n return callable(getattr(value, 'model_dump', None)) or (\n 'sklearn.base' in sys.modules and is_sklearn_estimator(value)\n )\n\n\ndef _serialize_leaf(value, path):\n \"\"\"Apply non-container conversions without allocating a traversal frame.\"\"\"\n integer_value = int.__int__(value) if isinstance(value, int) and not isinstance(value, bool) else None\n if integer_value is not None and (\n integer_value < -JS_SAFE_INTEGER_MAX or integer_value > JS_SAFE_INTEGER_MAX\n ):\n raise RuntimeError(\n f'Unsafe Python integer at {path}: {integer_value} is outside the JavaScript safe '\n 'integer range; return an explicit string or use an Arrow integer column'\n )\n if type(value) in (type(None), bool, int, float, str):\n return value\n np = sys.modules.get('numpy')\n if np is not None and isinstance(value, np.generic):\n extracted = value.item()\n if isinstance(extracted, np.generic):\n raise TypeError(\n f'NumPy scalar at {path} did not convert to a JSON value; '\n 'cast it explicitly before returning'\n )\n return _serialize_leaf(extracted, path)\n pydantic_value = serialize_pydantic(value)\n if pydantic_value is not _NO_PYDANTIC:\n return pydantic_value\n if isinstance(value, (set, frozenset)):\n return list(value)\n stdlib_value = serialize_stdlib(value)\n if stdlib_value is not None:\n return stdlib_value\n return value\n\n\ndef serialize(obj, *, force_json_markers, torch_allow_copy=False):\n \"\"\"\n Top-level result serializer.\n\n Scientific codecs are type-first and only inspect packages that the value can\n belong to. A value from an optional package implies that package is already in\n sys.modules, so these checks never cold-import the scientific stack. The\n package dispatch deliberately precedes the JSON-native fast path: e.g. a\n package-defined subclass of dict still receives its relevant codec check.\n Every other value is left untouched so the shared JSON encoder applies the\n exact same default conversion at the root and at any nested depth.\n \"\"\"\n root = [None]\n active_ids = set()\n stack = [('visit', obj, 0, 'result', root, 0)]\n visited_nodes = 0\n\n # Repeated aliases have value semantics and are intentionally serialized twice.\n while stack:\n frame = stack.pop()\n action = frame[0]\n if action == 'release-model':\n active_ids.remove(frame[1])\n continue\n if action == 'dict':\n _, current, depth, path, parent, key, output, iterator = frame\n try:\n item_key, item = next(iterator)\n except StopIteration:\n active_ids.remove(id(current))\n parent[key] = output\n continue\n stack.append(frame)\n if not (isinstance(item_key, (str, int, float, bool)) or item_key is None):\n invalid_path = _invalid_key_path(path, item_key)\n raise TypeError(\n f'keys must be str, int, float, bool or None, not '\n f'{type(item_key).__name__} at {invalid_path}'\n )\n child_key = _json_object_key(item_key)\n child_path = _serialize_path(path, child_key)\n if _needs_serialize_visit(item):\n stack.append(('visit', item, depth + 1, child_path, output, item_key))\n else:\n output[item_key] = _serialize_leaf(item, child_path)\n continue\n if action == 'sequence':\n _, current, depth, path, parent, key, output, index = frame\n if index == len(output):\n active_ids.remove(id(current))\n parent[key] = output if isinstance(current, list) else tuple(output)\n continue\n stack.append(('sequence', current, depth, path, parent, key, output, index + 1))\n item = current[index]\n child_path = _serialize_path(path, index)\n if _needs_serialize_visit(item):\n stack.append(\n ('visit', item, depth + 1, child_path, output, index)\n )\n else:\n output[index] = _serialize_leaf(item, child_path)\n continue\n\n _, current, depth, path, parent, key = frame\n try:\n scientific = _serialize_scientific(\n current,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n depth=depth,\n path=path,\n )\n except Exception as exc:\n if path == 'result' and 'safe integer range' not in str(exc):\n raise\n raise RuntimeError(f'Scientific value serialization failed at {path}: {exc}') from exc\n if scientific is not _NO_SCIENTIFIC:\n # Recognized envelopes are terminal containers to the JS decoder.\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, path)\n if scientific.get('__tywrap__') == 'torch.tensor':\n nested_path = _serialize_path(path, 'value')\n try:\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, nested_path)\n except Exception as exc:\n if path == 'result':\n raise\n raise RuntimeError(\n f'Scientific value serialization failed at {path}: {exc}'\n ) from exc\n parent[key] = scientific\n continue\n\n if callable(getattr(current, 'model_dump', None)):\n _check_serialize_depth(depth, path)\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, path)\n current_id = id(current)\n if current_id in active_ids:\n raise RuntimeError(f'Circular model conversion detected at {path}')\n active_ids.add(current_id)\n normalized = _serialize_leaf(current, path)\n if normalized is current:\n raise RuntimeError(f'Circular model conversion detected at {path}')\n stack.append(('release-model', current_id))\n if _needs_serialize_visit(normalized):\n stack.append(('visit', normalized, depth + 1, path, parent, key))\n else:\n parent[key] = _serialize_leaf(normalized, path)\n continue\n\n if isinstance(current, (dict, list, tuple)):\n _check_serialize_depth(depth, path)\n visited_nodes += 1\n _check_serialize_nodes(visited_nodes, path)\n current_id = id(current)\n if current_id in active_ids:\n raise RuntimeError(f'Circular reference detected at {path}')\n active_ids.add(current_id)\n\n if isinstance(current, dict):\n output = {}\n parent[key] = output\n stack.append(\n ('dict', current, depth, path, parent, key, output, iter(current.items()))\n )\n continue\n\n output = [None] * len(current)\n if isinstance(current, list):\n parent[key] = output\n stack.append(('sequence', current, depth, path, parent, key, output, 0))\n continue\n\n normalized = _serialize_leaf(current, path)\n if normalized is not current and _needs_serialize_visit(normalized):\n stack.append(('visit', normalized, depth, path, parent, key))\n elif normalized is not current:\n parent[key] = _serialize_leaf(normalized, path)\n else:\n parent[key] = normalized\n\n return root[0]\n\n\n# =============================================================================\n# JSON CODEC: value handling and size-limited encode/decode\n# =============================================================================\n#\n# This is the single Python implementation used by both the subprocess bridge and\n# the embedded Pyodide core. BridgeCodec adds payload-size enforcement around the\n# shared encoder; encode_value is the unbounded form used by the in-memory bridge.\n\ndef _is_nan_or_inf(value):\n if not isinstance(value, (int, float)):\n return False\n try:\n return math.isnan(value) or math.isinf(value)\n except (TypeError, ValueError):\n return False\n\n\ndef _is_numpy_scalar(obj):\n try:\n import numpy as np\n except ImportError:\n return False\n return isinstance(obj, (np.generic, np.ndarray)) and obj.ndim == 0\n\n\ndef _is_pandas_scalar(obj):\n try:\n import pandas as pd\n except ImportError:\n return False\n return isinstance(obj, (pd.Timestamp, pd.Timedelta, type(pd.NaT)))\n\n\ndef make_default_encoder(*, allow_nan):\n \"\"\"\n Build a json.dumps default= encoder matching BridgeCodec's value handling.\n\n Raises CodecError for NaN/Infinity extracted from numpy scalars (json.dumps\n itself rejects top-level/nested NaN/Infinity floats when allow_nan=False).\n \"\"\"\n\n def default_encoder(obj):\n # numpy/pandas scalars first (need .item() extraction).\n if _is_numpy_scalar(obj):\n extracted = obj.item()\n if not allow_nan and _is_nan_or_inf(extracted):\n raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON')\n return extracted\n\n if _is_pandas_scalar(obj):\n try:\n import pandas as pd\n except ImportError:\n pass\n else:\n if obj is pd.NaT or (hasattr(pd, 'isna') and pd.isna(obj)):\n return None\n if isinstance(obj, pd.Timestamp):\n return obj.isoformat()\n if isinstance(obj, pd.Timedelta):\n return obj.total_seconds()\n\n stdlib_value = serialize_stdlib(obj)\n if stdlib_value is not None:\n return stdlib_value\n\n if isinstance(obj, (bytes, bytearray)):\n return {\n '__type__': 'bytes',\n 'encoding': 'base64',\n 'data': base64.b64encode(obj).decode('ascii'),\n }\n\n model_dump = getattr(obj, 'model_dump', None)\n if callable(model_dump):\n try:\n return model_dump(by_alias=True, mode='json')\n except TypeError:\n return model_dump(by_alias=True)\n\n if isinstance(obj, (set, frozenset)):\n return list(obj)\n\n if isinstance(obj, complex):\n raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')\n\n raise TypeError(f'Object of type {type(obj).__name__} is not JSON serializable')\n\n return default_encoder\n\n\ndef encode_value(value, *, allow_nan):\n \"\"\"\n JSON-encode a fully-serialized response value, applying the BridgeCodec-equivalent\n default encoder and rejecting NaN/Infinity when allow_nan is False.\n\n Raises CodecError (wrapping the json.dumps ValueError) on NaN/Infinity, matching\n BridgeCodec's \"Cannot serialize NaN...\" wording so error parity holds.\n \"\"\"\n try:\n return json.dumps(value, default=make_default_encoder(allow_nan=allow_nan), allow_nan=allow_nan)\n except ValueError as exc:\n error_msg = str(exc).lower()\n # json.dumps(allow_nan=False) rejects NaN/Infinity with a ValueError whose\n # wording is Python-version dependent: 3.12+ appends the offending value\n # (\"...not JSON compliant: nan\"), but 3.10/3.11 emit only the canonical\n # \"Out of range float values are not JSON compliant\". Match that phrase too\n # so the typed error message is stable across versions.\n nonfinite_token = re.search(r'(^|[^a-z])(nan|inf|infinity)([^a-z]|$)', error_msg)\n if (\n not allow_nan and 'out of range float values are not json compliant' in error_msg\n ) or nonfinite_token:\n raise CodecError('Cannot serialize NaN - NaN/Infinity not allowed in JSON') from exc\n raise CodecError(f'JSON encoding failed: {exc}') from exc\n except TypeError as exc:\n raise CodecError(f'JSON encoding failed: {exc}') from exc\n\n\nclass BridgeCodec:\n \"\"\"Safe JSON codec with explicit value handling and payload-size limits.\"\"\"\n\n def __init__(self, allow_nan=False, max_payload_bytes=10 * 1024 * 1024):\n self.allow_nan = allow_nan\n self.max_payload_bytes = max_payload_bytes\n self._encoder = make_default_encoder(allow_nan=allow_nan)\n\n def encode(self, value):\n result = encode_value(value, allow_nan=self.allow_nan)\n if len(result.encode('utf-8')) > self.max_payload_bytes:\n raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')\n return result\n\n def decode(self, payload):\n if len(payload.encode('utf-8')) > self.max_payload_bytes:\n raise CodecError(f'Payload exceeds {self.max_payload_bytes} bytes')\n try:\n return json.loads(payload)\n except json.JSONDecodeError as exc:\n raise CodecError(f'JSON decoding failed: {exc}') from exc\n\n def _default_encoder(self, obj):\n \"\"\"Compatibility hook for callers that used the codec's JSON encoder.\"\"\"\n return self._encoder(obj)\n\n\n_default_codec = None\n\n\ndef get_default_codec():\n \"\"\"Return the lazily-created default BridgeCodec instance.\"\"\"\n global _default_codec\n if _default_codec is None:\n _default_codec = BridgeCodec()\n return _default_codec\n\n\ndef encode(value, *, allow_nan=False):\n \"\"\"Encode a value with the default codec settings.\"\"\"\n if allow_nan:\n return BridgeCodec(allow_nan=True).encode(value)\n return get_default_codec().encode(value)\n\n\ndef decode(payload):\n \"\"\"Decode a JSON payload with the default codec settings.\"\"\"\n return get_default_codec().decode(payload)\n\n\n# =============================================================================\n# REQUEST VALIDATION + HANDLERS + DISPATCH\n# =============================================================================\n\ndef require_protocol(msg):\n if not isinstance(msg, dict):\n raise ProtocolError('Invalid request payload')\n proto = msg.get('protocol')\n if proto != PROTOCOL:\n raise ProtocolError(f'Invalid protocol: {proto}')\n mid = msg.get('id')\n if not isinstance(mid, int):\n raise ProtocolError(f'Invalid request id: {mid}')\n return mid\n\n\ndef require_str(params, key):\n value = params.get(key)\n if not isinstance(value, str) or not value:\n raise ProtocolError(f'Missing {key}')\n return value\n\n\ndef coerce_list(value, key):\n if value is None:\n return []\n if not isinstance(value, list):\n raise ProtocolError(f'Invalid {key}')\n return value\n\n\ndef coerce_dict(value, key):\n if value is None:\n return {}\n if not isinstance(value, dict):\n raise ProtocolError(f'Invalid {key}')\n return value\n\n\ndef _invoke_call(\n params,\n *,\n allowed_modules,\n allow_private_attrs,\n has_envelope_markers,\n):\n module_name = require_str(params, 'module')\n function_name = require_str(params, 'functionName')\n args = deserialize(coerce_list(params.get('args'), 'args'), has_envelope_markers=has_envelope_markers)\n kwargs = deserialize(coerce_dict(params.get('kwargs'), 'kwargs'), has_envelope_markers=has_envelope_markers)\n mod = import_allowed_module(module_name, allowed_modules)\n # function_name may be dotted ('Class.method') for @classmethod/@staticmethod\n # calls, which the generated wrapper routes through call() rather than an\n # instance handle. resolve_allowed_attr_path guards each segment.\n func = resolve_allowed_attr_path(mod, function_name, allow_private_attrs=allow_private_attrs)\n return func(*args, **kwargs)\n\n\ndef _reject_generator(value):\n import inspect\n\n if inspect.isgenerator(value):\n raise TypeError('Generator results are not supported by tywrap RPC')\n if inspect.isasyncgen(value):\n raise TypeError('Async generator results are not supported by tywrap RPC')\n\n\nasync def _await_result(value):\n return await value\n\n\ndef handle_call(\n params,\n *,\n force_json_markers,\n torch_allow_copy,\n allowed_modules,\n allow_private_attrs,\n has_envelope_markers,\n):\n \"\"\"Run one call. The serial subprocess owns a fresh loop for awaitables.\"\"\"\n import asyncio\n import inspect\n\n res = _invoke_call(\n params,\n allowed_modules=allowed_modules,\n allow_private_attrs=allow_private_attrs,\n has_envelope_markers=has_envelope_markers,\n )\n _reject_generator(res)\n if inspect.isawaitable(res):\n # asyncio.run requires a coroutine, while user code may return any\n # awaitable. It closes the per-call loop on Python 3.10 through 3.12.\n res = asyncio.run(_await_result(res))\n _reject_generator(res)\n return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)\n\n\nasync def handle_call_async(\n params,\n *,\n force_json_markers,\n torch_allow_copy,\n allowed_modules,\n allow_private_attrs,\n has_envelope_markers,\n):\n \"\"\"Run one call on the caller's loop, which Pyodide owns.\"\"\"\n import inspect\n\n res = _invoke_call(\n params,\n allowed_modules=allowed_modules,\n allow_private_attrs=allow_private_attrs,\n has_envelope_markers=has_envelope_markers,\n )\n _reject_generator(res)\n if inspect.isawaitable(res):\n res = await res\n _reject_generator(res)\n return serialize(res, force_json_markers=force_json_markers, torch_allow_copy=torch_allow_copy)\n\n\ndef build_meta(\n *,\n bridge,\n pid,\n python_version,\n codec_fallback,\n arrow_available_override=None,\n):\n \"\"\"\n Build the bridge metadata payload.\n\n Field order here is part of the wire contract (the JS validator and the\n documented BridgeInfo shape). Callers supply the backend-specific identity:\n the subprocess server passes bridge='python-subprocess' and a real pid; the\n Pyodide server passes bridge='pyodide' and pid=None.\n\n arrow_available_override: when not None, report this value for arrowAvailable\n instead of probing pyarrow. The Pyodide server forces markers to JSON\n unconditionally, so it advertises arrowAvailable=False regardless of whether\n pyarrow happens to be importable in the WASM environment.\n \"\"\"\n arrow = arrow_available() if arrow_available_override is None else arrow_available_override\n meta = {\n 'protocol': PROTOCOL,\n 'protocolVersion': PROTOCOL_VERSION,\n 'bridge': bridge,\n 'pythonVersion': python_version,\n 'pid': pid,\n 'codecFallback': codec_fallback,\n 'arrowAvailable': arrow,\n 'scipyAvailable': module_available('scipy'),\n 'torchAvailable': module_available('torch'),\n 'sklearnAvailable': module_available('sklearn'),\n 'instances': 0,\n }\n return meta\n\n\ndef dispatch_request(\n msg,\n *,\n bridge,\n pid,\n force_json_markers,\n allow_nan=False,\n python_version=None,\n torch_allow_copy=False,\n arrow_available_override=None,\n allowed_modules=None,\n allow_private_attrs=False,\n has_envelope_markers=True,\n):\n \"\"\"\n Validate and route a request, returning the fully-serialized response dict\n ({'id', 'protocol', 'result'}). Raises ProtocolError for malformed requests\n and propagates handler exceptions to the caller, which is responsible for\n building the error envelope (so it controls traceback inclusion).\n\n allow_nan is accepted for signature symmetry; NaN rejection happens during\n the final encode_value() call, which the caller performs.\n\n allowed_modules: None (default) disables the import allowlist so existing\n behavior is preserved. Supplying a set restricts call imports to\n those modules (plus the stdlib the bridge itself needs) and raises\n ImportNotAllowedError otherwise. allow_private_attrs=False (default) blocks\n getattr of underscore-prefixed names; True restores unrestricted access. See\n the IMPORT / ATTRIBUTE ALLOWLIST section above for the full trust model.\n \"\"\"\n mid = require_protocol(msg)\n method = msg.get('method')\n if not isinstance(method, str):\n raise ProtocolError('Missing method')\n params = coerce_dict(msg.get('params'), 'params')\n if method == 'call':\n result = handle_call(\n params,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n allowed_modules=allowed_modules,\n allow_private_attrs=allow_private_attrs,\n has_envelope_markers=has_envelope_markers,\n )\n elif method == 'meta':\n if python_version is None:\n import sys\n python_version = sys.version.split()[0]\n codec_fallback = 'json' if force_json_markers else 'none'\n result = build_meta(\n bridge=bridge,\n pid=pid,\n python_version=python_version,\n codec_fallback=codec_fallback,\n arrow_available_override=arrow_available_override,\n )\n else:\n raise ProtocolError(f'Unknown method: {method}')\n return {'id': mid, 'protocol': PROTOCOL, 'result': result}\n\n\nasync def dispatch_request_async(\n msg,\n *,\n bridge,\n pid,\n force_json_markers,\n allow_nan=False,\n python_version=None,\n torch_allow_copy=False,\n arrow_available_override=None,\n allowed_modules=None,\n allow_private_attrs=False,\n has_envelope_markers=True,\n):\n \"\"\"Dispatch on the caller's event loop and return one response envelope.\"\"\"\n mid = require_protocol(msg)\n method = msg.get('method')\n if not isinstance(method, str):\n raise ProtocolError('Missing method')\n params = coerce_dict(msg.get('params'), 'params')\n if method == 'call':\n result = await handle_call_async(\n params,\n force_json_markers=force_json_markers,\n torch_allow_copy=torch_allow_copy,\n allowed_modules=allowed_modules,\n allow_private_attrs=allow_private_attrs,\n has_envelope_markers=has_envelope_markers,\n )\n elif method == 'meta':\n if python_version is None:\n import sys\n python_version = sys.version.split()[0]\n codec_fallback = 'json' if force_json_markers else 'none'\n result = build_meta(\n bridge=bridge,\n pid=pid,\n python_version=python_version,\n codec_fallback=codec_fallback,\n arrow_available_override=arrow_available_override,\n )\n else:\n raise ProtocolError(f'Unknown method: {method}')\n return {'id': mid, 'protocol': PROTOCOL, 'result': result}\n\n\ndef build_error_payload(mid, exc, *, include_traceback):\n \"\"\"\n Build a protocol error response. Protocol/validation errors omit traceback;\n handler errors include it. Field order matches the reference server.\n \"\"\"\n error = {'type': type(exc).__name__, 'message': str(exc)}\n if include_traceback:\n error['traceback'] = traceback.format_exc()\n return {\n 'id': mid if mid is not None else -1,\n 'protocol': PROTOCOL,\n 'error': error,\n }\n"; diff --git a/src/runtime/pyodide-transport.ts b/src/runtime/pyodide-transport.ts index 11d2bddc..2aa6d6ef 100644 --- a/src/runtime/pyodide-transport.ts +++ b/src/runtime/pyodide-transport.ts @@ -15,7 +15,7 @@ */ import { DisposableBase } from './bounded-context.js'; -import { BridgeProtocolError } from './errors.js'; +import { BridgeDisposedError, BridgeProtocolError, BridgeTimeoutError } from './errors.js'; import { PYODIDE_BRIDGE_CORE_SOURCE } from './pyodide-bootstrap-core.generated.js'; import { PROTOCOL_ID, @@ -100,6 +100,7 @@ export const BOOTSTRAP_PYTHON = ` import sys as __tywrap_sys import json as __tywrap_json import types as __tywrap_types +import asyncio as __tywrap_asyncio # Load the shared bridge core from the embedded source into a real module so the # Pyodide server runs the IDENTICAL protocol/serialization code as the reference @@ -111,9 +112,10 @@ __tywrap_sys.modules[${JSON.stringify(PYODIDE_CORE_MODULE_NAME)}] = __tywrap_cor exec(compile(__tywrap_core_source, '', 'exec'), __tywrap_core.__dict__) __tywrap_protocol = __tywrap_core.PROTOCOL +__tywrap_tasks = {} -def __tywrap_dispatch(message_json): +async def __tywrap_dispatch_task(msg, message_json, mid): """ Dispatch a protocol message and return a JSON response string. @@ -123,13 +125,9 @@ def __tywrap_dispatch(message_json): so NaN/Infinity is rejected with the same wording the subprocess server uses. """ core = __tywrap_core - mid = None try: - msg = __tywrap_json.loads(message_json) - if isinstance(msg, dict) and isinstance(msg.get('id'), int): - mid = msg.get('id') try: - out = core.dispatch_request( + out = await core.dispatch_request_async( msg, bridge='pyodide', pid=None, @@ -143,8 +141,7 @@ def __tywrap_dispatch(message_json): except Exception as e: out = core.build_error_payload(mid, e, include_traceback=True) except Exception as e: - # Malformed JSON / unexpected pre-dispatch failure: well-formed error, - # no traceback (matches the reference's outer handler). + # Unexpected pre-dispatch failure keeps the reference error envelope. out = core.build_error_payload(mid, e, include_traceback=False) try: @@ -160,6 +157,40 @@ def __tywrap_dispatch(message_json): # exactly as the subprocess server's fallback does. err_out = core.build_error_payload(mid, e, include_traceback=False) return __tywrap_json.dumps(err_out) + + +def __tywrap_forget_task(mid, task): + if __tywrap_tasks.get(mid) is task: + __tywrap_tasks.pop(mid, None) + + +def __tywrap_dispatch(message_json): + """Register a task before JavaScript can timeout or abort the call.""" + try: + msg = __tywrap_json.loads(message_json) + except Exception as e: + out = __tywrap_core.build_error_payload(None, e, include_traceback=False) + return __tywrap_json.dumps(out) + + mid = msg.get('id') if isinstance(msg, dict) and isinstance(msg.get('id'), int) else None + if mid is not None and mid in __tywrap_tasks: + error = __tywrap_core.ProtocolError(f'Duplicate request id: {mid}') + out = __tywrap_core.build_error_payload(mid, error, include_traceback=False) + return __tywrap_json.dumps(out) + + task = __tywrap_asyncio.get_event_loop().create_task( + __tywrap_dispatch_task(msg, message_json, mid) + ) + if mid is not None: + __tywrap_tasks[mid] = task + task.add_done_callback(lambda done: __tywrap_forget_task(mid, done)) + return task + + +def __tywrap_cancel(mid): + """Request cooperative cancellation of one active RPC task.""" + task = __tywrap_tasks.get(mid) + return task.cancel() if task is not None else False `; // ============================================================================= @@ -196,6 +227,7 @@ export class PyodideTransport extends DisposableBase implements Transport { private readonly indexURL: string; private readonly packages: readonly string[]; private py?: PyodideInstance; + private readonly activeCalls = new Map void; dispose: () => void }>(); /** * Create a new PyodideTransport. @@ -248,6 +280,9 @@ export class PyodideTransport extends DisposableBase implements Transport { * We clear our reference and rely on garbage collection. */ protected async doDispose(): Promise { + for (const call of this.activeCalls.values()) { + call.dispose(); + } this.py = undefined; } @@ -284,7 +319,11 @@ export class PyodideTransport extends DisposableBase implements Transport { ): Promise { return this.execute( async () => { - if (!this.py) { + const py = this.py; + if (this.state === 'disposing' || this.state === 'disposed') { + throw new BridgeDisposedError('Transport disposed'); + } + if (!py) { throw new BridgeProtocolError('Pyodide not initialized'); } @@ -306,41 +345,112 @@ export class PyodideTransport extends DisposableBase implements Transport { if (!parsed.params || typeof parsed.params !== 'object' || Array.isArray(parsed.params)) { throw new BridgeProtocolError('Message missing required fields: id, method'); } + if (this.activeCalls.has(parsed.id)) { + throw new BridgeProtocolError(`Duplicate request id: ${parsed.id}`); + } // Get the dispatch function - const dispatchFn = this.py.globals.get('__tywrap_dispatch'); + const dispatchFn = py.globals.get('__tywrap_dispatch'); if (!dispatchFn) { throw new BridgeProtocolError('Pyodide dispatch function not initialized'); } + let result: string | PromiseLike; try { - // Call the dispatch function - const invoke = dispatchFn as (messageJson: string) => string; - const responseJson = invoke(message); + result = (dispatchFn as (messageJson: string) => string | PromiseLike)(message); + } catch (error) { + this.destroyPyProxy(dispatchFn); + throw error; + } - // Validate response is valid JSON + const cancel = (): void => { + let cancelFn: unknown; try { - const response = JSON.parse(responseJson) as ProtocolResponse; - if (typeof response.id !== 'number' || !Number.isInteger(response.id)) { - throw new BridgeProtocolError('Invalid response from Python: missing numeric id'); - } - if (response.protocol !== undefined && response.protocol !== PROTOCOL_ID) { - throw new BridgeProtocolError( - `Invalid protocol version: expected "${PROTOCOL_ID}", got "${response.protocol}"` - ); - } - if (response.error) { - // Return the response as-is; let the caller handle the error - return responseJson; + cancelFn = py.globals.get('__tywrap_cancel'); + if (typeof cancelFn === 'function') { + (cancelFn as (id: number) => unknown)(parsed.id); } - return responseJson; + } catch { + // A timeout or disposal must retain its original error. + } finally { + this.destroyPyProxy(cancelFn); + } + }; + + let rejectEarly: (error: Error) => void = () => {}; + const interrupted = new Promise((_resolve, reject) => { + rejectEarly = reject; + }); + let finished = false; + let timer: ReturnType | undefined; + const stopTimer = (): void => { + if (timer !== undefined) clearTimeout(timer); + signal?.removeEventListener('abort', onAbort); + }; + const interrupt = (error: Error): void => { + if (finished) return; + finished = true; + stopTimer(); + cancel(); + rejectEarly(error); + }; + const onAbort = (): void => interrupt(new BridgeTimeoutError('Operation aborted')); + + const completion = Promise.resolve(result).then(responseJson => { + let response: ProtocolResponse; + try { + response = JSON.parse(responseJson) as ProtocolResponse; } catch { throw new BridgeProtocolError(`Invalid JSON response from Python: ${responseJson}`); } - } finally { - // Clean up the proxy - this.destroyPyProxy(dispatchFn); + if (typeof response.id !== 'number' || !Number.isInteger(response.id)) { + throw new BridgeProtocolError('Invalid response from Python: missing numeric id'); + } + if (response.id !== parsed.id) { + throw new BridgeProtocolError( + `Invalid response id: expected ${parsed.id}, got ${response.id}` + ); + } + if (response.protocol !== undefined && response.protocol !== PROTOCOL_ID) { + throw new BridgeProtocolError( + `Invalid protocol version: expected "${PROTOCOL_ID}", got "${response.protocol}"` + ); + } + return responseJson; + }); + + // This handler owns proxy cleanup even if the caller's timeout wins. + void completion.then( + () => { + finished = true; + stopTimer(); + this.activeCalls.delete(parsed.id); + this.destroyPyProxy(result); + this.destroyPyProxy(dispatchFn); + }, + () => { + finished = true; + stopTimer(); + this.activeCalls.delete(parsed.id); + this.destroyPyProxy(result); + this.destroyPyProxy(dispatchFn); + } + ); + + this.activeCalls.set(parsed.id, { + cancel, + dispose: () => interrupt(new BridgeDisposedError('Transport disposed')), + }); + if (timeoutMs > 0 && Number.isFinite(timeoutMs)) { + timer = setTimeout( + () => interrupt(new BridgeTimeoutError(`Operation timed out after ${timeoutMs}ms`)), + timeoutMs + ); } + signal?.addEventListener('abort', onAbort, { once: true }); + if (signal?.aborted) onAbort(); + + return Promise.race([completion, interrupted]); }, { timeoutMs, signal } ); diff --git a/src/runtime/rpc-client.ts b/src/runtime/rpc-client.ts index 3eee208f..a296bfd7 100644 --- a/src/runtime/rpc-client.ts +++ b/src/runtime/rpc-client.ts @@ -340,21 +340,24 @@ export class RpcClient extends DisposableBase { ): Promise { const fullMessage = this.stampMessage(message); - return this.execute(async () => { - // 1. Encode request (validates args) - const encoded = this.codec.encodeRequest(fullMessage); - - // 2. Send via transport - const responseStr = await this.transport.send( - encoded, - options?.timeoutMs ?? this.defaultTimeoutMs, - options?.signal, - fullMessage.id - ); - - // 3. Decode response (sync or Arrow-aware, per caller) - return decode(responseStr); - }, options); + return this.execute( + async () => { + // 1. Encode request (validates args) + const encoded = this.codec.encodeRequest(fullMessage); + + // 2. Send via transport + const responseStr = await this.transport.send( + encoded, + options?.timeoutMs ?? this.defaultTimeoutMs, + options?.signal, + fullMessage.id + ); + + // 3. Decode response (sync or Arrow-aware, per caller) + return decode(responseStr); + }, + { ...options, timeoutMs: options?.timeoutMs ?? this.defaultTimeoutMs } + ); } /** diff --git a/src/runtime/subprocess-transport.ts b/src/runtime/subprocess-transport.ts index 39003c05..01d32071 100644 --- a/src/runtime/subprocess-transport.ts +++ b/src/runtime/subprocess-transport.ts @@ -117,6 +117,7 @@ interface PendingRequest { resolve: (value: string) => void; reject: (error: Error) => void; timer?: NodeJS.Timeout; + written?: boolean; } /** @@ -137,6 +138,7 @@ interface QueuedWrite { * (and so never executes on) Python. */ isLive?: () => boolean; + onWrite?: () => void; /** True while this entry is waiting to be flushed. */ isQueued?: boolean; } @@ -277,6 +279,8 @@ export class SubprocessTransport extends DisposableBase implements Transport { }); private requestCount = 0; private needsRestart = false; + private retiredGeneration = false; + private retirement?: Promise; /** Serializes restart decisions with dispatch reservations. */ private dispatchMutex: Promise = Promise.resolve(); private activeDispatches = 0; @@ -321,6 +325,11 @@ export class SubprocessTransport extends DisposableBase implements Transport { }); } + /** Whether a timed-out or aborted request retired this process generation. */ + get requiresReplacement(): boolean { + return this.retiredGeneration; + } + // =========================================================================== // TRANSPORT INTERFACE // =========================================================================== @@ -350,7 +359,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { } // Check if process is alive - if (this.processExited || !this.process) { + if ((this.processExited || !this.process) && !this.needsRestart) { const stderrTail = this.getStderrTail(); const baseMsg = 'Python process is not running'; const msg = stderrTail ? `${baseMsg}. Stderr:\n${stderrTail}` : baseMsg; @@ -382,35 +391,30 @@ export class SubprocessTransport extends DisposableBase implements Transport { // Set up timeout if specified let timer: NodeJS.Timeout | undefined; - // Defined before the timer so the timeout path can also detach it. - const abortHandler = (): void => { - if (timer) { - clearTimeout(timer); - } + const abandon = (error: BridgeTimeoutError): void => { + if (this.pending.get(messageId) !== pendingEntry) return; + if (timer) clearTimeout(timer); + signal?.removeEventListener('abort', abortHandler); this.pending.delete(messageId); this.timedOutRequests.mark(messageId); - // Same late-frame discard as the timeout path (see below). this.responseReassembler?.discard(messageId); - reject(new BridgeTimeoutError('Operation aborted')); + reject(error); + // A request that reached Python may run forever. Retire only that + // process generation; a queued, unwritten request leaves it intact. + if (pendingEntry.written) this.retireProcessGeneration(); + }; + + // Defined before the timer so the timeout path can also detach it. + const abortHandler = (): void => { + abandon(new BridgeTimeoutError('Operation aborted')); }; if (timeoutMs > 0) { timer = setTimeout(() => { - this.pending.delete(messageId); - this.timedOutRequests.mark(messageId); - // Discard any in-flight chunked response stream for this id so late - // frames are dropped rather than desyncing stdout (the single-line - // timedOutRequests.consume above is one-shot and insufficient for a - // multi-frame stream). - this.responseReassembler?.discard(messageId); - // Detach the abort listener: on timeout the abort never fires, so - // without this it would leak on a long-lived AbortSignal and could - // re-enter abortHandler after this promise has already settled. - signal?.removeEventListener('abort', abortHandler); const stderrTail = this.getStderrTail(); const baseMsg = `Operation timed out after ${timeoutMs}ms`; const msg = stderrTail ? `${baseMsg}. Recent stderr:\n${stderrTail}` : baseMsg; - reject(new BridgeTimeoutError(msg)); + abandon(new BridgeTimeoutError(msg)); }, timeoutMs); } @@ -599,7 +603,10 @@ export class SubprocessTransport extends DisposableBase implements Transport { // Fence queued restart work before killing the process. A restart already // holding the mutex observes the disposing state before it can spawn; if it // spawned just before disposal began, this barrier kills that child too. - await this.withDispatchMutex(() => this.killProcess()); + await this.withDispatchMutex(async () => { + await this.retirement; + await this.killProcess(); + }); // Clear buffers this.stdoutBuffer = ''; @@ -744,6 +751,9 @@ export class SubprocessTransport extends DisposableBase implements Transport { throw new BridgeDisposedError('Transport has been disposed'); } + await this.retirement; + this.retirement = undefined; + // Kill existing process await this.killProcess(); @@ -772,6 +782,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { // Spawn a fresh process with the packaged always-on framing protocol. await this.spawnProcess(); + this.retiredGeneration = false; } /** @@ -783,6 +794,19 @@ export class SubprocessTransport extends DisposableBase implements Transport { this.needsRestart = true; } + private retireProcessGeneration(): void { + if (this.retiredGeneration || this.isLifecycleEnding()) return; + this.retiredGeneration = true; + this.needsRestart = true; + const error = new BridgeProtocolError('Python worker retired after request timeout or abort'); + this.rejectAllPending(error); + this.rejectAllQueuedWrites(error); + // killProcess removes old stdout listeners before a replacement can start. + this.retirement = this.killProcess().catch(() => { + this.processExited = true; + }); + } + // =========================================================================== // STREAM HANDLERS // =========================================================================== @@ -1073,10 +1097,11 @@ export class SubprocessTransport extends DisposableBase implements Transport { data: string, resolve: () => void, reject: (error: Error) => void, - isLive?: () => boolean + isLive?: () => boolean, + onWrite?: () => void ): QueuedWrite { const queuedAt = Date.now(); - const entry: QueuedWrite = { data, resolve, reject, queuedAt, isLive, isQueued: true }; + const entry: QueuedWrite = { data, resolve, reject, queuedAt, isLive, onWrite, isQueued: true }; // Set up timeout timer that fires if drain never happens entry.timeoutHandle = setTimeout(() => { @@ -1111,7 +1136,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { /** * Write data to stdin with backpressure handling. */ - private writeToStdin(data: string, isLive?: () => boolean): Promise { + private writeToStdin(data: string, isLive?: () => boolean, onWrite?: () => void): Promise { return new Promise((resolve, reject) => { if (!this.process?.stdin || this.processExited) { reject(new BridgeProtocolError(this.withStderrTail('Process stdin not available'))); @@ -1121,7 +1146,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { if (this.draining || this.writeQueue.length > this.writeQueueHead) { // Queue the write with timestamp, timeout timer, and liveness predicate // (checked again at flush — see processQueuedWrite). - this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive)); + this.writeQueue.push(this.createQueuedWrite(data, resolve, reject, isLive, onWrite)); return; } @@ -1134,6 +1159,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { // Try direct write (wrap in try-catch for synchronous EPIPE errors) try { + onWrite?.(); const canWrite = this.process.stdin.write(data); if (canWrite) { @@ -1193,6 +1219,9 @@ export class SubprocessTransport extends DisposableBase implements Transport { (pendingEntry !== undefined ? this.pending.get(messageId) === pendingEntry : this.pending.has(messageId)); + const onWrite = (): void => { + if (pendingEntry) pendingEntry.written = true; + }; const run = (): Promise => { if (!isLive()) { return Promise.resolve(); @@ -1200,9 +1229,9 @@ export class SubprocessTransport extends DisposableBase implements Transport { // Chunk when the encoded request exceeds the fixed per-frame ceiling // supplied to the packaged Python peer. Otherwise use one JSONL line. if (utf8ByteLength(message) > this.frameBytes) { - return this.writeChunkedRequest(message, messageId, signal, isLive); + return this.writeChunkedRequest(message, messageId, signal, isLive, onWrite); } - return this.writeToStdin(`${message}\n`, isLive); + return this.writeToStdin(`${message}\n`, isLive, onWrite); }; // Serialize the whole logical write onto the mutex tail. We chain the next @@ -1231,7 +1260,8 @@ export class SubprocessTransport extends DisposableBase implements Transport { message: string, messageId: number, signal?: AbortSignal, - isLive?: () => boolean + isLive?: () => boolean, + onWrite?: () => void ): Promise { const frames = encodeFrames(message, { id: messageId, @@ -1252,7 +1282,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { // One frame per JSONL line; await each so stdin backpressure is honored. // isLive gates a frame that ends up queued under backpressure past a late // cancellation, so an abandoned chunked request never completes on Python. - await this.writeToStdin(`${JSON.stringify(frame)}\n`, isLive); + await this.writeToStdin(`${JSON.stringify(frame)}\n`, isLive, onWrite); } } @@ -1307,6 +1337,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { } try { + queued.onWrite?.(); const canWrite = stdin.write(queued.data); if (canWrite) { diff --git a/test/fixtures/python/async_module.py b/test/fixtures/python/async_module.py index 9f3d348f..2a2bb248 100644 --- a/test/fixtures/python/async_module.py +++ b/test/fixtures/python/async_module.py @@ -19,6 +19,16 @@ async def process_items(items: List[str]) -> List[str]: return results +async def delayed_value(delay: float) -> str: + await asyncio.sleep(delay) + return "finished" + + +async def raise_after_await() -> str: + await asyncio.sleep(0) + raise ValueError("async failure") + + async def async_generator(count: int) -> AsyncIterator[int]: """Async generator yielding numbers.""" for i in range(count): diff --git a/test/menagerie/manifest.ts b/test/menagerie/manifest.ts index 6bd76e79..61330512 100644 --- a/test/menagerie/manifest.ts +++ b/test/menagerie/manifest.ts @@ -241,10 +241,9 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [ ['special-floats', 'special_floats(true)', 'special_floats', [true], /NaN|Infinity|serialize/i], ['tuple-key-dict', 'tuple_key_dict()', 'tuple_key_dict', [], /keys must be str/i], ['enum-member', 'enum_member()', 'enum_member', [], /TrafficLight|serializable/i], - ['coroutine-value', 'coroutine_value()', 'coroutine_value', [], /coroutine|serializable/i], ['dataclass-instance', 'dataclass_instance()', 'dataclass_instance', [], /serializable/i], ['complex-value', 'complex_value()', 'complex_value', [], /serializable/i], - ['generator-value', 'generator_value()', 'generator_value', [], /serializable/i], + ['generator-value', 'generator_value()', 'generator_value', [], /generator|supported/i], ].map(([id, call, functionName, args, pattern]) => valuesRow({ id: id as string, @@ -256,6 +255,13 @@ export const RUNTIME_CATALOGUE: readonly CatalogueRow[] = [ expected: error(pattern as RegExp), }) ), + valuesRow({ + id: 'coroutine-value', + call: 'coroutine_value()', + status: 'EXPECTED_OK', + currentBehavior: 'The bridge awaits the coroutine and returns its string result.', + expected: equal('coroutine-result'), + }), // NumPy: Arrow is the default subprocess path; JSON rows pin fallback-only differences. libraryRow({ diff --git a/test/runtime_conformance.test.ts b/test/runtime_conformance.test.ts index a95771c4..985b8aa8 100644 --- a/test/runtime_conformance.test.ts +++ b/test/runtime_conformance.test.ts @@ -356,12 +356,21 @@ class HttpReferenceBackend implements ConformanceBackend { const PYODIDE_CORE_HARNESS = `${BOOTSTRAP_PYTHON} import sys as __tywrap_driver_sys -for __tywrap_line in __tywrap_driver_sys.stdin: - __tywrap_line = __tywrap_line.strip() - if not __tywrap_line: - continue - __tywrap_driver_sys.stdout.write(__tywrap_dispatch(__tywrap_line) + '\\n') - __tywrap_driver_sys.stdout.flush() +import asyncio as __tywrap_driver_asyncio +import inspect as __tywrap_driver_inspect + +async def __tywrap_driver_main(): + for __tywrap_line in __tywrap_driver_sys.stdin: + __tywrap_line = __tywrap_line.strip() + if not __tywrap_line: + continue + __tywrap_result = __tywrap_dispatch(__tywrap_line) + if __tywrap_driver_inspect.isawaitable(__tywrap_result): + __tywrap_result = await __tywrap_result + __tywrap_driver_sys.stdout.write(__tywrap_result + '\\n') + __tywrap_driver_sys.stdout.flush() + +__tywrap_driver_asyncio.run(__tywrap_driver_main()) `; // The harness inlines the full ~31 KB bootstrap. Passing it via `python -c "..."` diff --git a/test/runtime_coroutines.test.ts b/test/runtime_coroutines.test.ts new file mode 100644 index 00000000..ee868648 --- /dev/null +++ b/test/runtime_coroutines.test.ts @@ -0,0 +1,448 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { delimiter, join } from 'node:path'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { pathToFileURL } from 'node:url'; +import { EventEmitter } from 'node:events'; +import { NodeBridge } from '../src/runtime/node.js'; +import { PyodideTransport } from '../src/runtime/pyodide-transport.js'; +import { PooledTransport } from '../src/runtime/pooled-transport.js'; +import { SubprocessTransport } from '../src/runtime/subprocess-transport.js'; +import { RpcClient } from '../src/runtime/rpc-client.js'; +import type { Transport } from '../src/runtime/transport.js'; +import { clearRuntimeBridge, setRuntimeBridge } from '../src/runtime/index.js'; +import { + BridgeDisposedError, + BridgeExecutionError, + BridgeTimeoutError, +} from '../src/runtime/errors.js'; +import { generate } from '../src/tywrap.js'; +import { isNodejs } from '../src/utils/runtime.js'; +import { PYTHON, PYTHON_AVAILABLE } from './helpers/python-probe.js'; + +const pythonPath = [ + join(process.cwd(), 'test/menagerie'), + join(process.cwd(), 'test/fixtures/python'), +].join(delimiter); +const nodeSuite = isNodejs() && PYTHON_AVAILABLE ? describe : describe.skip; + +nodeSuite('Coroutine RPC through Node', () => { + let bridge: NodeBridge | undefined; + + const createBridge = (timeoutMs = 5000): NodeBridge => + new NodeBridge({ + scriptPath: 'runtime/python_bridge.py', + pythonPath: PYTHON ?? undefined, + timeoutMs, + env: { PYTHONPATH: pythonPath }, + }); + + afterEach(async () => { + clearRuntimeBridge(); + await bridge?.dispose(); + bridge = undefined; + }); + + it('generates, imports, and executes asyncText', async () => { + const tempDir = await mkdtemp(join(process.cwd(), '.tmp-coroutine-')); + try { + await writeFile( + join(tempDir, 'coroutine_fixture.py'), + 'async def async_text() -> str:\n return "awaited"\n', + 'utf8' + ); + const outputDir = join(tempDir, 'generated'); + const generated = await generate({ + pythonModules: { coroutine_fixture: { runtime: 'node', typeHints: 'strict' } }, + pythonImportPath: [tempDir], + output: { dir: outputDir, format: 'esm', declaration: false, sourceMap: false }, + runtime: { node: { pythonPath: PYTHON ?? 'python3' } }, + performance: { caching: false, batching: false, compression: 'none' }, + } as never); + expect(generated.failures).toEqual([]); + const generatedPath = generated.written.find(path => path.endsWith('.generated.ts')); + expect(generatedPath).toBeDefined(); + + bridge = new NodeBridge({ + scriptPath: 'runtime/python_bridge.py', + pythonPath: PYTHON ?? undefined, + env: { PYTHONPATH: [tempDir, pythonPath].join(delimiter) }, + }); + setRuntimeBridge(bridge); + const mod = (await import(pathToFileURL(generatedPath as string).href)) as { + asyncText: () => Promise; + }; + expect(await mod.asyncText()).toBe('awaited'); + expect(await bridge.call('math', 'sqrt', [16])).toBe(4); + } finally { + await bridge?.dispose(); + bridge = undefined; + clearRuntimeBridge(); + await rm(tempDir, { recursive: true, force: true }); + } + }, 120000); + + it('preserves the Python exception envelope and traceback', async () => { + bridge = createBridge(); + try { + await bridge.call('async_module', 'raise_after_await', []); + throw new Error('Expected the async call to fail'); + } catch (error) { + expect(error).toBeInstanceOf(BridgeExecutionError); + expect((error as Error).message).toContain('ValueError: async failure'); + expect((error as BridgeExecutionError).traceback).toContain('raise_after_await'); + } + }); + + it('rejects both generator kinds without starting iteration', async () => { + bridge = createBridge(); + await expect(bridge.call('async_module', 'sync_generator', [2])).rejects.toThrow( + 'Generator results are not supported' + ); + await expect(bridge.call('async_module', 'async_generator', [2])).rejects.toThrow( + 'Async generator results are not supported' + ); + expect(await bridge.call('math', 'sqrt', [9])).toBe(3); + }); + + it('retires a timed-out worker and succeeds on a fresh process', async () => { + bridge = createBridge(1000); + const firstPid = await bridge.call('os', 'getpid', []); + await expect(bridge.call('async_module', 'delayed_value', [2])).rejects.toBeInstanceOf( + BridgeTimeoutError + ); + const nextPid = await bridge.call('os', 'getpid', []); + expect(nextPid).not.toBe(firstPid); + expect(await bridge.call('async_module', 'delayed_value', [0])).toBe('finished'); + }, 20000); + + it('disposes the process while a coroutine awaits', async () => { + bridge = createBridge(); + await bridge.call('os', 'getpid', []); + const pending = bridge.call('async_module', 'delayed_value', [3]); + await new Promise(resolve => setTimeout(resolve, 100)); + await bridge.dispose(); + await expect(pending).rejects.toBeInstanceOf(BridgeDisposedError); + }, 10000); +}); + +nodeSuite('Subprocess retirement boundary', () => { + it('keeps the process after an abort before send', async () => { + const transport = new SubprocessTransport({ + bridgeScript: 'runtime/python_bridge.py', + pythonPath: PYTHON ?? 'python3', + }); + const pidRequest = (id: number): string => + JSON.stringify({ + id, + protocol: 'tywrap/1', + method: 'call', + params: { module: 'os', functionName: 'getpid', args: [], kwargs: {} }, + }); + try { + const first = JSON.parse(await transport.send(pidRequest(1), 5000)) as { result: number }; + const controller = new AbortController(); + controller.abort(); + await expect(transport.send(pidRequest(2), 5000, controller.signal)).rejects.toBeInstanceOf( + BridgeTimeoutError + ); + expect(transport.requiresReplacement).toBe(false); + const second = JSON.parse(await transport.send(pidRequest(3), 5000)) as { result: number }; + expect(second.result).toBe(first.result); + } finally { + await transport.dispose(); + } + }, 15000); +}); + +describe('Subprocess partial-write abort', () => { + it('retires after the first frame, independent of timeout tombstones', async () => { + const controller = new AbortController(); + const write = vi.fn(() => { + controller.abort(); + return true; + }); + const stdin = Object.assign(new EventEmitter(), { write, end: () => undefined }); + const child = Object.assign(new EventEmitter(), { + stdin, + stdout: new EventEmitter(), + stderr: new EventEmitter(), + killed: false, + kill: () => { + child.killed = true; + queueMicrotask(() => child.emit('exit', 0, null)); + return true; + }, + }); + const transport = new SubprocessTransport({ bridgeScript: 'unused', maxLineLength: 64 }); + const internals = transport as unknown as { + _state: 'ready'; + process: unknown; + processExited: boolean; + timedOutRequests: { mark: (id: number) => void }; + }; + internals._state = 'ready'; + internals.processExited = false; + internals.process = child; + try { + await expect(transport.send(request, 1000, controller.signal)).rejects.toBeInstanceOf( + BridgeTimeoutError + ); + expect(write).toHaveBeenCalledTimes(1); + expect(transport.requiresReplacement).toBe(true); + + const baseline = Date.now(); + const clock = vi.spyOn(Date, 'now').mockReturnValue(baseline); + try { + for (let id = 0; id < 1001; id += 1) internals.timedOutRequests.mark(id); + clock.mockReturnValue(baseline + 11 * 60_000); + internals.timedOutRequests.mark(2000); + expect(internals.process).toBeNull(); + expect(transport.requiresReplacement).toBe(true); + } finally { + clock.mockRestore(); + } + } finally { + await transport.dispose(); + } + }); +}); + +describe('RPC default timeout', () => { + it('uses the configured default beyond 30 seconds', async () => { + vi.useFakeTimers(); + const transport: Transport = { + init: async () => undefined, + dispose: async () => undefined, + send: async () => + new Promise(resolve => { + setTimeout( + () => resolve(JSON.stringify({ id: 1, protocol: 'tywrap/1', result: 'ok' })), + 31_000 + ); + }), + capabilities: () => ({ + backend: 'subprocess', + supportsArrow: false, + supportsBinary: true, + supportsChunking: false, + supportsStreaming: false, + maxFrameBytes: Number.POSITIVE_INFINITY, + }), + get isReady() { + return true; + }, + get isDisposed() { + return false; + }, + }; + const client = new RpcClient({ transport, defaultTimeoutMs: 40_000 }); + try { + const call = client.sendMessage({ method: 'call', params: {} }); + await vi.advanceTimersByTimeAsync(31_000); + await expect(call).resolves.toBe('ok'); + } finally { + await client.dispose(); + vi.useRealTimers(); + } + }); +}); + +describe('Retired worker leases', () => { + function createFakePool(onReplacementWorkerReady?: () => Promise) { + let created = 0; + const disposals: Array> = []; + const pool = new PooledTransport({ + minWorkers: 1, + maxWorkers: 1, + onReplacementWorkerReady, + createTransport: () => { + const index = created++; + const dispose = vi.fn(async () => undefined); + disposals.push(dispose); + const worker: Transport & { requiresReplacement: boolean } = { + requiresReplacement: false, + init: async () => undefined, + dispose, + send: async () => { + if (index === 0) { + worker.requiresReplacement = true; + throw new BridgeTimeoutError('Operation timed out'); + } + return response; + }, + get isReady() { + return true; + }, + capabilities: () => ({ + backend: 'subprocess', + supportsArrow: false, + supportsBinary: true, + supportsChunking: false, + supportsStreaming: false, + maxFrameBytes: Number.POSITIVE_INFINITY, + }), + }; + return worker; + }, + }); + return { pool, disposals, count: () => created }; + } + + it('replaces a retired lease before serving the next request', async () => { + const warmup = vi.fn(async () => undefined); + const { pool, disposals, count } = createFakePool(warmup); + try { + await pool.init(); + await expect(pool.send(request, 100)).rejects.toBeInstanceOf(BridgeTimeoutError); + await vi.waitFor(() => expect(count()).toBe(2)); + expect(await pool.send(request, 100)).toBe(response); + expect(warmup).toHaveBeenCalledTimes(1); + expect(disposals[0]).toHaveBeenCalledTimes(1); + } finally { + await pool.dispose(); + } + }); + + it('does not publish a replacement after disposal', async () => { + const started = deferred(); + const gate = deferred(); + const { pool, disposals } = createFakePool(async () => { + started.resolve(undefined); + await gate.promise; + }); + await pool.init(); + await expect(pool.send(request, 100)).rejects.toBeInstanceOf(BridgeTimeoutError); + await started.promise; + await pool.dispose(); + gate.resolve(undefined); + await vi.waitFor(() => expect(disposals[1]).toHaveBeenCalledTimes(1)); + expect(pool.workerCount).toBe(0); + }); +}); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +const request = JSON.stringify({ + id: 41, + protocol: 'tywrap/1', + method: 'call', + params: { module: 'math', functionName: 'sqrt', args: [4], kwargs: {} }, +}); +const response = JSON.stringify({ id: 41, protocol: 'tywrap/1', result: 2 }); + +describe('Pyodide coroutine transport lifecycle', () => { + let transport: PyodideTransport | undefined; + + afterEach(async () => { + await transport?.dispose(); + transport = undefined; + delete (globalThis as { loadPyodide?: unknown }).loadPyodide; + }); + + function installPyodide(result: string | PromiseLike) { + const destroyTask = vi.fn(); + const destroyDispatch = vi.fn(); + const cancel = vi.fn(); + const dispatch = Object.assign(() => result, { destroy: destroyDispatch }); + const task = + typeof result === 'string' ? result : Object.assign(result, { destroy: destroyTask }); + const dispatchTask = Object.assign(() => task, { destroy: destroyDispatch }); + const globals = new Map([ + ['__tywrap_dispatch', typeof result === 'string' ? dispatch : dispatchTask], + ['__tywrap_cancel', cancel], + ]); + const loadPyodide = vi.fn(async () => ({ + runPython: () => undefined, + runPythonAsync: async () => undefined, + globals: { + get: (key: string) => globals.get(key), + set: (key: string, value: unknown) => globals.set(key, value), + }, + toPy: (value: unknown) => value, + loadPackage: async () => undefined, + })); + (globalThis as { loadPyodide?: unknown }).loadPyodide = loadPyodide; + return { cancel, destroyTask, destroyDispatch, loadPyodide }; + } + + it('does not start a pre-aborted call', async () => { + const mock = installPyodide(response); + transport = new PyodideTransport(); + const controller = new AbortController(); + controller.abort(); + await expect(transport.send(request, 1000, controller.signal)).rejects.toBeInstanceOf( + BridgeTimeoutError + ); + expect(mock.destroyDispatch).not.toHaveBeenCalled(); + expect(mock.cancel).not.toHaveBeenCalled(); + }); + + it('cancels on timeout and keeps proxies until the task settles', async () => { + const pending = deferred(); + const mock = installPyodide(pending.promise); + transport = new PyodideTransport(); + await expect(transport.send(request, 20)).rejects.toBeInstanceOf(BridgeTimeoutError); + expect(mock.cancel).toHaveBeenCalledWith(41); + expect(mock.destroyTask).not.toHaveBeenCalled(); + pending.reject(new Error('CancelledError')); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(mock.destroyTask).toHaveBeenCalledTimes(1); + expect(mock.destroyDispatch).toHaveBeenCalledTimes(1); + }); + + it('cancels only the aborted request', async () => { + const pending = deferred(); + const mock = installPyodide(pending.promise); + transport = new PyodideTransport(); + await transport.init(); + const controller = new AbortController(); + const call = transport.send(request, 0, controller.signal); + await new Promise(resolve => setTimeout(resolve, 0)); + controller.abort(); + await expect(call).rejects.toBeInstanceOf(BridgeTimeoutError); + expect(mock.cancel).toHaveBeenCalledTimes(1); + expect(mock.cancel).toHaveBeenCalledWith(41); + pending.reject(new Error('CancelledError')); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(mock.destroyTask).toHaveBeenCalledTimes(1); + }); + + it('rejects disposal during await and cleans up after settlement', async () => { + const pending = deferred(); + const mock = installPyodide(pending.promise); + transport = new PyodideTransport(); + await transport.init(); + const call = transport.send(request, 0); + await new Promise(resolve => setTimeout(resolve, 0)); + await transport.dispose(); + await expect(call).rejects.toBeInstanceOf(BridgeDisposedError); + expect(mock.cancel).toHaveBeenCalledWith(41); + expect(mock.destroyTask).not.toHaveBeenCalled(); + pending.reject(new Error('CancelledError')); + await new Promise(resolve => setTimeout(resolve, 0)); + expect(mock.destroyTask).toHaveBeenCalledTimes(1); + }); + + it('does not cancel a completed call at the timeout boundary', async () => { + const mock = installPyodide(Promise.resolve(response)); + transport = new PyodideTransport(); + expect(await transport.send(request, 20)).toBe(response); + await new Promise(resolve => setTimeout(resolve, 25)); + expect(mock.cancel).not.toHaveBeenCalled(); + expect(mock.destroyTask).toHaveBeenCalledTimes(1); + }); +}); From 78d36563bf499bd9568f656ed69c0c100074829c Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 00:48:46 -0700 Subject: [PATCH 2/7] fix(runtime): preserve timeout diagnostics and async cleanup --- src/runtime/pyodide-transport.ts | 36 ++++++++++++++--------------- src/runtime/rpc-client.ts | 12 ++++++---- src/runtime/subprocess-transport.ts | 20 ++++++++++++---- test/runtime_coroutines.test.ts | 6 ++++- test/transport.test.ts | 2 +- 5 files changed, 45 insertions(+), 31 deletions(-) diff --git a/src/runtime/pyodide-transport.ts b/src/runtime/pyodide-transport.ts index 2aa6d6ef..79564d03 100644 --- a/src/runtime/pyodide-transport.ts +++ b/src/runtime/pyodide-transport.ts @@ -384,11 +384,15 @@ export class PyodideTransport extends DisposableBase implements Transport { let finished = false; let timer: ReturnType | undefined; const stopTimer = (): void => { - if (timer !== undefined) clearTimeout(timer); + if (timer !== undefined) { + clearTimeout(timer); + } signal?.removeEventListener('abort', onAbort); }; const interrupt = (error: Error): void => { - if (finished) return; + if (finished) { + return; + } finished = true; stopTimer(); cancel(); @@ -420,22 +424,14 @@ export class PyodideTransport extends DisposableBase implements Transport { }); // This handler owns proxy cleanup even if the caller's timeout wins. - void completion.then( - () => { - finished = true; - stopTimer(); - this.activeCalls.delete(parsed.id); - this.destroyPyProxy(result); - this.destroyPyProxy(dispatchFn); - }, - () => { - finished = true; - stopTimer(); - this.activeCalls.delete(parsed.id); - this.destroyPyProxy(result); - this.destroyPyProxy(dispatchFn); - } - ); + const cleanup = (): void => { + finished = true; + stopTimer(); + this.activeCalls.delete(parsed.id); + this.destroyPyProxy(result); + this.destroyPyProxy(dispatchFn); + }; + completion.then(cleanup, cleanup); this.activeCalls.set(parsed.id, { cancel, @@ -448,7 +444,9 @@ export class PyodideTransport extends DisposableBase implements Transport { ); } signal?.addEventListener('abort', onAbort, { once: true }); - if (signal?.aborted) onAbort(); + if (signal?.aborted) { + onAbort(); + } return Promise.race([completion, interrupted]); }, diff --git a/src/runtime/rpc-client.ts b/src/runtime/rpc-client.ts index a296bfd7..ef3bd5ea 100644 --- a/src/runtime/rpc-client.ts +++ b/src/runtime/rpc-client.ts @@ -329,9 +329,8 @@ export class RpcClient extends DisposableBase { * supplies auto-init and exactly-one-attempt timeout/abort handling), where the only difference between * the sync and Arrow-aware paths is the supplied `decode` step. * - * Behavior-preserving extraction of the two twins; ordering, the - * `options?.timeoutMs ?? this.defaultTimeoutMs` fallback, and the - * `this.execute(..., options)` wrapping are unchanged. + * The transport gets the configured timeout. The outer bound adds one + * second so transport errors can include details such as Python stderr. */ private async sendVia( message: Omit, @@ -339,6 +338,9 @@ export class RpcClient extends DisposableBase { decode: (responseStr: string) => T | Promise ): Promise { const fullMessage = this.stampMessage(message); + const transportTimeoutMs = options?.timeoutMs ?? this.defaultTimeoutMs; + // Let the transport report its own timeout, including Python stderr. + const executionTimeoutMs = transportTimeoutMs > 0 ? transportTimeoutMs + 1_000 : 0; return this.execute( async () => { @@ -348,7 +350,7 @@ export class RpcClient extends DisposableBase { // 2. Send via transport const responseStr = await this.transport.send( encoded, - options?.timeoutMs ?? this.defaultTimeoutMs, + transportTimeoutMs, options?.signal, fullMessage.id ); @@ -356,7 +358,7 @@ export class RpcClient extends DisposableBase { // 3. Decode response (sync or Arrow-aware, per caller) return decode(responseStr); }, - { ...options, timeoutMs: options?.timeoutMs ?? this.defaultTimeoutMs } + { ...options, timeoutMs: executionTimeoutMs } ); } diff --git a/src/runtime/subprocess-transport.ts b/src/runtime/subprocess-transport.ts index 01d32071..759e3e72 100644 --- a/src/runtime/subprocess-transport.ts +++ b/src/runtime/subprocess-transport.ts @@ -392,8 +392,12 @@ export class SubprocessTransport extends DisposableBase implements Transport { let timer: NodeJS.Timeout | undefined; const abandon = (error: BridgeTimeoutError): void => { - if (this.pending.get(messageId) !== pendingEntry) return; - if (timer) clearTimeout(timer); + if (this.pending.get(messageId) !== pendingEntry) { + return; + } + if (timer) { + clearTimeout(timer); + } signal?.removeEventListener('abort', abortHandler); this.pending.delete(messageId); this.timedOutRequests.mark(messageId); @@ -401,7 +405,9 @@ export class SubprocessTransport extends DisposableBase implements Transport { reject(error); // A request that reached Python may run forever. Retire only that // process generation; a queued, unwritten request leaves it intact. - if (pendingEntry.written) this.retireProcessGeneration(); + if (pendingEntry.written) { + this.retireProcessGeneration(); + } }; // Defined before the timer so the timeout path can also detach it. @@ -795,7 +801,9 @@ export class SubprocessTransport extends DisposableBase implements Transport { } private retireProcessGeneration(): void { - if (this.retiredGeneration || this.isLifecycleEnding()) return; + if (this.retiredGeneration || this.isLifecycleEnding()) { + return; + } this.retiredGeneration = true; this.needsRestart = true; const error = new BridgeProtocolError('Python worker retired after request timeout or abort'); @@ -1220,7 +1228,9 @@ export class SubprocessTransport extends DisposableBase implements Transport { ? this.pending.get(messageId) === pendingEntry : this.pending.has(messageId)); const onWrite = (): void => { - if (pendingEntry) pendingEntry.written = true; + if (pendingEntry) { + pendingEntry.written = true; + } }; const run = (): Promise => { if (!isLive()) { diff --git a/test/runtime_coroutines.test.ts b/test/runtime_coroutines.test.ts index ee868648..a1fec65f 100644 --- a/test/runtime_coroutines.test.ts +++ b/test/runtime_coroutines.test.ts @@ -119,9 +119,13 @@ nodeSuite('Coroutine RPC through Node', () => { bridge = createBridge(); await bridge.call('os', 'getpid', []); const pending = bridge.call('async_module', 'delayed_value', [3]); + const settled = pending.then( + () => new Error('Expected disposal to reject the call'), + error => error as Error + ); await new Promise(resolve => setTimeout(resolve, 100)); await bridge.dispose(); - await expect(pending).rejects.toBeInstanceOf(BridgeDisposedError); + expect(await settled).toBeInstanceOf(BridgeDisposedError); }, 10000); }); diff --git a/test/transport.test.ts b/test/transport.test.ts index 2540fe93..f8e17470 100644 --- a/test/transport.test.ts +++ b/test/transport.test.ts @@ -1461,7 +1461,7 @@ describe('PyodideTransport', () => { const message = JSON.stringify(createValidMessage()); await expect(transport.send(message, 1000)).rejects.toThrow( - /Invalid JSON response from Python/ + /Invalid response from Python: missing numeric id/ ); }); }); From 1e8717bb77ccbc93e98372e9694c6e6fbaac0aa8 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 01:11:57 -0700 Subject: [PATCH 3/7] fix(runtime): reap retired Python children after forced shutdown --- docs/maintainers/coroutines.md | 4 + src/runtime/subprocess-transport.ts | 80 ++++++++++++++----- test/fixtures/python/ignore_sigterm_bridge.py | 10 +++ test/runtime_coroutines.test.ts | 63 +++++++++++++++ 4 files changed, 139 insertions(+), 18 deletions(-) create mode 100644 test/fixtures/python/ignore_sigterm_bridge.py diff --git a/docs/maintainers/coroutines.md b/docs/maintainers/coroutines.md index b28c5c8b..bcf0bdb6 100644 --- a/docs/maintainers/coroutines.md +++ b/docs/maintainers/coroutines.md @@ -22,6 +22,10 @@ process for later calls. Replacement runs the configured worker warmup. Python state held only in the retired process is lost. A request that times out before its first write does not retire the process. +Retirement sends SIGTERM first. If the child stays alive, it sends SIGKILL +after one second and waits for the child to exit. A failed kill blocks +replacement of that process generation. + Disposal kills the process and rejects pending requests. The caller receives one timeout, abort, or disposal error. A retired process cannot send a late response into a replacement process. A timeout does not cancel Python code in diff --git a/src/runtime/subprocess-transport.ts b/src/runtime/subprocess-transport.ts index 759e3e72..e6cbdb94 100644 --- a/src/runtime/subprocess-transport.ts +++ b/src/runtime/subprocess-transport.ts @@ -610,7 +610,11 @@ export class SubprocessTransport extends DisposableBase implements Transport { // holding the mutex observes the disposing state before it can spawn; if it // spawned just before disposal began, this barrier kills that child too. await this.withDispatchMutex(async () => { - await this.retirement; + try { + await this.retirement; + } catch { + // Retry the retained process handle during final disposal. + } await this.killProcess(); }); @@ -706,6 +710,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { const proc = this.process; this.process = null; + this.processExited = true; // Add a catch-all error handler to prevent uncaught exceptions during shutdown // This must be added BEFORE removing other listeners and ending stdin @@ -728,25 +733,63 @@ export class SubprocessTransport extends DisposableBase implements Transport { proc.stdout?.removeAllListeners(); proc.stderr?.removeAllListeners(); - // Kill the process - if (!proc.killed) { - proc.kill('SIGTERM'); + const hasExited = (): boolean => proc.exitCode != null || proc.signalCode != null; + if (hasExited()) { + return; + } - // Wait briefly for graceful exit - await new Promise(resolve => { - const timeout = setTimeout(() => { - if (!proc.killed) { - proc.kill('SIGKILL'); - } + await new Promise((resolve, reject) => { + let graceTimer: ReturnType | undefined; + let forceTimer: ReturnType | undefined; + const finish = (error?: Error): void => { + if (graceTimer) { + clearTimeout(graceTimer); + } + if (forceTimer) { + clearTimeout(forceTimer); + } + proc.removeListener('exit', onExit); + if (error) { + // Keep the handle for a later disposal retry. Never publish a new + // generation while the old child may still be alive. + this.process = proc; + reject(error); + } else { resolve(); + } + }; + const onExit = (): void => finish(); + proc.once('exit', onExit); + if (hasExited()) { + finish(); + return; + } + if (!proc.killed) { + try { + proc.kill('SIGTERM'); + } catch { + // The force timer below still bounds shutdown. + } + } + graceTimer = setTimeout(() => { + if (hasExited()) { + finish(); + return; + } + try { + proc.kill('SIGKILL'); + } catch { + // Report an unreaped child after the force deadline. + } + forceTimer = setTimeout(() => { + if (hasExited()) { + finish(); + } else { + finish(new BridgeProtocolError('Python process did not exit after SIGKILL')); + } }, 1000); - - proc.once('exit', () => { - clearTimeout(timeout); - resolve(); - }); - }); - } + }, 1000); + }); } /** @@ -810,7 +853,8 @@ export class SubprocessTransport extends DisposableBase implements Transport { this.rejectAllPending(error); this.rejectAllQueuedWrites(error); // killProcess removes old stdout listeners before a replacement can start. - this.retirement = this.killProcess().catch(() => { + this.retirement = this.killProcess(); + this.retirement.catch(() => { this.processExited = true; }); } diff --git a/test/fixtures/python/ignore_sigterm_bridge.py b/test/fixtures/python/ignore_sigterm_bridge.py new file mode 100644 index 00000000..cab3139b --- /dev/null +++ b/test/fixtures/python/ignore_sigterm_bridge.py @@ -0,0 +1,10 @@ +import signal +import sys +import time + + +signal.signal(signal.SIGTERM, signal.SIG_IGN) +print('READY', file=sys.stderr, flush=True) +sys.stdin.readline() +while True: + time.sleep(0.1) diff --git a/test/runtime_coroutines.test.ts b/test/runtime_coroutines.test.ts index a1fec65f..267dd928 100644 --- a/test/runtime_coroutines.test.ts +++ b/test/runtime_coroutines.test.ts @@ -3,6 +3,7 @@ import { delimiter, join } from 'node:path'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { pathToFileURL } from 'node:url'; import { EventEmitter } from 'node:events'; +import type { ChildProcess } from 'node:child_process'; import { NodeBridge } from '../src/runtime/node.js'; import { PyodideTransport } from '../src/runtime/pyodide-transport.js'; import { PooledTransport } from '../src/runtime/pooled-transport.js'; @@ -158,6 +159,68 @@ nodeSuite('Subprocess retirement boundary', () => { }, 15000); }); +nodeSuite('Subprocess retirement reaps the child', () => { + it('lets a responsive bridge exit without SIGKILL', async () => { + const transport = new SubprocessTransport({ + bridgeScript: 'runtime/python_bridge.py', + pythonPath: PYTHON ?? 'python3', + }); + try { + await transport.init(); + const child = (transport as unknown as { process: ChildProcess }).process; + const kill = vi.spyOn(child, 'kill'); + await transport.dispose(); + expect(kill).not.toHaveBeenCalledWith('SIGKILL'); + expect(child.exitCode != null || child.signalCode != null).toBe(true); + } finally { + await transport.dispose(); + } + }, 10_000); + + it.skipIf(process.platform === 'win32')( + 'forces exit when Python ignores SIGTERM', + async () => { + const transport = new SubprocessTransport({ + bridgeScript: join(process.cwd(), 'test/fixtures/python/ignore_sigterm_bridge.py'), + pythonPath: PYTHON ?? 'python3', + }); + let child: ChildProcess | undefined; + try { + await transport.init(); + const internals = transport as unknown as { + process: ChildProcess; + stderrBuffer: string; + }; + child = internals.process; + await vi.waitFor(() => expect(internals.stderrBuffer).toContain('READY'), { + timeout: 5000, + }); + const kill = vi.spyOn(child, 'kill'); + + await expect(transport.send(request, 250)).rejects.toBeInstanceOf(BridgeTimeoutError); + await transport.dispose(); + + expect(kill).toHaveBeenCalledWith('SIGTERM'); + expect(kill).toHaveBeenCalledWith('SIGKILL'); + expect(child.signalCode).toBe('SIGKILL'); + } finally { + if (child && child.exitCode == null && child.signalCode == null) { + child.kill('SIGKILL'); + await new Promise(resolve => { + const timer = setTimeout(resolve, 1000); + child?.once('exit', () => { + clearTimeout(timer); + resolve(); + }); + }); + } + await transport.dispose(); + } + }, + 10_000 + ); +}); + describe('Subprocess partial-write abort', () => { it('retires after the first frame, independent of timeout tombstones', async () => { const controller = new AbortController(); From 6b56a968dd4ffc4b46d661452f018dd138853a32 Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 01:15:32 -0700 Subject: [PATCH 4/7] style(runtime): satisfy shutdown lint rules --- src/runtime/subprocess-transport.ts | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/runtime/subprocess-transport.ts b/src/runtime/subprocess-transport.ts index e6cbdb94..c92d25ff 100644 --- a/src/runtime/subprocess-transport.ts +++ b/src/runtime/subprocess-transport.ts @@ -733,20 +733,22 @@ export class SubprocessTransport extends DisposableBase implements Transport { proc.stdout?.removeAllListeners(); proc.stderr?.removeAllListeners(); - const hasExited = (): boolean => proc.exitCode != null || proc.signalCode != null; + const hasExited = (): boolean => proc.exitCode !== null || proc.signalCode !== null; if (hasExited()) { return; } await new Promise((resolve, reject) => { - let graceTimer: ReturnType | undefined; - let forceTimer: ReturnType | undefined; + const timers: { + grace?: ReturnType; + force?: ReturnType; + } = {}; const finish = (error?: Error): void => { - if (graceTimer) { - clearTimeout(graceTimer); + if (timers.grace) { + clearTimeout(timers.grace); } - if (forceTimer) { - clearTimeout(forceTimer); + if (timers.force) { + clearTimeout(timers.force); } proc.removeListener('exit', onExit); if (error) { @@ -771,7 +773,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { // The force timer below still bounds shutdown. } } - graceTimer = setTimeout(() => { + timers.grace = setTimeout(() => { if (hasExited()) { finish(); return; @@ -781,7 +783,7 @@ export class SubprocessTransport extends DisposableBase implements Transport { } catch { // Report an unreaped child after the force deadline. } - forceTimer = setTimeout(() => { + timers.force = setTimeout(() => { if (hasExited()) { finish(); } else { From b380418c01aa678ea87812415f6f2b1f9fd40ded Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 01:23:24 -0700 Subject: [PATCH 5/7] fix(runtime): fence pool replacement until child cleanup succeeds --- docs/maintainers/coroutines.md | 54 +++++++------ src/runtime/pooled-transport.ts | 117 +++++++++++++++++++++++----- src/runtime/subprocess-transport.ts | 40 ++++++++-- test/runtime_coroutines.test.ts | 97 ++++++++++++++++++++++- 4 files changed, 256 insertions(+), 52 deletions(-) diff --git a/docs/maintainers/coroutines.md b/docs/maintainers/coroutines.md index bcf0bdb6..80b801e1 100644 --- a/docs/maintainers/coroutines.md +++ b/docs/maintainers/coroutines.md @@ -1,8 +1,8 @@ # Coroutine calls A `call` request returns one result or one error envelope. The bridge awaits a -Python awaitable before it serializes the result. It rejects generators and async -generators. It does not expose tasks, streams, retries, or cached results. +Python awaitable before it serializes the result. It rejects generators and +async generators. It does not expose tasks, streams, retries, or cached results. ## Node subprocess @@ -22,30 +22,34 @@ process for later calls. Replacement runs the configured worker warmup. Python state held only in the retired process is lost. A request that times out before its first write does not retire the process. -Retirement sends SIGTERM first. If the child stays alive, it sends SIGKILL -after one second and waits for the child to exit. A failed kill blocks -replacement of that process generation. +Retirement sends SIGTERM first. If the child stays alive, it sends SIGKILL after +one second and waits for the child to exit. A failed kill blocks replacement of +that process generation. The pool keeps the worker slot until disposal succeeds. +If cleanup fails, the pool rejects new leases and keeps the child handle for a +later disposal retry. -Disposal kills the process and rejects pending requests. The caller receives -one timeout, abort, or disposal error. A retired process cannot send a late -response into a replacement process. A timeout does not cancel Python code in -place; process retirement stops code that does not cooperate with cancellation. +Disposal kills the process and rejects pending requests. The caller receives one +timeout, abort, or disposal error. A retired process cannot send a late response +into a replacement process. A timeout does not cancel Python code in place; +process retirement stops code that does not cooperate with cancellation. ## Pyodide -Pyodide owns its [WebLoop](https://pyodide.org/en/0.28.1/usage/api/python-api/webloop.html). -The bootstrap registers a task by request ID before it returns the task to -JavaScript. The transport awaits the task and destroys its Pyodide proxies -after the task settles. Timeout, abort, and disposal call `Task.cancel()` for -that request. The task map removes settled and cancelled tasks. - -Cancellation is cooperative. Python receives `CancelledError` at an await. -The caller receives one timeout, abort, or disposal error. The transport -consumes a late task rejection without sending another response. - -CPU-bound Python on the browser thread can block both the JavaScript timer -and the Pyodide event loop. This transport cannot stop it at a deadline. -Pyodide documents [interrupt buffers](https://pyodide.org/en/0.28.1/usage/api/js-api.html#pyodide.setInterruptBuffer) -for web workers. The bridge does not install an interrupt buffer or terminate -a worker. Host applications must manage their own worker lifecycle if they -need that limit. +Pyodide owns its +[WebLoop](https://pyodide.org/en/0.28.1/usage/api/python-api/webloop.html). The +bootstrap registers a task by request ID before it returns the task to +JavaScript. The transport awaits the task and destroys its Pyodide proxies after +the task settles. Timeout, abort, and disposal call `Task.cancel()` for that +request. The task map removes settled and cancelled tasks. + +Cancellation is cooperative. Python receives `CancelledError` at an await. The +caller receives one timeout, abort, or disposal error. The transport consumes a +late task rejection without sending another response. + +CPU-bound Python on the browser thread can block both the JavaScript timer and +the Pyodide event loop. This transport cannot stop it at a deadline. Pyodide +documents +[interrupt buffers](https://pyodide.org/en/0.28.1/usage/api/js-api.html#pyodide.setInterruptBuffer) +for web workers. The bridge does not install an interrupt buffer or terminate a +worker. Host applications must manage their own worker lifecycle if they need +that limit. diff --git a/src/runtime/pooled-transport.ts b/src/runtime/pooled-transport.ts index c17ac6f6..58ee10b0 100644 --- a/src/runtime/pooled-transport.ts +++ b/src/runtime/pooled-transport.ts @@ -120,6 +120,10 @@ export class PooledTransport extends DisposableBase implements Transport { }; private readonly workers: TransportLease[] = []; private readonly waitQueue: QueuedWaiter[] = []; + private readonly retiringWorkers = new Set(); + private readonly retirements = new Set>(); + private retirementError?: Error; + private disposalInFlight?: Promise; /** Tracks workers being created to prevent race condition in acquire() */ private pendingCreations = 0; private cachedCapabilities?: TransportCapabilities; @@ -192,6 +196,24 @@ export class PooledTransport extends DisposableBase implements Transport { await this.fillToMinimumWorkers(); } + /** Retry retained worker cleanup after a failed disposal. */ + override dispose(): Promise { + if (this.disposalInFlight) { + return this.disposalInFlight; + } + const disposal = this.isDisposed ? this.retryRetiringWorkers() : super.dispose(); + this.disposalInFlight = disposal; + disposal.then( + () => { + this.disposalInFlight = undefined; + }, + () => { + this.disposalInFlight = undefined; + } + ); + return disposal; + } + /** * Dispose the pool and all workers. * @@ -207,17 +229,21 @@ export class PooledTransport extends DisposableBase implements Transport { } this.waitQueue.length = 0; - // Dispose all workers - const errors: Error[] = []; - for (const worker of this.workers) { + await Promise.all(this.retirements); + + // Retry cleanup for workers whose first disposal failed. + const errors = await this.cleanupRetiringWorkers(); + + // Dispose all active workers. + const activeWorkers = this.workers.splice(0); + for (const worker of activeWorkers) { try { await worker.transport.dispose(); } catch (e) { errors.push(e instanceof Error ? e : new Error(String(e))); + this.retiringWorkers.add(worker); } } - this.workers.length = 0; - // Report errors if any if (errors.length === 1) { throw errors[0]; @@ -244,6 +270,9 @@ export class PooledTransport extends DisposableBase implements Transport { * @throws BridgeExecutionError if pool is disposed while waiting */ async acquire(): Promise { + if (this.retirementError) { + throw this.retirementError; + } // Check for disposed state if (this.isDisposed || this.state === 'disposing') { throw new BridgeExecutionError('Pool has been disposed'); @@ -260,7 +289,10 @@ export class PooledTransport extends DisposableBase implements Transport { // Include pendingCreations to prevent race condition where multiple // concurrent acquire() calls all pass the length check before any // worker is actually added to the array - if (this.workers.length + this.pendingCreations < this.options.maxWorkers) { + if ( + this.workers.length + this.retiringWorkers.size + this.pendingCreations < + this.options.maxWorkers + ) { this.pendingCreations++; try { const newWorker = await this.createWorker(); @@ -377,20 +409,62 @@ export class PooledTransport extends DisposableBase implements Transport { * Remove a worker from the pool. * * This is called when a worker is detected as dead (crashed, pipe error, etc.). - * The worker's transport is disposed in the background. + * Hold its slot until disposal succeeds. Failed disposal blocks new leases. */ private removeWorker(worker: TransportLease): void { + if (this.state === 'disposing' || this.isDisposed) { + return; + } const index = this.workers.indexOf(worker); if (index !== -1) { this.workers.splice(index, 1); - // Dispose transport in background - don't await to avoid blocking - worker.transport.dispose().catch(() => { - // Ignore disposal errors for dead workers - }); - if (this.state === 'ready') { - this.scheduleReplacementWorker(); + this.retiringWorkers.add(worker); + const retirement = Promise.resolve() + .then(() => worker.transport.dispose()) + .then( + () => { + this.retiringWorkers.delete(worker); + if (this.state === 'ready') { + this.scheduleReplacementWorker(); + } + }, + error => { + this.retirementError = error instanceof Error ? error : new Error(String(error)); + for (const waiter of this.waitQueue) { + clearTimeout(waiter.timer); + waiter.reject(this.retirementError); + } + this.waitQueue.length = 0; + } + ) + .finally(() => { + this.retirements.delete(retirement); + }); + this.retirements.add(retirement); + } + } + + private async cleanupRetiringWorkers(): Promise { + const errors: Error[] = []; + for (const worker of this.retiringWorkers) { + try { + await worker.transport.dispose(); + this.retiringWorkers.delete(worker); + } catch (e) { + errors.push(e instanceof Error ? e : new Error(String(e))); } } + return errors; + } + + private async retryRetiringWorkers(): Promise { + const errors = await this.cleanupRetiringWorkers(); + if (errors.length === 1) { + throw errors[0]; + } + if (errors.length > 1) { + throw new AggregateError(errors, 'Multiple errors during worker disposal'); + } } // =========================================================================== @@ -430,11 +504,15 @@ export class PooledTransport extends DisposableBase implements Transport { } private getMinimumWorkerDeficit(): number { - return Math.max(0, this.options.minWorkers - (this.workers.length + this.pendingCreations)); + return Math.max( + 0, + this.options.minWorkers - + (this.workers.length + this.retiringWorkers.size + this.pendingCreations) + ); } private isShuttingDown(): boolean { - return this.isDisposed || this.state === 'disposing'; + return this.isDisposed || this.state === 'disposing' || this.retirementError !== undefined; } private async fillToMinimumWorkers(): Promise { @@ -458,10 +536,13 @@ export class PooledTransport extends DisposableBase implements Transport { * the full worker cold-start penalty after a timeout or crash. */ private scheduleReplacementWorker(): void { - if (this.state !== 'ready') { + if (this.state !== 'ready' || this.retirementError) { return; } - if (this.workers.length + this.pendingCreations >= this.options.maxWorkers) { + if ( + this.workers.length + this.retiringWorkers.size + this.pendingCreations >= + this.options.maxWorkers + ) { return; } @@ -535,7 +616,7 @@ export class PooledTransport extends DisposableBase implements Transport { throw error; } - if (this.state === 'disposing' || this.state === 'disposed') { + if (this.state === 'disposing' || this.state === 'disposed' || this.retirementError) { await transport.dispose().catch(() => { // Ignore disposal failures if the pool was torn down mid-creation. }); diff --git a/src/runtime/subprocess-transport.ts b/src/runtime/subprocess-transport.ts index c92d25ff..6b5d84f7 100644 --- a/src/runtime/subprocess-transport.ts +++ b/src/runtime/subprocess-transport.ts @@ -266,6 +266,8 @@ export class SubprocessTransport extends DisposableBase implements Transport { private process: ChildProcess | null = null; private processExited = false; private processError: Error | null = null; + private readonly shutdownHandlers = new WeakSet(); + private disposalInFlight?: Promise; // Stream buffers private stdoutBuffer = ''; @@ -586,6 +588,27 @@ export class SubprocessTransport extends DisposableBase implements Transport { await this.spawnProcess(); } + /** Retry cleanup if the first disposal could not reap the child. */ + override dispose(): Promise { + if (this.disposalInFlight) { + return this.disposalInFlight; + } + const disposal = + this.isDisposed && this.process + ? this.withDispatchMutex(() => this.killProcess()) + : super.dispose(); + this.disposalInFlight = disposal; + disposal.then( + () => { + this.disposalInFlight = undefined; + }, + () => { + this.disposalInFlight = undefined; + } + ); + return disposal; + } + /** * Dispose the transport by killing the Python process. */ @@ -714,11 +737,14 @@ export class SubprocessTransport extends DisposableBase implements Transport { // Add a catch-all error handler to prevent uncaught exceptions during shutdown // This must be added BEFORE removing other listeners and ending stdin - const noopErrorHandler = (): void => { - // Ignore errors during shutdown (e.g., EPIPE) - }; - proc.stdin?.on('error', noopErrorHandler); - proc.on('error', noopErrorHandler); + if (!this.shutdownHandlers.has(proc)) { + const noopErrorHandler = (): void => { + // Ignore errors during shutdown (e.g., EPIPE) + }; + proc.stdin?.on('error', noopErrorHandler); + proc.on('error', noopErrorHandler); + this.shutdownHandlers.add(proc); + } // Gracefully end stdin to prevent EPIPE on pending writes try { @@ -733,7 +759,9 @@ export class SubprocessTransport extends DisposableBase implements Transport { proc.stdout?.removeAllListeners(); proc.stderr?.removeAllListeners(); - const hasExited = (): boolean => proc.exitCode !== null || proc.signalCode !== null; + const hasExited = (): boolean => + (proc.exitCode !== null && proc.exitCode !== undefined) || + (proc.signalCode !== null && proc.signalCode !== undefined); if (hasExited()) { return; } diff --git a/test/runtime_coroutines.test.ts b/test/runtime_coroutines.test.ts index 267dd928..d130694b 100644 --- a/test/runtime_coroutines.test.ts +++ b/test/runtime_coroutines.test.ts @@ -14,6 +14,7 @@ import { clearRuntimeBridge, setRuntimeBridge } from '../src/runtime/index.js'; import { BridgeDisposedError, BridgeExecutionError, + BridgeProtocolError, BridgeTimeoutError, } from '../src/runtime/errors.js'; import { generate } from '../src/tywrap.js'; @@ -221,6 +222,52 @@ nodeSuite('Subprocess retirement reaps the child', () => { ); }); +describe('Subprocess cleanup retry', () => { + it('retains a child handle when kill fails and retries after disposal', async () => { + vi.useFakeTimers(); + let canExit = false; + const child = Object.assign(new EventEmitter(), { + stdin: Object.assign(new EventEmitter(), { end: () => undefined }), + stdout: new EventEmitter(), + stderr: new EventEmitter(), + exitCode: null as number | null, + signalCode: null as NodeJS.Signals | null, + killed: false, + kill: vi.fn((signal: NodeJS.Signals) => { + child.killed = true; + if (canExit && signal === 'SIGKILL') { + child.signalCode = 'SIGKILL'; + queueMicrotask(() => child.emit('exit', null, 'SIGKILL')); + } + return true; + }), + }); + const transport = new SubprocessTransport({ bridgeScript: 'unused' }); + const internals = transport as unknown as { _state: 'ready'; process: unknown }; + internals._state = 'ready'; + internals.process = child; + try { + const first = transport.dispose(); + expect(transport.dispose()).toBe(first); + await vi.advanceTimersByTimeAsync(2100); + await expect(first).rejects.toBeInstanceOf(BridgeProtocolError); + expect(transport.isDisposed).toBe(true); + expect(internals.process).toBe(child); + expect(child.kill).toHaveBeenCalledWith('SIGTERM'); + expect(child.kill).toHaveBeenCalledWith('SIGKILL'); + + canExit = true; + const retry = transport.dispose(); + await vi.advanceTimersByTimeAsync(1100); + await expect(retry).resolves.toBeUndefined(); + expect(internals.process).toBeNull(); + expect(child.signalCode).toBe('SIGKILL'); + } finally { + vi.useRealTimers(); + } + }); +}); + describe('Subprocess partial-write abort', () => { it('retires after the first frame, independent of timeout tombstones', async () => { const controller = new AbortController(); @@ -315,7 +362,10 @@ describe('RPC default timeout', () => { }); describe('Retired worker leases', () => { - function createFakePool(onReplacementWorkerReady?: () => Promise) { + function createFakePool( + onReplacementWorkerReady?: () => Promise, + firstDispose?: () => Promise + ) { let created = 0; const disposals: Array> = []; const pool = new PooledTransport({ @@ -324,7 +374,7 @@ describe('Retired worker leases', () => { onReplacementWorkerReady, createTransport: () => { const index = created++; - const dispose = vi.fn(async () => undefined); + const dispose = vi.fn(index === 0 && firstDispose ? firstDispose : async () => undefined); disposals.push(dispose); const worker: Transport & { requiresReplacement: boolean } = { requiresReplacement: false, @@ -370,6 +420,45 @@ describe('Retired worker leases', () => { } }); + it('holds the worker slot until retired cleanup succeeds', async () => { + const gate = deferred(); + const { pool, count } = createFakePool(undefined, () => gate.promise); + try { + await pool.init(); + await expect(pool.send(request, 100)).rejects.toBeInstanceOf(BridgeTimeoutError); + const next = pool.send(request, 100); + expect(count()).toBe(1); + expect(pool.workerCount).toBe(0); + + gate.resolve(undefined); + await expect(next).resolves.toBe(response); + expect(count()).toBe(2); + } finally { + gate.resolve(undefined); + await pool.dispose(); + } + }); + + it('blocks replacement after failed cleanup and retains a retryable worker', async () => { + const cleanupError = new BridgeProtocolError('Python process did not exit after SIGKILL'); + let attempts = 0; + const { pool, disposals, count } = createFakePool(undefined, async () => { + attempts++; + if (attempts < 3) { + throw cleanupError; + } + }); + await pool.init(); + await expect(pool.send(request, 100)).rejects.toBeInstanceOf(BridgeTimeoutError); + await expect(pool.send(request, 100)).rejects.toBe(cleanupError); + expect(count()).toBe(1); + expect(pool.workerCount).toBe(0); + await expect(pool.dispose()).rejects.toBe(cleanupError); + expect(disposals[0]).toHaveBeenCalledTimes(2); + await expect(pool.dispose()).resolves.toBeUndefined(); + expect(disposals[0]).toHaveBeenCalledTimes(3); + }); + it('does not publish a replacement after disposal', async () => { const started = deferred(); const gate = deferred(); @@ -380,7 +469,9 @@ describe('Retired worker leases', () => { await pool.init(); await expect(pool.send(request, 100)).rejects.toBeInstanceOf(BridgeTimeoutError); await started.promise; - await pool.dispose(); + const closing = pool.dispose(); + expect(pool.dispose()).toBe(closing); + await closing; gate.resolve(undefined); await vi.waitFor(() => expect(disposals[1]).toHaveBeenCalledTimes(1)); expect(pool.workerCount).toBe(0); From 8879171fab36d8e285f3cad4e4ae9a8aee699b6e Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 01:26:21 -0700 Subject: [PATCH 6/7] test(pool): wait for retired cleanup before replacement --- test/transport-pool.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/transport-pool.test.ts b/test/transport-pool.test.ts index cef32f12..2350360d 100644 --- a/test/transport-pool.test.ts +++ b/test/transport-pool.test.ts @@ -748,6 +748,7 @@ describe('PooledTransport', () => { removeWorker(worker: TransportLease): void; } ).removeWorker(worker); + await vi.waitFor(() => expect(transports).toHaveLength(2)); await pool.dispose(); await new Promise(resolve => setTimeout(resolve, 100)); From 11a1db782e3575158e7b0d0302d2a2a47b55db3d Mon Sep 17 00:00:00 2001 From: Brett Bonner Date: Sun, 20 Sep 2026 01:28:42 -0700 Subject: [PATCH 7/7] fix(pool): retain failed cleanup from unpublished workers --- src/runtime/pooled-transport.ts | 54 +++++++++++++++++-------- test/transport-pool.test.ts | 72 +++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+), 16 deletions(-) diff --git a/src/runtime/pooled-transport.ts b/src/runtime/pooled-transport.ts index 58ee10b0..05b0b629 100644 --- a/src/runtime/pooled-transport.ts +++ b/src/runtime/pooled-transport.ts @@ -429,12 +429,7 @@ export class PooledTransport extends DisposableBase implements Transport { } }, error => { - this.retirementError = error instanceof Error ? error : new Error(String(error)); - for (const waiter of this.waitQueue) { - clearTimeout(waiter.timer); - waiter.reject(this.retirementError); - } - this.waitQueue.length = 0; + this.recordCleanupFailure(error); } ) .finally(() => { @@ -467,6 +462,26 @@ export class PooledTransport extends DisposableBase implements Transport { } } + private recordCleanupFailure(error: unknown): Error { + const failure = error instanceof Error ? error : new Error(String(error)); + this.retirementError = failure; + for (const waiter of this.waitQueue) { + clearTimeout(waiter.timer); + waiter.reject(failure); + } + this.waitQueue.length = 0; + return failure; + } + + private async disposeUnpublishedWorker(worker: TransportLease): Promise { + try { + await worker.transport.dispose(); + } catch (error) { + this.retiringWorkers.add(worker); + throw this.recordCleanupFailure(error); + } + } + // =========================================================================== // POOL STATISTICS // =========================================================================== @@ -594,15 +609,22 @@ export class PooledTransport extends DisposableBase implements Transport { */ private async createWorker(onWorkerReady = this.options.onWorkerReady): Promise { const transport = this.options.createTransport(); - - // Initialize the transport - await transport.init(); - const worker: TransportLease = { transport, inFlightCount: 0, }; + try { + await transport.init(); + } catch (error) { + try { + await this.disposeUnpublishedWorker(worker); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Worker init and cleanup failed'); + } + throw error; + } + try { // Call onWorkerReady callback if provided if (onWorkerReady) { @@ -610,16 +632,16 @@ export class PooledTransport extends DisposableBase implements Transport { } } catch (error) { // Ensure partially initialized workers do not leak when warmup fails. - await transport.dispose().catch(() => { - // Ignore disposal failures during warmup failure handling. - }); + try { + await this.disposeUnpublishedWorker(worker); + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Worker warmup and cleanup failed'); + } throw error; } if (this.state === 'disposing' || this.state === 'disposed' || this.retirementError) { - await transport.dispose().catch(() => { - // Ignore disposal failures if the pool was torn down mid-creation. - }); + await this.disposeUnpublishedWorker(worker); throw new BridgeExecutionError('Pool disposed during worker creation'); } diff --git a/test/transport-pool.test.ts b/test/transport-pool.test.ts index 2350360d..4c523e9c 100644 --- a/test/transport-pool.test.ts +++ b/test/transport-pool.test.ts @@ -780,6 +780,78 @@ describe('PooledTransport', () => { expect(transports).toHaveLength(1); expect(transports[0]?.disposeCalled).toBe(true); }); + + it('retains a late worker when its cleanup fails after pool disposal', async () => { + let releaseInit!: () => void; + const initGate = new Promise(resolve => { + releaseInit = resolve; + }); + const cleanupError = new BridgeProtocolError('Late worker cleanup failed'); + const transport = new MockTransport(); + let disposalAttempts = 0; + transport.init = async () => { + await initGate; + transport.initCalled = true; + }; + transport.dispose = async () => { + disposalAttempts++; + if (disposalAttempts === 1) { + throw cleanupError; + } + transport.disposeCalled = true; + }; + pool = new PooledTransport({ createTransport: () => transport, maxWorkers: 1 }); + + await pool.init(); + const acquisition = pool.acquire().then( + () => undefined, + error => error as Error + ); + await pool.dispose(); + releaseInit(); + await expect(acquisition).resolves.toBe(cleanupError); + expect(disposalAttempts).toBe(1); + await expect(pool.dispose()).resolves.toBeUndefined(); + expect(disposalAttempts).toBe(2); + expect(transport.disposeCalled).toBe(true); + }); + + it.each(['init', 'warmup'] as const)( + 'retains an unpublished worker after failed %s cleanup', + async stage => { + const transport = new MockTransport(); + let disposalAttempts = 0; + const failure = new Error(`${stage} failed`); + if (stage === 'init') { + transport.init = async () => { + throw failure; + }; + } + transport.dispose = async () => { + disposalAttempts++; + if (disposalAttempts === 1) { + throw new BridgeProtocolError('Worker cleanup failed'); + } + transport.disposeCalled = true; + }; + pool = new PooledTransport({ + createTransport: () => transport, + minWorkers: 1, + maxWorkers: 1, + onWorkerReady: async () => { + if (stage === 'warmup') { + throw failure; + } + }, + }); + + await expect(pool.init()).rejects.toThrow(); + expect(disposalAttempts).toBe(1); + await expect(pool.dispose()).resolves.toBeUndefined(); + expect(disposalAttempts).toBe(2); + expect(transport.disposeCalled).toBe(true); + } + ); }); // ===========================================================================