Skip to content

Commit c3aba54

Browse files
committed
Re-implement in nanobind
Signed-off-by: Saul Cooperman <saulcoops@gmail.com>
1 parent 4bcc46b commit c3aba54

23 files changed

Lines changed: 1226 additions & 123 deletions

Dockerfile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ RUN apt-get update \
6666
python3.13-dev \
6767
python3.13-dbg \
6868
python3.13-venv \
69+
python3.14-dev \
70+
python3.14-dbg \
71+
python3.14-venv \
6972
make \
7073
cmake \
7174
gdb \
@@ -74,6 +77,7 @@ RUN apt-get update \
7477
lcov \
7578
file \
7679
less \
80+
bear \
7781
libzstd-dev \
7882
liblzma-dev \
7983
libbz2-dev \
@@ -85,7 +89,7 @@ RUN apt-get update \
8589
COPY --from=elfutils_builder /usr/local /usr/local
8690

8791
# Set environment variables
88-
ENV PYTHON=python3.12 \
92+
ENV PYTHON=python3.14 \
8993
VIRTUAL_ENV="/venv" \
9094
PATH="/venv/bin:$PATH" \
9195
PYTHONDONTWRITEBYTECODE=1 \

src/pystack/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from ._version import __version__
2-
from .traceback_formatter import print_thread
2+
from .traceback_formatter import TracebackPrinter
33

44
__all__ = [
55
"__version__",
6-
"print_thread",
6+
"TracebackPrinter",
77
]

src/pystack/__main__.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from textwrap import dedent
99
from typing import Any
1010
from typing import Dict
11+
from typing import List
1112
from typing import NoReturn
1213
from typing import Optional
1314
from typing import Set
@@ -17,9 +18,10 @@
1718
from pystack.process import decompress_gzip
1819
from pystack.process import is_elf
1920
from pystack.process import is_gzip
21+
from pystack.types import PyThread
2022

23+
from . import TracebackPrinter
2124
from . import errors
22-
from . import print_thread
2325
from .colors import colored
2426
from .engine import CoreFileAnalyzer
2527
from .engine import NativeReportingMode
@@ -283,18 +285,27 @@ def main() -> None:
283285
_exit_with_code(the_error)
284286

285287

288+
def _include_subinterpreters(threads: List[PyThread]) -> bool:
289+
return len(set(thread.interpreter_id for thread in threads)) > 1
290+
291+
286292
def process_remote(parser: argparse.ArgumentParser, args: argparse.Namespace) -> None:
287293
if not args.block and args.native_mode != NativeReportingMode.OFF:
288294
parser.error("Native traces are only available in blocking mode")
289295

290-
for thread in get_process_threads(
296+
threads = get_process_threads(
291297
args.pid,
292298
stop_process=args.block,
293299
native_mode=args.native_mode,
294300
locals=args.locals,
295301
method=StackMethod.ALL if args.exhaustive else StackMethod.AUTO,
296-
):
297-
print_thread(thread, args.native_mode)
302+
)
303+
304+
printer = TracebackPrinter(
305+
args.native_mode, include_subinterpreters=_include_subinterpreters(threads)
306+
)
307+
for thread in threads:
308+
printer.print_thread(thread)
298309

299310

300311
def format_psinfo_information(psinfo: Dict[str, Any]) -> str:
@@ -414,15 +425,19 @@ def process_core(parser: argparse.ArgumentParser, args: argparse.Namespace) -> N
414425
elf_id if elf_id else "<MISSING>",
415426
)
416427

417-
for thread in get_process_threads_for_core(
428+
threads = get_process_threads_for_core(
418429
corefile,
419430
executable,
420431
library_search_path=lib_search_path,
421432
native_mode=args.native_mode,
422433
locals=args.locals,
423434
method=StackMethod.ALL if args.exhaustive else StackMethod.AUTO,
424-
):
425-
print_thread(thread, args.native_mode)
435+
)
436+
printer = TracebackPrinter(
437+
args.native_mode, include_subinterpreters=_include_subinterpreters(threads)
438+
)
439+
for thread in threads:
440+
printer.print_thread(thread)
426441

427442

428443
if __name__ == "__main__": # pragma: no cover

src/pystack/_pystack/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ set(PYSTACK_SOURCES
1111
logging.cpp
1212
maps_parser.cpp
1313
mem.cpp
14+
native_frame.cpp
1415
process.cpp
1516
pycode.cpp
1617
pyframe.cpp
@@ -21,6 +22,7 @@ set(PYSTACK_SOURCES
2122
version.cpp
2223
version_detector.cpp
2324
bindings.cpp
25+
interpreter.cpp
2426
)
2527

2628
# Create the nanobind module

src/pystack/_pystack/bindings.cpp

Lines changed: 162 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
#include <algorithm>
12
#include <nanobind/nanobind.h>
23
#include <nanobind/stl/filesystem.h>
34
#include <nanobind/stl/function.h>
@@ -11,14 +12,19 @@
1112

1213
#include <filesystem>
1314
#include <memory>
15+
#include <numeric>
1416
#include <optional>
17+
#include <ranges>
1518
#include <unordered_set>
19+
#include <vector>
1620

1721
#include "corefile.h"
1822
#include "elf_common.h"
23+
#include "interpreter.h"
1924
#include "logging.h"
2025
#include "maps_parser.h"
2126
#include "mem.h"
27+
#include "native_frame.h"
2228
#include "process.h"
2329
#include "thread_builder.h"
2430

@@ -490,6 +496,7 @@ buildPyThreadObject(
490496
native_frames,
491497
thread.gil_status,
492498
thread.gc_status,
499+
thread.interpreter_id,
493500
nb::make_tuple(python_version.first, python_version.second),
494501
"name"_a = thread.name ? nb::cast(*thread.name) : nb::none());
495502
}
@@ -534,6 +541,107 @@ logMemoryMaps(const std::vector<pystack::VirtualMap>& maps, const char* source)
534541
}
535542
}
536543

