diff --git a/Lib/idlelib/idle_test/test_multicall.py b/Lib/idlelib/idle_test/test_multicall.py index fcce2030579f4be..db66fdf14ba2099 100644 --- a/Lib/idlelib/idle_test/test_multicall.py +++ b/Lib/idlelib/idle_test/test_multicall.py @@ -2,7 +2,7 @@ from idlelib import multicall import unittest -from test.support import requires +from test.support import requires, captured_stderr from tkinter import Tk, Text @@ -43,6 +43,36 @@ def test_yview(self): mctext = self.mc(self.root) self.assertIs(mctext.yview.__func__, Text.yview) + def test_valid_binding(self): + # A valid key binding must bind without a warning (cf. gh-55646). + mctext = self.mc(self.root) + with captured_stderr() as stderr: + mctext.event_add('<>', '') + mctext.bind('<>', lambda e: None) + self.assertEqual(stderr.getvalue(), '') + + def test_invalid_triplet_binding(self): + # gh-55646: '' parses into a triplet on every platform, + # so Tk rejects it in bind(). + mctext = self.mc(self.root) + with captured_stderr() as stderr: + mctext.event_add('<>', '') # Must not raise. + mctext.bind('<>', lambda e: None) # Must not raise. + warning = stderr.getvalue() + self.assertIn('invalid key binding', warning) + self.assertIn('test-bad', warning) # The offending action is named. + + def test_invalid_nontriplet_binding(self): + # gh-55646: '' has no valid modifier, so MultiCall does not + # parse it and falls back to Tk's event_add, which rejects it. + mctext = self.mc(self.root) + with captured_stderr() as stderr: + mctext.event_add('<>', '') # Must not raise. + mctext.bind('<>', lambda e: None) # Must not raise. + warning = stderr.getvalue() + self.assertIn('invalid key binding', warning) + self.assertIn('test-bad', warning) # The offending action is named. + def test_event_delete_unbound_sequence(self): # gh-89360: deleting a sequence that was not added to a virtual # event is ignored instead of raising ValueError. diff --git a/Lib/idlelib/multicall.py b/Lib/idlelib/multicall.py index 95f4fee7fbe740a..5589884e2af78fb 100644 --- a/Lib/idlelib/multicall.py +++ b/Lib/idlelib/multicall.py @@ -310,6 +310,17 @@ def _triplet_to_sequence(triplet): else: return '<'+_state_names[triplet[0]]+_types[triplet[1]][0]+'>' + +def warn_bad_binding(virtual, sequence, err): + # gh-55646: warn instead of crashing on an invalid key binding. + action = virtual[2:-2] if virtual[:2] == '<<' and virtual[-2:] == '>>' \ + else virtual + print(f'Warning: ignoring invalid key binding {sequence!r} ' + f'for {action!r}: {err}. ' + f'Please reconfigure it in the IDLE Settings dialog.', + file=sys.stderr) + + _multicall_dict = {} def MultiCallCreator(widget): """Return a MultiCall class which inherits its methods from the @@ -343,8 +354,17 @@ def bind(self, sequence=None, func=None, add=None): self.__binders[triplet[1]].unbind(triplet, ei[0]) ei[0] = func if ei[0] is not None: + bad = [] for triplet in ei[1]: - self.__binders[triplet[1]].bind(triplet, func) + try: + self.__binders[triplet[1]].bind(triplet, func) + except tkinter.TclError as err: + warn_bad_binding(sequence, + _triplet_to_sequence(triplet), + err) + bad.append(triplet) + for triplet in bad: # Drop the invalid sequences. + ei[1].remove(triplet) else: self.__eventinfo[sequence] = [func, []] return widget.bind(self, sequence, func, add) @@ -371,10 +391,19 @@ def event_add(self, virtual, *sequences): triplet = _parse_sequence(seq) if triplet is None: #print("Tkinter event_add(%s)" % seq, file=sys.__stderr__) - widget.event_add(self, virtual, seq) + try: + widget.event_add(self, virtual, seq) + except tkinter.TclError as err: + warn_bad_binding(virtual, seq, err) + continue # Drop the invalid sequence. else: if func is not None: - self.__binders[triplet[1]].bind(triplet, func) + try: + self.__binders[triplet[1]].bind(triplet, func) + except tkinter.TclError as err: + warn_bad_binding(virtual, + _triplet_to_sequence(triplet), err) + continue # Drop the invalid sequence. triplets.append(triplet) def event_delete(self, virtual, *sequences): diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index fd647153f92be6b..b84ef404c54cd02 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -1684,6 +1684,80 @@ def test_self_trace(self): self.assertEqual(this_thread_stack[1].funcname, "TestGetStackTrace.test_self_trace") self.assertTrue(this_thread_stack[1].filename.endswith("test_external_inspection.py")) + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_empty_native_thread_stack(self): + _testcapi = import_module("_testcapi") + lock = threading.Lock() + lock.acquire() + # A built-in callback leaves the C thread's Python stack empty. + _testcapi.call_in_temporary_c_thread(lock.acquire, False) + try: + for cache_frames, native in ((False, False), (False, True), + (True, False), (True, True)): + with self.subTest(cache_frames=cache_frames, native=native): + unwinder = RemoteUnwinder( + os.getpid(), all_threads=True, cache_frames=cache_frames, + native=native, + ) + _get_stack_trace_with_retry( + unwinder, condition=lambda trace: len(trace[0].threads) == 2, + ) + threads = unwinder.get_stack_trace()[0].threads + native_stack, python_stack = sorted( + (thread.frame_info for thread in threads), key=len, + ) + self.assertEqual(native_stack, []) + self.assertEqual( + python_stack[0].funcname, + "TestGetStackTrace.test_empty_native_thread_stack", + ) + finally: + lock.release() + _testcapi.join_temporary_c_thread() + + @skip_if_not_supported + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Test only runs on Linux with process_vm_readv support", + ) + def test_popping_python_frame_is_not_native(self): + script = """\ +def leaf(depth): + if depth: + leaf(depth - 1) + +while True: + leaf(300) +""" + with _managed_subprocess([sys.executable, "-c", script]) as process: + for _ in busy_retry(SHORT_TIMEOUT): + try: + unwinder = RemoteUnwinder( + process.pid, native=True, gc=False, cache_frames=False, + ) + except RuntimeError: + continue + break + samples = 0 + for _ in range(10_000): + try: + threads = unwinder.get_stack_trace()[0].threads + except TRANSIENT_ERRORS: + continue + if not threads: + continue + frames = threads[0].frame_info + names = [frame.funcname for frame in frames] + if "leaf" not in names: + continue + samples += 1 + self.assertNotIn(("leaf", ""), zip(names, names[1:])) + self.assertGreater(samples, 1000) + @skip_if_not_supported @unittest.skipIf( sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, diff --git a/Misc/NEWS.d/next/IDLE/2026-07-01-21-00-00.gh-issue-55646.kBind9.rst b/Misc/NEWS.d/next/IDLE/2026-07-01-21-00-00.gh-issue-55646.kBind9.rst new file mode 100644 index 000000000000000..31e97b3c64bcfee --- /dev/null +++ b/Misc/NEWS.d/next/IDLE/2026-07-01-21-00-00.gh-issue-55646.kBind9.rst @@ -0,0 +1,3 @@ +Fix IDLE crash at startup when the user configuration contains an invalid key +binding, such as ```` instead of ````. The invalid +binding is now ignored with a warning. diff --git a/Misc/NEWS.d/next/Library/2026-09-19-12-00-00.gh-issue-157605.Qh92xZ.rst b/Misc/NEWS.d/next/Library/2026-09-19-12-00-00.gh-issue-157605.Qh92xZ.rst new file mode 100644 index 000000000000000..6f680eda27268e5 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-19-12-00-00.gh-issue-157605.Qh92xZ.rst @@ -0,0 +1,2 @@ +Fix :mod:`profiling.sampling` failing when a native thread has an empty Python +stack. diff --git a/Modules/_remote_debugging/frames.c b/Modules/_remote_debugging/frames.c index 46968acc6ff1feb..9febe6a472071b5 100644 --- a/Modules/_remote_debugging/frames.c +++ b/Modules/_remote_debugging/frames.c @@ -163,6 +163,8 @@ find_frame_in_chunks(StackChunkList *chunks, uintptr_t remote_ptr) * FRAME PARSING FUNCTIONS * ============================================================================ */ +enum { FRAME_PARSE_INTERPRETER = 2 }; + int is_frame_valid( RemoteUnwinderObject *unwinder, @@ -170,14 +172,14 @@ is_frame_valid( uintptr_t code_object_addr ) { if ((void*)code_object_addr == NULL) { - return 0; + return 0; // Frame being cleared } void* frame = (void*)frame_addr; char owner = GET_MEMBER(char, frame, unwinder->debug_offsets.interpreter_frame.owner); if (owner == FRAME_OWNED_BY_INTERPRETER) { - return 0; // C frame or sentinel base frame + return FRAME_PARSE_INTERPRETER; // C frame or sentinel base frame } if (owner != FRAME_OWNED_BY_GENERATOR && owner != FRAME_OWNED_BY_THREAD) { @@ -313,6 +315,7 @@ process_frame_chain( ctx->last_frame_visited = 0; while ((void*)frame_addr != NULL) { + int parse_result = 0; PyObject *frame = NULL; uintptr_t next_frame_addr = 0; uintptr_t stackpointer = 0; @@ -326,14 +329,15 @@ process_frame_chain( assert(frame_count <= MAX_FRAMES); if (ctx->chunks && ctx->chunks->count > 0) { - if (parse_frame_from_chunks(unwinder, &frame, frame_addr, &next_frame_addr, &stackpointer, ctx->chunks) == 0) { + parse_result = parse_frame_from_chunks( + unwinder, &frame, frame_addr, &next_frame_addr, &stackpointer, ctx->chunks); + if (parse_result == 0) { goto parsed_frame; } PyErr_Clear(); } { uintptr_t address_of_code_object = 0; - int parse_result; if (ctx->prefetch.frame && ctx->prefetch.frame_addr == frame_addr) { parse_result = parse_frame_buffer( unwinder, &frame, ctx->prefetch.frame, @@ -358,19 +362,19 @@ process_frame_chain( continue; } - if (frame == NULL && PyList_GET_SIZE(ctx->frame_info) == 0) { - const char *e = "Failed to parse initial frame in chain"; - PyErr_SetString(PyExc_RuntimeError, e); - return -1; - } PyObject *extra_frame = NULL; if (unwinder->gc && frame_addr == ctx->gc_frame) { _Py_DECLARE_STR(gc, ""); extra_frame = &_Py_STR(gc); } + // A leading frame without Python code marks no transition between + // Python frames: it is a frame being popped or C code the thread is + // returning into. else if (unwinder->native && frame == NULL && + parse_result == FRAME_PARSE_INTERPRETER && next_frame_addr && + PyList_GET_SIZE(ctx->frame_info) > 0 && !(unwinder->gc && next_frame_addr == ctx->gc_frame)) { _Py_DECLARE_STR(native, "");