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 an except block.
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:
|
|
|
// 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:
|
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
Linked PRs
Bug report
With
all_threads=False, if the selected thread does not match the profiling mode,get_stack_trace()continues scanninginterp->threadsand may return another thread instead of an empty list.Reproduction
Easier to start with the code:
join()inside anexceptblock.With
only_active_thread=Trueandmode=PROFILING_MODE_EXCEPTIONthe sample should be empty:Root cause
unwind_stack_for_thread()advancescurrent_tstateto 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
get_stack_trace()then continues scanning, because thebreakonly runs after a thread is successfully appended:cpython/Modules/_remote_debugging/module.c
Lines 810 to 847 in 9028df3
The impact is very small: the
profiling.samplingCLI does not setonly_active_thread=True.CPython versions tested on:
CPython main branch
Operating systems tested on:
macOS
Linked PRs
_remote_debugging: Do not return another thread when the selected thread is filtered out #159132