Repository: qtile/qtile

Issue:
Cannot cancel multiple timers in widgets
The `timeout_add` method only expects one timer to be used by a widget at any point as, instead of returning a cancellable object, the method just assigns this to `self.future`:
https://github.com/qtile/qtile/blob/169842da985d21cfefeb1ed67013222c83407db0/libqtile/widget/base.py#L312-L318

This means that:
 - multiple timers cannot be cancelled
 - any widget with multiple timers will therefore not `finalize` correctly.

Posting this to remind me to make a fix!


Discussion:

Resolving patch:
diff --git a/libqtile/widget/base.py b/libqtile/widget/base.py
--- a/libqtile/widget/base.py
+++ b/libqtile/widget/base.py
@@ -152,6 +152,7 @@ def __init__(self, length, **config):
             self.length_type = bar.STATIC
             self.length = length
         self.configured = False
+        self._futures: List[asyncio.TimerHandle] = []
 
     @property
     def length(self):
@@ -221,8 +222,8 @@ async def _config_async(self):
         pass
 
     def finalize(self):
-        if hasattr(self, 'future'):
-            self.future.cancel()
+        for future in self._futures:
+            future.cancel()
         if hasattr(self, 'layout') and self.layout:
             self.layout.finalize()
         self.drawer.finalize()
@@ -311,12 +312,15 @@ def calculate_length(self):
 
     def timeout_add(self, seconds, method, method_args=()):
         """
-            This method calls either ``.call_later`` with given arguments.
+            This method calls ``.call_later`` with given arguments.
         """
