Control: tags -1 patch stretch Control: fixed -1 3.27.0 On Mon, 31 Mar 2014 15:49:38 +0200 Roland Koebler <r.koeb...@yahoo.de> wrote: > Package: python3-gi > Version: 3.2.2-2 > Severity: important > Tags: upstream > > Dear Maintainer, > > when starting a Python-GTK+-program from a terminal, pressing Ctrl-C > does nothing. Usually, Ctrl-C aborts programs, and it did with older > PyGTK-versions, but it doesn't with python3-gi; the same applies to > python-gi, and it probably also applies to upstream. > > It looks like KeyboardInterrupt or SIGINT is catched in Gtk.main(), > which is a very bad idea, and results in totally unexpected behaviour, > making it impossible to abort a program with Ctrl-C. > > Example: > > - normal behaviour of Ctrl-C in Python: > > $ python > >>> while(1): pass > ... > ^CTraceback (most recent call last): > File "<stdin>", line 1, in <module> > KeyboardInterrupt > > - python3-gi/python-gi behaviour: > > $ python > >>> from gi.repository import Gtk > >>> Gtk.main() > ^C^C^C^C^C^C^C^C^C^C > > - So, to stop Gtk.main(), I would manually have to kill Python: > > ^Z > [1]+ Stopped python > $ ps |grep python > 12517 pts/35 00:00:01 python > $ kill 12517 > $ fg > python > Terminated > $ > > This not only applies to running GTK+ in the interactive Python- interpreter, > but to all Python-GTK+-programs.
Formatted patches attached, they apply cleanly on 3.22.0-2 on Stretch and fix the problem. Please consider for stretch-p-u if time allows and you find the solution adequate. Thanks! -- Kind regards, Luca Boccassi
Description: Install a default SIGINT handler for functions which start an event loop Currently ctrl+c on a program blocked on Gtk.main() will raise an exception but not return control. While it's easy to set up the proper signal handling and stop the event loop or execute some other application shutdown code it's nice to have a good default behaviour for small prototypes/examples or when testing some code in an interactive console. This adds a context manager which registers a SIGINT handler only in case the default Python signal handler is active and restores the original handle afterwards. Since signal handlers registered through g_unix_signal_add() are not detected by Python's signal module we use PyOS_getsig() through ctypes to detect if the signal handler is changed from outside. In case of nested event loops, all of them will be aborted. In case an event loop is started in a thread, nothing will happen. The context manager is used in the overrides for Gtk.main(), Gtk.Dialog.run(), Gio.Application.run() and GLib.MainLoop.run() This also fixes GLib.MainLoop.run() replacing a non-default signal handler https://bugzilla.gnome.org/show_bug.cgi?id=698623 and not restoring the default one: Bug: https://bugzilla.gnome.org/show_bug.cgi?id=622084 Author: Christoph Reiter <crei...@src.gnome.org> Applied-Upstream: 3.27.0, https://github.com/GNOME/pygobject/commit/58f677bfaa0f117465a9e2146c5d83768b5a76ac --- a/Makefile.am +++ b/Makefile.am @@ -67,7 +67,8 @@ nobase_pyexec_PYTHON = \ gi/_propertyhelper.py \ gi/_signalhelper.py \ gi/_option.py \ - gi/_error.py + gi/_error.py \ + gi/_ossighelper.py # if we build in a separate tree, we need to symlink the *.py files from the # source tree; Python does not accept the extensions and modules in different --- /dev/null +++ b/gi/_ossighelper.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Christoph Reiter +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see <http://www.gnu.org/licenses/>. + +from __future__ import print_function + +import os +import sys +import socket +import signal +from contextlib import closing, contextmanager + + +def ensure_socket_not_inheritable(sock): + """Ensures that the socket is not inherited by child processes + + Raises: + EnvironmentError + NotImplementedError: With Python <3.4 on Windows + """ + + if hasattr(sock, "set_inheritable"): + sock.set_inheritable(False) + else: + try: + import fcntl + except ImportError: + raise NotImplementedError( + "Not implemented for older Python on Windows") + else: + fd = sock.fileno() + flags = fcntl.fcntl(fd, fcntl.F_GETFD) + fcntl.fcntl(fd, fcntl.F_SETFD, flags | fcntl.FD_CLOEXEC) + + +_wakeup_fd_is_active = False +"""Since we can't check if set_wakeup_fd() is already used for nested event +loops without introducing a race condition we keep track of it globally. +""" + + +@contextmanager +def wakeup_on_signal(): + """A decorator for functions which create a glib event loop to keep + Python signal handlers working while the event loop is idling. + + In case an OS signal is received will wake the default event loop up + shortly so that any registered Python signal handlers registered through + signal.signal() can run. + + Works on Windows but needs Python 3.5+. + + In case the wrapped function is not called from the main thread it will be + called as is and it will not wake up the default loop for signals. + """ + + global _wakeup_fd_is_active + + if _wakeup_fd_is_active: + yield + return + + from gi.repository import GLib + + # On Windows only Python 3.5+ supports passing sockets to set_wakeup_fd + set_wakeup_fd_supports_socket = ( + os.name != "nt" or sys.version_info[:2] >= (3, 5)) + # On Windows only Python 3 has an implementation of socketpair() + has_socketpair = hasattr(socket, "socketpair") + + if not has_socketpair or not set_wakeup_fd_supports_socket: + yield + return + + read_socket, write_socket = socket.socketpair() + with closing(read_socket), closing(write_socket): + + for sock in [read_socket, write_socket]: + sock.setblocking(False) + ensure_socket_not_inheritable(sock) + + try: + orig_fd = signal.set_wakeup_fd(write_socket.fileno()) + except ValueError: + # Raised in case this is not the main thread -> give up. + yield + return + else: + _wakeup_fd_is_active = True + + def signal_notify(source, condition): + if condition & GLib.IO_IN: + try: + return bool(read_socket.recv(1)) + except EnvironmentError as e: + print(e) + return False + return True + else: + return False + + try: + if os.name == "nt": + channel = GLib.IOChannel.win32_new_socket( + read_socket.fileno()) + else: + channel = GLib.IOChannel.unix_new(read_socket.fileno()) + + source_id = GLib.io_add_watch( + channel, + GLib.PRIORITY_DEFAULT, + (GLib.IOCondition.IN | GLib.IOCondition.HUP | + GLib.IOCondition.NVAL | GLib.IOCondition.ERR), + signal_notify) + try: + yield + finally: + GLib.source_remove(source_id) + finally: + write_fd = signal.set_wakeup_fd(orig_fd) + if write_fd != write_socket.fileno(): + # Someone has called set_wakeup_fd while func() was active, + # so let's re-revert again. + signal.set_wakeup_fd(write_fd) + _wakeup_fd_is_active = False --- a/gi/overrides/GLib.py +++ b/gi/overrides/GLib.py @@ -24,6 +24,7 @@ import warnings import sys import socket +from .._ossighelper import wakeup_on_signal from ..module import get_introspection_module from .._gi import (variant_type_from_string, source_new, source_set_callback, io_channel_read) @@ -571,7 +572,8 @@ class MainLoop(GLib.MainLoop): GLib.source_remove(self._signal_source) def run(self): - super(MainLoop, self).run() + with wakeup_on_signal(): + super(MainLoop, self).run() if hasattr(self, '_quit_by_sigint'): # caught by _main_loop_sigint_handler() raise KeyboardInterrupt --- a/gi/overrides/Gio.py +++ b/gi/overrides/Gio.py @@ -18,6 +18,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 # USA +from .._ossighelper import wakeup_on_signal from ..overrides import override, deprecated_init from ..module import get_introspection_module @@ -29,6 +30,15 @@ Gio = get_introspection_module('Gio') __all__ = [] +class Application(Gio.Application): + + def run(self, *args, **kwargs): + with wakeup_on_signal(): + return Gio.Application.run(self, *args, **kwargs) + + +Application = override(Application) +__all__.append('Application') class FileEnumerator(Gio.FileEnumerator): def __iter__(self): --- a/gi/overrides/Gtk.py +++ b/gi/overrides/Gtk.py @@ -24,6 +24,7 @@ import sys import warnings from gi.repository import GObject +from .._ossighelper import wakeup_on_signal from ..overrides import override, strip_boolean_result, deprecated_init from ..module import get_introspection_module from gi import PyGIDeprecationWarning @@ -539,6 +540,10 @@ class Dialog(Gtk.Dialog, Container): if add_buttons: self.add_buttons(*add_buttons) + def run(self, *args, **kwargs): + with wakeup_on_signal(): + return Gtk.Dialog.run(self, *args, **kwargs) + action_area = property(lambda dialog: dialog.get_action_area()) vbox = property(lambda dialog: dialog.get_content_area()) @@ -1559,6 +1564,15 @@ _Gtk_main_quit = Gtk.main_quit def main_quit(*args): _Gtk_main_quit() +_Gtk_main = Gtk.main + + +@override(Gtk.main) +def main(*args, **kwargs): + with wakeup_on_signal(): + return _Gtk_main(*args, **kwargs) + + stock_lookup = strip_boolean_result(Gtk.stock_lookup) __all__.append('stock_lookup') --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -126,6 +126,7 @@ EXTRA_DIST = \ test_docstring.py \ test_repository.py \ test_resulttuple.py \ + test_ossig.py \ compat_test_pygtk.py \ gi/__init__.py \ gi/overrides/__init__.py \ --- /dev/null +++ b/tests/test_ossig.py @@ -0,0 +1,102 @@ +# -*- coding: utf-8 -*- +# Copyright 2017 Christoph Reiter +# +# This library is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 2.1 of the License, or (at your option) any later version. +# +# This library is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public +# License along with this library; if not, see <http://www.gnu.org/licenses/>. + +import os +import signal +import unittest +import threading +from contextlib import contextmanager + +from gi.repository import Gtk, Gio, GLib +from gi._ossighelper import wakeup_on_signal + + +class TestOverridesWakeupOnAlarm(unittest.TestCase): + + @contextmanager + def _run_with_timeout(self, timeout, abort_func): + failed = [] + + def fail(): + abort_func() + failed.append(1) + return True + + fail_id = GLib.timeout_add(timeout, fail) + try: + yield + finally: + GLib.source_remove(fail_id) + self.assertFalse(failed) + + def test_basic(self): + self.assertEqual(signal.set_wakeup_fd(-1), -1) + with wakeup_on_signal(): + pass + self.assertEqual(signal.set_wakeup_fd(-1), -1) + + def test_in_thread(self): + failed = [] + + def target(): + try: + with wakeup_on_signal(): + pass + except: + failed.append(1) + + t = threading.Thread(target=target) + t.start() + t.join(5) + self.assertFalse(failed) + + @unittest.skipIf(os.name == "nt", "not on Windows") + def test_glib_mainloop(self): + loop = GLib.MainLoop() + signal.signal(signal.SIGALRM, lambda *args: loop.quit()) + GLib.idle_add(signal.setitimer, signal.ITIMER_REAL, 0.001) + + with self._run_with_timeout(2000, loop.quit): + loop.run() + + @unittest.skipIf(os.name == "nt", "not on Windows") + def test_gio_application(self): + app = Gio.Application() + signal.signal(signal.SIGALRM, lambda *args: app.quit()) + GLib.idle_add(signal.setitimer, signal.ITIMER_REAL, 0.001) + + with self._run_with_timeout(2000, app.quit): + app.hold() + app.connect("activate", lambda *args: None) + app.run() + + @unittest.skipIf(os.name == "nt", "not on Windows") + def test_gtk_main(self): + signal.signal(signal.SIGALRM, lambda *args: Gtk.main_quit()) + GLib.idle_add(signal.setitimer, signal.ITIMER_REAL, 0.001) + + with self._run_with_timeout(2000, Gtk.main_quit): + Gtk.main() + + @unittest.skipIf(os.name == "nt", "not on Windows") + def test_gtk_dialog_run(self): + w = Gtk.Window() + d = Gtk.Dialog(transient_for=w) + signal.signal(signal.SIGALRM, lambda *args: d.destroy()) + GLib.idle_add(signal.setitimer, signal.ITIMER_REAL, 0.001) + + with self._run_with_timeout(2000, d.destroy): + d.run()
Description: Make Python OS signal handlers run when an event loop is idling When Python receives a signal such as SIGINT it sets a flag and will execute the registered signal handler on the next call to PyErr_CheckSignals(). In case the main thread is blocked by an idling event loop (say Gtk.main() or Gtk.Dialog.run()) the check never happens and the signal handler will not get executed. To work around the issue use signal.set_wakeup_fd() to wake up the active event loop when a signal is received, which will invoke a Python callback which will lead to the signal handler being executed. This patch enables it in overrides for Gtk.main(), Gtk.Dialog.run(), Gio.Application.run() and GLib.MainLoop.run(). Works on Unix, and on Windows with Python 3.5+. With this fix in place it is possible to have a cross platform way to react to SIGINT (GLib.unix_signal_add() worked, but not on Windows), for example: signal.signal(signal.SIGINT, lambda *args: Gtk.main_quit()) Gtk.main() Bug: https://bugzilla.gnome.org/show_bug.cgi?id=622084 Author: Christoph Reiter <crei...@src.gnome.org> Applied-Upstream: 3.27.0, https://github.com/GNOME/pygobject/commit/a321f6e9d8f5b8e779892eab4ce759b60ff98e39 --- a/gi/_ossighelper.py +++ b/gi/_ossighelper.py @@ -20,6 +20,8 @@ import os import sys import socket import signal +import ctypes +import threading from contextlib import closing, contextmanager @@ -135,3 +137,116 @@ def wakeup_on_signal(): # so let's re-revert again. signal.set_wakeup_fd(write_fd) _wakeup_fd_is_active = False + + +pydll = ctypes.PyDLL(None) +PyOS_getsig = pydll.PyOS_getsig +PyOS_getsig.restype = ctypes.c_void_p +PyOS_getsig.argtypes = [ctypes.c_int] + +# We save the signal pointer so we can detect if glib has changed the +# signal handler behind Python's back (GLib.unix_signal_add) +if signal.getsignal(signal.SIGINT) is signal.default_int_handler: + startup_sigint_ptr = PyOS_getsig(signal.SIGINT) +else: + # Something has set the handler before import, we can't get a ptr + # for the default handler so make sure the pointer will never match. + startup_sigint_ptr = -1 + + +def sigint_handler_is_default(): + """Returns if on SIGINT the default Python handler would be called""" + + return (signal.getsignal(signal.SIGINT) is signal.default_int_handler and + PyOS_getsig(signal.SIGINT) == startup_sigint_ptr) + + +@contextmanager +def sigint_handler_set_and_restore_default(handler): + """Context manager for saving/restoring the SIGINT handler default state. + + Will only restore the default handler again if the handler is not changed + while the context is active. + """ + + assert sigint_handler_is_default() + + signal.signal(signal.SIGINT, handler) + sig_ptr = PyOS_getsig(signal.SIGINT) + try: + yield + finally: + if signal.getsignal(signal.SIGINT) is handler and \ + PyOS_getsig(signal.SIGINT) == sig_ptr: + signal.signal(signal.SIGINT, signal.default_int_handler) + + +def is_main_thread(): + """Returns True in case the function is called from the main thread""" + + return threading.current_thread().name == "MainThread" + + +_callback_stack = [] +_sigint_called = False + + +@contextmanager +def register_sigint_fallback(callback): + """Installs a SIGINT signal handler in case the default Python one is + active which calls 'callback' in case the signal occurs. + + Only does something if called from the main thread. + + In case of nested context managers the signal handler will be only + installed once and the callbacks will be called in the reverse order + of their registration. + + The old signal handler will be restored in case no signal handler is + registered while the context is active. + """ + + # To handle multiple levels of event loops we need to call the last + # callback first, wait until the inner most event loop returns control + # and only then call the next callback, and so on... until we + # reach the outer most which manages the signal handler and raises + # in the end + + global _callback_stack, _sigint_called + + if not is_main_thread(): + yield + return + + if not sigint_handler_is_default(): + if _callback_stack: + # This is an inner event loop, append our callback + # to the stack so the parent context can call it. + _callback_stack.append(callback) + try: + yield + finally: + if _sigint_called: + _callback_stack.pop()() + else: + # There is a signal handler set by the user, just do nothing + yield + return + + _sigint_called = False + + def sigint_handler(sig_num, frame): + global _callback_stack, _sigint_called + + if _sigint_called: + return + _sigint_called = True + _callback_stack.pop()() + + _callback_stack.append(callback) + with sigint_handler_set_and_restore_default(sigint_handler): + try: + yield + finally: + if _sigint_called: + signal.default_int_handler() --- a/gi/overrides/GLib.py +++ b/gi/overrides/GLib.py @@ -19,12 +19,11 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 # USA -import signal import warnings import sys import socket -from .._ossighelper import wakeup_on_signal +from .._ossighelper import wakeup_on_signal, register_sigint_fallback from ..module import get_introspection_module from .._gi import (variant_type_from_string, source_new, source_set_callback, io_channel_read) @@ -550,33 +549,13 @@ class MainLoop(GLib.MainLoop): def __new__(cls, context=None): return GLib.MainLoop.new(context, False) - # Retain classic pygobject behaviour of quitting main loops on SIGINT def __init__(self, context=None): - def _handler(loop): - loop.quit() - loop._quit_by_sigint = True - # We handle signal deletion in __del__, return True so GLib - # doesn't do the deletion for us. - return True - - if sys.platform != 'win32': - # compatibility shim, keep around until we depend on glib 2.36 - if hasattr(GLib, 'unix_signal_add'): - fn = GLib.unix_signal_add - else: - fn = GLib.unix_signal_add_full - self._signal_source = fn(GLib.PRIORITY_DEFAULT, signal.SIGINT, _handler, self) - - def __del__(self): - if hasattr(self, '_signal_source'): - GLib.source_remove(self._signal_source) + pass def run(self): - with wakeup_on_signal(): - super(MainLoop, self).run() - if hasattr(self, '_quit_by_sigint'): - # caught by _main_loop_sigint_handler() - raise KeyboardInterrupt + with register_sigint_fallback(self.quit): + with wakeup_on_signal(): + super(MainLoop, self).run() MainLoop = override(MainLoop) __all__.append('MainLoop') --- a/gi/overrides/Gio.py +++ b/gi/overrides/Gio.py @@ -18,7 +18,7 @@ # Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 # USA -from .._ossighelper import wakeup_on_signal +from .._ossighelper import wakeup_on_signal, register_sigint_fallback from ..overrides import override, deprecated_init from ..module import get_introspection_module @@ -33,8 +33,9 @@ __all__ = [] class Application(Gio.Application): def run(self, *args, **kwargs): - with wakeup_on_signal(): - return Gio.Application.run(self, *args, **kwargs) + with register_sigint_fallback(self.quit): + with wakeup_on_signal(): + return Gio.Application.run(self, *args, **kwargs) Application = override(Application) --- a/gi/overrides/Gtk.py +++ b/gi/overrides/Gtk.py @@ -24,7 +24,7 @@ import sys import warnings from gi.repository import GObject -from .._ossighelper import wakeup_on_signal +from .._ossighelper import wakeup_on_signal, register_sigint_fallback from ..overrides import override, strip_boolean_result, deprecated_init from ..module import get_introspection_module from gi import PyGIDeprecationWarning @@ -541,8 +541,9 @@ class Dialog(Gtk.Dialog, Container): self.add_buttons(*add_buttons) def run(self, *args, **kwargs): - with wakeup_on_signal(): - return Gtk.Dialog.run(self, *args, **kwargs) + with register_sigint_fallback(self.destroy): + with wakeup_on_signal(): + return Gtk.Dialog.run(self, *args, **kwargs) action_area = property(lambda dialog: dialog.get_action_area()) vbox = property(lambda dialog: dialog.get_content_area()) @@ -1569,8 +1570,9 @@ _Gtk_main = Gtk.main @override(Gtk.main) def main(*args, **kwargs): - with wakeup_on_signal(): - return _Gtk_main(*args, **kwargs) + with register_sigint_fallback(Gtk.main_quit): + with wakeup_on_signal(): + return _Gtk_main(*args, **kwargs) stock_lookup = strip_boolean_result(Gtk.stock_lookup) --- a/tests/test_ossig.py +++ b/tests/test_ossig.py @@ -21,7 +21,7 @@ import threading from contextlib import contextmanager from gi.repository import Gtk, Gio, GLib -from gi._ossighelper import wakeup_on_signal +from gi._ossighelper import wakeup_on_signal, register_sigint_fallback class TestOverridesWakeupOnAlarm(unittest.TestCase): @@ -100,3 +100,74 @@ class TestOverridesWakeupOnAlarm(unittes with self._run_with_timeout(2000, d.destroy): d.run() + + +class TestSigintFallback(unittest.TestCase): + + def setUp(self): + self.assertEqual( + signal.getsignal(signal.SIGINT), signal.default_int_handler) + + def tearDown(self): + self.assertEqual( + signal.getsignal(signal.SIGINT), signal.default_int_handler) + + def test_replace_handler_and_restore_nested(self): + with register_sigint_fallback(lambda: None): + new_handler = signal.getsignal(signal.SIGINT) + self.assertNotEqual(new_handler, signal.default_int_handler) + with register_sigint_fallback(lambda: None): + self.assertTrue(signal.getsignal(signal.SIGINT) is new_handler) + self.assertEqual( + signal.getsignal(signal.SIGINT), signal.default_int_handler) + + def test_no_replace_if_not_default(self): + new_handler = lambda *args: None + signal.signal(signal.SIGINT, new_handler) + try: + with register_sigint_fallback(lambda: None): + self.assertTrue(signal.getsignal(signal.SIGINT) is new_handler) + with register_sigint_fallback(lambda: None): + self.assertTrue( + signal.getsignal(signal.SIGINT) is new_handler) + self.assertTrue(signal.getsignal(signal.SIGINT) is new_handler) + finally: + signal.signal(signal.SIGINT, signal.default_int_handler) + + def test_noop_in_threads(self): + failed = [] + + def target(): + try: + with register_sigint_fallback(lambda: None): + with register_sigint_fallback(lambda: None): + self.assertTrue( + signal.getsignal(signal.SIGINT) is + signal.default_int_handler) + except: + failed.append(1) + + t = threading.Thread(target=target) + t.start() + t.join(5) + self.assertFalse(failed) + + @unittest.skipIf(os.name == "nt", "not on Windows") + def test_no_replace_if_set_by_glib(self): + id_ = GLib.unix_signal_add( + GLib.PRIORITY_DEFAULT, signal.SIGINT, lambda *args: None) + try: + # signal.getsignal() doesn't pick up that unix_signal_add() + # has changed the handler, but we should anyway. + self.assertEqual( + signal.getsignal(signal.SIGINT), signal.default_int_handler) + with register_sigint_fallback(lambda: None): + self.assertEqual( + signal.getsignal(signal.SIGINT), + signal.default_int_handler) + self.assertEqual( + signal.getsignal(signal.SIGINT), signal.default_int_handler) + finally: + GLib.source_remove(id_) + signal.signal(signal.SIGINT, signal.SIG_DFL) + signal.signal(signal.SIGINT, signal.default_int_handler)
signature.asc
Description: This is a digitally signed message part