Skip to content

Commit

Permalink
Apply rate limits for events (#247)
Browse files Browse the repository at this point in the history
* Add support for rate limiting events

* rate-limit glfw events

* rate-limit qt events

* comments, docstrings, and move code around

* Improve docs
  • Loading branch information
almarklein authored Mar 24, 2022
1 parent c52df1a commit 285e7ae
Show file tree
Hide file tree
Showing 4 changed files with 119 additions and 62 deletions.
114 changes: 88 additions & 26 deletions wgpu/gui/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,44 @@
import sys
import time
import logging
from contextlib import contextmanager
import ctypes.util
from collections import defaultdict


logger = logging.getLogger("wgpu")

err_hashes = {}


@contextmanager
def log_exception(kind):
"""Context manager to log any exceptions, but only log a one-liner
for subsequent occurances of the same error to avoid spamming by
repeating errors in e.g. a draw function or event callback.
"""
try:
yield
except Exception as err:
# Store exc info for postmortem debugging
exc_info = list(sys.exc_info())
exc_info[2] = exc_info[2].tb_next # skip *this* function
sys.last_type, sys.last_value, sys.last_traceback = exc_info
# Show traceback, or a one-line summary
msg = str(err)
msgh = hash(msg)
if msgh not in err_hashes:
# Provide the exception, so the default logger prints a stacktrace.
# IDE's can get the exception from the root logger for PM debugging.
err_hashes[msgh] = 1
logger.error(kind, exc_info=err)
else:
# We've seen this message before, return a one-liner instead.
err_hashes[msgh] = count = err_hashes[msgh] + 1
msg = kind + ": " + msg.split("\n")[0].strip()
msg = msg if len(msg) <= 70 else msg[:69] + "…"
logger.error(msg + f" ({count})")


class WgpuCanvasInterface:
"""This is the interface that a canvas object must implement in order
Expand Down Expand Up @@ -109,41 +141,18 @@ def _draw_frame_and_present(self):
# Perform the user-defined drawing code. When this errors,
# we should report the error and then continue, otherwise we crash.
# Returns the result of the context's present() call or None.
try:
with log_exception("Draw error"):
self.draw_frame()
except Exception as err:
self._log_exception("Draw error", err)
try:
with log_exception("Present error"):
if self._canvas_context:
return self._canvas_context.present()
except Exception as err:
self._log_exception("Present error", err)

def _get_draw_wait_time(self):
"""Get time (in seconds) to wait until the next draw in order to honour max_fps."""
now = time.perf_counter()
target_time = self._last_draw_time + 1.0 / self._max_fps
return max(0, target_time - now)

def _log_exception(self, kind, err):
"""Log the given exception instance, but only log a one-liner for
subsequent occurances of the same error to avoid spamming (which
can happen easily with errors in the drawing code).
"""
msg = str(err)
msgh = hash(msg)
if msgh not in self._err_hashes:
# Provide the exception, so the default logger prints a stacktrace.
# IDE's can get the exception from the root logger for PM debugging.
self._err_hashes[msgh] = 1
logger.error(kind, exc_info=err)
else:
# We've seen this message before, return a one-liner instead.
self._err_hashes[msgh] = count = self._err_hashes[msgh] + 1
msg = kind + ": " + msg.split("\n")[0].strip()
msg = msg if len(msg) <= 70 else msg[:69] + "…"
logger.error(msg + f" ({count})")

# Methods that must be overloaded

def get_pixel_ratio(self):
Expand Down Expand Up @@ -184,8 +193,45 @@ class WgpuAutoGui:

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._last_event_time = 0
self._pending_events = {}
self._event_handlers = defaultdict(set)

def _get_event_wait_time(self):
"""Calculate the time to wait for the next event dispatching
(for rate-limited events)."""
rate = 75 # events per second
now = time.perf_counter()
target_time = self._last_event_time + 1.0 / rate
return max(0, target_time - now)

def _handle_event_rate_limited(self, ev, call_later_func, match_keys, accum_keys):
"""Alternative `to handle_event()` for events that must be rate-limted.
If any of the `match_keys` keys of the new event differ from the currently
pending event, the old event is dispatched now. The `accum_keys` keys of
the current and new event are added together (e.g. to accumulate wheel delta).
This method is called in the following cases:
* When the timer runs out.
* When a non-rate-limited event is dispatched.
* When a rate-limited event of the same type is scheduled
that has different match_keys (e.g. modifiers changes).
"""
event_type = ev["event_type"]
# We may need to emit the old event. Otherwise, we need to update the new one.
old = self._pending_events.get(event_type, None)
if old:
if any(ev[key] != old[key] for key in match_keys):
self._dispatch_event(old)
else:
for key in accum_keys:
ev[key] = old[key] + ev[key]
# Make sure that we have scheduled a moment to handle events
if not self._pending_events:
call_later_func(self._get_event_wait_time(), self._dispatch_pending_events)
# Store the event object
self._pending_events[event_type] = ev

def handle_event(self, event):
"""Handle an incoming event.
Expand All @@ -194,9 +240,25 @@ def handle_event(self, event):
is a dict with at least the key event_type. For details, see
https://jupyter-rfb.readthedocs.io/en/latest/events.html
"""
# On any not-rate-limited event, we dispatch any pending events.
# This is to make sure that the original order of events is preserved.
self._dispatch_pending_events()
self._dispatch_event(event)

def _dispatch_pending_events(self):
"""Handle any pending rate-limited events."""
events = self._pending_events.values()
self._last_event_time = time.perf_counter()
self._pending_events = {}
for ev in events:
self._dispatch_event(ev)

def _dispatch_event(self, event):
"""Dispatch event to the event handlers."""
event_type = event.get("event_type")
for callback in self._event_handlers[event_type]:
callback(event)
with log_exception(f"Error during handling {event['event_type']} event"):
callback(event)

def add_event_handler(self, *args):
"""Register an event handler.
Expand Down
33 changes: 15 additions & 18 deletions wgpu/gui/glfw.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import time
import weakref
import asyncio
import traceback

import glfw

Expand Down Expand Up @@ -200,7 +199,7 @@ def _on_size_change(self, *args):
def _on_close(self, *args):
all_glfw_canvases.discard(self)
glfw.hide_window(self._window)
self._emit_event({"event_type": "close"})
self.handle_event({"event_type": "close"})

def _on_window_dirty(self, *args):
self._request_draw()
Expand Down Expand Up @@ -230,7 +229,7 @@ def _determine_size(self):
"height": self._logical_size[1],
"pixel_ratio": self._pixel_ratio,
}
self._emit_event(ev)
self.handle_event(ev)

def _set_logical_size(self, new_logical_size):
# There is unclarity about the window size in "screen pixels".
Expand Down Expand Up @@ -316,16 +315,6 @@ def close(self):
def is_closed(self):
return glfw.window_should_close(self._window)

def _emit_event(self, event):
try:
self.handle_event(event)
except Exception:
# Print exception and store exc info for postmortem debugging
exc_info = list(sys.exc_info())
exc_info[2] = exc_info[2].tb_next # skip *this* function
sys.last_type, sys.last_value, sys.last_traceback = exc_info
traceback.print_exception(*exc_info)

# User events

def _on_mouse_button(self, window, but, action, mods):
Expand Down Expand Up @@ -362,8 +351,11 @@ def _on_mouse_button(self, window, but, action, mods):
"ntouches": 0, # glfw dows not have touch support
"touches": {},
}
self._emit_event(ev)

# Emit the current event
self.handle_event(ev)

# Maybe emit a double-click event
self._follow_double_click(action, button)

def _follow_double_click(self, action, button):
Expand Down Expand Up @@ -413,7 +405,7 @@ def _follow_double_click(self, action, button):
"ntouches": 0, # glfw dows not have touch support
"touches": {},
}
self._emit_event(ev)
self.handle_event(ev)

def _on_cursor_pos(self, window, x, y):
# Store pointer position in logical coordinates
Expand All @@ -432,7 +424,10 @@ def _on_cursor_pos(self, window, x, y):
"ntouches": 0, # glfw dows not have touch support
"touches": {},
}
self._emit_event(ev)

