Add progress.single_shot() (#1683)

* Add progress.single_shot()

* Fix periodic garbage collection

* Properly cleanup mediasync timers

* Revert some replacements with `single_shot()`

These timers shouldn't fire if their widget is destroyed.

* Add timer docs explaining issues and alternatives

* Apply suggestions from code review

* Tweak docstrings
This commit is contained in:
RumovZ 2022-02-24 12:15:56 +01:00 committed by GitHub
parent 5eefb9bea7
commit a0d0f2f8fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 68 additions and 36 deletions

View file

@ -1150,9 +1150,7 @@ class DownloaderInstaller(QObject):
self.mgr.mw.progress.finish() self.mgr.mw.progress.finish()
# qt gets confused if on_done() opens new windows while the progress # qt gets confused if on_done() opens new windows while the progress
# modal is still cleaning up # modal is still cleaning up
self.mgr.mw.progress.timer( self.mgr.mw.progress.single_shot(50, lambda: self.on_done(self.log))
50, lambda: self.on_done(self.log), False, parent=self
)
def show_log_to_user(parent: QWidget, log: list[DownloadLogEntry]) -> None: def show_log_to_user(parent: QWidget, log: list[DownloadLogEntry]) -> None:
@ -1401,12 +1399,10 @@ def check_for_updates(
def update_info_received(future: Future) -> None: def update_info_received(future: Future) -> None:
# if syncing/in profile screen, defer message delivery # if syncing/in profile screen, defer message delivery
if not mgr.mw.col: if not mgr.mw.col:
mgr.mw.progress.timer( mgr.mw.progress.single_shot(
1000, 1000,
lambda: update_info_received(future), lambda: update_info_received(future),
False, False,
requiresCollection=False,
parent=mgr.mw,
) )
return return

View file

@ -106,7 +106,7 @@ class Previewer(QDialog):
def _on_finished(self, ok: int) -> None: def _on_finished(self, ok: int) -> None:
saveGeom(self, "preview") saveGeom(self, "preview")
self.mw.progress.timer(100, self._on_close, False, parent=self) self.mw.progress.single_shot(100, self._on_close)
def _on_replay_audio(self) -> None: def _on_replay_audio(self) -> None:
if self._state == "question": if self._state == "question":

View file

@ -552,7 +552,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
"Save unsaved edits then call callback()." "Save unsaved edits then call callback()."
if not self.note: if not self.note:
# calling code may not expect the callback to fire immediately # calling code may not expect the callback to fire immediately
self.mw.progress.timer(10, callback, False, parent=self.widget) self.mw.progress.single_shot(10, callback)
return return
self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback()) self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())

View file

