From 43de9df72edd249275cda7fe51071be9eab4e090 Mon Sep 17 00:00:00 2001 From: liuzhijie-0614 <75884011+liuzhijie-0614@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:59:04 +0800 Subject: [PATCH 1/2] gh-157660: Refresh cached TLBC arrays after growth --- Lib/test/test_external_inspection.py | 88 +++++++++++++++++++ ...26-09-18-12-00-00.gh-issue-157660.tlbc.rst | 2 + Modules/_remote_debugging/_remote_debugging.h | 4 +- Modules/_remote_debugging/code_objects.c | 29 ++++-- 4 files changed, 117 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-09-18-12-00-00.gh-issue-157660.tlbc.rst diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index 910fe96d5e7d81..89b4817f31e8cf 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -3044,6 +3044,94 @@ def _sample_frames( client_socket.sendall(send_ack) return frames + @skip_if_not_supported + @unittest.skipIf(sys._is_gil_enabled(), "Requires free-threading") + @unittest.skipIf( + sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, + "Requires process_vm_readv", + ) + def test_tlbc_cache_refresh_after_growth(self): + # All threads must exist before the first sample: allocating a new + # thread index would invalidate the cache and hide the growth bug. + script_body = """\ + import threading + import sys + + if sys._is_gil_enabled() or sys._xoptions.get("tlbc") == "0": + sock.sendall(b"skip-ready") + sys.exit() + + stop = threading.Event() + gates = [threading.Event() for _ in range(18)] + ready = [threading.Event() for _ in range(18)] + + def leaf(): + sock.sendall(b"leaf") + stop.wait() + + def worker(index): + ready[index].set() + gates[index].wait() + leaf() + + for index in range(18): + threading.Thread(target=worker, args=(index,), daemon=True).start() + ready[index].wait() + sock.sendall(f"{leaf.__code__.co_firstlineno + 2}:ready".encode()) + sock.recv(1) + gates[0].set() + sock.recv(1) + gates[-1].set() + sock.recv(1) + """ + + for cache_frames in (False, True): + with self.subTest(cache_frames=cache_frames): + with self._target_process(script_body) as ( + process, client_socket, make_unwinder + ): + signal = _wait_for_signal(client_socket, b"ready") + if b"skip" in signal: + self.skipTest("Target requires free-threading and TLBC") + expected_lineno = int(signal.split(b":", 1)[0]) + unwinder = make_unwinder(cache_frames=cache_frames) + client_socket.sendall(b"1") + _wait_for_signal(client_socket, b"leaf") + # The signal can arrive before leaf reaches stop.wait(). + # Wait for that known line in both sampling phases. + initial_leaf = None + for _ in range(MAX_TRIES): + frames = self._get_frames_with_retry(unwinder, {"leaf"}) + if frames: + initial_leaf = next(f for f in frames if f.funcname == "leaf") + if initial_leaf.location.lineno == expected_lineno: + break + time.sleep(RETRY_DELAY) + self.assertIsNotNone(initial_leaf, "Failed to cache initial TLBC array") + self.assertEqual(initial_leaf.location.lineno, expected_lineno) + + client_socket.sendall(b"2") + _wait_for_signal(client_socket, b"leaf") + leaves = [] + for _ in range(MAX_TRIES): + with contextlib.suppress(*TRANSIENT_ERRORS): + traces = unwinder.get_stack_trace() + leaves = [ + frame + for interp in traces + for thread in interp.threads + for frame in thread.frame_info + if frame.funcname == "leaf" + ] + if (len(leaves) == 2 and + all(f.location.lineno == expected_lineno for f in leaves)): + break + time.sleep(RETRY_DELAY) + self.assertEqual(len(leaves), 2, + "Original unwinder must sample both workers after growth") + self.assertEqual([f.location.lineno for f in leaves], + [expected_lineno] * 2) + @skip_if_not_supported @unittest.skipIf( sys.platform == "linux" and not PROCESS_VM_READV_SUPPORTED, diff --git a/Misc/NEWS.d/next/Library/2026-09-18-12-00-00.gh-issue-157660.tlbc.rst b/Misc/NEWS.d/next/Library/2026-09-18-12-00-00.gh-issue-157660.tlbc.rst new file mode 100644 index 00000000000000..2d469fedf19439 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-18-12-00-00.gh-issue-157660.tlbc.rst @@ -0,0 +1,2 @@ +Fix persistent sampling errors in :mod:`!_remote_debugging` when a code +object's thread-local bytecode array grows in a free-threaded build. diff --git a/Modules/_remote_debugging/_remote_debugging.h b/Modules/_remote_debugging/_remote_debugging.h index fa37fb7b2167ec..8abc2dd8828ca2 100644 --- a/Modules/_remote_debugging/_remote_debugging.h +++ b/Modules/_remote_debugging/_remote_debugging.h @@ -582,7 +582,9 @@ typedef struct { extern void tlbc_cache_entry_destroy(void *ptr); extern TLBCCacheEntry *get_tlbc_cache_entry(RemoteUnwinderObject *self, uintptr_t code_addr, uint32_t current_generation); -extern int cache_tlbc_array(RemoteUnwinderObject *unwinder, uintptr_t code_addr, uintptr_t tlbc_array_addr, uint32_t generation); +extern int cache_tlbc_array(RemoteUnwinderObject *unwinder, uintptr_t code_addr, + uintptr_t tlbc_array_addr, uint32_t generation, + bool force_refresh); #endif /* ============================================================================ diff --git a/Modules/_remote_debugging/code_objects.c b/Modules/_remote_debugging/code_objects.c index f83252524b96ff..8e4e544aca1620 100644 --- a/Modules/_remote_debugging/code_objects.c +++ b/Modules/_remote_debugging/code_objects.c @@ -41,14 +41,18 @@ get_tlbc_cache_entry(RemoteUnwinderObject *self, uintptr_t code_addr, uint32_t c } int -cache_tlbc_array(RemoteUnwinderObject *unwinder, uintptr_t code_addr, uintptr_t tlbc_array_addr, uint32_t generation) +cache_tlbc_array(RemoteUnwinderObject *unwinder, uintptr_t code_addr, + uintptr_t tlbc_array_addr, uint32_t generation, bool force_refresh) { + int (*read_memory)(proc_handle_t *, uintptr_t, size_t, void *) = + force_refresh ? _Py_RemoteDebug_ReadRemoteMemory + : _Py_RemoteDebug_PagedReadRemoteMemory; uintptr_t tlbc_array_ptr; void *tlbc_array = NULL; TLBCCacheEntry *entry = NULL; // Read the TLBC array pointer - if (read_ptr(unwinder, tlbc_array_addr, &tlbc_array_ptr) != 0) { + if (read_memory(&unwinder->handle, tlbc_array_addr, sizeof(tlbc_array_ptr), &tlbc_array_ptr) != 0) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read TLBC array pointer"); return 0; // Read error } @@ -61,7 +65,7 @@ cache_tlbc_array(RemoteUnwinderObject *unwinder, uintptr_t code_addr, uintptr_t // Read the TLBC array size Py_ssize_t tlbc_size; - if (_Py_RemoteDebug_PagedReadRemoteMemory(&unwinder->handle, tlbc_array_ptr, sizeof(tlbc_size), &tlbc_size) != 0) { + if (read_memory(&unwinder->handle, tlbc_array_ptr, sizeof(tlbc_size), &tlbc_size) != 0) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read TLBC array size"); return 0; // Read error } @@ -87,7 +91,7 @@ cache_tlbc_array(RemoteUnwinderObject *unwinder, uintptr_t code_addr, uintptr_t return 0; // Memory error } - if (_Py_RemoteDebug_PagedReadRemoteMemory(&unwinder->handle, tlbc_array_ptr, sizeof(Py_ssize_t) + array_data_size, tlbc_array) != 0) { + if (read_memory(&unwinder->handle, tlbc_array_ptr, sizeof(Py_ssize_t) + array_data_size, tlbc_array) != 0) { PyMem_RawFree(tlbc_array); set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to read TLBC array data"); return 0; // Read error @@ -442,7 +446,7 @@ parse_code_object(RemoteUnwinderObject *unwinder, if (!tlbc_entry) { // Cache miss - try to read and cache TLBC array - if (!cache_tlbc_array(unwinder, real_address, real_address + unwinder->debug_offsets.code_object.co_tlbc, unwinder->tlbc_generation)) { + if (!cache_tlbc_array(unwinder, real_address, real_address + unwinder->debug_offsets.code_object.co_tlbc, unwinder->tlbc_generation, false)) { set_exception_cause(unwinder, PyExc_RuntimeError, "Failed to cache TLBC array"); goto error; } @@ -451,6 +455,21 @@ parse_code_object(RemoteUnwinderObject *unwinder, // Validate tlbc_index and check TLBC cache if (tlbc_entry) { + if (ctx->tlbc_index >= tlbc_entry->tlbc_array_size) { + TLBCCacheEntry *old = _Py_hashtable_steal(unwinder->tlbc_cache, (void *)real_address); + if (old != NULL) { + tlbc_cache_entry_destroy(old); + } + if (!cache_tlbc_array(unwinder, real_address, real_address + unwinder->debug_offsets.code_object.co_tlbc, + unwinder->tlbc_generation, true)) { + goto error; + } + tlbc_entry = get_tlbc_cache_entry(unwinder, real_address, unwinder->tlbc_generation); + if (tlbc_entry == NULL) { + PyErr_SetString(PyExc_RuntimeError, "TLBC cache entry is missing after refresh"); + goto error; + } + } // Validate index bounds (also catches negative values since tlbc_index is signed) if (ctx->tlbc_index < 0 || ctx->tlbc_index >= tlbc_entry->tlbc_array_size) { PyErr_Format(PyExc_RuntimeError, From ed714b4100ef03659bb528b51b5230dc4c648560 Mon Sep 17 00:00:00 2001 From: liuzhijie-0614 <75884011+liuzhijie-0614@users.noreply.github.com> Date: Fri, 18 Sep 2026 14:10:03 +0800 Subject: [PATCH 2/2] Preserve the existing TLBC fallback after refresh --- Modules/_remote_debugging/code_objects.c | 27 +++++++++++------------- 1 file changed, 12 insertions(+), 15 deletions(-) diff --git a/Modules/_remote_debugging/code_objects.c b/Modules/_remote_debugging/code_objects.c index 8e4e544aca1620..7d1c9fde5d9ad3 100644 --- a/Modules/_remote_debugging/code_objects.c +++ b/Modules/_remote_debugging/code_objects.c @@ -453,23 +453,20 @@ parse_code_object(RemoteUnwinderObject *unwinder, tlbc_entry = get_tlbc_cache_entry(unwinder, real_address, unwinder->tlbc_generation); } + if (tlbc_entry && ctx->tlbc_index >= tlbc_entry->tlbc_array_size) { + TLBCCacheEntry *old = _Py_hashtable_steal(unwinder->tlbc_cache, (void *)real_address); + if (old != NULL) { + tlbc_cache_entry_destroy(old); + } + if (!cache_tlbc_array(unwinder, real_address, real_address + unwinder->debug_offsets.code_object.co_tlbc, + unwinder->tlbc_generation, true)) { + goto error; + } + tlbc_entry = get_tlbc_cache_entry(unwinder, real_address, unwinder->tlbc_generation); + } + // Validate tlbc_index and check TLBC cache if (tlbc_entry) { - if (ctx->tlbc_index >= tlbc_entry->tlbc_array_size) { - TLBCCacheEntry *old = _Py_hashtable_steal(unwinder->tlbc_cache, (void *)real_address); - if (old != NULL) { - tlbc_cache_entry_destroy(old); - } - if (!cache_tlbc_array(unwinder, real_address, real_address + unwinder->debug_offsets.code_object.co_tlbc, - unwinder->tlbc_generation, true)) { - goto error; - } - tlbc_entry = get_tlbc_cache_entry(unwinder, real_address, unwinder->tlbc_generation); - if (tlbc_entry == NULL) { - PyErr_SetString(PyExc_RuntimeError, "TLBC cache entry is missing after refresh"); - goto error; - } - } // Validate index bounds (also catches negative values since tlbc_index is signed) if (ctx->tlbc_index < 0 || ctx->tlbc_index >= tlbc_entry->tlbc_array_size) { PyErr_Format(PyExc_RuntimeError,