match_keys = {"buttons", "modifiers", "ntouches"}
accum_keys = {}
self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys)

def _on_scroll(self, window, dx, dy):
# wheel is 1 or -1 in glfw, in jupyter_rfb this is ~100
Expand All @@ -444,7 +439,9 @@ def _on_scroll(self, window, dx, dy):
"y": self._pointer_pos[1],
"modifiers": list(self._key_modifiers),
}
self._emit_event(ev)
match_keys = {"modifiers"}
accum_keys = {"dx", "dy"}
self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys)

def _on_key(self, window, key, scancode, action, mods):

Expand Down Expand Up @@ -481,7 +478,7 @@ def _on_key(self, window, key, scancode, action, mods):
"key": keyname,
"modifiers": list(self._key_modifiers),
}
self._emit_event(ev)
self.handle_event(ev)


# Make available under a name that is the same for all gui backends
Expand Down
2 changes: 2 additions & 0 deletions wgpu/gui/jupyter.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ def handle_event(self, event):
self._pixel_ratio = event["pixel_ratio"]
self._logical_size = event["width"], event["height"]

# No need to rate-limit the pointer_move and wheel events;
# they're already rate limited by jupyter_rfb in the client.
super().handle_event(event)

def get_frame(self):
Expand Down
32 changes: 14 additions & 18 deletions wgpu/gui/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@
import ctypes
import importlib
import sys
import traceback

from .base import WgpuCanvasBase, WgpuAutoGui


# Select GUI toolkit
for libname in ("PySide6", "PyQt6", "PySide2", "PyQt5"):
if libname in sys.modules:
Expand Down Expand Up @@ -267,18 +267,6 @@ def get_context(self, *args, **kwargs):
def request_draw(self, *args, **kwargs):
return self._subwidget.request_draw(*args, **kwargs)

# Auto event API

def _emit_event(self, event):
try:
self.handle_event(event)
except Exception:
# Print exception and store exc info for postmortem debugging
exc_info = list(sys.exc_info())
exc_info[2] = exc_info[2].tb_next # skip *this* function
sys.last_type, sys.last_value, sys.last_traceback = exc_info
traceback.print_exception(*exc_info)

# User events to jupyter_rfb events

def _key_event(self, event_type, event):
Expand All @@ -293,7 +281,7 @@ def _key_event(self, event_type, event):
"key": KEY_MAP.get(event.key(), event.text()),
"modifiers": modifiers,
}
self._emit_event(ev)
self.handle_event(ev)

def keyPressEvent(self, event): # noqa: N802
self._key_event("key_down", event)
Expand Down Expand Up @@ -331,7 +319,13 @@ def _mouse_event(self, event_type, event, touches=True):
"touches": {}, # TODO
}
)
self._emit_event(ev)

if event_type == "pointer_move":
match_keys = {"buttons", "modifiers", "ntouches"}
accum_keys = {}
self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys)
else:
self.handle_event(ev)

def mousePressEvent(self, event): # noqa: N802
self._mouse_event("pointer_down", event)
Expand Down Expand Up @@ -362,7 +356,9 @@ def wheelEvent(self, event): # noqa: N802
"y": event.position().y(),
"modifiers": modifiers,
}
self._emit_event(ev)
match_keys = {"modifiers"}
accum_keys = {"dx", "dy"}
self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys)

def resizeEvent(self, event): # noqa: N802
ev = {
Expand All @@ -371,10 +367,10 @@ def resizeEvent(self, event): # noqa: N802
"height": float(event.size().height()),
"pixel_ratio": self.get_pixel_ratio(),
}
self._emit_event(ev)
self.handle_event(ev)

def closeEvent(self, event): # noqa: N802
self._emit_event({"event_type": "close"})
self.handle_event({"event_type": "close"})


# Make available under a name that is the same for all gui backends
Expand Down

0 comments on commit 285e7ae

Please sign in to comment.