From cf3ad371c929aae9e444ff0b9a454bcbcd429112 Mon Sep 17 00:00:00 2001 From: Ben Creech Date: Thu, 25 Dec 2025 20:15:17 -0500 Subject: [PATCH] Simplify async interface --- HISTORY.md | 11 + README.md | 122 +++- pyproject.toml | 2 +- src/py_mini_racer/__about__.py | 2 +- src/py_mini_racer/__init__.py | 5 +- src/py_mini_racer/_context.py | 655 ++++++++++----------- src/py_mini_racer/_dll.py | 8 +- src/py_mini_racer/_exc.py | 7 + src/py_mini_racer/_js_value_manipulator.py | 65 -- src/py_mini_racer/_mini_racer.py | 206 ++++++- src/py_mini_racer/_objects.py | 376 ++++++++---- src/py_mini_racer/_types.py | 43 +- src/py_mini_racer/_wrap_py_function.py | 105 ---- src/v8_py_frontend/callback.h | 5 +- src/v8_py_frontend/context.cc | 27 +- src/v8_py_frontend/context.h | 8 +- src/v8_py_frontend/context_factory.cc | 2 +- src/v8_py_frontend/context_factory.h | 2 +- src/v8_py_frontend/exports.cc | 18 +- src/v8_py_frontend/exports.h | 23 +- src/v8_py_frontend/js_callback_maker.cc | 4 +- src/v8_py_frontend/js_callback_maker.h | 7 +- tests/gc_check.py | 40 +- tests/test_eval.py | 103 ++-- tests/test_functions.py | 23 +- tests/test_heap.py | 9 + tests/test_init.py | 4 +- tests/test_py_js_function.py | 118 ++-- uv.lock | 2 +- 29 files changed, 1146 insertions(+), 856 deletions(-) delete mode 100644 src/py_mini_racer/_js_value_manipulator.py delete mode 100644 src/py_mini_racer/_wrap_py_function.py diff --git a/HISTORY.md b/HISTORY.md index 9676ade9..10d1f6dc 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,16 @@ # History +## 0.14 (2026-01-03) + +- Major revamp of Python-side async handling: `PyMiniRacer` now manages most + asynchronous work (in particular, _cancelable_ work) through an `asyncio` event loop. + This is intended to make `PyMiniRacer` easier to reason about, removing opportunities + for deadlocks and race conditions. +- We expose new async methods for function evaluation which unblock the event loop for + long calculations while honoring standard `asyncio` task cancellation semantics. +- To improve determinism during teardown, we expose and strongly recomment a new context + manager, `mini_racer` to more explicitly create and tear down `MiniRacer` instances. + ## 0.13.2 (2025-12-25) - Improve performance of function calls by exposing Array.prototype.push (avoiding two diff --git a/README.md b/README.md index ca7e0004..794d648d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Minimal, modern embedded V8 for Python. [Full documentation](https://bpcreech.com/PyMiniRacer/). -## Features +## In brief - Latest ECMAScript support - Web Assembly support @@ -19,18 +19,13 @@ Minimal, modern embedded V8 for Python. MiniRacer can be easily used by Django or Flask projects to minify assets, run babel or WASM modules. -## New home! (As of March 2024) - PyMiniRacer was created by [Sqreen](https://github.com/sqreen), and originally lived at with the PyPI package -[`py-mini-racer`](https://pypi.org/project/py-mini-racer/). - -As of March 2024, after a few years without updates, [I](https://bpcreech.com) have -reached out to the original Sqreen team. We agreed that I should fork PyMiniRacer, -giving it a new home at with a new PyPI -package [`mini-racer`](https://pypi.org/project/mini-racer/) (_note: no `py-`_). It now -has [a new version](https://bpcreech.com/PyMiniRacer/history/#070-2024-03-06) for the -first time since 2021! +[`py-mini-racer`](https://pypi.org/project/py-mini-racer/). After dicussion with the +original Sqreen team, [I](https://bpcreech.com) have created a new official home for at + with a new PyPI package +[`mini-racer`](https://pypi.org/project/mini-racer/) (_note: no `py-`_). See +[the full history](https://bpcreech.com/PyMiniRacer/history) for more. ## Examples @@ -132,6 +127,28 @@ MiniRacer is ES6 capable: False ``` +JavaScript `null` and `undefined` are modeled in Python as `None` and `JSUndefined`, +respectively: + +```python + >>> list(ctx.eval("[null, undefined]")) + [None, JSUndefined] +``` + +You can prevent runaway execution in synchronous code using the `timeout_sec` parameter: + +```python + >>> ctx.eval('while (true) {}', timeout_sec=2) + # Spins for 2 seconds and then emits a traceback ending with... + raise JSTimeoutException from e + py_mini_racer._exc.JSTimeoutException: JavaScript was terminated by timeout + >>> func = ctx.eval('() => {while (true) {}}') + >>> func(timeout_sec=2) + # Spins for 2 seconds and then emits a traceback ending with... + raise JSTimeoutException from e + py_mini_racer._exc.JSTimeoutException: JavaScript was terminated by timeout +``` + MiniRacer supports asynchronous execution using JS `Promise` instances (_new in v0.10.0_): @@ -142,43 +159,90 @@ v0.10.0_): 42 ``` -You can use JS `Promise` instances with Python `async` (_new in v0.10.0_): +For more deterministic cleanup behavior, we strongly recommend allocating a MiniRacer +from a context manager (_new in v0.14.0_): ```python - >>> import asyncio + >>> from py_mini_racer import mini_racer + >>> with mini_racer() as ctx: + ... print(ctx.eval("Array.from('foobar').reverse().join('')")) + raboof +``` + +MiniRacer uses `asyncio` internally to manage V8. Both `MiniRacer()` and the +`mini_racer()` context manager will capture the currently-running event loop, or you can +specify a loop explicitly, and in non-async contexts, `MiniRacer` will launch its own +event loop with its own background thread to service it. (_new in v0.14.0_) + +```python + >>> from py_mini_racer import MiniRacer, mini_racer + >>> ctx = MiniRacer() # launches a new event loop in a new thread + >>> with mini_racer() as ctx: # same: launches a new event loop in a new thread + ... pass + ... >>> async def demo(): + ... with mini_racer() as ctx: # reuses the running event loop + ... pass + ... + >>> import asyncio + >>> asyncio.run(demo()) + >>> my_loop = asyncio.new_event_loop() + >>> with mini_racer(my_loop) as ctx: # uses the specified event loop + ... pass +``` + +When calling into MiniRacer from async code, you must await promises using `await` +(instead of `promise.get()`): + +```python + % python -m asyncio + >>> from py_mini_racer import mini_racer + >>> with mini_racer() as ctx: ... promise = ctx.eval( ... "new Promise((res, rej) => setTimeout(() => res(42), 10000))") - ... return await promise + ... print(await promise) # yields for 10 seconds, and then: ... - >>> asyncio.run(demo()) # blocks for 10 seconds, and then: 42 ``` -JavaScript `null` and `undefined` are modeled in Python as `None` and `JSUndefined`, -respectively: +`MiniRacer` does not support the `timeout_sec` parameter in async evaluation. Instead +request a cancelable evaluation and use a construct like `asyncio.wait_for`: ```python - >>> list(ctx.eval("[undefined, null]")) - [JSUndefined, None] + % python -m asyncio + >>> from py_mini_racer import mini_racer + >>> with mini_racer() as ctx: + ... # Use eval_cancelable(...), which has async semantics: + ... await asyncio.wait_for(ctx.eval_cancelable('while (true) {}'), timeout=2) + # Spins for 2 seconds and then emits a traceback ending with... + raise TimeoutError from exc_val + TimeoutError + >>> with mini_racer() as ctx: + ... func = ctx.eval('() => {while (true) {}}') + ... # Upgrade func using .cancelable(), which introduces async semantics: + ... cancelable_func = func.cancelable() + ... await asyncio.wait_for(cancelable_func(), timeout=2) + # Spins for 2 seconds and then emits a traceback ending with... + raise TimeoutError from exc_val + TimeoutError ``` -You can install callbacks from JavaScript to Python (_new in v0.12.0_): +You can install callbacks from JavaScript to Python (_new in v0.12.0_). Only async +callbacks are supported: ```python + % python -m asyncio + >>> from py_mini_racer import mini_racer >>> async def read_file(fn): ... with open(fn) as f: # (or aiofiles would be even better here) ... return f.read() ... - >>> async def get_dictionary(): - ... async with ctx.wrap_py_function(read_file) as jsfunc: - ... # "Install" our JS function on the global "this" object: - ... ctx.eval('this')['read_file'] = jsfunc - ... d = await ctx.eval('this.read_file("/usr/share/dict/words")') - ... return d.split() - ... - >>> dictionary = asyncio.run(get_dictionary()) - >>> print(dictionary[0:10]) + >>> with mini_racer() as ctx: + ... async with ctx.wrap_py_function(read_file) as jsfunc: + ... # "Install" our (async) JS function on the global "this" object: + ... ctx.eval('this')['read_file'] = jsfunc + ... d = await ctx.eval('read_file("/usr/share/dict/words")') + ... print(d.split()[0:10]) ['A', 'AA', 'AAA', "AA's", 'AB', 'ABC', "ABC's", 'ABCs', 'ABM', "ABM's"] ``` diff --git a/pyproject.toml b/pyproject.toml index d4637dc9..4ab76dad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ requires = ["setuptools>=80.9"] [project] name = "mini-racer" -version = "0.13.2" +version = "0.14.0" dynamic = ["readme"] description = "Minimal, modern embedded V8 for Python." license = "ISC" diff --git a/src/py_mini_racer/__about__.py b/src/py_mini_racer/__about__.py index 94d6fcf4..9c3e19bd 100644 --- a/src/py_mini_racer/__about__.py +++ b/src/py_mini_racer/__about__.py @@ -2,4 +2,4 @@ __author__ = "bpcreech" __email__ = "mini-racer@bpcreech.com" -__version__ = "0.13.2" +__version__ = "0.14.0" diff --git a/src/py_mini_racer/__init__.py b/src/py_mini_racer/__init__.py index 9fd157c0..68cc0ef8 100644 --- a/src/py_mini_racer/__init__.py +++ b/src/py_mini_racer/__init__.py @@ -16,8 +16,9 @@ JSTimeoutException, JSValueError, ) -from py_mini_racer._mini_racer import MiniRacer, StrictMiniRacer +from py_mini_racer._mini_racer import MiniRacer, StrictMiniRacer, mini_racer from py_mini_racer._types import ( + CancelableJSFunction, JSArray, JSFunction, JSMappedObject, @@ -32,6 +33,7 @@ __all__ = [ "DEFAULT_V8_FLAGS", + "CancelableJSFunction", "JSArray", "JSArrayIndexError", "JSEvalException", @@ -55,4 +57,5 @@ "PythonJSConvertedTypes", "StrictMiniRacer", "init_mini_racer", + "mini_racer", ] diff --git a/src/py_mini_racer/_context.py b/src/py_mini_racer/_context.py index 1844820f..36515de7 100644 --- a/src/py_mini_racer/_context.py +++ b/src/py_mini_racer/_context.py @@ -1,48 +1,32 @@ from __future__ import annotations -import ctypes -from concurrent.futures import Future as SyncFuture -from concurrent.futures import TimeoutError as SyncTimeoutError -from contextlib import contextmanager, suppress -from datetime import datetime, timezone -from itertools import count -from typing import TYPE_CHECKING, Any, ClassVar, cast - -from py_mini_racer._dll import init_mini_racer, mr_callback_func -from py_mini_racer._exc import ( - JSConversionException, - JSEvalException, - JSKeyError, - JSOOMException, - JSParseException, - JSTerminatedException, - JSTimeoutException, - JSValueError, -) -from py_mini_racer._js_value_manipulator import JSValueManipulator -from py_mini_racer._objects import ( - JSArrayImpl, - JSFunctionImpl, - JSMappedObjectImpl, - JSObjectImpl, - JSPromiseImpl, - JSSymbolImpl, -) +import asyncio +import queue +from contextlib import asynccontextmanager, contextmanager, suppress +from dataclasses import dataclass, field +from traceback import format_exc +from typing import TYPE_CHECKING, Any, NewType, Protocol, cast + +from py_mini_racer._dll import init_mini_racer +from py_mini_racer._exc import JSEvalException, JSPromiseError from py_mini_racer._types import ( + CancelableJSFunction, JSArray, JSFunction, + JSMappedObject, JSObject, JSPromise, JSUndefined, JSUndefinedType, + PyJsFunctionType, PythonJSConvertedTypes, ) from py_mini_racer._value_handle import ValueHandle if TYPE_CHECKING: - from collections.abc import Callable, Generator, Iterator, Sequence + import ctypes + from collections.abc import AsyncGenerator, Callable, Coroutine, Generator, Iterator - from py_mini_racer._dll import RawValueHandleTypeImpl from py_mini_racer._value_handle import RawValueHandleType @@ -53,140 +37,134 @@ def context_count() -> int: return int(dll.mr_context_count()) -class _ArrayBufferByte(ctypes.Structure): - # Cannot use c_ubyte directly because it uses None: - self._active_callbacks: dict[int, Callable[[ValueHandle], None]] = {} +@dataclass(frozen=True) +class _TaskSet: + """This is a very very simplistic standin for Python 3.11+ TaskGroups (whereas we + are still targeting Python 3.10).""" - # define an all-purpose callback: - @mr_callback_func - def mr_callback(callback_id: int, raw_val_handle: RawValueHandleType) -> None: - val_handle = raw_handle_wrapper(raw_val_handle) - callback = self._active_callbacks[callback_id] - callback(val_handle) + _event_loop: asyncio.AbstractEventLoop + _ongoing_tasks: set[asyncio.Task[PythonJSConvertedTypes]] - self.mr_callback = mr_callback + def start_task(self, coro: Coroutine[Any, Any, None]) -> None: + task = self._event_loop.create_task(coro) + self._ongoing_tasks.add(task) + task.add_done_callback(self._ongoing_tasks.discard) - self._next_callback_id = count() - @contextmanager - def register( - self, func: Callable[[ValueHandle], None] - ) -> Generator[int, None, None]: - callback_id = next(self._next_callback_id) +@asynccontextmanager +async def _make_task_set( + event_loop: asyncio.AbstractEventLoop, +) -> AsyncGenerator[_TaskSet, None]: + ongoing_tasks: set[asyncio.Task[PythonJSConvertedTypes]] = set() - self._active_callbacks[callback_id] = func + try: + yield _TaskSet(event_loop, ongoing_tasks) + finally: + for t in list(ongoing_tasks): + with suppress(asyncio.CancelledError): + t.cancel() + await t - try: - yield callback_id - finally: - self._active_callbacks.pop(callback_id) +class ObjectFactory(Protocol): + def value_handle_to_python( + self, ctx: Context, val_handle: ValueHandle + ) -> PythonJSConvertedTypes: ... + + def python_to_value_handle( + self, ctx: Context, obj: PythonJSConvertedTypes + ) -> ValueHandle: ... -class Context(JSValueManipulator): - """Wrapper for all operations involving the DLL and C++ MiniRacer::Context.""" - def __init__(self, dll: ctypes.CDLL) -> None: - self._dll: ctypes.CDLL | None = dll +def get_running_loop_or_none() -> asyncio.AbstractEventLoop | None: + try: + return asyncio.get_running_loop() + except RuntimeError: + return None - self._callback_registry = _CallbackRegistry(self._wrap_raw_handle) - self._ctx = dll.mr_init_context(self._callback_registry.mr_callback) - def _get_dll(self) -> ctypes.CDLL: - if self._dll is None: - msg = "Operation on closed Context" - raise ValueError(msg) +@dataclass(frozen=True) +class Context: + """Wrapper for all operations involving the DLL and C++ MiniRacer::Context.""" - return self._dll + _dll: ctypes.CDLL + _ctx: ContextType + event_loop: asyncio.AbstractEventLoop + _object_factory: ObjectFactory + _next_async_callback_id: Iterator[int] + _active_cancelable_mr_task_callbacks: dict[int, Callable[[ValueHandle], None]] = ( + field(default_factory=dict) + ) + _non_cancelable_mr_task_results_queue: queue.Queue[ValueHandle] = field( + default_factory=queue.Queue + ) def v8_version(self) -> str: - return str(self._get_dll().mr_v8_version().decode("utf-8")) + return str(self._dll.mr_v8_version().decode("utf-8")) def v8_is_using_sandbox(self) -> bool: """Checks for enablement of the V8 Sandbox. See https://v8.dev/blog/sandbox.""" - return bool(self._get_dll().mr_v8_is_using_sandbox()) + return bool(self._dll.mr_v8_is_using_sandbox()) - def evaluate( - self, code: str, timeout_sec: float | None = None - ) -> PythonJSConvertedTypes: + def handle_callback_from_v8( + self, callback_id: int, raw_val_handle: RawValueHandleType + ) -> None: + # Handle a callback from within the v8::Isolate. + # All work on the Isolate is blocked until this callback returns. That may + # may in turn be blocking incoming calls from Python, including other threads, + # asyncio event loops, etc. So we need to get out fast! + # We limit ourselves to wrapping the incoming handle (so we don't leak memory) + # and enqueing the incoming work for decoupled processing. + + val_handle = self._wrap_raw_handle(raw_val_handle) + + if callback_id == _UNCANCELABLE_TASK_CALLBACK_ID: + self._non_cancelable_mr_task_results_queue.put(val_handle) + else: + self.event_loop.call_soon_threadsafe( + self._handle_callback_from_v8_on_event_loop, callback_id, val_handle + ) + + def _handle_callback_from_v8_on_event_loop( + self, callback_id: int, val_handle: ValueHandle + ) -> None: + try: + callback = self._active_cancelable_mr_task_callbacks[callback_id] + except KeyError: + # Assume this callback was intentionally cancelled: + return + + callback(val_handle) + + @contextmanager + def _register_cancelable_mr_task_callback( + self, func: Callable[[ValueHandle], None] + ) -> Generator[int, None, None]: + callback_id = next(self._next_async_callback_id) + + self._active_cancelable_mr_task_callbacks[callback_id] = func + + try: + yield callback_id + finally: + self._active_cancelable_mr_task_callbacks.pop(callback_id) + + async def eval_cancelable(self, code: str) -> PythonJSConvertedTypes: code_handle = self._python_to_value_handle(code) - with self._run_mr_task( - self._get_dll().mr_eval, self._ctx, code_handle.raw - ) as future: - try: - return future.result(timeout=timeout_sec) - except SyncTimeoutError as e: - raise JSTimeoutException from e + return await self._run_cancelable_mr_task(self._dll.mr_eval, code_handle.raw) - def promise_then( - self, promise: JSPromise, on_resolved: JSFunction, on_rejected: JSFunction - ) -> None: + def eval(self, code: str) -> PythonJSConvertedTypes: + code_handle = self._python_to_value_handle(code) + + return self._run_uncancelable_mr_task(self._dll.mr_eval, code_handle.raw) + + async def await_promise(self, promise: JSPromise) -> PythonJSConvertedTypes: promise_handle = self._python_to_value_handle(promise) then_name_handle = self._python_to_value_handle("then") @@ -194,14 +172,44 @@ def promise_then( "JSFunction", self._value_handle_to_python( self._wrap_raw_handle( - self._get_dll().mr_get_object_item( + self._dll.mr_get_object_item( self._ctx, promise_handle.raw, then_name_handle.raw ) ) ), ) - then_func(on_resolved, on_rejected, this=promise) + future: asyncio.Future[PythonJSConvertedTypes] = self.event_loop.create_future() + + def on_resolved(val_handle: ValueHandle) -> None: + if future.cancelled(): + return + + future.set_result( + cast("JSArray", self._value_handle_to_python(val_handle))[0] + ) + + def on_rejected(val_handle: ValueHandle) -> None: + if future.cancelled(): + return + + value = cast("JSArray", self._value_handle_to_python(val_handle))[0] + if not isinstance(value, JSMappedObject): + msg = str(value) + elif "stack" in value: + msg = cast("str", value["stack"]) + else: + msg = str(value) + + future.set_exception(JSPromiseError(msg)) + + with ( + self._register_js_notification(on_resolved) as on_resolved_js_func, + self._register_js_notification(on_rejected) as on_rejected_js_func, + ): + then_func(on_resolved_js_func, on_rejected_js_func, this=promise) + + return await future def get_identity_hash(self, obj: JSObject) -> int: obj_handle = self._python_to_value_handle(obj) @@ -210,7 +218,7 @@ def get_identity_hash(self, obj: JSObject) -> int: "int", self._value_handle_to_python( self._wrap_raw_handle( - self._get_dll().mr_get_identity_hash(self._ctx, obj_handle.raw) + self._dll.mr_get_identity_hash(self._ctx, obj_handle.raw) ) ), ) @@ -222,7 +230,7 @@ def get_own_property_names( names = self._value_handle_to_python( self._wrap_raw_handle( - self._get_dll().mr_get_own_property_names(self._ctx, obj_handle.raw) + self._dll.mr_get_own_property_names(self._ctx, obj_handle.raw) ) ) if not isinstance(names, JSArray): @@ -237,9 +245,7 @@ def get_object_item( return self._value_handle_to_python( self._wrap_raw_handle( - self._get_dll().mr_get_object_item( - self._ctx, obj_handle.raw, key_handle.raw - ) + self._dll.mr_get_object_item(self._ctx, obj_handle.raw, key_handle.raw) ) ) @@ -253,7 +259,7 @@ def set_object_item( # Convert the value just to convert any exceptions (and GC the result) self._value_handle_to_python( self._wrap_raw_handle( - self._get_dll().mr_set_object_item( + self._dll.mr_set_object_item( self._ctx, obj_handle.raw, key_handle.raw, val_handle.raw ) ) @@ -266,9 +272,7 @@ def del_object_item(self, obj: JSObject, key: PythonJSConvertedTypes) -> None: # Convert the value just to convert any exceptions (and GC the result) self._value_handle_to_python( self._wrap_raw_handle( - self._get_dll().mr_del_object_item( - self._ctx, obj_handle.raw, key_handle.raw - ) + self._dll.mr_del_object_item(self._ctx, obj_handle.raw, key_handle.raw) ) ) @@ -278,9 +282,7 @@ def del_from_array(self, arr: JSArray, index: int) -> None: # Convert the value just to convert any exceptions (and GC the result) self._value_handle_to_python( self._wrap_raw_handle( - self._get_dll().mr_splice_array( - self._ctx, arr_handle.raw, index, 1, None - ) + self._dll.mr_splice_array(self._ctx, arr_handle.raw, index, 1, None) ) ) @@ -293,7 +295,7 @@ def array_insert( # Convert the value just to convert any exceptions (and GC the result) self._value_handle_to_python( self._wrap_raw_handle( - self._get_dll().mr_splice_array( + self._dll.mr_splice_array( self._ctx, arr_handle.raw, index, 0, new_val_handle.raw ) ) @@ -306,20 +308,41 @@ def array_push(self, arr: JSArray, new_val: PythonJSConvertedTypes) -> None: # Convert the value just to convert any exceptions (and GC the result) self._value_handle_to_python( self._wrap_raw_handle( - self._get_dll().mr_array_push( - self._ctx, arr_handle.raw, new_val_handle.raw - ) + self._dll.mr_array_push(self._ctx, arr_handle.raw, new_val_handle.raw) ) ) + def are_we_running_on_the_mini_racer_event_loop(self) -> bool: + return get_running_loop_or_none() is self.event_loop + + async def call_function_cancelable( + self, + func: CancelableJSFunction | JSFunction, + *args: PythonJSConvertedTypes, + this: JSObject | JSUndefinedType = JSUndefined, + ) -> PythonJSConvertedTypes: + argv = cast("JSArray", self.eval("[]")) + for arg in args: + argv.append(arg) + + func_handle = self._python_to_value_handle(func) + this_handle = self._python_to_value_handle(this) + argv_handle = self._python_to_value_handle(argv) + + return await self._run_cancelable_mr_task( + self._dll.mr_call_function, + func_handle.raw, + this_handle.raw, + argv_handle.raw, + ) + def call_function( self, func: JSFunction, *args: PythonJSConvertedTypes, this: JSObject | JSUndefinedType = JSUndefined, - timeout_sec: float | None = None, ) -> PythonJSConvertedTypes: - argv = cast("JSArray", self.evaluate("[]")) + argv = cast("JSArray", self.eval("[]")) for arg in args: argv.append(arg) @@ -327,119 +350,162 @@ def call_function( this_handle = self._python_to_value_handle(this) argv_handle = self._python_to_value_handle(argv) - with self._run_mr_task( - self._get_dll().mr_call_function, - self._ctx, + return self._run_uncancelable_mr_task( + self._dll.mr_call_function, func_handle.raw, this_handle.raw, argv_handle.raw, - ) as future: - try: - return future.result(timeout=timeout_sec) - except SyncTimeoutError as e: - raise JSTimeoutException from e + ) def set_hard_memory_limit(self, limit: int) -> None: - self._get_dll().mr_set_hard_memory_limit(self._ctx, limit) + self._dll.mr_set_hard_memory_limit(self._ctx, limit) def set_soft_memory_limit(self, limit: int) -> None: - self._get_dll().mr_set_soft_memory_limit(self._ctx, limit) + self._dll.mr_set_soft_memory_limit(self._ctx, limit) def was_hard_memory_limit_reached(self) -> bool: - return bool(self._get_dll().mr_hard_memory_limit_reached(self._ctx)) + return bool(self._dll.mr_hard_memory_limit_reached(self._ctx)) def was_soft_memory_limit_reached(self) -> bool: - return bool(self._get_dll().mr_soft_memory_limit_reached(self._ctx)) + return bool(self._dll.mr_soft_memory_limit_reached(self._ctx)) def low_memory_notification(self) -> None: - self._get_dll().mr_low_memory_notification(self._ctx) + self._dll.mr_low_memory_notification(self._ctx) def heap_stats(self) -> str: - with self._run_mr_task(self._get_dll().mr_heap_stats, self._ctx) as future: - return cast("str", future.result()) + return cast( + "str", + self._value_handle_to_python( + self._wrap_raw_handle(self._dll.mr_heap_stats(self._ctx)) + ), + ) def heap_snapshot(self) -> str: """Return a snapshot of the V8 isolate heap.""" - with self._run_mr_task(self._get_dll().mr_heap_snapshot, self._ctx) as future: - return cast("str", future.result()) + return cast( + "str", + self._value_handle_to_python( + self._wrap_raw_handle(self._dll.mr_heap_snapshot(self._ctx)) + ), + ) def value_count(self) -> int: """For tests only: how many value handles are still allocated?""" - return int(self._get_dll().mr_value_count(self._ctx)) + return int(self._dll.mr_value_count(self._ctx)) @contextmanager - def js_to_py_callback( - self, func: Callable[[PythonJSConvertedTypes | JSEvalException], None] - ) -> Iterator[JSFunction]: - """Make a JS callback which forwards to the given Python function. - - Note that it's crucial that the given Python function *not* call back - into the C++ MiniRacer context, or it will deadlock. Instead it should - signal another thread; e.g., by putting received data onto a queue or - future. - """ + def _register_js_notification( + self, func: Callable[[ValueHandle], None] + ) -> Generator[JSFunction, None, None]: + """Create a "notification": an async, one-way callback function, from JavaScript + to Python. + + "One-way" here means the function returns nothing. "async" means that on the JS + side, the function returns before it has been processed on the Python side.""" + + with self._register_cancelable_mr_task_callback(func) as callback_id: + yield cast( + "JSFunction", + self._value_handle_to_python( + self._wrap_raw_handle( + self._dll.mr_make_js_callback(self._ctx, callback_id) + ) + ), + ) - def func_py(val_handle: ValueHandle) -> None: + @asynccontextmanager + async def wrap_py_function_as_js_function( + self, func: PyJsFunctionType + ) -> AsyncGenerator[JSFunction, None]: + async def await_into_js_promise_resolvers(val_handle: ValueHandle) -> None: + params = self._value_handle_to_python(val_handle) + arguments, resolve, reject = cast("JSArray", params) try: - value = self._value_handle_to_python(val_handle) - except JSEvalException as e: - func(e) - return - - func(value) + result = await func(*cast("JSArray", arguments)) + cast("JSFunction", resolve)(result) + except Exception: # noqa: BLE001 + # Convert this Python exception into a JS exception so we can send + # it into JS: + err_maker = cast("JSFunction", self.eval("s => new Error(s)")) + cast("JSFunction", reject)( + err_maker(f"Error running Python function:\n{format_exc()}") + ) - with self._callback_registry.register(func_py) as callback_id: - cb = self._wrap_raw_handle( - self._get_dll().mr_make_js_callback(self._ctx, callback_id) - ) + async with _make_task_set(self.event_loop) as task_set: + with self._register_js_notification( + lambda val_handle: task_set.start_task( + await_into_js_promise_resolvers(val_handle) + ) + ) as js_to_py_notification: + # Every time our callback is called from JS, on the JS side we + # instantiate a JS Promise and immediately pass its resolution functions + # into our Python callback function. While we wait on Python's asyncio + # loop to process this call, we can return the Promise to the JS caller, + # thus exposing what looks like an ordinary async function on the JS + # side of things. + wrap_outbound_calls_with_js_promises = cast( + "JSFunction", + self.eval( + """ +fn => { + return (...arguments) => { + let p = Promise.withResolvers(); + + fn(arguments, p.resolve, p.reject); + + return p.promise; + } +} +""" + ), + ) - yield cast("JSFunction", self._value_handle_to_python(cb)) + yield cast( + "JSFunction", + wrap_outbound_calls_with_js_promises(js_to_py_notification), + ) def _wrap_raw_handle(self, raw: RawValueHandleType) -> ValueHandle: return ValueHandle(lambda: self._free(raw), raw) - def _create_intish_val(self, val: int, typ: int) -> ValueHandle: - return self._wrap_raw_handle( - self._get_dll().mr_alloc_int_val(self._ctx, val, typ) - ) + def create_intish_val(self, val: int, typ: int) -> ValueHandle: + return self._wrap_raw_handle(self._dll.mr_alloc_int_val(self._ctx, val, typ)) - def _create_doublish_val(self, val: float, typ: int) -> ValueHandle: - return self._wrap_raw_handle( - self._get_dll().mr_alloc_double_val(self._ctx, val, typ) - ) + def create_doublish_val(self, val: float, typ: int) -> ValueHandle: + return self._wrap_raw_handle(self._dll.mr_alloc_double_val(self._ctx, val, typ)) - def _create_string_val(self, val: str, typ: int) -> ValueHandle: + def create_string_val(self, val: str, typ: int) -> ValueHandle: b = val.encode("utf-8") return self._wrap_raw_handle( - self._get_dll().mr_alloc_string_val(self._ctx, b, len(b), typ) + self._dll.mr_alloc_string_val(self._ctx, b, len(b), typ) ) def _free(self, raw: RawValueHandleType) -> None: - dll = self._dll - if dll is not None: - dll.mr_free_value(self._ctx, raw) + self._dll.mr_free_value(self._ctx, raw) - @contextmanager - def _run_mr_task( + async def _run_cancelable_mr_task( self, dll_method: Any, # noqa: ANN401 *args: Any, # noqa: ANN401 - ) -> Iterator[SyncFuture[PythonJSConvertedTypes]]: - """Manages those tasks which generate callbacks from the MiniRacer DLL. + ) -> PythonJSConvertedTypes: + """Manages cancelable tasks within the MiniRacer DLL. Several MiniRacer functions (JS evaluation and 2 heap stats calls) are - asynchronous. They take a function callback and callback data parameter, and - they return a task handle. + cancelable and asynchronous. They take a function callback and callback data + parameter, and they return a task handle. In this method, we create a future for each callback to get the right data to the right caller, and we manage the lifecycle of the task and task handle. """ - future: SyncFuture[PythonJSConvertedTypes] = SyncFuture() + future: asyncio.Future[PythonJSConvertedTypes] = asyncio.Future() def callback(val_handle: ValueHandle) -> None: + if future.cancelled(): + return + try: value = self._value_handle_to_python(val_handle) except JSEvalException as e: @@ -448,142 +514,37 @@ def callback(val_handle: ValueHandle) -> None: future.set_result(value) - with self._callback_registry.register(callback) as callback_id: + with self._register_cancelable_mr_task_callback(callback) as callback_id: # Start the task: - task_id = dll_method(*args, callback_id) + task_id = dll_method(self._ctx, *args, callback_id) try: - # Let the caller handle waiting on the result: - yield future + return await future finally: # Cancel the task if it's not already done (this call is ignored if it's # already done) - self._get_dll().mr_cancel_task(self._ctx, task_id) + self._dll.mr_cancel_task(self._ctx, task_id) - # If the caller gives up on waiting, let's at least await the - # cancelation error for GC purposes: - with suppress(Exception): - future.result() + def _run_uncancelable_mr_task( + self, + dll_method: Any, # noqa: ANN401 + *args: Any, # noqa: ANN401 + ) -> PythonJSConvertedTypes: + """Like _run_cancelable_mr_task, but eschewing cancellation semantics and + instead just waiting on a result synchronously.""" - def close(self) -> None: - dll, self._dll = self._dll, None - if dll: - dll.mr_free_context(self._ctx) + # self._non_cancelable_mr_task_results_queue is single file, with no + # higher-level ordering mechanism, so it's important that we use it only from + # the event loop thread, to keep things in order: + assert self.are_we_running_on_the_mini_racer_event_loop() - def __del__(self) -> None: - self.close() + _task_id = dll_method(self._ctx, *args, _UNCANCELABLE_TASK_CALLBACK_ID) + val_handle = self._non_cancelable_mr_task_results_queue.get() + return self._value_handle_to_python(val_handle) - def _value_handle_to_python( # noqa: C901, PLR0911, PLR0912 + def _value_handle_to_python( self, val_handle: ValueHandle ) -> PythonJSConvertedTypes: - """Convert a binary value handle from the C++ side into a Python object.""" - - # A MiniRacer binary value handle is a pointer to a structure which, for some - # simple types like ints, floats, and strings, is sufficient to describe the - # data, enabling us to convert the value immediately and free the handle. - - # For more complex types, like Objects and Arrays, the handle is just an opaque - # pointer to a V8 object. In these cases, we retain the binary value handle, - # wrapping it in a Python object. We can then use the handle in follow-on API - # calls to work with the underlying V8 object. - - # In either case the handle is owned by the C++ side. It's the responsibility - # of the Python side to call mr_free_value() when done with with the handle - # to free up memory, but the C++ side will eventually free it on context - # teardown either way. - - raw = cast("RawValueHandleTypeImpl", val_handle.raw) - - typ = raw.contents.type - val = raw.contents.value - length = raw.contents.len - - error_info = _ERRORS.get(raw.contents.type) - if error_info: - klass, generic_msg = error_info - - msg = val.bytes_val[0:length].decode("utf-8") or generic_msg - raise klass(msg) - - if typ == _MiniRacerTypes.null: - return None - if typ == _MiniRacerTypes.undefined: - return JSUndefined - if typ == _MiniRacerTypes.bool: - return bool(val.int_val == 1) - if typ == _MiniRacerTypes.integer: - return int(val.int_val) - if typ == _MiniRacerTypes.double: - return float(val.double_val) - if typ == _MiniRacerTypes.str_utf8: - return str(val.bytes_val[0:length].decode("utf-8")) - if typ == _MiniRacerTypes.function: - return JSFunctionImpl(self, val_handle) - if typ == _MiniRacerTypes.date: - timestamp = val.double_val - # JS timestamps are milliseconds. In Python we are in seconds: - return datetime.fromtimestamp(timestamp / 1000.0, timezone.utc) - if typ == _MiniRacerTypes.symbol: - return JSSymbolImpl(self, val_handle) - if typ in (_MiniRacerTypes.shared_array_buffer, _MiniRacerTypes.array_buffer): - buf = _ArrayBufferByte * length - cdata = buf.from_address(val.value_ptr) - # Save a reference to ourselves to prevent garbage collection of the - # backing store: - cdata._origin = self # noqa: SLF001 - result = memoryview(cdata) - # Avoids "NotImplementedError: memoryview: unsupported format T{ ValueHandle: - if isinstance(obj, JSObjectImpl): - # JSObjects originate from the V8 side. We can just send back the handle - # we originally got. (This also covers derived types JSFunction, JSSymbol, - # JSPromise, and JSArray.) - return obj.raw_handle - - if obj is None: - return self._create_intish_val(0, _MiniRacerTypes.null) - if obj is JSUndefined: - return self._create_intish_val(0, _MiniRacerTypes.undefined) - if isinstance(obj, bool): - return self._create_intish_val(1 if obj else 0, _MiniRacerTypes.bool) - if isinstance(obj, int): - if obj - 2**31 <= obj < 2**31: - return self._create_intish_val(obj, _MiniRacerTypes.integer) - - # We transmit ints as int32, so "upgrade" to double upon overflow. - # (ECMAScript numeric is double anyway, but V8 does internally distinguish - # int types, so we try and preserve integer-ness for round-tripping - # purposes.) - # JS BigInt would be a closer representation of Python int, but upgrading - # to BigInt would probably be surprising for most applications, so for now, - # we approximate with double: - return self._create_doublish_val(obj, _MiniRacerTypes.double) - if isinstance(obj, float): - return self._create_doublish_val(obj, _MiniRacerTypes.double) - if isinstance(obj, str): - return self._create_string_val(obj, _MiniRacerTypes.str_utf8) - if isinstance(obj, datetime): - # JS timestamps are milliseconds. In Python we are in seconds: - return self._create_doublish_val( - obj.timestamp() * 1000.0, _MiniRacerTypes.date - ) - - # Note: we skip shared array buffers, so for now at least, handles to shared - # array buffers can only be transmitted from JS to Python. + return self._object_factory.value_handle_to_python(self, val_handle) - raise JSConversionException + def _python_to_value_handle(self, obj: PythonJSConvertedTypes) -> ValueHandle: + return self._object_factory.python_to_value_handle(self, obj) diff --git a/src/py_mini_racer/_dll.py b/src/py_mini_racer/_dll.py index a23066b4..b08bb164 100644 --- a/src/py_mini_racer/_dll.py +++ b/src/py_mini_racer/_dll.py @@ -108,16 +108,16 @@ def _build_dll_handle(dll_path: Path) -> ctypes.CDLL: # noqa: PLR0915 handle.mr_cancel_task.argtypes = [ctypes.c_uint64, ctypes.c_uint64] - handle.mr_heap_stats.argtypes = [ctypes.c_uint64, ctypes.c_uint64] - handle.mr_heap_stats.restype = ctypes.c_uint64 + handle.mr_heap_stats.argtypes = [ctypes.c_uint64] + handle.mr_heap_stats.restype = RawValueHandle handle.mr_low_memory_notification.argtypes = [ctypes.c_uint64] handle.mr_make_js_callback.argtypes = [ctypes.c_uint64, ctypes.c_uint64] handle.mr_make_js_callback.restype = RawValueHandle - handle.mr_heap_snapshot.argtypes = [ctypes.c_uint64, ctypes.c_uint64] - handle.mr_heap_snapshot.restype = ctypes.c_uint64 + handle.mr_heap_snapshot.argtypes = [ctypes.c_uint64] + handle.mr_heap_snapshot.restype = RawValueHandle handle.mr_get_identity_hash.argtypes = [ctypes.c_uint64, RawValueHandle] handle.mr_get_identity_hash.restype = RawValueHandle diff --git a/src/py_mini_racer/_exc.py b/src/py_mini_racer/_exc.py index 4fa7dae3..2c060868 100644 --- a/src/py_mini_racer/_exc.py +++ b/src/py_mini_racer/_exc.py @@ -58,3 +58,10 @@ class JSValueError(JSEvalException, ValueError): class JSConversionException(MiniRacerBaseException): """Type could not be converted to or from JavaScript.""" + + +class WrongReturnTypeException(MiniRacerBaseException): + """Invalid type returned by the JavaScript runtime.""" + + def __init__(self, typ: type) -> None: + super().__init__(f"Unexpected return value type {typ}") diff --git a/src/py_mini_racer/_js_value_manipulator.py b/src/py_mini_racer/_js_value_manipulator.py deleted file mode 100644 index ce4f2d42..00000000 --- a/src/py_mini_racer/_js_value_manipulator.py +++ /dev/null @@ -1,65 +0,0 @@ -from __future__ import annotations - -from typing import TYPE_CHECKING, Protocol - -from py_mini_racer._types import ( - JSArray, - JSFunction, - JSObject, - JSPromise, - JSUndefined, - JSUndefinedType, - PythonJSConvertedTypes, -) - -if TYPE_CHECKING: - from collections.abc import Callable - from contextlib import AbstractContextManager - - from py_mini_racer._exc import JSEvalException - - -class JSValueManipulator(Protocol): - def get_identity_hash(self, obj: JSObject) -> int: ... - - def get_own_property_names( - self, obj: JSObject - ) -> tuple[PythonJSConvertedTypes, ...]: ... - - def get_object_item( - self, obj: JSObject, key: PythonJSConvertedTypes - ) -> PythonJSConvertedTypes: ... - - def set_object_item( - self, obj: JSObject, key: PythonJSConvertedTypes, val: PythonJSConvertedTypes - ) -> None: ... - - def del_object_item(self, obj: JSObject, key: PythonJSConvertedTypes) -> None: ... - - def del_from_array(self, arr: JSArray, index: int) -> None: ... - - def array_insert( - self, arr: JSArray, index: int, new_val: PythonJSConvertedTypes - ) -> None: ... - - def array_push(self, arr: JSArray, new_val: PythonJSConvertedTypes) -> None: ... - - def call_function( - self, - func: JSFunction, - *args: PythonJSConvertedTypes, - this: JSObject | JSUndefinedType = JSUndefined, - timeout_sec: float | None = None, - ) -> PythonJSConvertedTypes: ... - - def js_to_py_callback( - self, func: Callable[[PythonJSConvertedTypes | JSEvalException], None] - ) -> AbstractContextManager[JSFunction]: ... - - def promise_then( - self, promise: JSPromise, on_resolved: JSFunction, on_rejected: JSFunction - ) -> None: ... - - def evaluate( - self, code: str, timeout_sec: float | None = None - ) -> PythonJSConvertedTypes: ... diff --git a/src/py_mini_racer/_mini_racer.py b/src/py_mini_racer/_mini_racer.py index 30eb4187..c101d461 100644 --- a/src/py_mini_racer/_mini_racer.py +++ b/src/py_mini_racer/_mini_racer.py @@ -1,17 +1,26 @@ from __future__ import annotations +import asyncio import json +from contextlib import ( + AbstractContextManager, + asynccontextmanager, + contextmanager, + suppress, +) +from itertools import count from json import JSONEncoder +from threading import Thread from typing import TYPE_CHECKING, Any, ClassVar -from py_mini_racer._context import Context -from py_mini_racer._dll import init_mini_racer -from py_mini_racer._exc import MiniRacerBaseException +from py_mini_racer._context import Context, ContextType, get_running_loop_or_none +from py_mini_racer._dll import init_mini_racer, mr_callback_func +from py_mini_racer._exc import JSTimeoutException, WrongReturnTypeException +from py_mini_racer._objects import ObjectFactoryImpl from py_mini_racer._set_timeout import INSTALL_SET_TIMEOUT -from py_mini_racer._wrap_py_function import wrap_py_function_as_js_function if TYPE_CHECKING: - from contextlib import AbstractAsyncContextManager + from collections.abc import AsyncGenerator, Generator from types import TracebackType from typing_extensions import Self @@ -21,13 +30,7 @@ PyJsFunctionType, PythonJSConvertedTypes, ) - - -class WrongReturnTypeException(MiniRacerBaseException): - """Invalid type returned by the JavaScript runtime.""" - - def __init__(self, typ: type) -> None: - super().__init__(f"Unexpected return value type {typ}") + from py_mini_racer._value_handle import RawValueHandleType class MiniRacer: @@ -40,7 +43,7 @@ class MiniRacer: with MiniRacer() as mr: ... - The MiniRacer instance will otherwise clean up the underlying V8 resource upon + The MiniRacer instance will otherwise clean up the underlying V8 resources upon garbage collection. Attributes: @@ -50,20 +53,42 @@ class MiniRacer: json_impl: ClassVar[Any] = json - def __init__(self) -> None: - dll = init_mini_racer(ignore_duplicate_init=True) - - self._ctx = Context(dll) + def __init__(self, context: Context | None = None) -> None: + if context is None: + self._own_context_maker: AbstractContextManager[Context] | None = ( + _make_context() + ) + self._ctx: Context | None = self._own_context_maker.__enter__() + else: + self._own_context_maker = None + self._ctx = context self.eval(INSTALL_SET_TIMEOUT) - def close(self) -> None: + def close( + self, + exc_type: type[BaseException] | None = None, + exc_val: BaseException | None = None, + exc_tb: TracebackType | None = None, + ) -> None: """Close this MiniRacer instance. It is an error to use this MiniRacer instance or any JS objects returned by it after calling this method. """ - self._ctx.close() + own_context_maker = self._own_context_maker + self._own_context_maker = None + self._ctx = None + + if own_context_maker is not None: + own_context_maker.__exit__(exc_type, exc_val, exc_tb) + + def __del__(self) -> None: + # Ignore ordering problems on process teardown. + # (A user who wants consistent teardown should use `with MiniRacer() as ctx` + # which makes the cleanup deterministic.) + with suppress(Exception): + self.close() def __enter__(self) -> Self: return self @@ -74,14 +99,12 @@ def __exit__( exc_val: BaseException | None, exc_tb: TracebackType | None, ) -> None: - del exc_type - del exc_val - del exc_tb - self.close() + self.close(exc_type, exc_val, exc_tb) @property def v8_version(self) -> str: """Return the V8 version string.""" + assert self._ctx is not None return self._ctx.v8_version() def eval( @@ -123,7 +146,42 @@ def eval( # Système international d'unités use seconds. timeout_sec = timeout / 1000 - return self._ctx.evaluate(code=code, timeout_sec=timeout_sec) + ctx = self._ctx + assert ctx is not None + + if not ctx.are_we_running_on_the_mini_racer_event_loop(): + + async def run() -> PythonJSConvertedTypes: + try: + return await asyncio.wait_for( + ctx.eval_cancelable(code), timeout=timeout_sec + ) + except asyncio.TimeoutError as e: + raise JSTimeoutException from e + + return asyncio.run_coroutine_threadsafe(run(), ctx.event_loop).result() + + assert timeout_sec is None, ( + "To apply a timeout in an async context, use " + "`await asyncio.wait_for(mr.eval_cancelable(your_params), " + "timeout=your_timeout)`" + ) + + return ctx.eval(code) + + async def eval_cancelable(self, code: str) -> PythonJSConvertedTypes: + """Evaluate JavaScript code in the V8 isolate. + + Similar to eval(), but runaway calls can be canceled by canceling the + coroutine's task, e.g., using: + + await asyncio.wait_for(mr.eval_cancelable(...), timeout=some_timeout) + + """ + + assert self._ctx is not None + + return await self._ctx.eval_cancelable(code) def execute( self, @@ -194,9 +252,10 @@ def call( js = f"{expr}.apply(this, {json_args})" return self.execute(js, timeout_sec=timeout_sec, max_memory=max_memory) - def wrap_py_function( + @asynccontextmanager + async def wrap_py_function( self, func: PyJsFunctionType - ) -> AbstractAsyncContextManager[JSFunction]: + ) -> AsyncGenerator[JSFunction, None]: """Wrap a Python function such that it can be called from JS. To be wrapped and exposed in JavaScript, a Python function should: @@ -215,7 +274,10 @@ def wrap_py_function( can be passed into MiniRacer and called by JS code. """ - return wrap_py_function_as_js_function(self._ctx, func) + assert self._ctx is not None + + async with self._ctx.wrap_py_function_as_js_function(func) as js_func: + yield js_func def set_hard_memory_limit(self, limit: int) -> None: """Set a hard memory limit on this V8 isolate. @@ -224,6 +286,8 @@ def set_hard_memory_limit(self, limit: int) -> None: :param int limit: memory limit in bytes or 0 to reset the limit """ + + assert self._ctx is not None self._ctx.set_hard_memory_limit(limit) def set_soft_memory_limit(self, limit: int) -> None: @@ -234,25 +298,113 @@ def set_soft_memory_limit(self, limit: int) -> None: :param int limit: memory limit in bytes or 0 to reset the limit """ + + assert self._ctx is not None self._ctx.set_soft_memory_limit(limit) def was_hard_memory_limit_reached(self) -> bool: """Return true if the hard memory limit was reached on the V8 isolate.""" + + assert self._ctx is not None return self._ctx.was_hard_memory_limit_reached() def was_soft_memory_limit_reached(self) -> bool: """Return true if the soft memory limit was reached on the V8 isolate.""" + + assert self._ctx is not None return self._ctx.was_soft_memory_limit_reached() def low_memory_notification(self) -> None: """Ask the V8 isolate to collect memory more aggressively.""" + + assert self._ctx is not None self._ctx.low_memory_notification() def heap_stats(self) -> Any: # noqa: ANN401 """Return the V8 isolate heap statistics.""" + assert self._ctx is not None return self.json_impl.loads(self._ctx.heap_stats()) + def heap_snapshot(self) -> Any: # noqa: ANN401 + """Return a snapshot of the V8 isolate heap.""" + + assert self._ctx is not None + return self.json_impl.loads(self._ctx.heap_snapshot()) + + +@contextmanager +def _running_event_loop( + event_loop: asyncio.AbstractEventLoop | None = None, +) -> Generator[asyncio.AbstractEventLoop, None, None]: + """Pick an asyncio loop. In descending order of precedence: + + 1. The caller-specified one, + 2. The running one (defined if we're being called from async context), or + 3. One we create and launch a thread for, on the spot. + """ + + event_loop = event_loop or get_running_loop_or_none() + + if event_loop is not None: + yield event_loop + return + + event_loop = asyncio.new_event_loop() + + def run_event_loop() -> None: + asyncio.set_event_loop(event_loop) + assert event_loop is not None + event_loop.run_forever() + event_loop.close() + + event_loop_thread = Thread(target=run_event_loop, daemon=True) + event_loop_thread.start() + + try: + yield event_loop + finally: + event_loop.call_soon_threadsafe(event_loop.stop) + event_loop_thread.join() + + +@contextmanager +def _make_context( + event_loop: asyncio.AbstractEventLoop | None = None, +) -> Generator[Context, None, None]: + dll = init_mini_racer(ignore_duplicate_init=True) + + context: Context + + # define an all-purpose callback: + @mr_callback_func + def mr_callback(callback_id: int, raw_val_handle: RawValueHandleType) -> None: + nonlocal context + context.handle_callback_from_v8(callback_id, raw_val_handle) + + next_cancelable_task_callback_id = count() + # reserve 0 as the callback for tasks we don't bother canceling; see + # _UNCANCELABLE_TASK_CALLBACK_ID: + _ = next(next_cancelable_task_callback_id) + + ctx = ContextType(dll.mr_init_context(mr_callback)) + try: + with _running_event_loop(event_loop) as loop: + context = Context( + dll, ctx, loop, ObjectFactoryImpl(), next_cancelable_task_callback_id + ) + yield context + finally: + dll.mr_free_context(ctx) + + +@contextmanager +def mini_racer( + event_loop: asyncio.AbstractEventLoop | None = None, +) -> Generator[MiniRacer, None]: + with _make_context(event_loop) as ctx: + yield MiniRacer(ctx) + # Compatibility with versions 0.4 & 0.5 StrictMiniRacer = MiniRacer diff --git a/src/py_mini_racer/_objects.py b/src/py_mini_racer/_objects.py index 4cea1df4..ff1e69ed 100644 --- a/src/py_mini_racer/_objects.py +++ b/src/py_mini_racer/_objects.py @@ -2,13 +2,25 @@ from __future__ import annotations -from asyncio import get_running_loop -from concurrent.futures import Future as SyncFuture +import asyncio +import ctypes +from datetime import datetime, timezone from operator import index as op_index -from typing import TYPE_CHECKING, Any, cast - -from py_mini_racer._exc import JSArrayIndexError, JSPromiseError +from typing import TYPE_CHECKING, Any, ClassVar, cast + +from py_mini_racer._exc import ( + JSArrayIndexError, + JSConversionException, + JSEvalException, + JSKeyError, + JSOOMException, + JSParseException, + JSTerminatedException, + JSTimeoutException, + JSValueError, +) from py_mini_racer._types import ( + CancelableJSFunction, JSArray, JSFunction, JSMappedObject, @@ -19,38 +31,22 @@ JSUndefinedType, PythonJSConvertedTypes, ) -from py_mini_racer._wrap_py_function import wrap_py_function_as_js_function if TYPE_CHECKING: - from asyncio import Future - from collections.abc import Generator, Iterator + from collections.abc import Generator, Iterator, Sequence - from py_mini_racer._exc import JSEvalException - from py_mini_racer._js_value_manipulator import JSValueManipulator + from py_mini_racer._context import Context + from py_mini_racer._dll import RawValueHandleTypeImpl from py_mini_racer._value_handle import ValueHandle -def _get_exception_msg(reason: PythonJSConvertedTypes) -> str: - if not isinstance(reason, JSMappedObject): - return str(reason) - - if "stack" in reason: - return cast("str", reason["stack"]) - - return str(reason) - - class JSObjectImpl(JSObject): - """A JavaScript object.""" - - def __init__( - self, val_manipulator: JSValueManipulator, handle: ValueHandle - ) -> None: - self._val_manipulator = val_manipulator + def __init__(self, ctx: Context, handle: ValueHandle) -> None: + self._ctx = ctx self._handle = handle def __hash__(self) -> int: - return self._val_manipulator.get_identity_hash(self) + return self._ctx.get_identity_hash(self) @property def raw_handle(self) -> ValueHandle: @@ -58,42 +54,30 @@ def raw_handle(self) -> ValueHandle: class JSMappedObjectImpl(JSObjectImpl, JSMappedObject): - """A JavaScript object with Pythonic MutableMapping methods (`keys()`, - `__getitem__()`, etc). - - `keys()` and `__iter__()` will return properties from any prototypes as well as this - object, as if using a for-in statement in JavaScript. - """ - def __iter__(self) -> Iterator[PythonJSConvertedTypes]: return iter(self._get_own_property_names()) def __getitem__(self, key: PythonJSConvertedTypes) -> PythonJSConvertedTypes: - return self._val_manipulator.get_object_item(self, key) + return self._ctx.get_object_item(self, key) def __setitem__( self, key: PythonJSConvertedTypes, val: PythonJSConvertedTypes ) -> None: - self._val_manipulator.set_object_item(self, key, val) + self._ctx.set_object_item(self, key, val) def __delitem__(self, key: PythonJSConvertedTypes) -> None: - self._val_manipulator.del_object_item(self, key) + self._ctx.del_object_item(self, key) def __len__(self) -> int: return len(self._get_own_property_names()) def _get_own_property_names(self) -> tuple[PythonJSConvertedTypes, ...]: - return self._val_manipulator.get_own_property_names(self) + return self._ctx.get_own_property_names(self) class JSArrayImpl(JSArray, JSObjectImpl): - """JavaScript array. - - Has Pythonic MutableSequence methods (e.g., `insert()`, `__getitem__()`, ...). - """ - def __len__(self) -> int: - return cast("int", self._val_manipulator.get_object_item(self, "length")) + return cast("int", self._ctx.get_object_item(self, "length")) def __getitem__(self, index: int | slice) -> Any: # noqa: ANN401 if not isinstance(index, int): @@ -104,7 +88,7 @@ def __getitem__(self, index: int | slice) -> Any: # noqa: ANN401 index += len(self) if 0 <= index < len(self): - return self._val_manipulator.get_object_item(self, index) + return self._ctx.get_object_item(self, index) raise IndexError @@ -112,7 +96,7 @@ def __setitem__(self, index: int | slice, val: Any) -> None: # noqa: ANN401 if not isinstance(index, int): raise TypeError - self._val_manipulator.set_object_item(self, index, val) + self._ctx.set_object_item(self, index, val) def __delitem__(self, index: int | slice) -> None: if not isinstance(index, int): @@ -128,106 +112,262 @@ def __delitem__(self, index: int | slice) -> None: # bounds: raise JSArrayIndexError - self._val_manipulator.del_from_array(self, index) + self._ctx.del_from_array(self, index) def insert(self, index: int, new_obj: PythonJSConvertedTypes) -> None: - self._val_manipulator.array_insert(self, index, new_obj) + self._ctx.array_insert(self, index, new_obj) def __iter__(self) -> Iterator[PythonJSConvertedTypes]: for i in range(len(self)): - yield self._val_manipulator.get_object_item(self, i) + yield self._ctx.get_object_item(self, i) def append(self, value: PythonJSConvertedTypes) -> None: - self._val_manipulator.array_push(self, value) + self._ctx.array_push(self, value) class JSFunctionImpl(JSMappedObjectImpl, JSFunction): - """JavaScript function. - - You can call this object from Python, passing in positional args to match what the - JavaScript function expects, along with a keyword argument, `timeout_sec`. - """ - def __call__( self, *args: PythonJSConvertedTypes, this: JSObject | JSUndefinedType = JSUndefined, timeout_sec: float | None = None, ) -> PythonJSConvertedTypes: - return self._val_manipulator.call_function( - self, *args, this=this, timeout_sec=timeout_sec + if not self._ctx.are_we_running_on_the_mini_racer_event_loop(): + + async def run() -> PythonJSConvertedTypes: + try: + return await asyncio.wait_for( + self._ctx.call_function_cancelable(self, *args, this=this), + timeout=timeout_sec, + ) + except asyncio.TimeoutError as e: + raise JSTimeoutException from e + + return asyncio.run_coroutine_threadsafe( + run(), self._ctx.event_loop + ).result() + + assert timeout_sec is None, ( + "To apply a timeout in an async context, use " + "`await asyncio.wait_for(your_func.cancelable()(your_params), " + "timeout=your_timeout)`" ) + return self._ctx.call_function(self, *args, this=this) + + def cancelable(self) -> CancelableJSFunction: + return CancelableJSFunctionImpl(self._ctx, self._handle) + + +class CancelableJSFunctionImpl(JSMappedObjectImpl, CancelableJSFunction): + async def __call__( + self, + *args: PythonJSConvertedTypes, + this: JSObject | JSUndefinedType = JSUndefined, + ) -> PythonJSConvertedTypes: + return await self._ctx.call_function_cancelable(self, *args, this=this) + class JSSymbolImpl(JSMappedObjectImpl, JSSymbol): - """JavaScript symbol.""" + pass class JSPromiseImpl(JSObjectImpl, JSPromise): - """JavaScript Promise. + def get(self, *, timeout: float | None = None) -> PythonJSConvertedTypes: + assert not self._ctx.are_we_running_on_the_mini_racer_event_loop(), ( + "In an async context, call `await promise` instead of promise.get()" + ) - To get a value, call `promise.get()` to block, or `await promise` from within an - `async` coroutine. Either will raise a Python exception if the JavaScript Promise - is rejected. - """ + async def run() -> PythonJSConvertedTypes: + try: + return await asyncio.wait_for( + self._ctx.await_promise(self), timeout=timeout + ) + except asyncio.TimeoutError as e: + raise JSTimeoutException from e - def get(self, *, timeout: float | None = None) -> PythonJSConvertedTypes: - """Get the value, or raise an exception. This call blocks. - - Args: - timeout: number of milliseconds after which the execution is interrupted. - This is deprecated; use timeout_sec instead. - """ - - future: SyncFuture[JSArray] = SyncFuture() - is_rejected = False - - def on_resolved(value: PythonJSConvertedTypes | JSEvalException) -> None: - future.set_result(cast("JSArray", value)) - - def on_rejected(value: PythonJSConvertedTypes | JSEvalException) -> None: - nonlocal is_rejected - is_rejected = True - future.set_result(cast("JSArray", value)) - - with ( - self._val_manipulator.js_to_py_callback(on_resolved) as on_resolved_js_func, - self._val_manipulator.js_to_py_callback(on_rejected) as on_rejected_js_func, - ): - self._val_manipulator.promise_then( - self, on_resolved_js_func, on_rejected_js_func - ) + return asyncio.run_coroutine_threadsafe(run(), self._ctx.event_loop).result() - result = future.result(timeout=timeout) + def __await__(self) -> Generator[Any, None, Any]: + return self._ctx.await_promise(self).__await__() - if is_rejected: - msg = _get_exception_msg(result[0]) - raise JSPromiseError(msg) - return result[0] +class _ArrayBufferByte(ctypes.Structure): + # Cannot use c_ubyte directly because it uses Generator[Any, None, Any]: - return self._do_await().__await__() - - async def _do_await(self) -> PythonJSConvertedTypes: - future: Future[PythonJSConvertedTypes] = get_running_loop().create_future() - - async def on_resolved(value: PythonJSConvertedTypes) -> None: - future.set_result(value) - - async def on_rejected(value: PythonJSConvertedTypes) -> None: - future.set_exception(JSPromiseError(_get_exception_msg(value))) - - async with ( - wrap_py_function_as_js_function( - self._val_manipulator, on_resolved - ) as on_resolved_js_func, - wrap_py_function_as_js_function( - self._val_manipulator, on_rejected - ) as on_rejected_js_func, - ): - self._val_manipulator.promise_then( - self, on_resolved_js_func, on_rejected_js_func + +class _MiniRacerTypes: + """MiniRacer types identifier + + Note: it needs to be coherent with mini_racer.cc. + """ + + invalid = 0 + null = 1 + bool = 2 + integer = 3 + double = 4 + str_utf8 = 5 + array = 6 + # deprecated: + hash = 7 + date = 8 + symbol = 9 + object = 10 + undefined = 11 + + function = 100 + shared_array_buffer = 101 + array_buffer = 102 + promise = 103 + + execute_exception = 200 + parse_exception = 201 + oom_exception = 202 + timeout_exception = 203 + terminated_exception = 204 + value_exception = 205 + key_exception = 206 + + +_ERRORS: dict[int, tuple[type[JSEvalException], str]] = { + _MiniRacerTypes.parse_exception: ( + JSParseException, + "Unknown JavaScript error during parse", + ), + _MiniRacerTypes.execute_exception: ( + JSEvalException, + "Uknown JavaScript error during execution", + ), + _MiniRacerTypes.oom_exception: (JSOOMException, "JavaScript memory limit reached"), + _MiniRacerTypes.terminated_exception: ( + JSTerminatedException, + "JavaScript was terminated", + ), + _MiniRacerTypes.key_exception: (JSKeyError, "No such key found in object"), + _MiniRacerTypes.value_exception: ( + JSValueError, + "Bad value passed to JavaScript engine", + ), +} + + +class ObjectFactoryImpl: + def value_handle_to_python( # noqa: C901, PLR0911, PLR0912 + self, ctx: Context, val_handle: ValueHandle + ) -> PythonJSConvertedTypes: + """Convert a binary value handle from the C++ side into a Python object.""" + + # A MiniRacer binary value handle is a pointer to a structure which, for some + # simple types like ints, floats, and strings, is sufficient to describe the + # data, enabling us to convert the value immediately and free the handle. + + # For more complex types, like Objects and Arrays, the handle is just an opaque + # pointer to a V8 object. In these cases, we retain the binary value handle, + # wrapping it in a Python object. We can then use the handle in follow-on API + # calls to work with the underlying V8 object. + + # In either case the handle is owned by the C++ side. It's the responsibility + # of the Python side to call mr_free_value() when done with with the handle + # to free up memory, but the C++ side will eventually free it on context + # teardown either way. + + raw = cast("RawValueHandleTypeImpl", val_handle.raw) + + typ = raw.contents.type + val = raw.contents.value + length = raw.contents.len + + error_info = _ERRORS.get(raw.contents.type) + if error_info: + klass, generic_msg = error_info + + msg = val.bytes_val[0:length].decode("utf-8") or generic_msg + raise klass(msg) + + if typ == _MiniRacerTypes.null: + return None + if typ == _MiniRacerTypes.undefined: + return JSUndefined + if typ == _MiniRacerTypes.bool: + return bool(val.int_val == 1) + if typ == _MiniRacerTypes.integer: + return int(val.int_val) + if typ == _MiniRacerTypes.double: + return float(val.double_val) + if typ == _MiniRacerTypes.str_utf8: + return str(val.bytes_val[0:length].decode("utf-8")) + if typ == _MiniRacerTypes.function: + return JSFunctionImpl(ctx, val_handle) + if typ == _MiniRacerTypes.date: + timestamp = val.double_val + # JS timestamps are milliseconds. In Python we are in seconds: + return datetime.fromtimestamp(timestamp / 1000.0, timezone.utc) + if typ == _MiniRacerTypes.symbol: + return JSSymbolImpl(ctx, val_handle) + if typ in (_MiniRacerTypes.shared_array_buffer, _MiniRacerTypes.array_buffer): + buf = _ArrayBufferByte * length + cdata = buf.from_address(val.value_ptr) + # Save a reference to the context to prevent garbage collection of the + # backing store: + cdata._origin = ctx # noqa: SLF001 + result = memoryview(cdata) + # Avoids "NotImplementedError: memoryview: unsupported format T{ ValueHandle: + if isinstance(obj, JSObjectImpl): + # JSObjects originate from the V8 side. We can just send back the handle + # we originally got. (This also covers derived types JSFunction, JSSymbol, + # JSPromise, and JSArray.) + return obj.raw_handle + + if obj is None: + return ctx.create_intish_val(0, _MiniRacerTypes.null) + if obj is JSUndefined: + return ctx.create_intish_val(0, _MiniRacerTypes.undefined) + if isinstance(obj, bool): + return ctx.create_intish_val(1 if obj else 0, _MiniRacerTypes.bool) + if isinstance(obj, int): + if obj - 2**31 <= obj < 2**31: + return ctx.create_intish_val(obj, _MiniRacerTypes.integer) + + # We transmit ints as int32, so "upgrade" to double upon overflow. + # (ECMAScript numeric is double anyway, but V8 does internally distinguish + # int types, so we try and preserve integer-ness for round-tripping + # purposes.) + # JS BigInt would be a closer representation of Python int, but upgrading + # to BigInt would probably be surprising for most applications, so for now, + # we approximate with double: + return ctx.create_doublish_val(obj, _MiniRacerTypes.double) + if isinstance(obj, float): + return ctx.create_doublish_val(obj, _MiniRacerTypes.double) + if isinstance(obj, str): + return ctx.create_string_val(obj, _MiniRacerTypes.str_utf8) + if isinstance(obj, datetime): + # JS timestamps are milliseconds. In Python we are in seconds: + return ctx.create_doublish_val( + obj.timestamp() * 1000.0, _MiniRacerTypes.date ) - return await future + # Note: we skip shared array buffers, so for now at least, handles to shared + # array buffers can only be transmitted from JS to Python. + + raise JSConversionException diff --git a/src/py_mini_racer/_types.py b/src/py_mini_racer/_types.py index af0d73d9..b0ea8899 100644 --- a/src/py_mini_racer/_types.py +++ b/src/py_mini_racer/_types.py @@ -54,8 +54,17 @@ class JSArray(MutableSequence["PythonJSConvertedTypes"], JSObject): class JSFunction(JSMappedObject): """JavaScript function. + This type is returned by synchronous MiniRacer contexts. + You can call this object from Python, passing in positional args to match what the - JavaScript function expects, along with a keyword argument, `timeout_sec`. + JavaScript function expects. + + In synchronous code you may supply an additional keyword argument, `timeout_sec`. + + If you are running in an async context in the same event loop as Miniracer, you must + not supply a non-None value for timeout_sec. Instead call func.cancelable() to + obtain a CancellableJSFunction, and use a construct like asyncio.wait_for(...) to + apply a timeout. """ def __call__( @@ -66,6 +75,28 @@ def __call__( ) -> PythonJSConvertedTypes: raise NotImplementedError + def cancelable(self) -> CancelableJSFunction: + raise NotImplementedError + + +class CancelableJSFunction(JSMappedObject): + """JavaScript function. + + This type is returned by JSFunction.cancelable(). + + You can call this object from Python, passing in positional args to match what the + JavaScript function expects. Calls on the Python side are async, regardless of + whether the underlying JS function is async (so a call to an async JS function will + return a JSPromise, requiring a "double await" in Python to get the result). + """ + + async def __call__( + self, + *args: PythonJSConvertedTypes, + this: JSObject | JSUndefinedType = JSUndefined, + ) -> PythonJSConvertedTypes: + raise NotImplementedError + class JSSymbol(JSMappedObject): """JavaScript symbol.""" @@ -74,9 +105,9 @@ class JSSymbol(JSMappedObject): class JSPromise(JSObject): """JavaScript Promise. - To get a value, call `promise.get()` to block, or `await promise` from within an - `async` coroutine. Either will raise a Python exception if the JavaScript Promise - is rejected. + To get a value, call `promise.get()` (which blocks) or `await promise` (in async + code). Both operations will raise a Python exception if the JavaScript Promise is + rejected. """ def get(self, *, timeout: float | None = None) -> PythonJSConvertedTypes: @@ -85,9 +116,6 @@ def get(self, *, timeout: float | None = None) -> PythonJSConvertedTypes: def __await__(self) -> Generator[Any, None, Any]: raise NotImplementedError - async def _do_await(self) -> PythonJSConvertedTypes: - raise NotImplementedError - PythonJSConvertedTypes: TypeAlias = ( JSUndefinedType @@ -100,6 +128,7 @@ async def _do_await(self) -> PythonJSConvertedTypes: | memoryview | JSPromise | JSFunction + | CancelableJSFunction | JSMappedObject | JSSymbol | JSArray diff --git a/src/py_mini_racer/_wrap_py_function.py b/src/py_mini_racer/_wrap_py_function.py deleted file mode 100644 index 85045fa1..00000000 --- a/src/py_mini_racer/_wrap_py_function.py +++ /dev/null @@ -1,105 +0,0 @@ -from __future__ import annotations - -import asyncio -from contextlib import asynccontextmanager -from dataclasses import dataclass, field -from traceback import format_exc -from typing import TYPE_CHECKING, cast - -if TYPE_CHECKING: - from collections.abc import AsyncGenerator - - from py_mini_racer._exc import JSEvalException - from py_mini_racer._js_value_manipulator import JSValueManipulator - from py_mini_racer._types import ( - JSArray, - JSFunction, - PyJsFunctionType, - PythonJSConvertedTypes, - ) - - -@asynccontextmanager -async def wrap_py_function_as_js_function( - context: JSValueManipulator, func: PyJsFunctionType -) -> AsyncGenerator[JSFunction, None]: - with context.js_to_py_callback( - _JsToPyCallbackProcessor( - func, context, asyncio.get_running_loop() - ).process_one_invocation_from_js - ) as js_to_py_callback: - # Every time our callback is called from JS, on the JS side we - # instantiate a JS Promise and immediately pass its resolution functions - # into our Python callback function. While we wait on Python's asyncio - # loop to process this call, we can return the Promise to the JS caller, - # thus exposing what looks like an ordinary async function on the JS - # side of things. - wrap_outbound_calls_with_js_promises = cast( - "JSFunction", - context.evaluate( - """ -fn => { - return (...arguments) => { - let p = Promise.withResolvers(); - - fn(arguments, p.resolve, p.reject); - - return p.promise; - } -} -""" - ), - ) - - yield cast( - "JSFunction", wrap_outbound_calls_with_js_promises(js_to_py_callback) - ) - - -@dataclass(frozen=True) -class _JsToPyCallbackProcessor: - """Processes incoming calls from JS into Python. - - Note that this is not thread-safe and is thus suitable for use with only one asyncio - loop.""" - - _py_func: PyJsFunctionType - _val_manipulator: JSValueManipulator - _loop: asyncio.AbstractEventLoop - _ongoing_callbacks: set[asyncio.Task[PythonJSConvertedTypes | JSEvalException]] = ( - field(default_factory=set) - ) - - def process_one_invocation_from_js( - self, params: PythonJSConvertedTypes | JSEvalException - ) -> None: - async def await_into_js_promise_resolvers( - arguments: JSArray, resolve: JSFunction, reject: JSFunction - ) -> None: - try: - result = await self._py_func(*arguments) - resolve(result) - except Exception: # noqa: BLE001 - # Convert this Python exception into a JS exception so we can send - # it into JS: - err_maker = cast( - "JSFunction", self._val_manipulator.evaluate("s => new Error(s)") - ) - reject(err_maker(f"Error running Python function:\n{format_exc()}")) - - # Start a new task to await this invocation: - def start_task() -> None: - arguments, resolve, reject = cast("JSArray", params) - task = self._loop.create_task( - await_into_js_promise_resolvers( - cast("JSArray", arguments), - cast("JSFunction", resolve), - cast("JSFunction", reject), - ) - ) - - self._ongoing_callbacks.add(task) - - task.add_done_callback(self._ongoing_callbacks.discard) - - self._loop.call_soon_threadsafe(start_task) diff --git a/src/v8_py_frontend/callback.h b/src/v8_py_frontend/callback.h index 192bcfd2..c42e0773 100644 --- a/src/v8_py_frontend/callback.h +++ b/src/v8_py_frontend/callback.h @@ -7,10 +7,9 @@ namespace MiniRacer { -using Callback = void (*)(uint64_t, BinaryValueHandle*); +using RawCallback = void (*)(uint64_t, BinaryValueHandle*); -using RememberValueAndCallback = - std::function; +using CallbackFn = std::function; } // end namespace MiniRacer diff --git a/src/v8_py_frontend/context.cc b/src/v8_py_frontend/context.cc index ab4d892f..4fe8c7b9 100644 --- a/src/v8_py_frontend/context.cc +++ b/src/v8_py_frontend/context.cc @@ -21,7 +21,7 @@ namespace MiniRacer { -Context::Context(v8::Platform* platform, Callback callback) +Context::Context(v8::Platform* platform, RawCallback callback) : isolate_manager_(platform), isolate_object_collector_(&isolate_manager_), isolate_memory_monitor_(&isolate_manager_), @@ -125,20 +125,21 @@ void Context::CancelTask(uint64_t task_id) { cancelable_task_manager_.Cancel(task_id); } -auto Context::HeapSnapshot(uint64_t callback_id) -> uint64_t { - return RunTask( - [this](v8::Isolate* isolate) { - return heap_reporter_.HeapSnapshot(isolate); - }, - callback_id); +auto Context::HeapSnapshot() -> BinaryValueHandle* { + return bv_registry_.Remember(isolate_manager_ + .Run([this](v8::Isolate* isolate) mutable { + return heap_reporter_.HeapSnapshot( + isolate); + }) + .get()); } -auto Context::HeapStats(uint64_t callback_id) -> uint64_t { - return RunTask( - [this](v8::Isolate* isolate) { - return heap_reporter_.HeapStats(isolate); - }, - callback_id); +auto Context::HeapStats() -> BinaryValueHandle* { + return bv_registry_.Remember(isolate_manager_ + .Run([this](v8::Isolate* isolate) mutable { + return heap_reporter_.HeapStats(isolate); + }) + .get()); } auto Context::GetIdentityHash(BinaryValueHandle* obj_handle) diff --git a/src/v8_py_frontend/context.h b/src/v8_py_frontend/context.h index 327231b5..347f19b3 100644 --- a/src/v8_py_frontend/context.h +++ b/src/v8_py_frontend/context.h @@ -22,7 +22,7 @@ class ValueHandleConverter; class Context { public: - explicit Context(v8::Platform* platform, Callback callback); + explicit Context(v8::Platform* platform, RawCallback callback); ~Context(); Context(const Context&) = delete; @@ -41,8 +41,8 @@ class Context { template auto AllocBinaryValue(Params&&... params) -> BinaryValueHandle*; void CancelTask(uint64_t task_id); - auto HeapSnapshot(uint64_t callback_id) -> uint64_t; - auto HeapStats(uint64_t callback_id) -> uint64_t; + auto HeapSnapshot() -> BinaryValueHandle*; + auto HeapStats() -> BinaryValueHandle*; auto Eval(BinaryValueHandle* code_handle, uint64_t callback_id) -> uint64_t; @@ -81,7 +81,7 @@ class Context { IsolateMemoryMonitor isolate_memory_monitor_; BinaryValueFactory bv_factory_; BinaryValueRegistry bv_registry_; - RememberValueAndCallback callback_; + CallbackFn callback_; ContextHolder context_holder_; JSCallbackMaker js_callback_maker_; CodeEvaluator code_evaluator_; diff --git a/src/v8_py_frontend/context_factory.cc b/src/v8_py_frontend/context_factory.cc index c6bf1b2a..f2bc7344 100644 --- a/src/v8_py_frontend/context_factory.cc +++ b/src/v8_py_frontend/context_factory.cc @@ -28,7 +28,7 @@ auto ContextFactory::Get() -> ContextFactory* { return singleton_; } -auto ContextFactory::MakeContext(Callback callback) -> uint64_t { +auto ContextFactory::MakeContext(RawCallback callback) -> uint64_t { // Actually create the context before we get the lock, in case the program is // making Contexts in other threads: auto context = std::make_shared(current_platform_.get(), callback); diff --git a/src/v8_py_frontend/context_factory.h b/src/v8_py_frontend/context_factory.h index b17b2569..fc5c56e8 100644 --- a/src/v8_py_frontend/context_factory.h +++ b/src/v8_py_frontend/context_factory.h @@ -21,7 +21,7 @@ class ContextFactory { const std::filesystem::path& icu_path); static auto Get() -> ContextFactory*; - auto MakeContext(Callback callback) -> uint64_t; + auto MakeContext(RawCallback callback) -> uint64_t; auto GetContext(uint64_t context_id) -> std::shared_ptr; void FreeContext(uint64_t context_id); auto Count() -> size_t; diff --git a/src/v8_py_frontend/exports.cc b/src/v8_py_frontend/exports.cc index d761ee1c..1d6aba73 100644 --- a/src/v8_py_frontend/exports.cc +++ b/src/v8_py_frontend/exports.cc @@ -40,7 +40,7 @@ LIB_EXPORT void mr_init_v8(const char* v8_flags, const char* icu_path) { MiniRacer::ContextFactory::Init(v8_flags, icu_path); } -LIB_EXPORT auto mr_init_context(MiniRacer::Callback callback) -> uint64_t { +LIB_EXPORT auto mr_init_context(MiniRacer::RawCallback callback) -> uint64_t { auto* context_factory = MiniRacer::ContextFactory::Get(); if (context_factory == nullptr) { return 0; @@ -115,13 +115,13 @@ LIB_EXPORT void mr_cancel_task(uint64_t context_id, uint64_t task_id) { context->CancelTask(task_id); } -LIB_EXPORT auto mr_heap_stats(uint64_t context_id, - uint64_t callback_id) -> uint64_t { +LIB_EXPORT auto mr_heap_stats(uint64_t context_id) + -> MiniRacer::BinaryValueHandle* { auto context = GetContext(context_id); if (!context) { - return 0; + return nullptr; } - return context->HeapStats(callback_id); + return context->HeapStats(); } LIB_EXPORT void mr_set_hard_memory_limit(uint64_t context_id, size_t limit) { @@ -273,13 +273,13 @@ LIB_EXPORT auto mr_call_function(uint64_t context_id, callback_id); } -LIB_EXPORT auto mr_heap_snapshot(uint64_t context_id, - uint64_t callback_id) -> uint64_t { +LIB_EXPORT auto mr_heap_snapshot(uint64_t context_id) + -> MiniRacer::BinaryValueHandle* { auto context = GetContext(context_id); if (!context) { - return 0; + return nullptr; } - return context->HeapSnapshot(callback_id); + return context->HeapSnapshot(); } LIB_EXPORT auto mr_value_count(uint64_t context_id) -> size_t { diff --git a/src/v8_py_frontend/exports.h b/src/v8_py_frontend/exports.h index de59d16a..df2fd486 100644 --- a/src/v8_py_frontend/exports.h +++ b/src/v8_py_frontend/exports.h @@ -45,7 +45,7 @@ LIB_EXPORT auto mr_v8_is_using_sandbox() -> bool; * Consequently the best thing for the callback to do is to signal another * thread (e.g., using a future or thread-safe queue) and immediately return. **/ -LIB_EXPORT auto mr_init_context(MiniRacer::Callback callback) -> uint64_t; +LIB_EXPORT auto mr_init_context(MiniRacer::RawCallback callback) -> uint64_t; /** Free a MiniRacer context. * @@ -209,8 +209,7 @@ LIB_EXPORT auto mr_array_push(uint64_t context_id, /** Cancel the given asynchronous task. * - * (Such tasks are started by mr_eval, mr_call_function, mr_heap_stats, and - * mr_heap_snapshot). + * (Such tasks are started by mr_eval and mr_call_function). **/ LIB_EXPORT void mr_cancel_task(uint64_t context_id, uint64_t task_id); @@ -248,26 +247,16 @@ LIB_EXPORT auto mr_call_function(uint64_t context_id, /** Get stats for the V8 heap. * * This function is intended for use in debugging only. - * - * This call is processed asynchronously and as such accepts a callback ID. - * The callback ID and a MiniRacer::BinaryValueHandle* containing the - * evaluation result are passed back to the callback upon completion. A task ID - * is returned which can be passed back to mr_cancel_task to cancel evaluation. **/ -LIB_EXPORT auto mr_heap_stats(uint64_t context_id, - uint64_t callback_id) -> uint64_t; +LIB_EXPORT auto mr_heap_stats(uint64_t context_id) + -> MiniRacer::BinaryValueHandle*; /** Get a snapshot of the V8 heap. * * This function is intended for use in debugging only. - * - * This call is processed asynchronously and as such accepts a callback ID. - * The callback ID and a MiniRacer::BinaryValueHandle* containing the - * evaluation result are passed back to the callback upon completion. A task ID - * is returned which can be passed back to mr_cancel_task to cancel evaluation. **/ -LIB_EXPORT auto mr_heap_snapshot(uint64_t context_id, - uint64_t callback_id) -> uint64_t; +LIB_EXPORT auto mr_heap_snapshot(uint64_t context_id) + -> MiniRacer::BinaryValueHandle*; // NOLINTEND(bugprone-easily-swappable-parameters) diff --git a/src/v8_py_frontend/js_callback_maker.cc b/src/v8_py_frontend/js_callback_maker.cc index a9806baf..b1aff0d6 100644 --- a/src/v8_py_frontend/js_callback_maker.cc +++ b/src/v8_py_frontend/js_callback_maker.cc @@ -19,7 +19,7 @@ namespace MiniRacer { JSCallbackCaller::JSCallbackCaller(BinaryValueFactory* bv_factory, - RememberValueAndCallback callback) + CallbackFn callback) : bv_factory_(bv_factory), callback_(std::move(callback)) {} void JSCallbackCaller::DoCallback(v8::Local context, @@ -42,7 +42,7 @@ auto JSCallbackMaker::GetCallbackCallers() JSCallbackMaker::JSCallbackMaker(ContextHolder* context_holder, BinaryValueFactory* bv_factory, - RememberValueAndCallback callback) + CallbackFn callback) : context_holder_(context_holder), bv_factory_(bv_factory), callback_caller_holder_( diff --git a/src/v8_py_frontend/js_callback_maker.h b/src/v8_py_frontend/js_callback_maker.h index b17e0627..f86950d2 100644 --- a/src/v8_py_frontend/js_callback_maker.h +++ b/src/v8_py_frontend/js_callback_maker.h @@ -23,8 +23,7 @@ namespace MiniRacer { */ class JSCallbackCaller { public: - JSCallbackCaller(BinaryValueFactory* bv_factory, - RememberValueAndCallback callback); + JSCallbackCaller(BinaryValueFactory* bv_factory, CallbackFn callback); void DoCallback(v8::Local context, uint64_t callback_id, @@ -32,7 +31,7 @@ class JSCallbackCaller { private: BinaryValueFactory* bv_factory_; - RememberValueAndCallback callback_; + CallbackFn callback_; }; /** Creates a JS callback wrapped around the given C callback function pointer. @@ -41,7 +40,7 @@ class JSCallbackMaker { public: JSCallbackMaker(ContextHolder* context_holder, BinaryValueFactory* bv_factory, - RememberValueAndCallback callback); + CallbackFn callback); auto MakeJSCallback(v8::Isolate* isolate, uint64_t callback_id) -> BinaryValue::Ptr; diff --git a/tests/gc_check.py b/tests/gc_check.py index f62c42e4..ddce4cb2 100644 --- a/tests/gc_check.py +++ b/tests/gc_check.py @@ -1,6 +1,7 @@ from __future__ import annotations -from gc import collect +import asyncio +import gc from time import sleep, time from typing import TYPE_CHECKING @@ -8,6 +9,9 @@ from py_mini_racer import MiniRacer +_GC_WAIT_SECS = 5 + + def assert_no_v8_objects(mr: MiniRacer) -> None: """Test helper for garbage collection. @@ -16,12 +20,36 @@ def assert_no_v8_objects(mr: MiniRacer) -> None: all allocated objects). This is a somewhat kludgey test helper to verify those tricks are working. - The Python gc doesn't seem particularly deterministic, so we do 2 collects - and a sleep here to reduce the flake rate. + The Python gc doesn't seem particularly deterministic, so we do multiple + collects and a sleep here to reduce the test flake rate. """ + + ctx = mr._ctx # noqa: SLF001 + assert ctx is not None + start = time() - while time() - start < 5 and mr._ctx.value_count() != 0: # noqa: PLR2004, SLF001 - collect() + while time() - start < _GC_WAIT_SECS and ctx.value_count() != 0: + gc.collect() sleep(0.05) - assert mr._ctx.value_count() == 0 # noqa: SLF001 + # Thus should only be reachable if we forgot to wrap an incoming pointer with a + # ValueHandle (because ValueHandle.__del__ should otherwise take care of disposing + # the C++ object): + assert ctx.value_count() == 0, "Foud uncollected BinaryValues on the C++ side" + + +async def async_assert_no_v8_objects(mr: MiniRacer) -> None: + """See assert_no_v8_objects.""" + + ctx = mr._ctx # noqa: SLF001 + assert ctx is not None + + start = time() + while time() - start < _GC_WAIT_SECS and ctx.value_count() != 0: + gc.collect() + await asyncio.sleep(0.05) + + # Thus should only be reachable if we forgot to wrap an incoming pointer with a + # ValueHandle (because ValueHandle.__del__ should otherwise take care of disposing + # the C++ object): + assert ctx.value_count() == 0, "Foud uncollected BinaryValues on the C++ side" diff --git a/tests/test_eval.py b/tests/test_eval.py index 6bbd51b4..d5bebee7 100644 --- a/tests/test_eval.py +++ b/tests/test_eval.py @@ -2,7 +2,7 @@ from __future__ import annotations -from asyncio import run as asyncio_run +import asyncio from time import sleep, time from typing import cast @@ -19,7 +19,8 @@ JSUndefined, MiniRacer, ) -from tests.gc_check import assert_no_v8_objects +from py_mini_racer._mini_racer import mini_racer +from tests.gc_check import assert_no_v8_objects, async_assert_no_v8_objects # Wait time for async tests to finish. _ASYNC_COMPLETION_WAIT_SEC = 10 @@ -195,6 +196,9 @@ def test_timeout() -> None: assert exc_info.value.args[0] == "JavaScript was terminated by timeout" + # Make sure the isolate still accepts work: + assert mr.eval("1") == 1 + del exc_info assert_no_v8_objects(mr) @@ -218,6 +222,29 @@ def test_timeout_ms() -> None: assert_no_v8_objects(mr) +def test_timeout_async() -> None: + timeout = 0.3 + start_time = time() + + async def run_test() -> None: + with mini_racer() as mr: + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for( + mr.eval_cancelable("while (true) {}"), timeout=timeout + ) + + duration = time() - start_time + # Make sure it timed out on time, and allow a large leeway + assert timeout * 0.9 <= duration <= timeout + 2 + + # Make sure the isolate still accepts work: + assert mr.eval("1") == 1 + + await async_assert_no_v8_objects(mr) + + asyncio.run(run_test()) + + def test_max_memory_soft() -> None: mr = MiniRacer() mr.set_soft_memory_limit(100000000) @@ -418,25 +445,26 @@ def test_promise_sync() -> None: def test_promise_async() -> None: - mr = MiniRacer() - async def run_test() -> None: - p = cast( - "JSPromise", - mr.eval( - """ + with mini_racer() as mr: + p = cast( + "JSPromise", + mr.eval( + """ new Promise((res, rej) => setTimeout(() => res(42), 1000)); // 1 s timeout """ - ), - ) - start = time() - result = await p - assert time() - start > 0.5 # noqa: PLR2004 - assert time() - start < _ASYNC_COMPLETION_WAIT_SEC - assert result == 42 # noqa: PLR2004 + ), + ) - asyncio_run(run_test()) - assert_no_v8_objects(mr) + start = time() + result = await p + assert time() - start > 0.5 # noqa: PLR2004 + assert time() - start < _ASYNC_COMPLETION_WAIT_SEC + assert result == 42 # noqa: PLR2004 + del p, result + await async_assert_no_v8_objects(mr) + + asyncio.run(run_test()) def test_resolved_promise_sync() -> None: @@ -451,15 +479,16 @@ def test_resolved_promise_sync() -> None: def test_resolved_promise_async() -> None: - mr = MiniRacer() - async def run_test() -> None: - p = cast("JSPromise", mr.eval("Promise.resolve(6*7)")) - val = await p - assert val == 42 # noqa: PLR2004 + with mini_racer() as mr: + p = cast("JSPromise", mr.eval("Promise.resolve(6*7)")) + val = await p - asyncio_run(run_test()) - assert_no_v8_objects(mr) + assert val == 42 # noqa: PLR2004 + del p, val + await async_assert_no_v8_objects(mr) + + asyncio.run(run_test()) def test_rejected_promise_sync() -> None: @@ -482,23 +511,25 @@ def test_rejected_promise_sync() -> None: def test_rejected_promise_async() -> None: - mr = MiniRacer() - async def run_test() -> None: - p = cast("JSPromise", mr.eval("Promise.reject(new Error('this is an error'))")) - with pytest.raises(JSPromiseError) as exc_info: - await p - - assert ( - exc_info.value.args[0] - == """\ + with mini_racer() as mr: + p = cast( + "JSPromise", mr.eval("Promise.reject(new Error('this is an error'))") + ) + with pytest.raises(JSPromiseError) as exc_info: + await p + + assert ( + exc_info.value.args[0] + == """\ JavaScript rejected promise with reason: Error: this is an error at :1:16 """ - ) + ) + del p, exc_info + await async_assert_no_v8_objects(mr) - asyncio_run(run_test()) - assert_no_v8_objects(mr) + asyncio.run(run_test()) def test_rejected_promise_sync_stringerror() -> None: diff --git a/tests/test_functions.py b/tests/test_functions.py index e8c54a55..7662bddb 100644 --- a/tests/test_functions.py +++ b/tests/test_functions.py @@ -2,12 +2,14 @@ from __future__ import annotations +import asyncio from typing import TYPE_CHECKING, cast import pytest from py_mini_racer import JSEvalException, JSTimeoutException, MiniRacer -from tests.gc_check import assert_no_v8_objects +from py_mini_racer._mini_racer import mini_racer +from tests.gc_check import assert_no_v8_objects, async_assert_no_v8_objects if TYPE_CHECKING: from py_mini_racer import JSArray, JSFunction, JSMappedObject @@ -85,5 +87,24 @@ def test_timeout() -> None: assert exc_info.value.args[0] == "JavaScript was terminated by timeout" + # make sure the isolate still accepts work: + assert mr.eval("1") == 1 + del func, exc_info assert_no_v8_objects(mr) + + +def test_timeout_async() -> None: + async def run_test() -> None: + with mini_racer() as mr: + func = cast("JSFunction", mr.eval("() => { while(1) { } }")).cancelable() + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(func(), timeout=1) + + # make sure the isolate still accepts work: + assert mr.eval("1") == 1 + + del func + await async_assert_no_v8_objects(mr) + + asyncio.run(run_test()) diff --git a/tests/test_heap.py b/tests/test_heap.py index 4c91af99..1c449d47 100644 --- a/tests/test_heap.py +++ b/tests/test_heap.py @@ -11,3 +11,12 @@ def test_heap_stats() -> None: assert mr.heap_stats()["total_heap_size"] > 0 assert_no_v8_objects(mr) + + +def test_heap_snapshot() -> None: + mr = MiniRacer() + + assert mr.heap_snapshot()["edges"] + assert mr.heap_snapshot()["strings"] + + assert_no_v8_objects(mr) diff --git a/tests/test_init.py b/tests/test_init.py index c0b6f3af..2f2377bc 100644 --- a/tests/test_init.py +++ b/tests/test_init.py @@ -44,7 +44,9 @@ def test_version() -> None: def test_sandbox() -> None: mr = MiniRacer() - assert mr._ctx.v8_is_using_sandbox() # noqa: SLF001 + ctx = mr._ctx # noqa: SLF001 + assert ctx is not None + assert ctx.v8_is_using_sandbox() def test_del() -> None: diff --git a/tests/test_py_js_function.py b/tests/test_py_js_function.py index ec968d43..df174b4f 100644 --- a/tests/test_py_js_function.py +++ b/tests/test_py_js_function.py @@ -10,42 +10,49 @@ import pytest -from py_mini_racer import JSPromise, JSPromiseError, MiniRacer -from tests.gc_check import assert_no_v8_objects +from py_mini_racer import JSFunction, JSPromise, JSPromiseError, mini_racer +from tests.gc_check import async_assert_no_v8_objects if TYPE_CHECKING: from py_mini_racer import JSFunction + from py_mini_racer._mini_racer import MiniRacer -def test_basic() -> None: - mr = MiniRacer() +_NUM_LOOPS = 10 + +def test_basic() -> None: data = [] async def append(*args: Any) -> str: # noqa: ANN401 data.append(args) return "foobar" - async def run() -> None: + async def define_and_use_function(mr: MiniRacer) -> None: async with mr.wrap_py_function(append) as jsfunc: # "Install" our JS function on the global "this" object: cast("JSFunction", mr.eval("x => this.func = x"))(jsfunc) + # Call our function a couple times, through JS: assert await cast("JSPromise", mr.eval("this.func(42)")) == "foobar" assert await cast("JSPromise", mr.eval('this.func("blah")')) == "foobar" - assert data == [(42,), ("blah",)] - - for _ in range(100): + assert data == [(42,), ("blah",)] data[:] = [] - asyncio_run(run()) - assert_no_v8_objects(mr) + async def run() -> None: + with mini_racer() as mr: + for _ in range(_NUM_LOOPS): + await define_and_use_function(mr) + + await async_assert_no_v8_objects(mr) + + for _ in range(_NUM_LOOPS): + asyncio_run(run()) def test_exception() -> None: # Test a Python callback which raises exceptions - mr = MiniRacer() async def append(*args: Any) -> NoReturn: # noqa: ANN401 del args @@ -53,37 +60,37 @@ async def append(*args: Any) -> NoReturn: # noqa: ANN401 raise RuntimeError(boo) async def run() -> None: - async with mr.wrap_py_function(append) as jsfunc: - # "Install" our JS function on the global "this" object: - cast("JSFunction", mr.eval("x => this.func = x"))(jsfunc) + with mini_racer() as mr: + async with mr.wrap_py_function(append) as jsfunc: + # "Install" our JS function on the global "this" object: + cast("JSFunction", mr.eval("x => this.func = x"))(jsfunc) - with pytest.raises(JSPromiseError) as exc_info: - await cast("JSPromise", mr.eval("this.func(42)")) + with pytest.raises(JSPromiseError) as exc_info: + await cast("JSPromise", mr.eval("this.func(42)")) - assert exc_info.value.args[0].startswith( - """\ + assert exc_info.value.args[0].startswith( + """\ JavaScript rejected promise with reason: Error: Error running Python function: Traceback (most recent call last): """ - ) + ) - assert exc_info.value.args[0].endswith( - """\ + assert exc_info.value.args[0].endswith( + """\ at :1:6 """ - ) + ) - for _ in range(100): - asyncio_run(run()) + del exc_info, jsfunc + await async_assert_no_v8_objects(mr) - assert_no_v8_objects(mr) + for _ in range(_NUM_LOOPS): + asyncio_run(run()) def test_slow() -> None: # Test a Python callback which runs slowly, but is faster in parallel. - mr = MiniRacer() - data = [] async def append(*args: Any) -> str: # noqa: ANN401 @@ -92,15 +99,23 @@ async def append(*args: Any) -> str: # noqa: ANN401 return "foobar" async def run() -> None: - async with mr.wrap_py_function(append) as jsfunc: - # "Install" our JS function on the global "this" object: - cast("JSFunction", mr.eval("x => this.func = x"))(jsfunc) + with mini_racer() as mr: + async with mr.wrap_py_function(append) as jsfunc: + # "Install" our JS function on the global "this" object: + cast("JSFunction", mr.eval("x => this.func = x"))(jsfunc) - pending = [cast("JSPromise", mr.eval("this.func(42)")) for _ in range(100)] + pending = [ + cast("JSPromise", mr.eval("this.func(42)")) for _ in range(100) + ] - assert await gather(*pending) == ["foobar"] * 100 + assert await gather(*pending) == ["foobar"] * 100 assert data == [(42,)] * 100 + data.clear() + del pending, jsfunc + await async_assert_no_v8_objects(mr) + + data.clear() start = time() asyncio_run(run()) @@ -109,45 +124,44 @@ async def run() -> None: # sequentially): assert time() - start < 10 # noqa: PLR2004 - assert_no_v8_objects(mr) - def test_call_on_exit() -> None: """Checks that calls from JS made while we're trying to tear down the wrapped function are ignored and don't break anything.""" - mr = MiniRacer() - data = [] async def run() -> None: - fut: Future[None] = Future() + append_called_fut: Future[None] = Future() async def append(*args: Any) -> str: # noqa: ANN401 data.append(args) - fut.set_result(None) + append_called_fut.set_result(None) # sleep long enough that the test will fail unless this is either # interrupted, or never started to begin with: await asyncio_sleep(10000) return "foobar" - async with mr.wrap_py_function(append) as jsfunc: - # "Install" our JS function on the global "this" object: - cast("JSFunction", mr.eval("x => this.func = x"))(jsfunc) + with mini_racer() as mr: + async with mr.wrap_py_function(append) as jsfunc: + # "Install" our JS function on the global "this" object: + cast("JSFunction", mr.eval("x => this.func = x"))(jsfunc) + + # Note: we don't await the promise, meaning we just start a call and + # never finish it: + assert isinstance(mr.eval("this.func(42)"), JSPromise) - # Note: we don't await the promise, meaning we just start a call and never - # finish it: - assert isinstance(mr.eval("this.func(42)"), JSPromise) + await append_called_fut - await fut + # After this line, we start tearing down the mr.wrap_py_function context + # manager, which entails stopping the call processor. + # Let's make sure we don't fall over ourselves (it's fair to either + # process the last straggling calls, or ignore them, but make sure we + # don't hang). - # After this line, we start tearing down the mr.wrap_py_function context - # manager, which entails stopping the call processor. - # Let's make sure we don't fall over ourselves (it's fair to either process - # the last straggling calls, or ignore them, but make sure we don't hang). + del jsfunc + await async_assert_no_v8_objects(mr) - for _ in range(100): + for _ in range(_NUM_LOOPS): data[:] = [] asyncio_run(run()) - - assert_no_v8_objects(mr) diff --git a/uv.lock b/uv.lock index 2e0545bf..695bd8bb 100644 --- a/uv.lock +++ b/uv.lock @@ -491,7 +491,7 @@ wheels = [ [[package]] name = "mini-racer" -version = "0.13.2" +version = "0.14.0" source = { editable = "." } [package.dev-dependencies]