python / python/cpython

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

Abierto
#157,660 2 comentarios 0 reacciones 0 asignados Ver en GitHub

Nadie ha tomado este issue todavía.

extension-modules topic-free-threading topic-profiling type-bug
Lenguaje dominante
Python
Estrellas
77.2k
Forks
35.9k
Métricas de merge de PR
Métricas de PR pendientes

Descripción

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

Linked PRs
  • gh-157732

Guía de contribución

Abrir la guía de contribución

Primeros pasos

  1. Lee el issue completo y luego la guía de contribución del proyecto.
  2. Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
  3. Haz un fork del repositorio y trabaja en una rama.
  4. Abre un pull request que haga referencia al número del issue.

Línea de trabajo

Empieza por Modules/_remote_debugging/code_objects.c y module.c; después inspecciona Objects/codeobject.c y Python/index_pool.c para estudiar el comportamiento de la caché y de la generación. Ejecuta la reproducción proporcionada de Linux con free-threading y compara las fases anterior al crecimiento, posterior al crecimiento y del unwinder nuevo. Se considera terminado cuando un unwinder existente sigue tomando muestras después del crecimiento de TLBC sin rechazar índices válidos, mientras las comprobaciones de límites siguen siendo efectivas.

Escrito por el modelo de indexación a partir del texto del issue.

Evaluación

Stack tecnológico
c, python
Área
backend, devtools
Tipo de issue
Error
Dificultad
4/5
Tiempo estimado
3-5 días
Estado de actividad
Estancado
Claridad
Bien especificado
Aptitud para principiantes
25/100

Recibe los nuevos issues en tu correo

Un resumen breve de issues de GitHub para principiantes.