Skip to content

Add subinterpreter support#279

Merged
godlygeek merged 20 commits into
bloomberg:mainfrom
scopreon:add_subinterpreter_support
Jul 8, 2026
Merged

Add subinterpreter support#279
godlygeek merged 20 commits into
bloomberg:mainfrom
scopreon:add_subinterpreter_support

Conversation

@scopreon

@scopreon scopreon commented Feb 22, 2026

Copy link
Copy Markdown
Contributor

Issue number of the reported bug or feature request: #59,#280

Describe your changes
This PR adds simple subinterpreter support to pystack. It preserves all original functionality but does it a few more times by traversing the interpreter linked list.

Testing performed
Tests added

Additional context

@codecov-commenter

codecov-commenter commented Feb 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 67.09130% with 155 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.93%. Comparing base (0e1b1ee) to head (fc42745).

Files with missing lines Patch % Lines
tests/integration/test_subinterpreters.py 39.39% 100 Missing ⚠️
src/pystack/_pystack/pythread.cpp 31.81% 15 Missing ⚠️
src/pystack/_pystack/bindings.cpp 87.27% 14 Missing ⚠️
tests/integration/subinterpreters_shim.py 0.00% 13 Missing ⚠️
src/pystack/traceback_formatter.py 75.00% 5 Missing ⚠️
src/pystack/_pystack/logging.cpp 76.92% 3 Missing ⚠️
src/pystack/_pystack/process.cpp 0.00% 3 Missing ⚠️
src/pystack/_pystack/interpreter.cpp 87.50% 1 Missing ⚠️
src/pystack/_pystack/native_frame.cpp 88.88% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #279      +/-   ##
==========================================
- Coverage   79.05%   77.93%   -1.12%     
==========================================
  Files          51       56       +5     
  Lines        5577     5988     +411     
  Branches      587      631      +44     
