Skip to content

Commit 285e7ae

Browse files
authored
Apply rate limits for events (#247)
* Add support for rate limiting events * rate-limit glfw events * rate-limit qt events * comments, docstrings, and move code around * Improve docs
1 parent c52df1a commit 285e7ae

File tree

4 files changed

+119
-62
lines changed

4 files changed

+119
-62
lines changed

wgpu/gui/base.py

Lines changed: 88 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,44 @@
22
import sys
33
import time
44
import logging
5+
from contextlib import contextmanager
56
import ctypes.util
67
from collections import defaultdict
78

89

910
logger = logging.getLogger("wgpu")
1011

12+
err_hashes = {}
13+
14+
15+
@contextmanager
16+
def log_exception(kind):
17+
"""Context manager to log any exceptions, but only log a one-liner
18+
for subsequent occurances of the same error to avoid spamming by
19+
repeating errors in e.g. a draw function or event callback.
20+
"""
21+
try:
22+
yield
23+
except Exception as err:
24+
# Store exc info for postmortem debugging
25+
exc_info = list(sys.exc_info())
26+
exc_info[2] = exc_info[2].tb_next # skip *this* function
27+
sys.last_type, sys.last_value, sys.last_traceback = exc_info
28+
# Show traceback, or a one-line summary
29+
msg = str(err)
30+
msgh = hash(msg)
31+
if msgh not in err_hashes:
32+
# Provide the exception, so the default logger prints a stacktrace.
33+
# IDE's can get the exception from the root logger for PM debugging.
34+
err_hashes[msgh] = 1
35+
logger.error(kind, exc_info=err)
36+
else:
37+
# We've seen this message before, return a one-liner instead.
38+
err_hashes[msgh] = count = err_hashes[msgh] + 1
39+
msg = kind + ": " + msg.split("\n")[0].strip()
40+
msg = msg if len(msg) <= 70 else msg[:69] + "…"
41+
logger.error(msg + f" ({count})")
42+
1143

1244
class WgpuCanvasInterface:
1345
"""This is the interface that a canvas object must implement in order
@@ -109,41 +141,18 @@ def _draw_frame_and_present(self):
109141
# Perform the user-defined drawing code. When this errors,
110142
# we should report the error and then continue, otherwise we crash.
111143
# Returns the result of the context's present() call or None.
112-
try:
144+
with log_exception("Draw error"):
113145
self.draw_frame()
114-
except Exception as err:
115-
self._log_exception("Draw error", err)
116-
try:
146+
with log_exception("Present error"):
117147
if self._canvas_context:
118148
return self._canvas_context.present()
119-
except Exception as err:
120-
self._log_exception("Present error", err)
121149

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

128-
def _log_exception(self, kind, err):
129-
"""Log the given exception instance, but only log a one-liner for
130-
subsequent occurances of the same error to avoid spamming (which
131-
can happen easily with errors in the drawing code).
132-
"""
133-
msg = str(err)
134-
msgh = hash(msg)
135-
if msgh not in self._err_hashes:
136-
# Provide the exception, so the default logger prints a stacktrace.
137-
# IDE's can get the exception from the root logger for PM debugging.
138-
self._err_hashes[msgh] = 1
139-
logger.error(kind, exc_info=err)
140-
else:
141-
# We've seen this message before, return a one-liner instead.
142-
self._err_hashes[msgh] = count = self._err_hashes[msgh] + 1
143-
msg = kind + ": " + msg.split("\n")[0].strip()
144-
msg = msg if len(msg) <= 70 else msg[:69] + "…"
145-
logger.error(msg + f" ({count})")
146-
147156
# Methods that must be overloaded
148157

149158
def get_pixel_ratio(self):
@@ -184,8 +193,45 @@ class WgpuAutoGui:
184193

185194
def __init__(self, *args, **kwargs):
186195
super().__init__(*args, **kwargs)
196+
self._last_event_time = 0
197+
self._pending_events = {}
187198
self._event_handlers = defaultdict(set)
188199

200+
def _get_event_wait_time(self):
201+
"""Calculate the time to wait for the next event dispatching
202+
(for rate-limited events)."""
203+
rate = 75 # events per second
204+
now = time.perf_counter()
205+
target_time = self._last_event_time + 1.0 / rate
206+
return max(0, target_time - now)
207+
208+
def _handle_event_rate_limited(self, ev, call_later_func, match_keys, accum_keys):
209+
"""Alternative `to handle_event()` for events that must be rate-limted.
210+
If any of the `match_keys` keys of the new event differ from the currently
211+
pending event, the old event is dispatched now. The `accum_keys` keys of
212+
the current and new event are added together (e.g. to accumulate wheel delta).
213+
214+
This method is called in the following cases:
215+
* When the timer runs out.
216+
* When a non-rate-limited event is dispatched.
217+
* When a rate-limited event of the same type is scheduled
218+
that has different match_keys (e.g. modifiers changes).
219+
"""
220+
event_type = ev["event_type"]
221+
# We may need to emit the old event. Otherwise, we need to update the new one.
222+
old = self._pending_events.get(event_type, None)
223+
if old:
224+
if any(ev[key] != old[key] for key in match_keys):
225+
self._dispatch_event(old)
226+
else:
227+
for key in accum_keys:
228+
ev[key] = old[key] + ev[key]
229+
# Make sure that we have scheduled a moment to handle events
230+
if not self._pending_events:
231+
call_later_func(self._get_event_wait_time(), self._dispatch_pending_events)
232+
# Store the event object
233+
self._pending_events[event_type] = ev
234+
189235
def handle_event(self, event):
190236
"""Handle an incoming event.
191237
@@ -194,9 +240,25 @@ def handle_event(self, event):
194240
is a dict with at least the key event_type. For details, see
195241
https://jupyter-rfb.readthedocs.io/en/latest/events.html
196242
"""
243+
# On any not-rate-limited event, we dispatch any pending events.
244+
# This is to make sure that the original order of events is preserved.
245+
self._dispatch_pending_events()
246+
self._dispatch_event(event)
247+
248+
def _dispatch_pending_events(self):
249+
"""Handle any pending rate-limited events."""
250+
events = self._pending_events.values()
251+
self._last_event_time = time.perf_counter()
252+
self._pending_events = {}
253+
for ev in events:
254+
self._dispatch_event(ev)
255+
256+
def _dispatch_event(self, event):
257+
"""Dispatch event to the event handlers."""
197258
event_type = event.get("event_type")
198259
for callback in self._event_handlers[event_type]:
199-
callback(event)
260+
with log_exception(f"Error during handling {event['event_type']} event"):
261+
callback(event)
200262

201263
def add_event_handler(self, *args):
202264
"""Register an event handler.

wgpu/gui/glfw.py

Lines changed: 15 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import time
1414
import weakref
1515
import asyncio
16-
import traceback
1716

1817
import glfw
1918

@@ -200,7 +199,7 @@ def _on_size_change(self, *args):
200199
def _on_close(self, *args):
201200
all_glfw_canvases.discard(self)
202201
glfw.hide_window(self._window)
203-
self._emit_event({"event_type": "close"})
202+
self.handle_event({"event_type": "close"})
204203

205204
def _on_window_dirty(self, *args):
206205
self._request_draw()
@@ -230,7 +229,7 @@ def _determine_size(self):
230229
"height": self._logical_size[1],
231230
"pixel_ratio": self._pixel_ratio,
232231
}
233-
self._emit_event(ev)
232+
self.handle_event(ev)
234233

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

319-
def _emit_event(self, event):
320-
try:
321-
self.handle_event(event)
322-
except Exception:
323-
# Print exception and store exc info for postmortem debugging
324-
exc_info = list(sys.exc_info())
325-
exc_info[2] = exc_info[2].tb_next # skip *this* function
326-
sys.last_type, sys.last_value, sys.last_traceback = exc_info
327-
traceback.print_exception(*exc_info)
328-
329318
# User events
330319

331320
def _on_mouse_button(self, window, but, action, mods):
@@ -362,8 +351,11 @@ def _on_mouse_button(self, window, but, action, mods):
362351
"ntouches": 0, # glfw dows not have touch support
363352
"touches": {},
364353
}
365-
self._emit_event(ev)
366354

355+
# Emit the current event
356+
self.handle_event(ev)
357+
358+
# Maybe emit a double-click event
367359
self._follow_double_click(action, button)
368360

369361
def _follow_double_click(self, action, button):
@@ -413,7 +405,7 @@ def _follow_double_click(self, action, button):
413405
"ntouches": 0, # glfw dows not have touch support
414406
"touches": {},
415407
}
416-
self._emit_event(ev)
408+
self.handle_event(ev)
417409

418410
def _on_cursor_pos(self, window, x, y):
419411
# Store pointer position in logical coordinates
@@ -432,7 +424,10 @@ def _on_cursor_pos(self, window, x, y):
432424
"ntouches": 0, # glfw dows not have touch support
433425
"touches": {},
434426
}
435-
self._emit_event(ev)
427+
428+
match_keys = {"buttons", "modifiers", "ntouches"}
429+
accum_keys = {}
430+
self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys)
436431

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

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

