Bug description
On a free-threaded CPython build, a long-lived _remote_debugging.RemoteUnwinder can retain a stale copy of a code object's TLBC pointer array after the target grows it. Sampling then repeatedly raises Invalid tlbc_index until the cache is invalidated. Recreating only the unwinder restores sampling of the same live target.
Reproduced on Linux x86-64 with GIL disabled and TLBC enabled, using an unmodified upstream checkout at commit 6409b1e8548ae7cb5dcca0d971653d574254ec1f (3.15.0rc2+dev, GCC 8.3.0). The reproduction calls the C extension directly. I have not executed it on current main.
Failure sequence
- Threads A and B already exist, with TLBC indices 3 and 18 respectively. The interpreter’s tlbc_generation is G.
- Thread A executes foo. At this point, foo’s TLBC array has 16 slots, enough to accommodate A’s index.
- The unwinder samples the target and caches foo’s TLBC array with capacity 16. Its cached generation is G.
- Thread B enters foo for the first time. To accommodate index 18, the target grows foo’s TLBC array to 32 slots. This per-code array growth does not change tlbc_generation, which remains G.
- On the next sample, the unwinder sees no generation change, so it retains the cached array with capacity 16.
- While unwinding B’s frame, it reads TLBC index 18 and checks it against the stale capacity. Since 18 >= 16, it raises Invalid tlbc_index and the sampling call fails.
Minimal reproduction
Save as repro_tlbc_simple.py and run with a free-threaded build containing _remote_debugging:
./python -X gil=0 repro_tlbc_simple.py
All 18 worker threads are created before sampling begins. The first worker executes leaf; the last worker waits for a signal before entering the same function. No target threads are created or destroyed between the three sampling phases. The sampler is the target's parent.
# Linux: ./python -X gil=0 -X tlbc=1 repro_tlbc_bounds.py
import os
import signal
import sys
import threading
import time
from _remote_debugging import RemoteUnwinder
assert not sys._is_gil_enabled(), 'Use a free-threaded build with -X gil=0'
ready_r, ready_w = os.pipe()
go_r, go_w = os.pipe()
pid = os.fork()
if pid == 0:
def leaf():
while True:
pass
def worker(i):
if i == 17:
os.read(go_r, 1) # Enter leaf only after the first sample phase.
elif i != 0:
threading.Event().wait()
leaf()
for i in range(18):
threading.Thread(target=worker, args=(i,), daemon=True).start()
os.write(ready_w, b'1') # All workers exist before sampling starts.
threading.Event().wait()
def sample(label, unwinder):
ok = bounds_errors = other_errors = 0
end = time.monotonic() + 2
while time.monotonic() < end:
try:
unwinder.get_stack_trace()
ok += 1
except Exception as exc:
if 'Invalid tlbc_index' in str(exc):
bounds_errors += 1
else:
other_errors += 1
print(f'{label}: {ok=} {bounds_errors=} {other_errors=}', flush=True)
try:
os.read(ready_r, 1)
unwinder = RemoteUnwinder(pid, all_threads=True)
sample('Before growth', unwinder)
os.write(go_w, b'1') # Wake an existing high-index worker; no new threads.
time.sleep(0.1)
sample('After growth', unwinder)
sample('Fresh unwinder', RemoteUnwinder(pid, all_threads=True))
finally:
os.kill(pid, signal.SIGKILL)
os.waitpid(pid, 0)
Actual result
Observed output (two seconds per phase):
Before growth: ok=17270 failed=0
After growth: ok=0 failed=310973 Invalid tlbc_index 18 (array size 16, corrupted remote memory)
Fresh unwinder: ok=17443 failed=0
Exact counts depend on scheduling. These are successful/failed API calls, not performance measurements. Other transient remote-read errors may occur; the reported bug is the persistent valid-index/old-capacity failure.
Expected result
A target code object's TLBC array growing should not leave an existing unwinder repeatedly rejecting valid indices against an old cached capacity. It should obtain current TLBC information or fail transiently, without requiring recreation of the unwinder.
Root-cause analysis
At the tested revision:
All threads exist: generation = G
Low-index thread executes leaf: TLBC capacity = 16
Unwinder caches leaf's array: capacity = 16, generation = G
Existing high-index thread enters leaf: capacity grows to 32, generation stays G
Unwinder retains the old array and repeatedly rejects index 18 against capacity 16
Index 18 is the observed TLBC index, not an OS thread ID; a fix should not hardcode it. The script does not inspect private memory layouts; the expansion mechanism above follows from the source and the controlled transition.
A possible fix is a bounded cache refresh on a nonnegative index exceeding the cached size, rereading the current co_tlbc pointer and avoiding stale remote-page data. A complete fix should also consider an in-range NULL slot becoming populated without generation change. Bounds checks must remain in place.
Related: #144316 concerned missing exception handling, rather than this cache-invalidation trigger. No process crash or memory corruption is demonstrated here.
CPython versions tested on
3.15
Operating systems tested on
Linux
Bug description
On a free-threaded CPython build, a long-lived
_remote_debugging.RemoteUnwindercan retain a stale copy of a code object's TLBC pointer array after the target grows it. Sampling then repeatedly raisesInvalid tlbc_indexuntil the cache is invalidated. Recreating only the unwinder restores sampling of the same live target.Reproduced on Linux x86-64 with GIL disabled and TLBC enabled, using an unmodified upstream checkout at commit
6409b1e8548ae7cb5dcca0d971653d574254ec1f(3.15.0rc2+dev, GCC 8.3.0). The reproduction calls the C extension directly. I have not executed it on current main.Failure sequence
Minimal reproduction
Save as
repro_tlbc_simple.pyand run with a free-threaded build containing_remote_debugging:All 18 worker threads are created before sampling begins. The first worker executes
leaf; the last worker waits for a signal before entering the same function. No target threads are created or destroyed between the three sampling phases. The sampler is the target's parent.Actual result
Observed output (two seconds per phase):
Exact counts depend on scheduling. These are successful/failed API calls, not performance measurements. Other transient remote-read errors may occur; the reported bug is the persistent valid-index/old-capacity failure.
Expected result
A target code object's TLBC array growing should not leave an existing unwinder repeatedly rejecting valid indices against an old cached capacity. It should obtain current TLBC information or fail transiently, without requiring recreation of the unwinder.
Root-cause analysis
At the tested revision:
init_codeandcreate_tlbc_lock_heldinitialize ordinary code objects with 16 TLBC slots and grow the array on demand when a thread's index exceeds its capacity._PyIndexPool_AllocIndex/_PyIndexPool_FreeIndexincrementtlbc_generationwhen indices are allocated/released. Per-code array growth does not increment it.Index 18 is the observed TLBC index, not an OS thread ID; a fix should not hardcode it. The script does not inspect private memory layouts; the expansion mechanism above follows from the source and the controlled transition.
A possible fix is a bounded cache refresh on a nonnegative index exceeding the cached size, rereading the current
co_tlbcpointer and avoiding stale remote-page data. A complete fix should also consider an in-range NULL slot becoming populated without generation change. Bounds checks must remain in place.Related: #144316 concerned missing exception handling, rather than this cache-invalidation trigger. No process crash or memory corruption is demonstrated here.
CPython versions tested on
3.15
Operating systems tested on
Linux