Skip to content

_remote_debugging retains stale TLBC arrays after growth in free-threaded builds #157660

Description

@liuzhijie-0614

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

  1. Threads A and B already exist, with TLBC indices 3 and 18 respectively. The interpreter’s tlbc_generation is G.
  2. Thread A executes foo. At this point, foo’s TLBC array has 16 slots, enough to accommodate A’s index.
  3. The unwinder samples the target and caches foo’s TLBC array with capacity 16. Its cached generation is G.
  4. 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.
  5. On the next sample, the unwinder sees no generation change, so it retains the cached array with capacity 16.
  6. 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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions