Skip to content

Commit de2221c

Browse files
Eventloop scheduling improvements for stop_on_error_timeout and schedule_next (#1212)
Co-authored-by: Steven Silvester <[email protected]>
1 parent 2071a88 commit de2221c

File tree

2 files changed

+69
-26
lines changed

2 files changed

+69
-26
lines changed

ipykernel/eventloops.py

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -82,15 +82,19 @@ def _notify_stream_qt(kernel):
8282
def enum_helper(name):
8383
return operator.attrgetter(name.rpartition(".")[0])(sys.modules[QtCore.__package__])
8484

85+
def exit_loop():
86+
"""fall back to main loop"""
87+
kernel._qt_notifier.setEnabled(False)
88+
kernel.app.qt_event_loop.quit()
89+
8590
def process_stream_events():
8691
"""fall back to main loop when there's a socket event"""
8792
# call flush to ensure that the stream doesn't lose events
8893
# due to our consuming of the edge-triggered FD
8994
# flush returns the number of events consumed.
9095
# if there were any, wake it up
9196
if kernel.shell_stream.flush(limit=1):
92-
kernel._qt_notifier.setEnabled(False)
93-
kernel.app.qt_event_loop.quit()
97+
exit_loop()
9498

9599
if not hasattr(kernel, "_qt_notifier"):
96100
fd = kernel.shell_stream.getsockopt(zmq.FD)
@@ -101,18 +105,31 @@ def process_stream_events():
101105
else:
102106
kernel._qt_notifier.setEnabled(True)
103107

108+
# allow for scheduling exits from the loop in case a timeout needs to
109+
# be set from the kernel level
110+
def _schedule_exit(delay):
111+
"""schedule fall back to main loop in [delay] seconds"""
112+
# The signatures of QtCore.QTimer.singleShot are inconsistent between PySide and PyQt
113+
# if setting the TimerType, so we create a timer explicitly and store it
114+
# to avoid a memory leak.
115+
# PreciseTimer is needed so we exit after _at least_ the specified delay, not within 5% of it
116+
if not hasattr(kernel, "_qt_timer"):
117+
kernel._qt_timer = QtCore.QTimer(kernel.app)
118+
kernel._qt_timer.setSingleShot(True)
119+
kernel._qt_timer.setTimerType(enum_helper("QtCore.Qt.TimerType").PreciseTimer)
120+
kernel._qt_timer.timeout.connect(exit_loop)
121+
kernel._qt_timer.start(int(1000 * delay))
122+
123+
loop_qt._schedule_exit = _schedule_exit
124+
104125
# there may already be unprocessed events waiting.
105126
# these events will not wake zmq's edge-triggered FD
106127
# since edge-triggered notification only occurs on new i/o activity.
107128
# process all the waiting events immediately
108129
# so we start in a clean state ensuring that any new i/o events will notify.
109130
# schedule first call on the eventloop as soon as it's running,
110131
# so we don't block here processing events
111-
if not hasattr(kernel, "_qt_timer"):
112-
kernel._qt_timer = QtCore.QTimer(kernel.app)
113-
kernel._qt_timer.setSingleShot(True)
114-
kernel._qt_timer.timeout.connect(process_stream_events)
115-
kernel._qt_timer.start(0)
132+
QtCore.QTimer.singleShot(0, process_stream_events)
116133

117134

118135
@register_integration("qt", "qt5", "qt6")
@@ -229,23 +246,33 @@ def __init__(self, app):
229246
self.app = app
230247
self.app.withdraw()
231248

232-
def process_stream_events(stream, *a, **kw):
249+
def exit_loop():
250+
"""fall back to main loop"""
251+
app.tk.deletefilehandler(kernel.shell_stream.getsockopt(zmq.FD))
252+
app.quit()
253+
app.destroy()
254+
del kernel.app_wrapper
255+
256+
def process_stream_events(*a, **kw):
233257
"""fall back to main loop when there's a socket event"""
234-
if stream.flush(limit=1):
235-
app.tk.deletefilehandler(stream.getsockopt(zmq.FD))
236-
app.quit()
237-
app.destroy()
238-
del kernel.app_wrapper
258+
if kernel.shell_stream.flush(limit=1):
259+
exit_loop()
260+
261+
# allow for scheduling exits from the loop in case a timeout needs to
262+
# be set from the kernel level
263+
def _schedule_exit(delay):
264+
"""schedule fall back to main loop in [delay] seconds"""
265+
app.after(int(1000 * delay), exit_loop)
266+
267+
loop_tk._schedule_exit = _schedule_exit
239268

240269
# For Tkinter, we create a Tk object and call its withdraw method.
241270
kernel.app_wrapper = BasicAppWrapper(app)
242-
243-
notifier = partial(process_stream_events, kernel.shell_stream)
244-
# seems to be needed for tk
245-
notifier.__name__ = "notifier" # type:ignore[attr-defined]
246-
app.tk.createfilehandler(kernel.shell_stream.getsockopt(zmq.FD), READABLE, notifier)
271+
app.tk.createfilehandler(
272+
kernel.shell_stream.getsockopt(zmq.FD), READABLE, process_stream_events
273+
)
247274
# schedule initial call after start
248-
app.after(0, notifier)
275+
app.after(0, process_stream_events)
249276

250277
app.mainloop()
251278

@@ -560,6 +587,10 @@ def enable_gui(gui, kernel=None):
560587
# User wants to turn off integration; clear any evidence if Qt was the last one.
561588
if hasattr(kernel, "app"):
562589
delattr(kernel, "app")
590+
if hasattr(kernel, "_qt_notifier"):
591+
delattr(kernel, "_qt_notifier")
592+
if hasattr(kernel, "_qt_timer"):
593+
delattr(kernel, "_qt_timer")
563594
else:
564595
if gui.startswith("qt"):
565596
# Prepare the kernel here so any exceptions are displayed in the client.

ipykernel/kernelbase.py

Lines changed: 19 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ def enter_eventloop(self):
472472
self.log.info("Exiting as there is no eventloop")
473473
return
474474

475-
def advance_eventloop():
475+
async def advance_eventloop():
476476
# check if eventloop changed:
477477
if self.eventloop is not eventloop:
478478
self.log.info("exiting eventloop %s", eventloop)
@@ -494,10 +494,13 @@ def advance_eventloop():
494494

495495
def schedule_next():
496496
"""Schedule the next advance of the eventloop"""
497-
# flush the eventloop every so often,
498-
# giving us a chance to handle messages in the meantime
497+
# call_later allows the io_loop to process other events if needed.
498+
# Going through schedule_dispatch ensures all other dispatches on msg_queue
499+
# are processed before we enter the eventloop, even if the previous dispatch was
500+
# already consumed from the queue by process_one and the queue is
501+
# technically empty.
499502
self.log.debug("Scheduling eventloop advance")
500-
self.io_loop.call_later(0.001, advance_eventloop)
503+
self.io_loop.call_later(0.001, partial(self.schedule_dispatch, advance_eventloop))
501504

502505
# begin polling the eventloop
503506
schedule_next()
@@ -1202,9 +1205,18 @@ async def stop_aborting():
12021205
# before we reset the flag
12031206
schedule_stop_aborting = partial(self.schedule_dispatch, stop_aborting)
12041207

1205-
# if we have a delay, give messages this long to arrive on the queue
1206-
# before we stop aborting requests
1207-
asyncio.get_event_loop().call_later(self.stop_on_error_timeout, schedule_stop_aborting)
1208+
if self.stop_on_error_timeout:
1209+
# if we have a delay, give messages this long to arrive on the queue
1210+
# before we stop aborting requests
1211+
self.io_loop.call_later(self.stop_on_error_timeout, schedule_stop_aborting)
1212+
# If we have an eventloop, it may interfere with the call_later above.
1213+
# If the loop has a _schedule_exit method, we call that so the loop exits
1214+
# after stop_on_error_timeout, returning to the main io_loop and letting
1215+
# the call_later fire.
1216+
if self.eventloop is not None and hasattr(self.eventloop, "_schedule_exit"):
1217+
self.eventloop._schedule_exit(self.stop_on_error_timeout + 0.01)
1218+
else:
1219+
schedule_stop_aborting()
12081220

12091221
def _send_abort_reply(self, stream, msg, idents):
12101222
"""Send a reply to an aborted request"""

0 commit comments

Comments
 (0)