Repository navigation
_remote_debugging: only_active_thread=True can return a different thread after mode filtering #159130
Description
Bug report
With all_threads=False, if the selected thread does not match the profiling mode, get_stack_trace() continues scanning interp->threads and may return another thread instead of an empty list.
Reproduction
Easier to start with the code:
- The sampler thread holds the GIL and has no exception.
- The main thread sits in
join()inside anexceptblock.
With only_active_thread=True and mode=PROFILING_MODE_EXCEPTION the sample should be empty:
2026-10-10T18:29:19.396589000+0200 maurycy@gimel /Users/maurycy/src/github.com/maurycy/cpython (main 9028df3) % cat /tmp/repro_filter.py
import os, threading, time
from _remote_debugging import RemoteUnwinder
PROFILING_MODE_EXCEPTION = 4
def sample():
time.sleep(0.2)
unwinder = RemoteUnwinder(
os.getpid(), only_active_thread=True, mode=PROFILING_MODE_EXCEPTION
)
print("sampler:", threading.get_native_id())
print(
"sampled:",
[
(t.thread_id, t.frame_info[0].funcname)
for i in unwinder.get_stack_trace()
for t in i.threads
],
)
t = threading.Thread(target=sample)
t.start()
try:
raise ValueError
except ValueError:
t.join()
2026-10-10T18:29:19.638834000+0200 maurycy@gimel /Users/maurycy/src/github.com/maurycy/cpython (main 9028df3) % ./python.exe /tmp/repro_filter.py
sampler: 10805582
sampled: [(10805580, 'Thread.join')]Root cause
unwind_stack_for_thread() advances current_tstate to the next thread when the selected thread does not match the profiling mode:
cpython/Modules/_remote_debugging/threads.c
Lines 510 to 531 in 9028df3
| // Check if we should skip this thread based on mode | |
| int should_skip = 0; | |
| if (unwinder->skip_non_matching_threads) { | |
| if (unwinder->mode == PROFILING_MODE_CPU) { | |
| // Skip if not on CPU | |
| should_skip = !(status_flags & THREAD_STATUS_ON_CPU); | |
| } else if (unwinder->mode == PROFILING_MODE_GIL) { | |
| // Skip if doesn't have GIL | |
| should_skip = !(status_flags & THREAD_STATUS_HAS_GIL); | |
| } else if (unwinder->mode == PROFILING_MODE_EXCEPTION) { | |
| // Skip if thread doesn't have an exception active | |
| should_skip = !(status_flags & THREAD_STATUS_HAS_EXCEPTION); | |
| } | |
| // PROFILING_MODE_WALL and PROFILING_MODE_ALL never skip | |
| } | |
| if (should_skip) { | |
| // Advance to next thread and return NULL to skip processing | |
| *current_tstate = GET_MEMBER(uintptr_t, ts, unwinder->debug_offsets.thread_state.next); | |
| return NULL; | |
| } |
get_stack_trace() then continues scanning, because the break only runs after a thread is successfully appended:
cpython/Modules/_remote_debugging/module.c
Lines 810 to 847 in 9028df3
| if (!frame_info) { | |
| // Check if this was an intentional skip due to mode-based filtering | |
| if ((self->mode == PROFILING_MODE_CPU || self->mode == PROFILING_MODE_GIL || | |
| self->mode == PROFILING_MODE_EXCEPTION) && !PyErr_Occurred()) { | |
| // Detect cycle: if current_tstate didn't advance, we have corrupted data | |
| if (current_tstate == prev_tstate) { | |
| Py_DECREF(interpreter_threads); | |
| PyErr_Format(PyExc_RuntimeError, | |
| "Thread list cycle detected at address 0x%lx (corrupted remote memory)", | |
| current_tstate); | |
| set_exception_cause(self, PyExc_RuntimeError, | |
| "Thread list cycle detected (corrupted remote memory)"); | |
| Py_CLEAR(result); | |
| goto exit; | |
| } | |
| // Thread was skipped due to mode filtering, continue to next thread | |
| continue; | |
| } | |
| // This was an actual error | |
| Py_DECREF(interpreter_threads); | |
| set_exception_cause(self, PyExc_RuntimeError, "Failed to unwind stack for thread"); | |
| Py_CLEAR(result); | |
| goto exit; | |
| } | |
| if (PyList_Append(interpreter_threads, frame_info) == -1) { | |
| Py_DECREF(frame_info); | |
| Py_DECREF(interpreter_threads); | |
| set_exception_cause(self, PyExc_RuntimeError, "Failed to append thread frame info"); | |
| Py_CLEAR(result); | |
| goto exit; | |
| } | |
| Py_DECREF(frame_info); | |
| // If targeting specific thread or only active thread, process just one | |
| if (self->tstate_addr || self->only_active_thread) { | |
| break; | |
| } |
The impact is very small: the profiling.sampling CLI does not set only_active_thread=True.
CPython versions tested on:
CPython main branch
Operating systems tested on:
macOS
Activity
[-]`_remote_debugging`: a thread skipped by the mode filter replaced by the next thread in the list[/-][+]`_remote_debugging`: a thread skipped by the mode filter replaced by a different thread[/+][-]`_remote_debugging`: a thread skipped by the mode filter replaced by a different thread[/-][+]`_remote_debugging`: `only_active_thread=True` can return a different thread after mode filtering[/+]