@@ -481,7 +478,7 @@ def _on_key(self, window, key, scancode, action, mods):
481478
"key": keyname,
482479
"modifiers": list(self._key_modifiers),
483480
}
484-
self._emit_event(ev)
481+
self.handle_event(ev)
485482

486483

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

wgpu/gui/jupyter.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@ def handle_event(self, event):
4848
self._pixel_ratio = event["pixel_ratio"]
4949
self._logical_size = event["width"], event["height"]
5050

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

5355
def get_frame(self):

wgpu/gui/qt.py

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
import ctypes
77
import importlib
88
import sys
9-
import traceback
109

1110
from .base import WgpuCanvasBase, WgpuAutoGui
1211

12+
1313
# Select GUI toolkit
1414
for libname in ("PySide6", "PyQt6", "PySide2", "PyQt5"):
1515
if libname in sys.modules:
@@ -267,18 +267,6 @@ def get_context(self, *args, **kwargs):
267267
def request_draw(self, *args, **kwargs):
268268
return self._subwidget.request_draw(*args, **kwargs)
269269

270-
# Auto event API
271-
272-
def _emit_event(self, event):
273-
try:
274-
self.handle_event(event)
275-
except Exception:
276-
# Print exception and store exc info for postmortem debugging
277-
exc_info = list(sys.exc_info())
278-
exc_info[2] = exc_info[2].tb_next # skip *this* function
279-
sys.last_type, sys.last_value, sys.last_traceback = exc_info
280-
traceback.print_exception(*exc_info)
281-
282270
# User events to jupyter_rfb events
283271

284272
def _key_event(self, event_type, event):
@@ -293,7 +281,7 @@ def _key_event(self, event_type, event):
293281
"key": KEY_MAP.get(event.key(), event.text()),
294282
"modifiers": modifiers,
295283
}
296-
self._emit_event(ev)
284+
self.handle_event(ev)
297285

298286
def keyPressEvent(self, event): # noqa: N802
299287
self._key_event("key_down", event)
@@ -331,7 +319,13 @@ def _mouse_event(self, event_type, event, touches=True):
331319
"touches": {}, # TODO
332320
}
333321
)
334-
self._emit_event(ev)
322+
323+
if event_type == "pointer_move":
324+
match_keys = {"buttons", "modifiers", "ntouches"}
325+
accum_keys = {}
326+
self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys)
327+
else:
328+
self.handle_event(ev)
335329

336330
def mousePressEvent(self, event): # noqa: N802
337331
self._mouse_event("pointer_down", event)
@@ -362,7 +356,9 @@ def wheelEvent(self, event): # noqa: N802
362356
"y": event.position().y(),
363357
"modifiers": modifiers,
364358
}
365-
self._emit_event(ev)
359+
match_keys = {"modifiers"}
360+
accum_keys = {"dx", "dy"}
361+
self._handle_event_rate_limited(ev, call_later, match_keys, accum_keys)
366362

367363
def resizeEvent(self, event): # noqa: N802
368364
ev = {
@@ -371,10 +367,10 @@ def resizeEvent(self, event): # noqa: N802
371367
"height": float(event.size().height()),
372368
"pixel_ratio": self.get_pixel_ratio(),
373369
}
374-
self._emit_event(ev)
370+
self.handle_event(ev)
375371

376372
def closeEvent(self, event): # noqa: N802
377-
self._emit_event({"event_type": "close"})
373+
self.handle_event({"event_type": "close"})
378374

379375

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

0 commit comments

Comments
 (0)