From 2dbcf91f5edcb216fbc0f2cd2b6dfc35fee4c4e4 Mon Sep 17 00:00:00 2001 From: LucasZhou Date: Thu, 1 Oct 2026 16:38:52 -0500 Subject: [PATCH 1/5] gh-158539: Fix exception mode missing handlers in generators/coroutines The sampling profiler's exception mode decided whether a thread was handling an exception by reading the embedded PyThreadState.exc_state. Generators, coroutines and async generators repoint tstate->exc_info at their own _PyErr_StackItem while they run, so an except block running in one of them (or in a function they call) stored the exception in that item instead, and was never sampled. Follow tstate->exc_info and its previous_item chain, mirroring _PyErr_GetTopmostException(), and export the two debug offsets needed to walk the chain from remote memory. The common case where exc_info points at the embedded exc_state keeps the existing zero-extra-read fast path. --- Include/internal/pycore_debug_offsets.h | 4 + Lib/test/test_external_inspection.py | 395 ++++++++++++++++++ ...-10-01-14-00-00.gh-issue-158539.q1w2e3.rst | 5 + .../debug_offsets_validation.h | 9 +- Modules/_remote_debugging/threads.c | 63 ++- 5 files changed, 466 insertions(+), 10 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-01-14-00-00.gh-issue-158539.q1w2e3.rst diff --git a/Include/internal/pycore_debug_offsets.h b/Include/internal/pycore_debug_offsets.h index 6e1eb573c8c2c83..93f0efd882cdc7e 100644 --- a/Include/internal/pycore_debug_offsets.h +++ b/Include/internal/pycore_debug_offsets.h @@ -113,11 +113,13 @@ typedef struct _Py_DebugOffsets { uint64_t gil_requested; uint64_t current_exception; uint64_t exc_state; + uint64_t exc_info; } thread_state; // Exception stack item offset struct { uint64_t exc_value; + uint64_t previous_item; } err_stackitem; // InterpreterFrame offset; @@ -304,9 +306,11 @@ typedef struct _Py_DebugOffsets { .gil_requested = offsetof(PyThreadState, gil_requested), \ .current_exception = offsetof(PyThreadState, current_exception), \ .exc_state = offsetof(PyThreadState, exc_state), \ + .exc_info = offsetof(PyThreadState, exc_info), \ }, \ .err_stackitem = { \ .exc_value = offsetof(_PyErr_StackItem, exc_value), \ + .previous_item = offsetof(_PyErr_StackItem, previous_item), \ }, \ .interpreter_frame = { \ .size = sizeof(_PyInterpreterFrame), \ diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index c83d2cb2abeac81..5921a66a71ef7cf 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -2935,6 +2935,23 @@ class TestExceptionDetectionScenarios(RemoteInspectionTestBase): 4. finally_no_exception: Finally block with no exception raised -> Should NOT have HAS_EXCEPTION (no exception state) + + 5. except_block_in_generator: Thread inside an except block running in a + generator + -> SHOULD have HAS_EXCEPTION (exc_info points at the generator's + _PyErr_StackItem, not the thread's embedded exc_state) + + 6. except_block_in_genexpr_callee: Except block in a function called from a + generator expression + -> SHOULD have HAS_EXCEPTION (same reason as 5) + + 7. except_block_in_coroutine: Thread inside an except block running in a + coroutine + -> SHOULD have HAS_EXCEPTION (same reason as 5) + + 8. except_block_in_callee_from_coroutine: Except block in a function called + from a coroutine + -> SHOULD have HAS_EXCEPTION (same reason as 5) """ def _make_single_scenario_script(self, port, scenario): @@ -3018,6 +3035,105 @@ def target_thread(): while True: time.sleep(0.01) +t = threading.Thread(target=target_thread) +t.start() +t.join() +""", + "except_block_in_generator": f"""\ +import socket +import threading +import time + +def target_thread(): + '''Inside except block that runs in a generator''' + conn = socket.create_connection(("localhost", {port})) + conn.sendall(b"ready:" + str(threading.get_native_id()).encode()) + + def gen(): + try: + raise ValueError("test") + except ValueError: + while True: + time.sleep(0.01) + yield + + for _ in gen(): + pass + +t = threading.Thread(target=target_thread) +t.start() +t.join() +""", + "except_block_in_genexpr_callee": f"""\ +import socket +import threading +import time + +def target_thread(): + '''Inside except block in a function called from a generator expression''' + conn = socket.create_connection(("localhost", {port})) + conn.sendall(b"ready:" + str(threading.get_native_id()).encode()) + + def callee(): + try: + raise ValueError("test") + except ValueError: + while True: + time.sleep(0.01) + + list(callee() for _ in range(1)) + +t = threading.Thread(target=target_thread) +t.start() +t.join() +""", + "except_block_in_coroutine": f"""\ +import asyncio +import socket +import threading +import time + +def target_thread(): + '''Inside except block that runs in a coroutine''' + conn = socket.create_connection(("localhost", {port})) + conn.sendall(b"ready:" + str(threading.get_native_id()).encode()) + + async def coro(): + try: + raise ValueError("test") + except ValueError: + while True: + time.sleep(0.01) + + asyncio.run(coro()) + +t = threading.Thread(target=target_thread) +t.start() +t.join() +""", + "except_block_in_callee_from_coroutine": f"""\ +import asyncio +import socket +import threading +import time + +def target_thread(): + '''Inside except block in a function called from a coroutine''' + conn = socket.create_connection(("localhost", {port})) + conn.sendall(b"ready:" + str(threading.get_native_id()).encode()) + + def callee(): + try: + raise ValueError("test") + except ValueError: + while True: + time.sleep(0.01) + + async def coro(): + callee() + + asyncio.run(coro()) + t = threading.Thread(target=target_thread) t.start() t.join() @@ -3181,6 +3297,285 @@ def test_finally_no_exception_no_flag(self): self.assertIsNotNone(thread_tid, "Thread ID not received") self._check_exception_status(p, thread_tid, expect_exception=False) + @unittest.skipIf( + sys.platform not in ("linux", "darwin", "win32"), + "Test only runs on supported platforms (Linux, macOS, or Windows)", + ) + @unittest.skipIf( + sys.platform == "android", "Android raises Linux-specific exception" + ) + def test_except_block_in_generator_has_exception(self): + """gh-158539: a handler running in a generator has HAS_EXCEPTION. + + Generators repoint ``tstate->exc_info`` at their own + ``_PyErr_StackItem``, so the embedded ``exc_state`` stays empty and the + profiler must follow ``exc_info`` to see the handled exception. + """ + with self._run_scenario_process("except_block_in_generator") as (p, thread_tid): + self.assertIsNotNone(thread_tid, "Thread ID not received") + self._check_exception_status(p, thread_tid, expect_exception=True) + + @unittest.skipIf( + sys.platform not in ("linux", "darwin", "win32"), + "Test only runs on supported platforms (Linux, macOS, or Windows)", + ) + @unittest.skipIf( + sys.platform == "android", "Android raises Linux-specific exception" + ) + def test_except_block_in_genexpr_callee_has_exception(self): + """gh-158539: a handler in a function called from a generator expression. + + The handler itself lives in an ordinary function, but the generator + expression on the stack means ``exc_info`` does not point at the + thread's embedded ``exc_state``. + """ + with self._run_scenario_process( + "except_block_in_genexpr_callee" + ) as (p, thread_tid): + self.assertIsNotNone(thread_tid, "Thread ID not received") + self._check_exception_status(p, thread_tid, expect_exception=True) + + @unittest.skipIf( + sys.platform not in ("linux", "darwin", "win32"), + "Test only runs on supported platforms (Linux, macOS, or Windows)", + ) + @unittest.skipIf( + sys.platform == "android", "Android raises Linux-specific exception" + ) + def test_except_block_in_coroutine_has_exception(self): + """gh-158539: a handler running in a coroutine has HAS_EXCEPTION.""" + with self._run_scenario_process("except_block_in_coroutine") as (p, thread_tid): + self.assertIsNotNone(thread_tid, "Thread ID not received") + self._check_exception_status(p, thread_tid, expect_exception=True) + + @unittest.skipIf( + sys.platform not in ("linux", "darwin", "win32"), + "Test only runs on supported platforms (Linux, macOS, or Windows)", + ) + @unittest.skipIf( + sys.platform == "android", "Android raises Linux-specific exception" + ) + def test_except_block_in_callee_from_coroutine_has_exception(self): + """gh-158539: a handler in a function called from a coroutine.""" + with self._run_scenario_process( + "except_block_in_callee_from_coroutine" + ) as (p, thread_tid): + self.assertIsNotNone(thread_tid, "Thread ID not received") + self._check_exception_status(p, thread_tid, expect_exception=True) + + +class TestExceptionDetectionInProcess(RemoteInspectionTestBase): + """gh-158539: HAS_EXCEPTION for handlers running in generators/coroutines. + + ``TestExceptionDetectionScenarios`` samples a child process and therefore + needs subprocess debugging permissions. These tests inspect the current + process with ``RemoteUnwinder`` and only need self-inspection, so they also + run on macOS without special entitlements. + """ + + @classmethod + def setUpClass(cls): + try: + RemoteUnwinder(os.getpid(), all_threads=True).get_stack_trace() + except Exception as exc: + raise unittest.SkipTest(f"self-inspection is unavailable: {exc}") + + def _check_running_handler( + self, target, expect_exception, *, mode=PROFILING_MODE_ALL, + skip_non_matching_threads=False, + ): + """Run *target* in a thread and check its HAS_EXCEPTION flag. + + *target* receives ``(ready, stop)`` events and must signal ``ready`` + only once it is executing inside the code region under test, then keep + running until ``stop`` is set. + """ + stop = threading.Event() + ready = threading.Event() + failure = [] + + def runner(): + try: + target(ready, stop) + except BaseException as exc: + failure.append(exc) + ready.set() + + thread = threading.Thread(target=runner, daemon=True) + thread.start() + try: + self.assertTrue(ready.wait(SHORT_TIMEOUT), "handler never started") + self.assertFalse(failure, f"handler raised {failure!r}") + + unwinder = RemoteUnwinder( + os.getpid(), + all_threads=True, + mode=mode, + skip_non_matching_threads=skip_non_matching_threads, + ) + observed = [] + for _ in busy_retry(SHORT_TIMEOUT): + with contextlib.suppress(*TRANSIENT_ERRORS): + statuses = self._get_thread_statuses(unwinder.get_stack_trace()) + status = statuses.get(thread.native_id) + if status is None: + continue + has_exception = bool(status & THREAD_STATUS_HAS_EXCEPTION) + observed.append(has_exception) + if has_exception == expect_exception: + break + self.assertTrue( + observed, "target thread status was never observed" + ) + self.assertIn( + expect_exception, + observed, + f"HAS_EXCEPTION was never {expect_exception} while the " + f"handler was running (observed {observed})", + ) + finally: + stop.set() + thread.join(SHORT_TIMEOUT) + + def _busy_until_stopped(self, ready, stop): + ready.set() + while not stop.is_set(): + time.sleep(0.001) + + def test_handler_in_function(self): + def target(ready, stop): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + + self._check_running_handler(target, expect_exception=True) + + def test_handler_in_generator(self): + def target(ready, stop): + def gen(): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + yield + + for _ in gen(): + pass + + self._check_running_handler(target, expect_exception=True) + + def test_handler_in_genexpr_callee(self): + def target(ready, stop): + def callee(): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + + list(callee() for _ in range(1)) + + self._check_running_handler(target, expect_exception=True) + + def test_handler_in_coroutine(self): + async def coro(ready, stop): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + + def target(ready, stop): + asyncio.run(coro(ready, stop)) + + self._check_running_handler(target, expect_exception=True) + + def test_handler_in_callee_from_coroutine(self): + def callee(ready, stop): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + + async def coro(ready, stop): + callee(ready, stop) + + def target(ready, stop): + asyncio.run(coro(ready, stop)) + + self._check_running_handler(target, expect_exception=True) + + def test_outer_handler_while_generator_runs(self): + """A generator with no handler of its own must not hide the outer one. + + ``exc_info`` points at the generator's empty ``_PyErr_StackItem`` whose + ``previous_item`` is the thread's ``exc_state``, so the profiler has to + walk the chain to find the exception ``sys.exception()`` reports. + """ + def target(ready, stop): + def gen(): + self._busy_until_stopped(ready, stop) + yield + + try: + raise ValueError("outer") + except ValueError: + for _ in gen(): + pass + + self._check_running_handler(target, expect_exception=True) + + def test_generator_without_exception(self): + def target(ready, stop): + def gen(): + self._busy_until_stopped(ready, stop) + yield + + for _ in gen(): + pass + + self._check_running_handler(target, expect_exception=False) + + def test_generator_finally_after_except(self): + """The handled exception is cleared before the generator's finally.""" + def target(ready, stop): + def gen(): + try: + raise ValueError("test") + except ValueError: + pass + finally: + self._busy_until_stopped(ready, stop) + yield + + for _ in gen(): + pass + + self._check_running_handler(target, expect_exception=False) + + def test_exception_mode_filter_keeps_generator_handler(self): + """The exception-mode thread filter must not drop a generator handler. + + This mirrors what ``--mode=exception`` actually does: threads without + HAS_EXCEPTION are skipped before their stack is unwound. + """ + def target(ready, stop): + def gen(): + try: + raise ValueError("test") + except ValueError: + self._busy_until_stopped(ready, stop) + yield + + for _ in gen(): + pass + + self._check_running_handler( + target, + expect_exception=True, + mode=PROFILING_MODE_EXCEPTION, + skip_non_matching_threads=True, + ) + @requires_remote_subprocess_debugging() class TestFrameCaching(RemoteInspectionTestBase): diff --git a/Misc/NEWS.d/next/Library/2026-10-01-14-00-00.gh-issue-158539.q1w2e3.rst b/Misc/NEWS.d/next/Library/2026-10-01-14-00-00.gh-issue-158539.q1w2e3.rst new file mode 100644 index 000000000000000..b1ab9c8e017fb26 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-01-14-00-00.gh-issue-158539.q1w2e3.rst @@ -0,0 +1,5 @@ +Fix :mod:`profiling.sampling` exception mode discarding samples for code +running inside an ``except`` block in a generator or coroutine. The remote +debugger now follows ``tstate->exc_info`` and its ``previous_item`` chain +instead of only reading the embedded ``exc_state``, matching the exception +that :func:`sys.exception` reports. diff --git a/Modules/_remote_debugging/debug_offsets_validation.h b/Modules/_remote_debugging/debug_offsets_validation.h index c0c01a0a639e196..5c1b3b18e2496a2 100644 --- a/Modules/_remote_debugging/debug_offsets_validation.h +++ b/Modules/_remote_debugging/debug_offsets_validation.h @@ -31,7 +31,7 @@ #define FIELD_SIZE(type, member) sizeof(((type *)0)->member) enum { - PY_REMOTE_DEBUG_OFFSETS_TOTAL_SIZE = 888, + PY_REMOTE_DEBUG_OFFSETS_TOTAL_SIZE = 904, PY_REMOTE_ASYNC_DEBUG_OFFSETS_TOTAL_SIZE = 104, }; @@ -257,6 +257,7 @@ validate_fixed_field( APPLY(thread_state, holds_gil, sizeof(int), _Alignof(int), buffer_size); \ APPLY(thread_state, gil_requested, sizeof(int), _Alignof(int), buffer_size); \ APPLY(thread_state, current_exception, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ + APPLY(thread_state, exc_info, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ APPLY(thread_state, thread_id, sizeof(unsigned long), _Alignof(long), buffer_size); \ APPLY(thread_state, next, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ APPLY(thread_state, current_frame, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ @@ -357,6 +358,12 @@ _PyRemoteDebug_ValidateDebugOffsetsLayout(struct _Py_DebugOffsets *debug_offsets sizeof(uintptr_t), _Alignof(uintptr_t), sizeof(_PyErr_StackItem)); + PY_REMOTE_DEBUG_VALIDATE_FIXED_FIELD( + err_stackitem, + previous_item, + sizeof(uintptr_t), + _Alignof(uintptr_t), + sizeof(_PyErr_StackItem)); PY_REMOTE_DEBUG_VALIDATE_NESTED_FIELD( thread_state, exc_state, diff --git a/Modules/_remote_debugging/threads.c b/Modules/_remote_debugging/threads.c index 04c70cc96d6bd1e..52f7b0767bab25c 100644 --- a/Modules/_remote_debugging/threads.c +++ b/Modules/_remote_debugging/threads.c @@ -17,6 +17,11 @@ #include #endif +/* Upper bound on how far the handled-exception chain (exc_info->previous_item) + * is followed in remote memory. The chain is normally at most a couple of + * entries deep; the bound only guards against corrupted memory. */ +#define MAX_EXCEPTION_CHAIN_DEPTH 16 + /* ============================================================================ * THREAD ITERATION FUNCTIONS * ============================================================================ */ @@ -436,16 +441,56 @@ unwind_stack_for_thread( has_exception = 1; } - // Check exc_state.exc_value (exception being handled in except block) - // exc_state is embedded in PyThreadState, so we read it directly from - // the thread state buffer. This catches most cases; nested exception - // handlers where exc_info points elsewhere are rare. + // Check the exception currently being handled by an except block. + // + // The active _PyErr_StackItem is normally exc_state, embedded in the + // thread state, but generators, coroutines and async generators repoint + // tstate->exc_info at their own _PyErr_StackItem while they run. Reading + // only the embedded exc_state therefore misses every handler that runs in + // a generator or coroutine, or in a function one of them calls. Follow + // exc_info and walk previous_item like _PyErr_GetTopmostException() so + // that an outer handler is still found while a generator without a handler + // of its own is running. if (!has_exception) { - uintptr_t exc_value = GET_MEMBER(uintptr_t, ts, - unwinder->debug_offsets.thread_state.exc_state + - unwinder->debug_offsets.err_stackitem.exc_value); - if (exc_value != 0) { - has_exception = 1; + uintptr_t exc_info = GET_MEMBER(uintptr_t, ts, + unwinder->debug_offsets.thread_state.exc_info); + uintptr_t exc_state_addr = + *current_tstate + unwinder->debug_offsets.thread_state.exc_state; + uintptr_t exc_value_offset = + unwinder->debug_offsets.err_stackitem.exc_value; + uintptr_t previous_item_offset = + unwinder->debug_offsets.err_stackitem.previous_item; + + for (int depth = 0; exc_info != 0 && depth < MAX_EXCEPTION_CHAIN_DEPTH; + depth++) + { + if (exc_info == exc_state_addr) { + // Bottom of the chain: the stack item embedded in the thread + // state, which is already in the local thread state buffer. + uintptr_t exc_value = GET_MEMBER(uintptr_t, ts, + unwinder->debug_offsets.thread_state.exc_state + + exc_value_offset); + if (exc_value != 0) { + has_exception = 1; + } + break; + } + uintptr_t exc_value = 0; + if (read_ptr(unwinder, exc_info + exc_value_offset, &exc_value) < 0) { + PyErr_Clear(); // Best effort: treat as no active exception + break; + } + if (exc_value != 0) { + has_exception = 1; + break; + } + uintptr_t previous_item = 0; + if (read_ptr(unwinder, exc_info + previous_item_offset, + &previous_item) < 0) { + PyErr_Clear(); + break; + } + exc_info = previous_item; } } From 23ae30faedefd6d74bd319b60d263b5345e322a0 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 15:06:05 +0100 Subject: [PATCH 2/5] gh-158539: Handle deep exception chains without changing debug offsets --- Include/internal/pycore_debug_offsets.h | 4 - Lib/test/test_external_inspection.py | 206 ++---------------- .../debug_offsets_validation.h | 15 +- Modules/_remote_debugging/threads.c | 30 ++- 4 files changed, 42 insertions(+), 213 deletions(-) diff --git a/Include/internal/pycore_debug_offsets.h b/Include/internal/pycore_debug_offsets.h index 93f0efd882cdc7e..6e1eb573c8c2c83 100644 --- a/Include/internal/pycore_debug_offsets.h +++ b/Include/internal/pycore_debug_offsets.h @@ -113,13 +113,11 @@ typedef struct _Py_DebugOffsets { uint64_t gil_requested; uint64_t current_exception; uint64_t exc_state; - uint64_t exc_info; } thread_state; // Exception stack item offset struct { uint64_t exc_value; - uint64_t previous_item; } err_stackitem; // InterpreterFrame offset; @@ -306,11 +304,9 @@ typedef struct _Py_DebugOffsets { .gil_requested = offsetof(PyThreadState, gil_requested), \ .current_exception = offsetof(PyThreadState, current_exception), \ .exc_state = offsetof(PyThreadState, exc_state), \ - .exc_info = offsetof(PyThreadState, exc_info), \ }, \ .err_stackitem = { \ .exc_value = offsetof(_PyErr_StackItem, exc_value), \ - .previous_item = offsetof(_PyErr_StackItem, previous_item), \ }, \ .interpreter_frame = { \ .size = sizeof(_PyInterpreterFrame), \ diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index d28bf6f3a2ce37c..efe36cde341198c 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -3046,23 +3046,6 @@ class TestExceptionDetectionScenarios(RemoteInspectionTestBase): 4. finally_no_exception: Finally block with no exception raised -> Should NOT have HAS_EXCEPTION (no exception state) - - 5. except_block_in_generator: Thread inside an except block running in a - generator - -> SHOULD have HAS_EXCEPTION (exc_info points at the generator's - _PyErr_StackItem, not the thread's embedded exc_state) - - 6. except_block_in_genexpr_callee: Except block in a function called from a - generator expression - -> SHOULD have HAS_EXCEPTION (same reason as 5) - - 7. except_block_in_coroutine: Thread inside an except block running in a - coroutine - -> SHOULD have HAS_EXCEPTION (same reason as 5) - - 8. except_block_in_callee_from_coroutine: Except block in a function called - from a coroutine - -> SHOULD have HAS_EXCEPTION (same reason as 5) """ def _make_single_scenario_script(self, port, scenario): @@ -3146,105 +3129,6 @@ def target_thread(): while True: time.sleep(0.01) -t = threading.Thread(target=target_thread) -t.start() -t.join() -""", - "except_block_in_generator": f"""\ -import socket -import threading -import time - -def target_thread(): - '''Inside except block that runs in a generator''' - conn = socket.create_connection(("localhost", {port})) - conn.sendall(b"ready:" + str(threading.get_native_id()).encode()) - - def gen(): - try: - raise ValueError("test") - except ValueError: - while True: - time.sleep(0.01) - yield - - for _ in gen(): - pass - -t = threading.Thread(target=target_thread) -t.start() -t.join() -""", - "except_block_in_genexpr_callee": f"""\ -import socket -import threading -import time - -def target_thread(): - '''Inside except block in a function called from a generator expression''' - conn = socket.create_connection(("localhost", {port})) - conn.sendall(b"ready:" + str(threading.get_native_id()).encode()) - - def callee(): - try: - raise ValueError("test") - except ValueError: - while True: - time.sleep(0.01) - - list(callee() for _ in range(1)) - -t = threading.Thread(target=target_thread) -t.start() -t.join() -""", - "except_block_in_coroutine": f"""\ -import asyncio -import socket -import threading -import time - -def target_thread(): - '''Inside except block that runs in a coroutine''' - conn = socket.create_connection(("localhost", {port})) - conn.sendall(b"ready:" + str(threading.get_native_id()).encode()) - - async def coro(): - try: - raise ValueError("test") - except ValueError: - while True: - time.sleep(0.01) - - asyncio.run(coro()) - -t = threading.Thread(target=target_thread) -t.start() -t.join() -""", - "except_block_in_callee_from_coroutine": f"""\ -import asyncio -import socket -import threading -import time - -def target_thread(): - '''Inside except block in a function called from a coroutine''' - conn = socket.create_connection(("localhost", {port})) - conn.sendall(b"ready:" + str(threading.get_native_id()).encode()) - - def callee(): - try: - raise ValueError("test") - except ValueError: - while True: - time.sleep(0.01) - - async def coro(): - callee() - - asyncio.run(coro()) - t = threading.Thread(target=target_thread) t.start() t.join() @@ -3408,72 +3292,6 @@ def test_finally_no_exception_no_flag(self): self.assertIsNotNone(thread_tid, "Thread ID not received") self._check_exception_status(p, thread_tid, expect_exception=False) - @unittest.skipIf( - sys.platform not in ("linux", "darwin", "win32"), - "Test only runs on supported platforms (Linux, macOS, or Windows)", - ) - @unittest.skipIf( - sys.platform == "android", "Android raises Linux-specific exception" - ) - def test_except_block_in_generator_has_exception(self): - """gh-158539: a handler running in a generator has HAS_EXCEPTION. - - Generators repoint ``tstate->exc_info`` at their own - ``_PyErr_StackItem``, so the embedded ``exc_state`` stays empty and the - profiler must follow ``exc_info`` to see the handled exception. - """ - with self._run_scenario_process("except_block_in_generator") as (p, thread_tid): - self.assertIsNotNone(thread_tid, "Thread ID not received") - self._check_exception_status(p, thread_tid, expect_exception=True) - - @unittest.skipIf( - sys.platform not in ("linux", "darwin", "win32"), - "Test only runs on supported platforms (Linux, macOS, or Windows)", - ) - @unittest.skipIf( - sys.platform == "android", "Android raises Linux-specific exception" - ) - def test_except_block_in_genexpr_callee_has_exception(self): - """gh-158539: a handler in a function called from a generator expression. - - The handler itself lives in an ordinary function, but the generator - expression on the stack means ``exc_info`` does not point at the - thread's embedded ``exc_state``. - """ - with self._run_scenario_process( - "except_block_in_genexpr_callee" - ) as (p, thread_tid): - self.assertIsNotNone(thread_tid, "Thread ID not received") - self._check_exception_status(p, thread_tid, expect_exception=True) - - @unittest.skipIf( - sys.platform not in ("linux", "darwin", "win32"), - "Test only runs on supported platforms (Linux, macOS, or Windows)", - ) - @unittest.skipIf( - sys.platform == "android", "Android raises Linux-specific exception" - ) - def test_except_block_in_coroutine_has_exception(self): - """gh-158539: a handler running in a coroutine has HAS_EXCEPTION.""" - with self._run_scenario_process("except_block_in_coroutine") as (p, thread_tid): - self.assertIsNotNone(thread_tid, "Thread ID not received") - self._check_exception_status(p, thread_tid, expect_exception=True) - - @unittest.skipIf( - sys.platform not in ("linux", "darwin", "win32"), - "Test only runs on supported platforms (Linux, macOS, or Windows)", - ) - @unittest.skipIf( - sys.platform == "android", "Android raises Linux-specific exception" - ) - def test_except_block_in_callee_from_coroutine_has_exception(self): - """gh-158539: a handler in a function called from a coroutine.""" - with self._run_scenario_process( - "except_block_in_callee_from_coroutine" - ) as (p, thread_tid): - self.assertIsNotNone(thread_tid, "Thread ID not received") - self._check_exception_status(p, thread_tid, expect_exception=True) - class TestExceptionDetectionInProcess(RemoteInspectionTestBase): """gh-158539: HAS_EXCEPTION for handlers running in generators/coroutines. @@ -3488,7 +3306,7 @@ class TestExceptionDetectionInProcess(RemoteInspectionTestBase): def setUpClass(cls): try: RemoteUnwinder(os.getpid(), all_threads=True).get_stack_trace() - except Exception as exc: + except PermissionError as exc: raise unittest.SkipTest(f"self-inspection is unavailable: {exc}") def _check_running_handler( @@ -3646,6 +3464,28 @@ def gen(): self._check_running_handler(target, expect_exception=False) + def test_outer_handler_while_nested_generators_run(self): + def target(ready, stop): + def gen(depth): + if depth: + yield from gen(depth - 1) + else: + self._busy_until_stopped(ready, stop) + yield + + try: + raise ValueError("outer") + except ValueError: + for _ in gen(32): + pass + + self._check_running_handler( + target, + expect_exception=True, + mode=PROFILING_MODE_EXCEPTION, + skip_non_matching_threads=True, + ) + def test_generator_finally_after_except(self): """The handled exception is cleared before the generator's finally.""" def target(ready, stop): diff --git a/Modules/_remote_debugging/debug_offsets_validation.h b/Modules/_remote_debugging/debug_offsets_validation.h index 5c1b3b18e2496a2..e2030948c1b1e8b 100644 --- a/Modules/_remote_debugging/debug_offsets_validation.h +++ b/Modules/_remote_debugging/debug_offsets_validation.h @@ -31,7 +31,7 @@ #define FIELD_SIZE(type, member) sizeof(((type *)0)->member) enum { - PY_REMOTE_DEBUG_OFFSETS_TOTAL_SIZE = 904, + PY_REMOTE_DEBUG_OFFSETS_TOTAL_SIZE = 888, PY_REMOTE_ASYNC_DEBUG_OFFSETS_TOTAL_SIZE = 104, }; @@ -249,6 +249,7 @@ validate_fixed_field( #define PY_REMOTE_DEBUG_RUNTIME_STATE_FIELDS(APPLY, buffer_size) \ APPLY(runtime_state, interpreters_head, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size) +/* current_exception also covers the adjacent exc_info pointer. */ #define PY_REMOTE_DEBUG_THREAD_STATE_FIELDS(APPLY, buffer_size) \ APPLY(thread_state, native_thread_id, sizeof(unsigned long), _Alignof(long), buffer_size); \ APPLY(thread_state, interp, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ @@ -256,8 +257,7 @@ validate_fixed_field( APPLY(thread_state, status, FIELD_SIZE(PyThreadState, _status), _Alignof(unsigned int), buffer_size); \ APPLY(thread_state, holds_gil, sizeof(int), _Alignof(int), buffer_size); \ APPLY(thread_state, gil_requested, sizeof(int), _Alignof(int), buffer_size); \ - APPLY(thread_state, current_exception, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ - APPLY(thread_state, exc_info, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ + APPLY(thread_state, current_exception, 2 * sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ APPLY(thread_state, thread_id, sizeof(unsigned long), _Alignof(long), buffer_size); \ APPLY(thread_state, next, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ APPLY(thread_state, current_frame, sizeof(uintptr_t), _Alignof(uintptr_t), buffer_size); \ @@ -352,16 +352,11 @@ _PyRemoteDebug_ValidateDebugOffsetsLayout(struct _Py_DebugOffsets *debug_offsets PY_REMOTE_DEBUG_THREAD_STATE_FIELDS( PY_REMOTE_DEBUG_VALIDATE_FIELD, SIZEOF_THREAD_STATE); + /* exc_value also covers the adjacent previous_item pointer. */ PY_REMOTE_DEBUG_VALIDATE_FIXED_FIELD( err_stackitem, exc_value, - sizeof(uintptr_t), - _Alignof(uintptr_t), - sizeof(_PyErr_StackItem)); - PY_REMOTE_DEBUG_VALIDATE_FIXED_FIELD( - err_stackitem, - previous_item, - sizeof(uintptr_t), + 2 * sizeof(uintptr_t), _Alignof(uintptr_t), sizeof(_PyErr_StackItem)); PY_REMOTE_DEBUG_VALIDATE_NESTED_FIELD( diff --git a/Modules/_remote_debugging/threads.c b/Modules/_remote_debugging/threads.c index 2644e9632b71228..68c38d6de6f6a2e 100644 --- a/Modules/_remote_debugging/threads.c +++ b/Modules/_remote_debugging/threads.c @@ -17,10 +17,15 @@ #include #endif -/* Upper bound on how far the handled-exception chain (exc_info->previous_item) - * is followed in remote memory. The chain is normally at most a couple of - * entries deep; the bound only guards against corrupted memory. */ -#define MAX_EXCEPTION_CHAIN_DEPTH 16 +/* Bound traversal of corrupted remote exception chains. */ +#define MAX_EXCEPTION_CHAIN_DEPTH (2 << 15) + +/* Derive unexported offsets from adjacent fields to keep the debug-offset + * table compatible across patch releases. */ +static_assert(offsetof(PyThreadState, exc_info) == + offsetof(PyThreadState, current_exception) + sizeof(uintptr_t)); +static_assert(offsetof(_PyErr_StackItem, previous_item) == + offsetof(_PyErr_StackItem, exc_value) + sizeof(uintptr_t)); /* ============================================================================ * THREAD ITERATION FUNCTIONS @@ -441,25 +446,18 @@ unwind_stack_for_thread( has_exception = 1; } - // Check the exception currently being handled by an except block. - // - // The active _PyErr_StackItem is normally exc_state, embedded in the - // thread state, but generators, coroutines and async generators repoint - // tstate->exc_info at their own _PyErr_StackItem while they run. Reading - // only the embedded exc_state therefore misses every handler that runs in - // a generator or coroutine, or in a function one of them calls. Follow - // exc_info and walk previous_item like _PyErr_GetTopmostException() so - // that an outer handler is still found while a generator without a handler - // of its own is running. + // Generators and coroutines use their own exception stack items. + // Follow exc_info to find the innermost handler, as sys.exception() does. if (!has_exception) { uintptr_t exc_info = GET_MEMBER(uintptr_t, ts, - unwinder->debug_offsets.thread_state.exc_info); + unwinder->debug_offsets.thread_state.current_exception + + sizeof(uintptr_t)); uintptr_t exc_state_addr = *current_tstate + unwinder->debug_offsets.thread_state.exc_state; uintptr_t exc_value_offset = unwinder->debug_offsets.err_stackitem.exc_value; uintptr_t previous_item_offset = - unwinder->debug_offsets.err_stackitem.previous_item; + exc_value_offset + sizeof(uintptr_t); for (int depth = 0; exc_info != 0 && depth < MAX_EXCEPTION_CHAIN_DEPTH; depth++) From 6c0b716fe9284a620680498571c7488a8a06609f Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 15:12:28 +0100 Subject: [PATCH 3/5] gh-158539: Use portable static assertion messages --- Modules/_remote_debugging/threads.c | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/_remote_debugging/threads.c b/Modules/_remote_debugging/threads.c index 68c38d6de6f6a2e..dcc31cc8d710a60 100644 --- a/Modules/_remote_debugging/threads.c +++ b/Modules/_remote_debugging/threads.c @@ -23,9 +23,11 @@ /* Derive unexported offsets from adjacent fields to keep the debug-offset * table compatible across patch releases. */ static_assert(offsetof(PyThreadState, exc_info) == - offsetof(PyThreadState, current_exception) + sizeof(uintptr_t)); + offsetof(PyThreadState, current_exception) + sizeof(uintptr_t), + "exc_info must immediately follow current_exception"); static_assert(offsetof(_PyErr_StackItem, previous_item) == - offsetof(_PyErr_StackItem, exc_value) + sizeof(uintptr_t)); + offsetof(_PyErr_StackItem, exc_value) + sizeof(uintptr_t), + "previous_item must immediately follow exc_value"); /* ============================================================================ * THREAD ITERATION FUNCTIONS From cea31121cb8baafb05114ce9618e417de56b44c9 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 15:23:59 +0100 Subject: [PATCH 4/5] gh-158539: Keep layout assertions with debug-offset validation --- Modules/_remote_debugging/debug_offsets_validation.h | 9 +++++++++ Modules/_remote_debugging/threads.c | 9 --------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Modules/_remote_debugging/debug_offsets_validation.h b/Modules/_remote_debugging/debug_offsets_validation.h index e2030948c1b1e8b..66c35e5522a9c7d 100644 --- a/Modules/_remote_debugging/debug_offsets_validation.h +++ b/Modules/_remote_debugging/debug_offsets_validation.h @@ -48,6 +48,15 @@ static_assert( PY_REMOTE_ASYNC_DEBUG_OFFSETS_TOTAL_SIZE, "Update _remote_debugging validation for _Py_AsyncioModuleDebugOffsets"); +/* Derive unexported offsets from adjacent fields to keep the debug-offset + * table compatible across patch releases. */ +static_assert(offsetof(PyThreadState, exc_info) == + offsetof(PyThreadState, current_exception) + sizeof(uintptr_t), + "exc_info must immediately follow current_exception"); +static_assert(offsetof(_PyErr_StackItem, previous_item) == + offsetof(_PyErr_StackItem, exc_value) + sizeof(uintptr_t), + "previous_item must immediately follow exc_value"); + /* * This logic lives in a private header because it is shared by module.c and * asyncio.c. Keep the helpers static inline so they stay local to those users diff --git a/Modules/_remote_debugging/threads.c b/Modules/_remote_debugging/threads.c index dcc31cc8d710a60..198134fe6cfbea7 100644 --- a/Modules/_remote_debugging/threads.c +++ b/Modules/_remote_debugging/threads.c @@ -20,15 +20,6 @@ /* Bound traversal of corrupted remote exception chains. */ #define MAX_EXCEPTION_CHAIN_DEPTH (2 << 15) -/* Derive unexported offsets from adjacent fields to keep the debug-offset - * table compatible across patch releases. */ -static_assert(offsetof(PyThreadState, exc_info) == - offsetof(PyThreadState, current_exception) + sizeof(uintptr_t), - "exc_info must immediately follow current_exception"); -static_assert(offsetof(_PyErr_StackItem, previous_item) == - offsetof(_PyErr_StackItem, exc_value) + sizeof(uintptr_t), - "previous_item must immediately follow exc_value"); - /* ============================================================================ * THREAD ITERATION FUNCTIONS * ============================================================================ */ From beac4bdd50d147305031671ddf462282b44730e4 Mon Sep 17 00:00:00 2001 From: Pablo Galindo Salgado Date: Mon, 5 Oct 2026 15:44:36 +0100 Subject: [PATCH 5/5] gh-158539: Use the platform guard for in-process inspection tests --- Lib/test/test_external_inspection.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index efe36cde341198c..0c189e60a93b8ee 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -3293,6 +3293,7 @@ def test_finally_no_exception_no_flag(self): self._check_exception_status(p, thread_tid, expect_exception=False) +@skip_if_not_supported class TestExceptionDetectionInProcess(RemoteInspectionTestBase): """gh-158539: HAS_EXCEPTION for handlers running in generators/coroutines.