==========================================
+ Hits         4409     4667     +258     
- Misses       1168     1321     +153     
Flag Coverage Δ
cpp 77.93% <67.09%> (-1.12%) ⬇️
python 77.93% <67.09%> (-1.12%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@pablogsal

pablogsal commented Mar 8, 2026

Copy link
Copy Markdown
Collaborator

After chatting with @godlygeek when he was here in London I have devised a way that we can use to make --native work. I have pushed a commit with the idea. I have only tested this partially but I haven't gone in full
"let's see if this can break" mode yet so take it with a pinch of salt, but looks like it works. The idea is described in the commit.

Note: this still needs refinement

Example:

pystack add_subinterpreter_support  ? ❯ pystack remote 257596  --native
Interpreter-6
  Traceback for thread 257597 (Thread-1 (launc) [] (most recent call last):
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (Python) File "<script>", line 5, in <module>
      (C) File "./Modules/timemodule.c", line 408, in time_sleep.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/timemodule.c", line 2257, in pysleep (inlined) (/usr/lib/libpython3.14.so.1.0)
      (C) File "../sysdeps/unix/sysv/linux/clock_nanosleep.c", line 48, in clock_nanosleep@GLIBC_2.2.5 (/usr/lib/libc.so.6)
      (C) File "/usr/src/debug/glibc/glibc/nptl/cancellation.c", line 49, in __internal_syscall_cancel (/usr/lib/libc.so.6)
      (C) File "../sysdeps/unix/sysv/linux/x86_64/syscall_cancel.S", line 56, in __syscall_cancel_arch (/usr/lib/libc.so.6)

Interpreter-5
  Traceback for thread 257598 (Thread-2 (launc) [] (most recent call last):
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (Python) File "<script>", line 5, in <module>
      (C) File "./Modules/timemodule.c", line 408, in time_sleep.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/timemodule.c", line 2257, in pysleep (inlined) (/usr/lib/libpython3.14.so.1.0)
      (C) File "../sysdeps/unix/sysv/linux/clock_nanosleep.c", line 48, in clock_nanosleep@GLIBC_2.2.5 (/usr/lib/libc.so.6)
      (C) File "/usr/src/debug/glibc/glibc/nptl/cancellation.c", line 49, in __internal_syscall_cancel (/usr/lib/libc.so.6)
      (C) File "../sysdeps/unix/sysv/linux/x86_64/syscall_cancel.S", line 56, in __syscall_cancel_arch (/usr/lib/libc.so.6)

Interpreter-4
  Traceback for thread 257597 (Thread-1 (launc) [] (most recent call last):
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (Python) File "<script>", line 17, in <module>
      (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
          excinfo = _interpreters.exec(self._id, code, restrict=True)
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)

Interpreter-3
  Traceback for thread 257598 (Thread-2 (launc) [] (most recent call last):
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (Python) File "<script>", line 17, in <module>
      (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
          excinfo = _interpreters.exec(self._id, code, restrict=True)
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)

Interpreter-2
  Traceback for thread 257598 (Thread-2 (launc) [] (most recent call last):
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (Python) File "<script>", line 17, in <module>
      (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
          excinfo = _interpreters.exec(self._id, code, restrict=True)
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)

Interpreter-1
  Traceback for thread 257597 (Thread-1 (launc) [] (most recent call last):
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (Python) File "<script>", line 17, in <module>
      (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
          excinfo = _interpreters.exec(self._id, code, restrict=True)
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)

Interpreter-0 (main)
  Traceback for thread 257598 (Thread-2 (launc) [] (most recent call last):
      (C) File "../sysdeps/unix/sysv/linux/x86_64/clone3.S", line 78, in __clone3 (/usr/lib/libc.so.6)
      (C) File "/usr/src/debug/glibc/glibc/nptl/pthread_create.c", line 454, in start_thread (/usr/lib/libc.so.6)
      (C) File "Python/thread_pthread.h", line 242, in pythread_wrapper (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_threadmodule.c", line 359, in thread_run (/usr/lib/libpython3.14.so.1.0)
      (Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap
          self._bootstrap_inner()
      (Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner
          self._context.run(self.run)
      (C) File "Python/context.c", line 722, in context_run.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (Python) File "/usr/lib/python3.14/threading.py", line 1024, in run
          self._target(*self._args, **self._kwargs)
      (Python) File "/tmp/pytest-of-pablogsal/pytest-9/test_subinterpreters_two_threa0/subinterpreters_two_threads_three_each.py", line 72, in launch_chain
          interp1.exec(level1_code)
      (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
          excinfo = _interpreters.exec(self._id, code, restrict=True)
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)

  Traceback for thread 257597 (Thread-1 (launc) [] (most recent call last):
      (C) File "../sysdeps/unix/sysv/linux/x86_64/clone3.S", line 78, in __clone3 (/usr/lib/libc.so.6)
      (C) File "/usr/src/debug/glibc/glibc/nptl/pthread_create.c", line 454, in start_thread (/usr/lib/libc.so.6)
      (C) File "Python/thread_pthread.h", line 242, in pythread_wrapper (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_threadmodule.c", line 359, in thread_run (/usr/lib/libpython3.14.so.1.0)
      (Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap
          self._bootstrap_inner()
      (Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner
          self._context.run(self.run)
      (C) File "Python/context.c", line 722, in context_run.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (Python) File "/usr/lib/python3.14/threading.py", line 1024, in run
          self._target(*self._args, **self._kwargs)
      (Python) File "/tmp/pytest-of-pablogsal/pytest-9/test_subinterpreters_two_threa0/subinterpreters_two_threads_three_each.py", line 72, in launch_chain
          interp1.exec(level1_code)
      (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
          excinfo = _interpreters.exec(self._id, code, restrict=True)
      (C) File "Objects/methodobject.c", line 564, in cfunction_call.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/_interpretersmodule.c", line 1145, in interp_exec (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 684, in _run_in_interpreter.cold (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)
      (C) File "./Modules/_interpretersmodule.c", line 626, in _run_script (inlined) (/usr/lib/python3.14/lib-dynload/_interpreters.cpython-314-x86_64-linux-gnu.so)

  Traceback for thread 257596 (python3.14) [] (most recent call last):
      (C) File "???", line 0, in _start (/usr/bin/python3.14)
      (C) File "../csu/libc-start.c", line 360, in __libc_start_main@@GLIBC_2.34 (/usr/lib/libc.so.6)
      (C) File "../sysdeps/nptl/libc_start_call_main.h", line 59, in __libc_start_call_main (/usr/lib/libc.so.6)
      (C) File "Modules/main.c", line 829, in Py_BytesMain (/usr/lib/libpython3.14.so.1.0)
      (C) File "Modules/main.c", line 775, in Py_RunMain (/usr/lib/libpython3.14.so.1.0)
      (C) File "Modules/main.c", line 694, in pymain_run_python (inlined) (/usr/lib/libpython3.14.so.1.0)
      (C) File "Modules/main.c", line 429, in pymain_run_file (inlined) (/usr/lib/libpython3.14.so.1.0)
      (C) File "Modules/main.c", line 410, in pymain_run_file_obj (inlined) (/usr/lib/libpython3.14.so.1.0)
      (C) File "Python/pythonrun.c", line 1293, in pyrun_file.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "Python/pythonrun.c", line 1459, in run_mod.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (Python) File "/tmp/pytest-of-pablogsal/pytest-9/test_subinterpreters_two_threa0/subinterpreters_two_threads_three_each.py", line 81, in <module>
          time.sleep(1)
      (C) File "./Modules/timemodule.c", line 408, in time_sleep.lto_priv.0 (/usr/lib/libpython3.14.so.1.0)
      (C) File "./Modules/timemodule.c", line 2257, in pysleep (inlined) (/usr/lib/libpython3.14.so.1.0)
      (C) File "../sysdeps/unix/sysv/linux/clock_nanosleep.c", line 48, in clock_nanosleep@GLIBC_2.2.5 (/usr/lib/libc.so.6)
      (C) File "/usr/src/debug/glibc/glibc/nptl/cancellation.c", line 49, in __internal_syscall_cancel (/usr/lib/libc.so.6)
      (C) File "../sysdeps/unix/sysv/linux/x86_64/syscall_cancel.S", line 56, in __syscall_cancel_arch (/usr/lib/libc.so.6)

@godlygeek

Copy link
Copy Markdown
Contributor

Fix an issue where the PID offset could not be determined when multiple subinterpreters were present. Previously, pystack only checked the first interpreter in the linked list, which was not guaranteed to be the main interpreter. The fix now iterates over all interpreters and correctly locates the TID.

This seems to be true, but it also doesn't seem to actually matter. If I understand correctly, you're talking about this assignment failing when there's multiple interpreters:

    if (tid_offset_in_pthread_struct == 0) {
        tid_offset_in_pthread_struct = findPthreadTidOffset(manager, addr);
    }

and logging out

    LOG(ERROR) << "Could not find tid offset in pthread structure";

when it does.

But, tid_offset_in_pthread_struct is only used by PyThread::inferTidFromPThreadStructure, which is itself only called from this block:

    if (manager->versionIsAtLeast(3, 11)) {
        the_tid = ts.getField(&py_thread_v::o_native_thread_id);
    } else {
        the_tid = inferTidFromPThreadStructure(manager, pthread_id);
    }

I'm pretty sure that getting support for multiple interpreters working on Python 3.10 and earlier is out of scope for us, so I think we should just skip this call entirely when manager->versionIsAtLeast(3, 11) instead:

diff --git a/src/pystack/_pystack/pythread.cpp b/src/pystack/_pystack/pythread.cpp
index ae496e9..f287fb4 100644
--- a/src/pystack/_pystack/pythread.cpp
+++ b/src/pystack/_pystack/pythread.cpp
@@ -418,7 +409,7 @@ getThreadFromInterpreterState(
         const std::shared_ptr<const AbstractProcessManager>& manager,
         remote_addr_t addr)
 {
-    if (tid_offset_in_pthread_struct == 0) {
+    if (tid_offset_in_pthread_struct == 0 && !manager->versionIsAtLeast(3, 11)) {
         tid_offset_in_pthread_struct = findPthreadTidOffset(manager, addr);
     }

That gets rid of the warning without needing any of the changes to findPthreadTidOffset, and pystack gets a little bit faster too because we stop computing something we don't actually need.

Let me know if you disagree with that assessment, @scopreon!

Comment thread src/pystack/_pystack/interpreter.cpp Outdated
Comment thread src/pystack/_pystack/interpreter.pxd Outdated
Comment thread news/279.feature.rst Outdated
Comment thread src/pystack/_pystack/pythread.cpp Outdated
Comment thread src/pystack/_pystack/pythread.cpp Outdated
Comment thread src/pystack/_pystack.pyx Outdated
Comment thread src/pystack/_pystack.pyx Outdated
Comment thread src/pystack/_pystack.pyx Outdated
Comment thread src/pystack/_pystack.pyx Outdated
Comment thread src/pystack/_pystack.pyx Outdated
@scopreon

scopreon commented Mar 14, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the help taking a deep dive into this @pablogsal and @godlygeek , I haven't had a chance to take a proper look at the --native fix, but it looks awesome. Will address feedback and look into it this weekend :)

@scopreon

Copy link
Copy Markdown
Contributor Author

I've addressed all the comments I could although there are still a few unanswered qs, thanks for the feedback.

Nice insight on the inferTidFromPThreadStructure change Matt, I've added the condition and a comment accordingly

@scopreon scopreon force-pushed the add_subinterpreter_support branch from baf7f42 to c3aba54 Compare May 20, 2026 20:59
@scopreon

Copy link
Copy Markdown
Contributor Author

Hi @pablogsal @godlygeek , thank you for your super useful help. I finally managed to find the time to resolve the more serious conflicts created from #272 (an impressive but scary PR).

I've approximately ported the old code from 77a8d89 to use nanobind and brought over tests etc. There are still some outstanding questions: #279 (comment), #279 (comment), #279 (comment), #279 (comment) and #279 (comment) (sorry for the long list). Would it be possible to have a review at some point please.

@godlygeek godlygeek force-pushed the add_subinterpreter_support branch 2 times, most recently from 5260e6b to 827bdcc Compare July 1, 2026 23:14
scopreon and others added 5 commits July 3, 2026 00:26
Signed-off-by: Saul Cooperman <saulcoops@gmail.com>
Signed-off-by: Saul Cooperman <saulcoops@gmail.com>
Signed-off-by: Saul Cooperman <saulcoops@gmail.com>
Signed-off-by: Saul Cooperman <saulcoops@gmail.com>
Replace the `TracebackPrinter` class with a `print_threads()` function
that detects shared-TID thread groups positionally. When threads from
multiple subinterpreters share an OS thread, `format_thread()` now
accepts `continuing_from_previous`/`continues_to_next` flags to emit
a single "Traceback for thread" header with per-interpreter annotations
like "In the main interpreter" or "In interpreter N".

This relies on all of the interpreters' stacks for a given TID being
printed sequentially. Previously that wasn't guaranteed, now it is.
I've reworked `_normalize_threads()` to group threads by TID and sort
each group by stack anchor even when native traces aren't enabled.
This is what users would expect, anyway.

Move the `frame_type()` classification back to Python, keeping only
`is_eval_frame()` in C++ since that's what all native stack slicing
needs.

Remove the `py_isv314` version template. It's not necessary now that we
only support using the `_Py_DebugOffsets` for 3.14+ and have dropped the
hardcoded 3.14 offsets.

Extract `num_entry_frames()` and `sort_threads_by_stack_anchor()` as
standalone helpers. Fix the `std::accumulate` call to use a `size_t`
rather than `int` for the accumulator.

For Python < 3.7, `getInterpreterId()` now returns the interpreter
address as a unique interpreter identifier instead of hardcoding 0.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
@godlygeek godlygeek force-pushed the add_subinterpreter_support branch from 5094828 to 16d2ba6 Compare July 3, 2026 05:04
godlygeek added 2 commits July 3, 2026 03:37
Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
This function is tricky enough to justify unit testing it, so expose it
directly through pybind so we can exercise it with pytest.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
@godlygeek godlygeek force-pushed the add_subinterpreter_support branch from 16d2ba6 to f9e500d Compare July 3, 2026 07:38
@godlygeek

Copy link
Copy Markdown
Contributor

I've spent some time iterating on this and I I've gotten it to a place where I'm almost ready to land it.

I'm debating whether we should disable walking the interpreters list entirely for Python 3.12 and older. I was hoping we'd be able to support at least 3.12, but python/cpython#109860 makes it so that we can't reliably figure out which native thread a Python thread is running on. And any version older than 3.12 is probably a complete non-starter: the stack anchor approach that we're using to figure out what order the stacks for a given thread should go in relies on some having the FRAME_OWNED_BY_CSTACK shim frames that were added in 3.12.

@scopreon if you get a chance, give my commits a review and see if there's anything you disagree with, please.

godlygeek added 4 commits July 3, 2026 11:12
Previously it was being duplicated in each of the `PyThreadData` in the
vector, even though it's not a per-thread attribute.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
It's no longer necessary, we can now get a compile_commands.json
directly from CMake using:

    pip install -v -e . --no-build-isolation \
      -Cbuild-dir='build/{wheel_tag}' \
      -Ccmake.define.CMAKE_EXPORT_COMPILE_COMMANDS=ON

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
While working on this PR I found myself repeatedly reimplementing and
then removing this debug logging. Let's just leave it in, but make it
conditional on `-vv` being used so that we don't pay the cost of
formatting the debug info when no one is going to see it.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
Because all threads in a group share the same TID, they necessarily also
share the same native thread, so the only two possibilities are that
either all threads in the group have no native stack, or all threads in
the group have a native stack. We only need to look at the first thread
in the group to tell which of those two it is.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
@godlygeek

Copy link
Copy Markdown
Contributor

OK, after my changes, the output now looks like this for one thread with multiple interpreters:

Traceback for thread 297838 (most recent call last):
  In the main interpreter []
    (Python) File "<string>", line 1, in <module>
    (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
        excinfo = _interpreters.exec(self._id, code, restrict=True)
  In interpreter 1 [Has the GIL]
    (Python) File "<script>", line 1, in <module>

Or this for a more involved example like the subinterpreters_two_threads_three_each.py from the test suite:

Traceback for thread 314561 (Thread-1 (launc) (most recent call last):
  In the main interpreter []
    (Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap
        self._bootstrap_inner()
    (Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner
        self._context.run(self.run)
    (Python) File "/usr/lib/python3.14/threading.py", line 1024, in run
        self._target(*self._args, **self._kwargs)
    (Python) File "/tmp/pytest-of-mwoznisk/pytest-83709/test_subinterpreters_two_threa0/subinterpreters_two_threads_three_each.py", line 72, in launch_chain
        interp1.exec(level1_code)
    (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
        excinfo = _interpreters.exec(self._id, code, restrict=True)
  In interpreter 1 []
    (Python) File "<script>", line 17, in <module>
    (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
        excinfo = _interpreters.exec(self._id, code, restrict=True)
  In interpreter 3 []
    (Python) File "<script>", line 17, in <module>
    (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
        excinfo = _interpreters.exec(self._id, code, restrict=True)
  In interpreter 6 []
    (Python) File "<script>", line 5, in <module>

Traceback for thread 314562 (Thread-2 (launc) (most recent call last):
  In the main interpreter []
    (Python) File "/usr/lib/python3.14/threading.py", line 1044, in _bootstrap
        self._bootstrap_inner()
    (Python) File "/usr/lib/python3.14/threading.py", line 1082, in _bootstrap_inner
        self._context.run(self.run)
    (Python) File "/usr/lib/python3.14/threading.py", line 1024, in run
        self._target(*self._args, **self._kwargs)
    (Python) File "/tmp/pytest-of-mwoznisk/pytest-83709/test_subinterpreters_two_threa0/subinterpreters_two_threads_three_each.py", line 72, in launch_chain
        interp1.exec(level1_code)
    (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
        excinfo = _interpreters.exec(self._id, code, restrict=True)
  In interpreter 2 []
    (Python) File "<script>", line 17, in <module>
    (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
        excinfo = _interpreters.exec(self._id, code, restrict=True)
  In interpreter 4 []
    (Python) File "<script>", line 17, in <module>
    (Python) File "/usr/lib/python3.14/concurrent/interpreters/__init__.py", line 215, in exec
        excinfo = _interpreters.exec(self._id, code, restrict=True)
  In interpreter 5 []
    (Python) File "<script>", line 5, in <module>

Traceback for thread 314560 (python) [] (most recent call last):
    (Python) File "/tmp/pytest-of-mwoznisk/pytest-83709/test_subinterpreters_two_threa0/subinterpreters_two_threads_three_each.py", line 81, in <module>
        time.sleep(1)

The output isn't changed for any thread with only one interpreter being used on it. Threads stacks from multiple interpreters have "In the main interpreter" or "In interpreter N" lines inserted at a half indent level to show where control leaves one interpreter and enters another, and those lines have the [] that would normally go on the "Traceback for thread" line, because that status can be different for different interpreters (like for my first example above, where the thread is holding the GIL for interpreter 1 but not the GIL for the main interpreter).

@scopreon

scopreon commented Jul 5, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @godlygeek for tidying everything up, looks solid. The traceback looks neat with the GIL information etc. It's a shame that the 3.12 backport for python/cpython#109860 never made it in.

I'm debating whether we should disable walking the interpreters list entirely for Python 3.12 and older

If we do allow walking the interpreter list, we should explicitly warn for versions <= 3.12 maybe? Or allow the ability to disable it. The only thing worse than a broken tool is one that breaks secretly/randomly (unless the failure mode is obvious?). Tbh I'm ok with disabling it entirely for <= 3.12 and just provide a reason in docs. Totally up to you.

Other than that, no concerns on my wide wrt impl etc :)

@godlygeek

Copy link
Copy Markdown
Contributor

The only thing worse than a broken tool is one that breaks secretly/randomly [...]
Tbh I'm ok with disabling it entirely for <= 3.12 and just provide a reason in docs.

Yeah, I think that sounds best for now.

It's a shame that the 3.12 backport for python/cpython#109860 never made it in.

I think this mostly affects the _xxsubinterpreters module, which I tried to use for testing this in 3.12 - without the bugfix backport, we find the wrong native thread id for threads run in a subinterpreter using the _xxsubinterpreters module. In theory we could work around that by writing a test-only extension module for exercising subinterpreters just on 3.12, or it may even be possible to do it with ctypes... Perhaps I'll see if Claude can figure out a way to do it with ctypes directly in the shim, actually...

The traceback looks neat with the GIL information etc.

I've realized one other change that I should make here: right now I'm only showing which interpreter a thread is using when there's more than one interpreter on that thread, but I really need to show that whenever there's multiple interpreters in the process. Even if a given thread is specific to a single interpreter, someone reading the traceback would really want to know which of the N interpreters it's specific to, I think.

@godlygeek godlygeek force-pushed the add_subinterpreter_support branch from a2a6cb7 to ed98766 Compare July 5, 2026 23:48
godlygeek added 4 commits July 5, 2026 20:18
If there are multiple interpreters in use, always show which interpreter
every thread is associated with.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
Since neither `concurrent.interpreters` nor `_interpreters` exists for
Python 3.12, use the private `_xxsubinterpreters` module instead.

Note that this requires us to change the test structure a bit to create
an interpreter in the thread we first want to use it in, to avoid being
bitten by gh-109860.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
This was the first version that introduced entry frames, and since it
had entry frames but didn't yet have shim frames, we can use the address
of the entry frame on the C stack as a stack anchor.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
@godlygeek godlygeek force-pushed the add_subinterpreter_support branch from ff82788 to 6e956e4 Compare July 6, 2026 00:18
@godlygeek

Copy link
Copy Markdown
Contributor

Alright, I've taken this a bit farther. I managed to get the tests working for 3.12 using _xxsubinterpreters despite python/cpython#109860 - we can make the test suite handle that by only creating the interpreter from the thread that we plan to first use it in. The bug is that the __main__ for that interpreter is incorrectly reported as being associated with the thread the interpreter was created in rather than the thread the __main__ is really being run in, but we can work around that by ensuring both are the same TID.

I managed to also get things working for 3.11, which was the first version to add PyInterpreterFrame and entry frames. We can use the address of the PyInterpreterFrame of the most recent entry frame as a stack anchor for Python 3.11.

And I updated the behavior for 3.10 and earlier to simply log a warning if multiple interpreters are detected for Python 3.10 or earlier, and to ignore every interpreter but the last in the list (which should be the main interpreter, unless the main interpreter is dead but others are still alive somehow). I think that's the best we can do for those older versions, for which we have no stack anchor and so cannot determine the correct order to list the stacks for the multiple interpreters on a single OS thread in.

I also made it so that, if we detected more than 1 interpreter in the process, we always log the interpreter in the traceback, even for threads with only one interpreter in use, or threads not associated with any interpreter. I assume this is what users are most likely to want to see while trying to make sense of multi-interpreter tracebacks.

godlygeek added 2 commits July 6, 2026 15:19
This test was passing everywhere but Alpine because GNU `env` takes
a `-S` argument, but the busybox `env` in Alpine doesn't.

We need to suppress the `-S` flag when we're running `env` instead of
running a Python interpreter.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
Rather than sleeping and hoping we waited long enough for every worker
thread and subinterpreter to start, use a pipe for synchronization
within the test scripts just as we do between the pytest runner process
and the test scripts.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
@godlygeek godlygeek force-pushed the add_subinterpreter_support branch 2 times, most recently from 1065aa6 to dd50a47 Compare July 6, 2026 22:43
This will make it easier to maintain these compared to the previous
nested triple quoted strings.

Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
@godlygeek godlygeek force-pushed the add_subinterpreter_support branch from dd50a47 to 584c66e Compare July 6, 2026 22:55
Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>
@godlygeek godlygeek force-pushed the add_subinterpreter_support branch from d7c3bb2 to ff85c99 Compare July 7, 2026 01:15
@godlygeek

Copy link
Copy Markdown
Contributor

OK, I've cleaned up a few more small things here:

  • The tests were failing on Alpine after a change I made to add -S to the tests' python invocations so that custom .pth files didn't wind up running in the subinterpreters. I've fixed that
  • CI was showing some flakiness for some of these new subinterpreter tests, which were using timing to try to wait "long enough" for the stacks to get into the state the test needed. I've switched that to using pipes for synchronization within the test script, just like we already were using between the test script and the test body running in pytest
  • Then I refactored those test scripts out into separate .py files, so that we can benefit from linting on them and don't need to deal with the syntax hurdles of nesting multiple levels of quoting
  • And finally, I added a new page to the docs describing the new feature and showing an example, and updated the changelog to link to it.

At this point, I think I'm ready to land this. @scopreon please give my latest changes a look if you can, and make sure I didn't screw anything up while polishing this.

@scopreon scopreon left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Have some comments, all nits so ffti

:shipit:

Comment thread src/pystack/_pystack/bindings.cpp
Comment thread src/pystack/_pystack/thread_builder.cpp Outdated
Comment thread tests/integration/test_subinterpreters.py Outdated
Comment thread tests/integration/test_subinterpreters.py
Signed-off-by: Matt Wozniski <mwozniski@bloomberg.net>

@godlygeek godlygeek left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, let's call this done. Thanks for bearing with us on this one, @scopreon - this turned out to be a much, much more involved change than I had originally anticipated. good first issue, I thought. Hah!

Thank you for participating in our trial by fire 😆

I'm very happy with where we wound up on this, though. I originally didn't think we'd be able to get hybrid stacks working, and getting that working makes this both cooler and more useful than I had originally hoped, so, yay!

@godlygeek godlygeek merged commit 8394c63 into bloomberg:main Jul 8, 2026
71 of 72 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants