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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions examples/tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ def test_examples_screenshots(
def unload_module():
del sys.modules[module_name]

request.addfinalizer(unload_module)
if request:
request.addfinalizer(unload_module)

if not hasattr(example, "canvas"):
# some examples we screenshot test don't have a canvas as a global variable when imported,
Expand Down Expand Up @@ -188,4 +189,4 @@ def test_examples_run(module, force_offscreen):
os.environ["RENDERCANVAS_FORCE_OFFSCREEN"] = "true"
pytest.getoption = lambda x: False
is_lavapipe = True
test_examples_screenshots("validate_volume", pytest, None, None)
test_examples_screenshots("cube", pytest, mock_time, None, None)
2 changes: 1 addition & 1 deletion tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ def test_enums_and_flags_and_structs():

def test_base_wgpu_api():
# Fake a device and an adapter
adapter = wgpu.GPUAdapter(None, set(), {}, wgpu.GPUAdapterInfo({}), None)
adapter = wgpu.GPUAdapter(None, set(), {}, wgpu.GPUAdapterInfo({}))
queue = wgpu.GPUQueue("", None, None)
device = wgpu.GPUDevice("device08", -1, adapter, {42, 43}, {}, queue)

Expand Down
32 changes: 16 additions & 16 deletions tests/test_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ def poller():
async def test_promise_async_loop_simple():
loop = SillyLoop()

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

loop.process_events()
result = await promise
Expand All @@ -226,7 +226,7 @@ async def test_promise_async_loop_normal():
def handler(input):
return input * 2

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

loop.process_events()
result = await promise
Expand All @@ -240,7 +240,7 @@ async def test_promise_async_loop_fail2():
def handler(input):
return input / 0

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

loop.process_events()
with raises(ZeroDivisionError):
Expand Down Expand Up @@ -272,7 +272,7 @@ def callback(r):
nonlocal result
result = r

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

promise.then(callback)
loop.process_events()
Expand All @@ -291,7 +291,7 @@ def callback(r):
def handler(input):
return input * 2

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

promise.then(callback)
loop.process_events()
Expand All @@ -315,7 +315,7 @@ def err_callback(e):
def handler(input):
return input / 0

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

promise.then(callback, err_callback)
loop.process_events()
Expand All @@ -338,7 +338,7 @@ def callback1(r):
nonlocal result
result = r

promise = MyPromise("test", None, loop=loop)
promise = MyPromise("test", None, _loop=loop)

p = promise.then(callback1)
loop.process_events()
Expand Down Expand Up @@ -371,7 +371,7 @@ def callback3(r):
nonlocal result
result = r

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

p = promise.then(callback1).then(callback2).then(callback3)
assert isinstance(p, GPUPromise)
Expand Down Expand Up @@ -400,7 +400,7 @@ def err_callback(e):
nonlocal error
error = e

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

p = promise.then(callback1).then(callback2).then(callback3, err_callback)
assert isinstance(p, GPUPromise)
Expand Down Expand Up @@ -430,7 +430,7 @@ def err_callback(e):
nonlocal error
error = e

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

p = promise.then(callback1).then(callback2).then(callback3, err_callback)
assert isinstance(p, GPUPromise)
Expand All @@ -454,7 +454,7 @@ def callback2(r):
def callback3(r):
results.append(r * 3)

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

promise.then(callback1)
promise.then(callback2)
Expand All @@ -473,7 +473,7 @@ def test_promise_chaining_after_resolve():
def callback1(r):
results.append(r)

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

# Adding handler has no result, because promise is not yet resolved.
promise.then(callback1)
Expand Down Expand Up @@ -503,16 +503,16 @@ def test_promise_chaining_with_promises():
result = None

def callback1(r):
return GPUPromise("test", lambda _: r * 3, loop=loop)
return GPUPromise("test", lambda _: r * 3, _loop=loop)

def callback2(r):
return GPUPromise("test", lambda _: r + 2, loop=loop)
return GPUPromise("test", lambda _: r + 2, _loop=loop)

def callback3(r):
nonlocal result
result = r

promise = GPUPromise("test", None, loop=loop)
promise = GPUPromise("test", None, _loop=loop)

p = promise.then(callback1).then(callback2).then(callback3)
assert isinstance(p, GPUPromise)
Expand All @@ -535,7 +535,7 @@ def test_promise_decorator():
def handler(input):
return input * 2

promise = GPUPromise("test", handler, loop=loop)
promise = GPUPromise("test", handler, _loop=loop)

@promise
def decorated(r):
Expand Down
84 changes: 60 additions & 24 deletions wgpu/_async.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,43 @@
logger = logging.getLogger("wgpu")


class StubLoop:
def __init__(self, name, call_soon_threadsafe):
self.name = name
self.call_soon_threadsafe = call_soon_threadsafe

def __repr__(self):
return f"<StubLoop for {self.name} at {hex(id(self))}>"


def get_running_loop():
"""Get an object with a call_soon_threadsafe() method.

Sniffio is used for this, and it supports asyncio, trio, and rendercanvas.utils.asyncadapter.
If this function returns None, it means that the GPUPromise will not support ``await`` and ``.then()``.

It's relatively easy to register a custom loop to sniffio so that this code works on it.
"""

try:
name = sniffio.current_async_library()
except sniffio.AsyncLibraryNotFoundError:
return None

if name == "trio":
trio = sys.modules[name]
token = trio.lowlevel.current_trio_token()
return StubLoop("trio", token.run_sync_soon)
else: # asyncio, rendercanvas.utils.asyncadapter, and easy to mimic for custom loops
try:
mod = sys.modules[name]
loop = mod.get_running_loop()
loop.call_soon_threadsafe # noqa: B018 - access to make sure it exists
return loop
except Exception:
return None


# The async_sleep and AsyncEvent are a copy of the implementation in rendercanvas.asyncs


Expand All @@ -35,16 +72,6 @@ def __new__(cls):
AwaitedType = TypeVar("AwaitedType")


class LoopInterface:
"""A loop object must have (at least) this API.

Rendercanvas loop objects do, asyncio.loop does too.
"""

def call_soon(self, callback: Callable, *args: object):
raise NotImplementedError()


def get_backoff_time_generator() -> Generator[float, None, None]:
"""Generates sleep-times, start at 0 then increasing to 100Hz and sticking there."""
for _ in range(5):
Expand Down Expand Up @@ -88,24 +115,20 @@ def __init__(
title: str,
handler: Callable | None,
*,
loop: LoopInterface | None = None,
keepalive: object = None,
_loop: object = None, # for testing and chaining
):
"""
Arguments:
title (str): The title of this promise, mostly for debugging purposes.
handler (callable, optional): The function to turn promise input into the result. If None,
the result will simply be the input.
loop (LoopInterface, optional): A loop object that at least has a ``call_soon()`` method.
If not given, this promise does not support .then() or promise-chaining.
keepalive (object, optional): Pass any data via this arg who's lifetime must be bound to the
resolving of this promise.

"""
self._title = str(title) # title for debugging
self._handler = handler # function to turn input into the result

self._loop = loop # Event loop instance, can be None
self._keepalive = keepalive # just to keep something alive

self._state = "pending" # "pending", "pending-rejected", "pending-fulfilled", "rejected", "fulfilled"
Expand All @@ -117,6 +140,9 @@ def __init__(
self._error_callbacks = []
self._UNRESOLVED.add(self)

# we only care about call_soon_threadsafe, but clearer to just have a loop object
self._loop = _loop or get_running_loop()
Copy link
Collaborator

@Korijn Korijn Nov 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am a little worried about the overhead of instantiating a GPUPromise though :o it seems like a bunch of time-consuming work on a potentially hot code path


def __repr__(self):
return f"<GPUPromise '{self._title}' {self._state} at {hex(id(self))}>"

Expand All @@ -140,7 +166,9 @@ def _set_input(self, result: object, *, resolve_now=True) -> None:
# If the input is a promise, we need to wait for it, i.e. chain to self.
if isinstance(result, GPUPromise):
if self._loop is None:
self._set_error("Cannot chain GPUPromise if the loop is not set.")
self._set_error(
"Cannot chain GPUPromise because no running loop could be detected."
)
else:
result._chain(self)
return
Expand Down Expand Up @@ -197,9 +225,12 @@ def _resolve_callback(self):
# Allow tasks that await this promise to continue.
if self._async_event is not None:
self._async_event.set()
# The callback may already be resolved
# If the value is set, let's resolve it so the handlers get called. But swallow the promise's value/failure.
if self._state.startswith("pending-"):
self._resolve()
try:
self._resolve()
except Exception:
pass

def _resolve(self):
"""Finalize the promise, by calling the handler to get the result, and then invoking callbacks."""
Expand Down Expand Up @@ -253,7 +284,7 @@ def sync_wait(self) -> AwaitedType:

def _sync_wait(self):
# Each subclass may implement this in its own way. E.g. it may wait for
# the _thread_event, it may poll the device in a loop while checking the
# the _thread_event, it may poll the device in a while-loop while checking the
# status, and Pyodide may use its special logic to sync wait the JS
# promise.
raise NotImplementedError()
Expand All @@ -276,7 +307,9 @@ def then(
The callback will receive one argument: the result of the promise.
"""
if self._loop is None:
raise RuntimeError("Cannot use GPUPromise.then() if the loop is not set.")
raise RuntimeError(
"Cannot use GPUPromise.then() because no running loop could be detected."
)
if not callable(callback):
raise TypeError(
f"GPUPromise.then() got a callback that is not callable: {callback!r}"
Expand All @@ -293,7 +326,7 @@ def then(
title = self._title + " -> " + callback_name

# Create new promise
new_promise = self.__class__(title, callback, loop=self._loop)
new_promise = self.__class__(title, callback, _loop=self._loop)
self._chain(new_promise)

if error_callback is not None:
Expand All @@ -307,7 +340,9 @@ def catch(self, callback: Callable[[Exception], None] | None):
The callback will receive one argument: the error object.
"""
if self._loop is None:
raise RuntimeError("Cannot use GPUPromise.catch() if the loop is not set.")
raise RuntimeError(
"Cannot use GPUPromise.catch() because not running loop could be detected."
)
if not callable(callback):
raise TypeError(
f"GPUPromise.catch() got a callback that is not callable: {callback!r}"
Expand All @@ -317,7 +352,7 @@ def catch(self, callback: Callable[[Exception], None] | None):
title = "Catcher for " + self._title

# Create new promise
new_promise = self.__class__(title, callback, loop=self._loop)
new_promise = self.__class__(title, callback, _loop=self._loop)

# Custom chain
with self._lock:
Expand All @@ -329,7 +364,8 @@ def catch(self, callback: Callable[[Exception], None] | None):

def __await__(self):
if self._loop is None:
# An async busy loop
# An async busy loop. In theory we should be able to remove this code, but it helps make the transition
# simpler, since then we depend less on https://github.com/pygfx/rendercanvas/pull/151
async def awaiter():
if self._state == "pending":
# Do small incremental async naps. Other tasks and threads can run.
Expand Down
Loading
Loading