TIL: Inspecting Python Threads

One of my favourite things about Python is that pretty much everything about the language can be inspected at runtime and today I found out this is true even for threads.

While working on this PR I kept running into issues where the esbonio process would not terminate, despite everything to my knowledge at least, was being shutdown as expected. Having encountered issues like this before, I knew the culprit was likely to be some background thread was stuck on something and keeping the process alive. The problem was I had no idea which thread it could be or what it was doing.

Thanks to this Stack Overflow post, I discovered Python does provide the tools to figure it out.

With a breakpoint set just before the point where the process should be exiting, we can first enumerate all the active threads.

>>> import threading
>>> {t.ident: t.name for t in threading.enumerate()}
{
    140658776004416: 'MainThread',
    140658503321280: 'pydevd.Writer',
    140658492835520: 'pydevd.Reader',
    140658482349760: 'pydevd.CommandThread',
    140658471864000: 'pydevd.CheckAliveThread',
    140658444601024: 'Thread-6',
    140658354423488: 'Thread-7'
}

Ignoring all the threads that appear to be part of the debugger (pydevd.*), the issue is probably in Thread-6 or Thread-7. With the help of the sys module we can get the current stack frame for each thread

>>> import sys
>>> thread_6_stack_frame = sys._current_frames()[140658444601024]
>>> thread_7_stack_frame = sys._current_frames()[140658354423488]

Then using the traceback module we can print the current call stack!

>>> traceback.print_stack(thread_6_stack_frame)
File "/var/home/linuxbrew/.linuxbrew/Cellar/python@3.13/3.13.0_1/lib/python3.13/threading.py", line 1012, in _bootstrap
  self._bootstrap_inner()
File "/var/home/linuxbrew/.linuxbrew/Cellar/python@3.13/3.13.0_1/lib/python3.13/threading.py", line 1041, in _bootstrap_inner
  self.run()
File "/var/home/alex/.vscode/extensions/ms-python.debugpy-2024.12.0-linux-x64/bundled/libs/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_daemon_thread.py", line 53, in run
  self._on_run()
File "/var/home/alex/.vscode/extensions/ms-python.debugpy-2024.12.0-linux-x64/bundled/libs/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_timeout.py", line 43, in _on_run
  self._event.wait(wait_time)
File "/var/home/linuxbrew/.linuxbrew/Cellar/python@3.13/3.13.0_1/lib/python3.13/threading.py", line 656, in wait
  with self._cond:
File "/var/home/linuxbrew/.linuxbrew/Cellar/python@3.13/3.13.0_1/lib/python3.13/threading.py", line 369, in wait
  if not gotit:

Hmm, not so useful.. but then again looking closer at the call stack this thread also seems to be related to the debugger. What about the other thread?

>>> traceback.print_stack(thread_7_stack_frame)
File "/var/home/linuxbrew/.linuxbrew/Cellar/python@3.13/3.13.0_1/lib/python3.13/threading.py", line 1012, in _bootstrap
  self._bootstrap_inner()
File "/var/home/linuxbrew/.linuxbrew/Cellar/python@3.13/3.13.0_1/lib/python3.13/threading.py", line 1041, in _bootstrap_inner
  self.run()
File "/home/alex/.local/share/hatch/env/virtual/esbonio/3pU26gTf/hatch-test.py3.13-sphinx6/lib/python3.13/site-packages/aiosqlite/core.py", line 107, in run
  tx_item = self._tx.get()

Ah! This thread is stuck somewhere in the aiosqlite library, looks like I wasn’t cleaning up all the DB connections like I thought I was!