From 2f31619710f69f94c736c0fe17d4023b6e546dce Mon Sep 17 00:00:00 2001 From: Vikash G Date: Wed, 20 May 2026 20:59:59 +0530 Subject: [PATCH 1/2] Fixed #37109 -- Consolidated SIGINT handling logic into the base database client. --- django/db/backends/base/client.py | 18 +++++++++++++++++- django/db/backends/mysql/client.py | 12 ------------ django/db/backends/postgresql/client.py | 12 ------------ tests/dbshell/tests.py | 22 ++++++++++++++++++++++ 4 files changed, 39 insertions(+), 25 deletions(-) diff --git a/django/db/backends/base/client.py b/django/db/backends/base/client.py index 1640bedcadc6..fe16f72ae990 100644 --- a/django/db/backends/base/client.py +++ b/django/db/backends/base/client.py @@ -1,4 +1,5 @@ import os +import signal import subprocess @@ -28,4 +29,19 @@ def runshell(self, parameters): self.connection.settings_dict, parameters ) env = {**os.environ, **env} if env else None - subprocess.run(args, env=env, check=True) + sigint_handler = None + if hasattr(signal, "SIGINT"): + try: + sigint_handler = signal.getsignal(signal.SIGINT) + except ValueError: + pass + try: + if sigint_handler is not None: + signal.signal(signal.SIGINT, signal.SIG_IGN) + subprocess.run(args, env=env, check=True) + finally: + if sigint_handler is not None: + try: + signal.signal(signal.SIGINT, sigint_handler) + except ValueError: + pass diff --git a/django/db/backends/mysql/client.py b/django/db/backends/mysql/client.py index 6aa11b2e1f82..0c09a2ca1e39 100644 --- a/django/db/backends/mysql/client.py +++ b/django/db/backends/mysql/client.py @@ -1,5 +1,3 @@ -import signal - from django.db.backends.base.client import BaseDatabaseClient @@ -60,13 +58,3 @@ def settings_to_cmd_args_env(cls, settings_dict, parameters): args += [database] args.extend(parameters) return args, env - - def runshell(self, parameters): - sigint_handler = signal.getsignal(signal.SIGINT) - try: - # Allow SIGINT to pass to mysql to abort queries. - signal.signal(signal.SIGINT, signal.SIG_IGN) - super().runshell(parameters) - finally: - # Restore the original SIGINT handler. - signal.signal(signal.SIGINT, sigint_handler) diff --git a/django/db/backends/postgresql/client.py b/django/db/backends/postgresql/client.py index 4d79869e8744..7ee82aba3b60 100644 --- a/django/db/backends/postgresql/client.py +++ b/django/db/backends/postgresql/client.py @@ -1,5 +1,3 @@ -import signal - from django.db.backends.base.client import BaseDatabaseClient @@ -52,13 +50,3 @@ def settings_to_cmd_args_env(cls, settings_dict, parameters): if passfile: env["PGPASSFILE"] = str(passfile) return args, (env or None) - - def runshell(self, parameters): - sigint_handler = signal.getsignal(signal.SIGINT) - try: - # Allow SIGINT to pass to psql to abort queries. - signal.signal(signal.SIGINT, signal.SIG_IGN) - super().runshell(parameters) - finally: - # Restore the original SIGINT handler. - signal.signal(signal.SIGINT, sigint_handler) diff --git a/tests/dbshell/tests.py b/tests/dbshell/tests.py index 8dd3d22c9843..c0842587df92 100644 --- a/tests/dbshell/tests.py +++ b/tests/dbshell/tests.py @@ -15,3 +15,25 @@ def test_command_missing(self): with self.assertRaisesMessage(CommandError, msg): with mock.patch("subprocess.run", side_effect=FileNotFoundError): call_command("dbshell") + + @mock.patch("django.db.backends.base.client.subprocess.run") + def test_sigint_ignored_during_runshell(self, mock_run): + import signal + + from django.db.backends.base.client import BaseDatabaseClient + + original_handler = signal.getsignal(signal.SIGINT) + + def mock_run_side_effect(*args, **kwargs): + self.assertEqual(signal.getsignal(signal.SIGINT), signal.SIG_IGN) + + mock_run.side_effect = mock_run_side_effect + + client = BaseDatabaseClient(connection) + # Mock settings_to_cmd_args_env to return dummy args + with mock.patch.object( + client, "settings_to_cmd_args_env", return_value=(["mock_db_client"], None) + ): + client.runshell([]) + + self.assertEqual(signal.getsignal(signal.SIGINT), original_handler) From 87f0aa5aafb0ffe81d129a9e2a582bad32325e5f Mon Sep 17 00:00:00 2001 From: Vikash G Date: Wed, 20 May 2026 21:48:03 +0530 Subject: [PATCH 2/2] Fixed #37109 -- Handled KeyboardInterrupt in BaseCommand.run_from_argv. --- django/core/management/base.py | 5 +++++ tests/admin_scripts/tests.py | 25 +++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/django/core/management/base.py b/django/core/management/base.py index 8f2447905064..b615a525b850 100644 --- a/django/core/management/base.py +++ b/django/core/management/base.py @@ -430,6 +430,11 @@ def run_from_argv(self, argv): else: self.stderr.write("%s: %s" % (e.__class__.__name__, e)) sys.exit(e.returncode) + except KeyboardInterrupt: + if options.traceback: + raise + self.stderr.write("\nOperation cancelled.") + sys.exit(1) finally: try: connections.close_all() diff --git a/tests/admin_scripts/tests.py b/tests/admin_scripts/tests.py index 914f54720ce5..02646022fe17 100644 --- a/tests/admin_scripts/tests.py +++ b/tests/admin_scripts/tests.py @@ -2232,6 +2232,31 @@ def raise_command_error(*args, **kwargs): with self.assertRaises(CommandError): command.run_from_argv(["", "", "--traceback"]) + def test_run_from_argv_keyboard_interrupt(self): + """ + Test run_from_argv handles KeyboardInterrupt cleanly by printing an + error message and exiting with 1. + """ + err = StringIO() + command = BaseCommand(stderr=err) + + def raise_keyboard_interrupt(*args, **kwargs): + raise KeyboardInterrupt() + + command.execute = raise_keyboard_interrupt + + # If --traceback is not present, should print "Operation cancelled." + # and exit with SystemExit(1) + err.truncate(0) + with self.assertRaises(SystemExit) as cm: + command.run_from_argv(["", ""]) + self.assertEqual(cm.exception.code, 1) + self.assertIn("Operation cancelled.", err.getvalue()) + + # If --traceback is present, should propagate KeyboardInterrupt + with self.assertRaises(KeyboardInterrupt): + command.run_from_argv(["", "", "--traceback"]) + def test_run_from_argv_non_ascii_error(self): """ Non-ASCII message of CommandError does not raise any