Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

bpo-1054041: Exit properly after an uncaught ^C. #11862

Merged
merged 19 commits into from
Feb 16, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Include/internal/pycore_pylifecycle.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ extern "C" {
# error "this header requires Py_BUILD_CORE or Py_BUILD_CORE_BUILTIN define"
#endif

/* True if the main interpreter thread exited due to an unhandled
* KeyboardInterrupt exception, suggesting the user pressed ^C. */
PyAPI_DATA(int) _Py_UnhandledKeyboardInterrupt;

PyAPI_FUNC(int) _Py_UnixMain(int argc, char **argv);

PyAPI_FUNC(int) _Py_SetFileSystemEncoding(
Expand Down
61 changes: 57 additions & 4 deletions Lib/test/test_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,48 @@ def test_valid_signals(self):
self.assertNotIn(signal.NSIG, s)
self.assertLess(len(s), signal.NSIG)

@unittest.skipUnless(sys.executable, "sys.executable required.")
def test_keyboard_interrupt_exit_code(self):
"""KeyboardInterrupt triggers exit via SIGINT."""
process = subprocess.run(
[sys.executable, "-c",
"import os,signal; os.kill(os.getpid(), signal.SIGINT)"],
stderr=subprocess.PIPE)
self.assertIn(b"KeyboardInterrupt", process.stderr)
self.assertEqual(process.returncode, -signal.SIGINT)

@unittest.skipUnless(sys.executable, "sys.executable required.")
def test_keyboard_interrupt_communicated_to_shell(self):
"""KeyboardInterrupt exits such that shells detect a ^C."""
try:
bash_proc = subprocess.run(
["bash", "-c", 'echo "${BASH_VERSION}"'],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
except OSError:
raise unittest.SkipTest("bash required.")
if bash_proc.returncode:
raise unittest.SkipTest("could not determine bash version.")
bash_ver = bash_proc.stdout.decode("ascii").strip()
bash_major_minor = [int(n) for n in bash_ver.split(".", 2)[:2]]
if bash_major_minor < [4, 4]:
# In older versions of bash, -i does not work as needed
# _for this automated test_. Older shells do behave as
# expected in manual interactive use.
raise unittest.SkipTest(f"bash version {bash_ver} is too old.")
# The motivation for https://bugs.python.org/issue1054041.
# An _interactive_ shell (bash -i simulates that here) detects
# when a command exits via ^C and stops executing further
# commands.
process = subprocess.run(
["bash", "-ic",
f"{sys.executable} -c 'import os,signal; os.kill(os.getpid(), signal.SIGINT)'; "
"echo TESTFAIL using bash \"${BASH_VERSION}\""],
stderr=subprocess.PIPE, stdout=subprocess.PIPE)
self.assertIn(b"KeyboardInterrupt", process.stderr)
# An interactive shell will abort if python exits properly to
# indicate that a KeyboardInterrupt occurred.
self.assertNotIn(b"TESTFAIL", process.stdout)


@unittest.skipUnless(sys.platform == "win32", "Windows specific")
class WindowsSignalTests(unittest.TestCase):
Expand Down Expand Up @@ -112,6 +154,20 @@ def test_issue9324(self):
with self.assertRaises(ValueError):
signal.signal(7, handler)

@unittest.skipUnless(sys.executable, "sys.executable required.")
def test_keyboard_interrupt_exit_code(self):
"""KeyboardInterrupt triggers an exit using STATUS_CONTROL_C_EXIT."""
# We don't test via os.kill(os.getpid(), signal.CTRL_C_EVENT) here
# as that requires setting up a console control handler in a child
# in its own process group. Doable, but quite complicated. (see
# @eryksun on https://github.com/python/cpython/pull/11862)
process = subprocess.run(
[sys.executable, "-c", "raise KeyboardInterrupt"],
stderr=subprocess.PIPE)
self.assertIn(b"KeyboardInterrupt", process.stderr)
STATUS_CONTROL_C_EXIT = 0xC000013A
self.assertEqual(process.returncode, STATUS_CONTROL_C_EXIT)


class WakeupFDTests(unittest.TestCase):

Expand Down Expand Up @@ -1217,11 +1273,8 @@ def handler(signum, frame):
class RaiseSignalTest(unittest.TestCase):

def test_sigint(self):
try:
with self.assertRaises(KeyboardInterrupt):
signal.raise_signal(signal.SIGINT)
self.fail("Expected KeyInterrupt")
except KeyboardInterrupt:
pass

@unittest.skipIf(sys.platform != "win32", "Windows specific test")
def test_invalid_argument(self):
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
When the main interpreter exits due to an uncaught KeyboardInterrupt, the process now exits in the appropriate manner for its parent process to detect that a SIGINT or ^C terminated the process. This allows shells and batch scripts to understand that the user has asked them to stop.
30 changes: 30 additions & 0 deletions Modules/main.c
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
#include "pycore_pystate.h"

#include <locale.h>
#ifdef HAVE_SIGNAL_H
#include <signal.h>
#endif
#include <stdio.h>
#if defined(HAVE_GETPID) && defined(HAVE_UNISTD_H)
#include <unistd.h>
#endif

#if defined(MS_WINDOWS) || defined(__CYGWIN__)
# include <windows.h>
Expand Down Expand Up @@ -1830,6 +1837,29 @@ pymain_main(_PyMain *pymain)

pymain_free(pymain);

if (_Py_UnhandledKeyboardInterrupt) {
/* https://bugs.python.org/issue1054041 - We need to exit via the
* SIG_DFL handler for SIGINT if KeyboardInterrupt went unhandled.
* If we don't, a calling process such as a shell may not know
* about the user's ^C. https://www.cons.org/cracauer/sigint.html */
#if defined(HAVE_GETPID) && !defined(MS_WINDOWS)
if (PyOS_setsig(SIGINT, SIG_DFL) == SIG_ERR) {
perror("signal"); /* Impossible in normal environments. */
} else {
kill(getpid(), SIGINT);
}
/* If setting SIG_DFL failed, or kill failed to terminate us,
* there isn't much else we can do aside from an error code. */
#endif /* HAVE_GETPID && !MS_WINDOWS */
#ifdef MS_WINDOWS
/* cmd.exe detects this, prints ^C, and offers to terminate. */
/* https://msdn.microsoft.com/en-us/library/cc704588.aspx */
pymain->status = STATUS_CONTROL_C_EXIT;
#else
pymain->status = SIGINT + 128;
#endif /* !MS_WINDOWS */
}

return pymain->status;
}

Expand Down
1 change: 1 addition & 0 deletions Python/pylifecycle.c
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ static void call_py_exitfuncs(PyInterpreterState *);
static void wait_for_thread_shutdown(void);
static void call_ll_exitfuncs(void);

int _Py_UnhandledKeyboardInterrupt = 0;
_PyRuntimeState _PyRuntime = _PyRuntimeState_INIT;

_PyInitError
Expand Down
16 changes: 14 additions & 2 deletions Python/pythonrun.c
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

#include "Python-ast.h"
#undef Yield /* undefine macro conflicting with <winbase.h> */
#include "pycore_pylifecycle.h"
#include "pycore_pystate.h"
#include "grammar.h"
#include "node.h"
Expand Down Expand Up @@ -1027,6 +1028,17 @@ flush_io(void)
PyErr_Restore(type, value, traceback);
}

static PyObject *
run_eval_code_obj(PyCodeObject *co, PyObject *globals, PyObject *locals)
{
PyObject *v;
v = PyEval_EvalCode((PyObject*)co, globals, locals);
if (!v && PyErr_Occurred() == PyExc_KeyboardInterrupt) {
_Py_UnhandledKeyboardInterrupt = 1;
}
return v;
}

static PyObject *
run_mod(mod_ty mod, PyObject *filename, PyObject *globals, PyObject *locals,
PyCompilerFlags *flags, PyArena *arena)
Expand All @@ -1036,7 +1048,7 @@ run_mod(mod_ty mod, PyObject *filename, PyObject *globals, PyObject *locals,
co = PyAST_CompileObject(mod, filename, flags, -1, arena);
if (co == NULL)
return NULL;
v = PyEval_EvalCode((PyObject*)co, globals, locals);
v = run_eval_code_obj(co, globals, locals);
Py_DECREF(co);
return v;
}
Expand Down Expand Up @@ -1073,7 +1085,7 @@ run_pyc_file(FILE *fp, const char *filename, PyObject *globals,
}
fclose(fp);
co = (PyCodeObject *)v;
v = PyEval_EvalCode((PyObject*)co, globals, locals);
v = run_eval_code_obj(co, globals, locals);
if (v && flags)
flags->cf_flags |= (co->co_flags & PyCF_MASK);
Py_DECREF(co);
Expand Down