-        self.future = self.qtile.call_later(
+        future = self.qtile.call_later(
             seconds, self._wrapper, method, *method_args
         )
 
+        self._futures.append(future)
+        return future
+
     def call_process(self, command, **kwargs):
         """
             This method uses `subprocess.check_output` to run the given command
@@ -325,7 +329,15 @@ def call_process(self, command, **kwargs):
         """
         return subprocess.check_output(command, **kwargs, encoding="utf-8")
 
+    def _remove_dead_timers(self):
+        """Remove completed and cancelled timers from the list."""
+        self._futures = [
+            timer for timer in self._futures
+            if not (timer.cancelled() or timer.when() < self.qtile._eventloop.time())
+        ]
+
     def _wrapper(self, method, *method_args):
+        self._remove_dead_timers()
         try:
             method(*method_args)
         except:  # noqa: E722
diff --git a/libqtile/widget/pulse_volume.py b/libqtile/widget/pulse_volume.py
--- a/libqtile/widget/pulse_volume.py
+++ b/libqtile/widget/pulse_volume.py
@@ -58,6 +58,7 @@ def finalize(self):
         lib.pa_mainloop_quit(self.loop, 1)
         lib.pa_context_unref(self.context)
         lib.pa_mainloop_free(self.loop)
+        Volume.finalize(self)
 
     def connect(self):
         """
diff --git a/libqtile/widget/quick_exit.py b/libqtile/widget/quick_exit.py
--- a/libqtile/widget/quick_exit.py
+++ b/libqtile/widget/quick_exit.py
@@ -42,7 +42,6 @@ def __init__(self, widget=bar.CALCULATED, **config):
         self.is_counting = False
         self.text = self.default_text
         self.countdown = self.countdown_start
-        self.__call_later_funcs = []
 
         self.add_callbacks({'Button1': self.cmd_trigger})
 
@@ -50,8 +49,7 @@ def __reset(self):
         self.is_counting = False
         self.countdown = self.countdown_start
         self.text = self.default_text
-        for f in self.__call_later_funcs:
-            f.cancel()
+        self.timer.cancel()
 
     def update(self):
         if not self.is_counting:
@@ -59,8 +57,7 @@ def update(self):
 
         self.countdown -= 1
         self.text = self.countdown_format.format(self.countdown)
-        self.timeout_add(self.timer_interval, self.update)
-        self.__call_later_funcs.append(self.future)
+        self.timer = self.timeout_add(self.timer_interval, self.update)
         self.draw()
 
         if self.countdown == 0:


Test patch:
diff --git a/test/widgets/test_base.py b/test/widgets/test_base.py
new file mode 100644
--- /dev/null
+++ b/test/widgets/test_base.py
@@ -0,0 +1,75 @@
+# Copyright (c) 2021 elParaguayo
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in
+# all copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+import libqtile.config
+from libqtile.widget.base import _Widget
+
+
+class TimerWidget(_Widget):
+
+    def cmd_set_timer1(self):
+        self.timer1 = self.timeout_add(10, self.cmd_set_timer1)
+
+    def cmd_cancel_timer1(self):
+        self.timer1.cancel()
+
+    def cmd_set_timer2(self):
+        self.timer2 = self.timeout_add(10, self.cmd_set_timer2)
+
+    def cmd_cancel_timer2(self):
+        self.timer2.cancel()
+
+    def cmd_get_active_timers(self):
+        active = [x for x in self._futures if x._scheduled]
+        return len(active)
+
+
+def test_multiple_timers(minimal_conf_noscreen, manager_nospawn):
+    config = minimal_conf_noscreen
+    config.screens = [
+        libqtile.config.Screen(
+            top=libqtile.bar.Bar([TimerWidget(10)], 10)
+        )
+    ]
+
+    # Start manager and check no active timers
+    manager_nospawn.start(config)
+    assert manager_nospawn.c.widget["timerwidget"].get_active_timers() == 0
+
+    # Start both timers and confirm both are active
+    manager_nospawn.c.widget["timerwidget"].set_timer1()
+    manager_nospawn.c.widget["timerwidget"].set_timer2()
+    assert manager_nospawn.c.widget["timerwidget"].get_active_timers() == 2
+
+    # Cancel timer1
+    manager_nospawn.c.widget["timerwidget"].cancel_timer1()
+    assert manager_nospawn.c.widget["timerwidget"].get_active_timers() == 1
+
+    # Cancel timer2
+    manager_nospawn.c.widget["timerwidget"].cancel_timer2()
+    assert manager_nospawn.c.widget["timerwidget"].get_active_timers() == 0
+
+    # Restart both timers
+    manager_nospawn.c.widget["timerwidget"].set_timer1()
+    manager_nospawn.c.widget["timerwidget"].set_timer2()
+    assert manager_nospawn.c.widget["timerwidget"].get_active_timers() == 2
+
+    # Verify that `finalize()` cancels all timers.
+    manager_nospawn.c.widget["timerwidget"].eval("self.finalize()")
+    assert manager_nospawn.c.widget["timerwidget"].get_active_timers() == 0
diff --git a/test/widgets/test_mpris2widget.py b/test/widgets/test_mpris2widget.py
--- a/test/widgets/test_mpris2widget.py
+++ b/test/widgets/test_mpris2widget.py
@@ -42,6 +42,10 @@ class TimerObj:
         def cancel(self):
             pass
 
+        @property
+        def _scheduled(self):
+            return False
+
     return TimerObj()
 
 
@@ -125,7 +129,7 @@ def test_mpris2_signal_handling(fake_qtile, patched_module, fake_window):
     fakebar.width = 10
     fakebar.height = 10
     fakebar.draw = no_op
-    fake_qtile.call_later = fake_timer
+    mp.timeout_add = fake_timer
     mp._configure(fake_qtile, fakebar)
 
     assert mp.displaytext == ""
@@ -188,7 +192,7 @@ def test_mpris2_custom_stop_text(fake_qtile, patched_module, fake_window):
     fakebar.width = 10
     fakebar.height = 10
     fakebar.draw = no_op
-    fake_qtile.call_later = fake_timer
+    mp.timeout_add = fake_timer
     mp._configure(fake_qtile, fakebar)
     mp.configured = True
 
@@ -209,7 +213,7 @@ def test_mpris2_no_metadata(fake_qtile, patched_module, fake_window):
     fakebar.width = 10
     fakebar.height = 10
     fakebar.draw = no_op
-    fake_qtile.call_later = no_op
+    mp.timeout_add = fake_timer
     mp._configure(fake_qtile, fakebar)
     mp.configured = True
 
@@ -226,7 +230,7 @@ def test_mpris2_no_scroll(fake_qtile, patched_module, fake_window):
     fakebar.width = 10
     fakebar.height = 10
     fakebar.draw = no_op
-    fake_qtile.call_later = no_op
+    mp.timeout_add = fake_timer
     mp._configure(fake_qtile, fakebar)
     mp.configured = True
 
@@ -244,7 +248,7 @@ def test_mpris2_clear_after_scroll(fake_qtile, patched_module, fake_window):
     fakebar.width = 10
     fakebar.height = 10
     fakebar.draw = no_op
-    fake_qtile.call_later = no_op
+    mp.timeout_add = fake_timer
     mp._configure(fake_qtile, fakebar)
     mp.configured = True