diff --git a/Lib/test/test_external_inspection.py b/Lib/test/test_external_inspection.py index fd647153f92be6b..f01af0b2128f934 100644 --- a/Lib/test/test_external_inspection.py +++ b/Lib/test/test_external_inspection.py @@ -2253,6 +2253,127 @@ def get_trace_with_opcodes(pid): location.col_offset, location.end_col_offset) self.assertIn(actual, valid_locations) + @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): + # Reproducer from gh-157660. + script = textwrap.dedent("""\ + import os, threading + from _remote_debugging import RemoteUnwinder + from test import support + + go = threading.Event() + stop = threading.Event() + + def leaf(): + stop.wait() + + def wait_for_leaf_frames(u, expected_count): + for _ in support.sleeping_retry( + support.SHORT_TIMEOUT, + f"Expected {expected_count} leaf frames", + ): + try: + traces = u.get_stack_trace() + except RuntimeError as exc: + if str(exc) != "Failed to parse initial frame in chain": + raise + continue + count = sum( + f.funcname == "leaf" + for i in traces + for t in i.threads for f in t.frame_info + ) + if count == expected_count: + return + + threading.Thread(target=leaf, daemon=True).start() + for _ in range(16): + threading.Thread(target=stop.wait, daemon=True).start() + threading.Thread(target=lambda: (go.wait(), leaf()), daemon=True).start() + + u = RemoteUnwinder(os.getpid(), all_threads=True, cache_frames=False) + wait_for_leaf_frames(u, 1) + go.set() + wait_for_leaf_frames(u, 2) + """) + result = subprocess.run( + [sys.executable, "-X", "gil=0", "-X", "tlbc=1", "-c", script], + capture_output=True, + text=True, + timeout=SHORT_TIMEOUT, + ) + self.assertEqual( + result.returncode, 0, + f"stdout: {result.stdout}\nstderr: {result.stderr}", + ) + + @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_slot_fill(self): + # Reproducer from gh-157660. + script = textwrap.dedent("""\ + import os, threading + from _remote_debugging import RemoteUnwinder + + go = threading.Event() + stop = threading.Event() + + def leaf(): + stop.wait() + + from test import support + + def lines(u, expected_count): + for _ in support.sleeping_retry( + support.SHORT_TIMEOUT, + f"Expected {expected_count} leaf frames", + ): + try: + traces = u.get_stack_trace() + except RuntimeError as exc: + if str(exc) != "Failed to parse initial frame in chain": + raise + continue + result = sorted( + f.location.lineno + for i in traces + for t in i.threads for f in t.frame_info + if f.funcname == "leaf" + ) + # A new frame can still point at the function definition. + if (len(result) == expected_count and + leaf.__code__.co_firstlineno not in result): + return result + + threading.Thread(target=leaf, daemon=True).start() + threading.Thread(target=lambda: (go.wait(), leaf()), daemon=True).start() + u = RemoteUnwinder(os.getpid(), all_threads=True, cache_frames=False) + before = lines(u, 1) + assert before == [8], before + go.set() + cached = lines(u, 2) + assert cached == [8, 8], cached + """) + result = subprocess.run( + [sys.executable, "-X", "gil=0", "-X", "tlbc=1", "-c", script], + capture_output=True, + text=True, + timeout=SHORT_TIMEOUT, + ) + self.assertEqual( + result.returncode, 0, + f"stdout: {result.stdout}\nstderr: {result.stderr}", + ) + class TestUnsupportedPlatformHandling(unittest.TestCase): @unittest.skipIf( 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 000000000000000..d43d5f2e5ede06e --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-09-18-12-00-00.gh-issue-157660.tlbc.rst @@ -0,0 +1,2 @@ +Fix :mod:`profiling.sampling` reporting errors or incorrect line numbers in free-threaded +builds when a thread-local bytecode array grows or gains entries after being cached. diff --git a/Modules/_remote_debugging/code_objects.c b/Modules/_remote_debugging/code_objects.c index f83252524b96ff4..6ad058850196269 100644 --- a/Modules/_remote_debugging/code_objects.c +++ b/Modules/_remote_debugging/code_objects.c @@ -32,9 +32,12 @@ get_tlbc_cache_entry(RemoteUnwinderObject *self, uintptr_t code_addr, uint32_t c TLBCCacheEntry *entry = _Py_hashtable_get(self->tlbc_cache, key); if (entry && entry->generation != current_generation) { - // Entry is stale, remove it by setting to NULL - _Py_hashtable_set(self->tlbc_cache, key, NULL); - entry = NULL; + // Entry is stale, remove it from the cache and destroy it + TLBCCacheEntry *old = _Py_hashtable_steal(self->tlbc_cache, key); + if (old != NULL) { + tlbc_cache_entry_destroy(old); + } + return NULL; } return entry; @@ -449,6 +452,22 @@ parse_code_object(RemoteUnwinderObject *unwinder, tlbc_entry = get_tlbc_cache_entry(unwinder, real_address, unwinder->tlbc_generation); } + if (tlbc_entry && ctx->tlbc_index >= 0) { + uintptr_t *entries = (uintptr_t *)((char *)tlbc_entry->tlbc_array + sizeof(Py_ssize_t)); + if (ctx->tlbc_index >= tlbc_entry->tlbc_array_size || + entries[ctx->tlbc_index] == 0) { + 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)) { + goto error; + } + tlbc_entry = get_tlbc_cache_entry(unwinder, real_address, unwinder->tlbc_generation); + } + } + // Validate tlbc_index and check TLBC cache if (tlbc_entry) { // Validate index bounds (also catches negative values since tlbc_index is signed)