544+
std::vector<pystack::PyThreadData>
545+
_slice_native_stack(std::vector<pystack::PyThreadData> data)
546+
{
547+
// Capture a canonical
548+
auto canonical_thread =
549+
std::find_if(data.begin(), data.end(), [](const pystack::PyThreadData& py_thread_data) {
550+
return !py_thread_data.native_frames.empty();
551+
});
552+
if (canonical_thread == data.end()) {
553+
return data;
554+
}
555+
556+
// Capture canonical frames and python version
557+
const std::vector<pystack::NativeFrame> canonical_frames = canonical_thread->native_frames;
558+
const auto python_version = data[0].python_version;
559+
560+
std::vector<std::size_t> eval_index;
561+
for (std::size_t i = 0; i < canonical_frames.size(); ++i) {
562+
if (pystack::is_eval_frame(canonical_frames[i].symbol, python_version)) {
563+
eval_index.push_back(i);
564+
}
565+
}
566+
567+
const auto total_entry_frames = static_cast<std::size_t>(
568+
std::accumulate(data.begin(), data.end(), 0, [](int acc, const pystack::PyThreadData& d) {
569+
return acc
570+
+ static_cast<int>(std::count_if(
571+
d.frames.begin(),
572+
d.frames.end(),
573+
[](const pystack::PyFrameData& frame) { return frame.is_entry; }));
574+
}));
575+
576+
if (eval_index.size() != total_entry_frames) {
577+
return data;
578+
}
579+
580+
std::vector<pystack::PyThreadData> ordered_threads = std::move(data);
581+
// Sort by:
582+
// 1. With stack anchor (!=0) before without
583+
// 2. Stack anchor in descending order
584+
// 3. Index in PyThreadData (handled by stable_sort)
585+
std::stable_sort(
586+
ordered_threads.begin(),
587+
ordered_threads.end(),
588+
[](const pystack::PyThreadData& a, const pystack::PyThreadData& b) {
589+
return std::make_tuple(a.stack_anchor == 0 ? 1 : 0, -a.stack_anchor)
590+
< std::make_tuple(b.stack_anchor == 0 ? 1 : 0, -b.stack_anchor);
591+
});
592+
593+
// Slice frames according to eval frames per python thread
594+
std::size_t cursor = 0;
595+
for (auto& thread_data : ordered_threads) {
596+
const auto required_eval_frames = static_cast<std::size_t>(std::count_if(
597+
thread_data.frames.begin(),
598+
thread_data.frames.end(),
599+
[](const pystack::PyFrameData& py_frame) { return py_frame.is_entry; }));
600+
601+
if (required_eval_frames == 0) {
602+
continue;
603+
}
604+
605+
const std::size_t end = cursor + required_eval_frames;
606+
const std::size_t from = eval_index[cursor];
607+
const std::size_t to = end < eval_index.size() ? eval_index[end] : canonical_frames.size();
608+
thread_data.native_frames.assign(canonical_frames.begin() + from, canonical_frames.begin() + to);
609+
cursor = end;
610+
}
611+
return ordered_threads;
612+
}
613+
614+
std::vector<pystack::PyThreadData>
615+
_normalize_threads(std::vector<pystack::PyThreadData> threads, NativeReportingMode native_mode)
616+
{
617+
if (native_mode == NativeReportingMode::OFF) {
618+
return threads;
619+
}
620+
621+
// First pass: bucket threads by TID (capture index only)
622+
std::unordered_map<int, std::vector<std::size_t>> indices_by_tid;
623+
for (std::size_t i = 0; i < threads.size(); ++i) {
624+
indices_by_tid[threads[i].tid].push_back(i);
625+
}
626+
627+
// Second pass: for groups that share a TID, slice native stacks.
628+
for (auto& [_, indices] : indices_by_tid) {
629+
if (indices.size() <= 1) {
630+
continue;
631+
}
632+
std::vector<pystack::PyThreadData> group;
633+
for (const std::size_t idx : indices) {
634+
group.push_back(std::move(threads[idx]));
635+
}
636+
auto sliced = _slice_native_stack(std::move(group));
637+
for (std::size_t i = 0; i < indices.size(); ++i) {
638+
threads[indices[i]] = std::move(sliced[i]);
639+
}
640+
}
641+
642+
return threads;
643+
}
644+
537645
nb::object
538646
get_process_threads(
539647
pid_t pid,
@@ -571,21 +679,28 @@ get_process_threads(
571679
} else {
572680
python_version = manager->python_version();
573681
std::vector<int> all_tids = pystack::getThreadIds(manager->get_manager());
574-
575-
if (head != 0) {
576-
bool add_native = native_mode != NativeReportingMode::OFF;
577-
python_threads = pystack::buildThreadsFromInterpreter(
578-
manager->get_manager(),
579-
head,
580-
pid,
581-
add_native,
582-
locals);
583-
584-
for (const auto& thread : python_threads) {
682+
bool add_native = native_mode != NativeReportingMode::OFF;
683+
684+
while (head) {
685+
std::vector<pystack::PyThreadData> new_threads =
686+
pystack::buildThreadsFromInterpreter(
687+
manager->get_manager(),
688+
head,
689+
pid,
690+
add_native,
691+
locals);
692+
693+
for (const auto& thread : new_threads) {
585694
all_tids.erase(
586695
std::remove(all_tids.begin(), all_tids.end(), thread.tid),
587696
all_tids.end());
588697
}
698+
python_threads.insert(
699+
python_threads.end(),
700+
std::make_move_iterator(new_threads.begin()),
701+
std::make_move_iterator(new_threads.end()));
702+
703+
head = pystack::InterpreterUtils::getNextInterpreter(manager->get_manager(), head);
589704
}
590705

591706
if (native_mode == NativeReportingMode::ALL) {
@@ -606,7 +721,7 @@ get_process_threads(
606721
}
607722

608723
nb::list result;
609-
for (const auto& thread : python_threads) {
724+
for (const auto& thread : _normalize_threads(python_threads, native_mode)) {
610725
result.append(buildPyThreadObject(thread, types, python_version));
611726
}
612727
for (const auto& thread : native_only_threads) {
@@ -651,11 +766,11 @@ get_process_threads_for_core(
651766
}
652767

653768
nb::list result;
769+
std::vector<pystack::PyThreadData> ret_cpp;
654770
std::vector<int> all_tids = pystack::getThreadIds(manager->get_manager());
771+
bool add_native = native_mode != NativeReportingMode::OFF;
655772

656-
if (head != 0) {
657-
bool add_native = native_mode == NativeReportingMode::PYTHON
658-
|| native_mode == NativeReportingMode::ALL;
773+
while (head) {
659774
auto threads = pystack::buildThreadsFromInterpreter(
660775
manager->get_manager(),
661776
head,
@@ -664,11 +779,16 @@ get_process_threads_for_core(
664779
locals);
665780

666781
for (const auto& thread : threads) {
667-
result.append(buildPyThreadObject(thread, types, manager->python_version()));
668782
all_tids.erase(
669783
std::remove(all_tids.begin(), all_tids.end(), thread.tid),
670784
all_tids.end());
671785
}
786+
ret_cpp.insert(
787+
ret_cpp.end(),
788+
std::make_move_iterator(threads.begin()),
789+
std::make_move_iterator(threads.end()));
790+
791+
head = pystack::InterpreterUtils::getNextInterpreter(manager->get_manager(), head);
672792
}
673793

674794
if (native_mode == NativeReportingMode::ALL) {
@@ -678,6 +798,10 @@ get_process_threads_for_core(
678798
}
679799
}
680800

801+
for (const auto& thread : _normalize_threads(ret_cpp, native_mode)) {
802+
result.append(buildPyThreadObject(thread, types, manager->python_version()));
803+
}
804+
681805
return result;
682806
} catch (const NotEnoughInformationError&) {
683807
throw;
@@ -863,4 +987,26 @@ NB_MODULE(_pystack, m)
863987
// intercept_runtime_errors decorator - re-export from pystack.errors
864988
nb::module_ pystack_errors = nb::module_::import_("pystack.errors");
865989
m.attr("intercept_runtime_errors") = pystack_errors.attr("intercept_runtime_errors");
990+
991+
nb::enum_<pystack::NativeFrame::FrameType>(m, "NativeFrameType")
992+
.value("IGNORE", pystack::NativeFrame::FrameType::IGNORE)
993+
.value("EVAL", pystack::NativeFrame::FrameType::EVAL)
994+
.value("OTHER", pystack::NativeFrame::FrameType::OTHER);
995+
996+
m.def("is_eval_frame",
997+
&pystack::is_eval_frame,
998+
"symbol"_a,
999+
"python_version"_a,
1000+
"Return True if the symbol is a CPython eval frame function");
1001+
1002+
m.def(
1003+
"frame_type",
1004+
[](const std::string& symbol, std::optional<std::pair<int, int>> python_version) {
1005+
pystack::NativeFrame frame{};
1006+
frame.symbol = symbol;
1007+
return pystack::frame_type(frame, python_version);
1008+
},
1009+
"symbol"_a,
1010+
"python_version"_a = nb::none(),
1011+
"Return the FrameType for a native frame symbol");
8661012
}

0 commit comments

Comments
 (0)