@ -187,9 +187,7 @@ class AnkiQt(QMainWindow):
fn() fn()
gui_hooks.main_window_did_init() gui_hooks.main_window_did_init()
self.progress.timer( self.progress.single_shot(10, on_window_init, False)
10, on_window_init, False, requiresCollection=False, parent=self
)
def setupUI(self) -> None: def setupUI(self) -> None:
self.col = None self.col = None
@ -223,12 +221,10 @@ class AnkiQt(QMainWindow):
def setupProfileAfterWebviewsLoaded(self) -> None: def setupProfileAfterWebviewsLoaded(self) -> None:
for w in (self.web, self.bottomWeb): for w in (self.web, self.bottomWeb):
if not w._domDone: if not w._domDone:
self.progress.timer( self.progress.single_shot(
10, 10,
self.setupProfileAfterWebviewsLoaded, self.setupProfileAfterWebviewsLoaded,
False, False,
requiresCollection=False,
parent=self,
) )
return return
else: else:
@ -914,7 +910,7 @@ title="{}" {}>{}</button>""".format(
self.col.db.rollback() self.col.db.rollback()
self.close() self.close()
self.progress.timer(100, quit, False, parent=self) self.progress.single_shot(100, quit)
def setupProgress(self) -> None: def setupProgress(self) -> None:
self.progress = aqt.progress.ProgressManager(self) self.progress = aqt.progress.ProgressManager(self)
@ -1361,7 +1357,7 @@ title="{}" {}>{}</button>""".format(
self.progress.timer(5 * 60 * 1000, self.on_autosync_timer, True, parent=self) self.progress.timer(5 * 60 * 1000, self.on_autosync_timer, True, parent=self)
# periodic garbage collection # periodic garbage collection
self.progress.timer( self.progress.timer(
15 * 60 * 1000, self.garbage_collect_now, False, parent=self 15 * 60 * 1000, self.garbage_collect_now, True, False, parent=self
) )
# ensure Python interpreter runs at least once per second, so that # ensure Python interpreter runs at least once per second, so that
# SIGINT/SIGTERM is processed without a long delay # SIGINT/SIGTERM is processed without a long delay
@ -1693,12 +1689,10 @@ title="{}" {}>{}</button>""".format(
if self.state == "startup": if self.state == "startup":
# try again in a second # try again in a second
self.progress.timer( self.progress.single_shot(
1000, 1000,
lambda: self.onAppMsg(buf), lambda: self.onAppMsg(buf),
False, False,
requiresCollection=False,
parent=self,
) )
return return
elif self.state == "profileManager": elif self.state == "profileManager":
@ -1764,9 +1758,7 @@ title="{}" {}>{}</button>""".format(
def deferred_delete_and_garbage_collect(self, obj: QObject) -> None: def deferred_delete_and_garbage_collect(self, obj: QObject) -> None:
obj.deleteLater() obj.deleteLater()
self.progress.timer( self.progress.single_shot(1000, self.garbage_collect_now, False)
1000, self.garbage_collect_now, False, requiresCollection=False, parent=self
)
def disable_automatic_garbage_collection(self) -> None: def disable_automatic_garbage_collection(self) -> None:
gc.collect() gc.collect()

View file

@ -78,7 +78,7 @@ class MediaSyncer:
def _on_finished(self, future: Future) -> None: def _on_finished(self, future: Future) -> None:
self._syncing = False self._syncing = False
if self._progress_timer: if self._progress_timer:
self._progress_timer.stop() self._progress_timer.deleteLater()
self._progress_timer = None self._progress_timer = None
gui_hooks.media_sync_did_start_or_stop(False) gui_hooks.media_sync_did_start_or_stop(False)
@ -131,7 +131,7 @@ class MediaSyncer:
def check_finished() -> None: def check_finished() -> None:
if not self.is_syncing(): if not self.is_syncing():
timer.stop() timer.deleteLater()
on_finished() on_finished()
timer = self.mw.progress.timer(150, check_finished, True, False, parent=self.mw) timer = self.mw.progress.timer(150, check_finished, True, False, parent=self.mw)

View file

@ -26,7 +26,7 @@ class ProgressManager:
# Safer timers # Safer timers
########################################################################## ##########################################################################
# A custom timer which avoids firing while a progress dialog is active # Custom timers which avoid firing while a progress dialog is active
# (likely due to some long-running DB operation) # (likely due to some long-running DB operation)
def timer( def timer(
@ -38,7 +38,7 @@ class ProgressManager:
*, *,
parent: QObject = None, parent: QObject = None,
) -> QTimer: ) -> QTimer:
"""Create and start a standard Anki timer. """Create and start a standard Anki timer. For an alternative see `single_shot()`.
If the timer fires while a progress window is shown: If the timer fires while a progress window is shown:
- if it is a repeating timer, it will wait the same delay again - if it is a repeating timer, it will wait the same delay again
@ -47,16 +47,65 @@ class ProgressManager:
If requiresCollection is True, the timer will not fire if the If requiresCollection is True, the timer will not fire if the
collection has been unloaded. Setting it to False will allow the collection has been unloaded. Setting it to False will allow the
timer to fire even when there is no collection, but will still timer to fire even when there is no collection, but will still
only fire when there is no current progress dialog.""" only fire when there is no current progress dialog.
Issues and alternative
---
The created timer will only be destroyed when `parent` is destroyed.
This can cause memory leaks, because anything captured by `func` isn't freed either.
If there is no QObject that will get destroyed reasonably soon, and you have to
pass `mw`, you should call `deleteLater()` on the returned QTimer as soon as
it's served its purpose, or use `single_shot()`.
Also note that you may not be able to pass an adequate parent, if you want to
make a callback after a widget closes. If you passed that widget, the timer
would get destroyed before it could fire.
"""
if parent is None: if parent is None:
print_deprecation_warning( print_deprecation_warning(
"to avoid memory leaks, pass an appropriate parent to progress.timer()" "to avoid memory leaks, pass an appropriate parent to progress.timer()"
" or use progress.single_shot()"
) )
parent = self.mw parent = self.mw
qtimer = QTimer(parent)
if not repeat:
qtimer.setSingleShot(True)
qconnect(qtimer.timeout, self._get_handler(func, repeat, requiresCollection))
qtimer.start(ms)
return qtimer
def single_shot(
self,
ms: int,
func: Callable[[], None],
requires_collection: bool = True,
) -> None:
"""Create and start a one-off Anki timer. For an alternative and more
documentation, see `timer()`.
Issues and alternative
---
`single_shot()` cleans itself up, so a passed closure won't leak any memory.
However, if `func` references a QObject other than `mw`, which gets deleted before the
timer fires, an Exception is raised. To avoid this, either use `timer()` passing
that object as the parent, or check in `func` with `sip.isdeleted(object)` if
it still exists.
On the other hand, if a widget is supposed to make an external callback after it closes,
you likely want to use `single_shot()`, which will fire even if the calling
widget is already destroyed.
"""
QTimer.singleShot(ms, self._get_handler(func, False, requires_collection))
def _get_handler(
self, func: Callable[[], None], repeat: bool, requires_collection: bool
) -> Callable[[], None]:
def handler() -> None: def handler() -> None:
if requiresCollection and not self.mw.col: if requires_collection and not self.mw.col:
# no current collection; timer is no longer valid # no current collection; timer is no longer valid
print(f"Ignored progress func as collection unloaded: {repr(func)}") print(f"Ignored progress func as collection unloaded: {repr(func)}")
return return
@ -70,14 +119,9 @@ class ProgressManager:
pass pass
else: else:
# retry in 100ms # retry in 100ms
self.timer(100, func, False, requiresCollection, parent=parent) self.single_shot(100, func, requires_collection)
t = QTimer(parent) return handler
if not repeat:
t.setSingleShot(True)
qconnect(t.timeout, handler)
t.start(ms)
return t
# Creating progress dialogs # Creating progress dialogs
########################################################################## ##########################################################################

View file

@ -628,7 +628,7 @@ html {{ {font} }}
if qvar is None: if qvar is None:
mw.progress.timer(1000, mw.reset, False, parent=self) mw.progress.single_shot(1000, mw.reset)
return return
self.setFixedHeight(int(qvar)) self.setFixedHeight(int(qvar))