Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/maintainers/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
55 changes: 55 additions & 0 deletions docs/maintainers/coroutines.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# 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.

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.

## 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.
117 changes: 113 additions & 4 deletions runtime/tywrap_bridge_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)


Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading