Update on Breaking the LLDB/Python Revlock

This is an update of the ongoing work of breaking the LLDB/Python revlock. We’re almost there and a lot has changed along the way, so I wanted to give an overview (for those that have not been following along) and provide an update on where we ended up.

Background

We can distinguish two ways LLDB and Python are used together:

  1. Python in LLDB: When using LLDB, either directly (e.g. from the command line or with lldb-dap), or as a library, it comes with an embedded Python interpreter. LLDB drops you into the embedded interpreter when you use the script command, and it’s what powers LLDB’s ability to execute Python code for things like data formatters and breakpoint callbacks. In this mode, LLDB loads Python.
  2. LLDB in Python: When using LLDB from Python, via import lldb, Python loads LLDB. In this scenario, LLDB uses the existing interpreter so it can share state between the two.

Both use cases are critical and apply equally to other scripting languages such as Lua. Each introduces its own trade-offs that have shaped LLDB’s design.

The Source of the LLDB/Python Revlock

LLDB’s dependency on Python came with a long-standing caveat: the Python that LLDB ran against had to be the exact one it was built against.

Load-Time Dependency

Both use cases mentioned earlier need the Python library mapped into the process, which is why LLDB has historically linked against it at build time.

LLDB (specifically libLLDB) has a load-time dependency on its build-time Python at a specific install name. If the Python library doesn’t exist at runtime, the dynamic loader fails to load LLDB, often resulting in a crash. Since this happens before LLDB gets a chance to run, there’s no way to fail gracefully.

When you import lldb in an existing Python interpreter, it has already loaded its own copy of the Python library. If that library doesn’t match the one LLDB linked against, the dynamic loader will pull in a second copy. Having two copies of the same library is generally dangerous, and not something Python supports.

Unstable Python C API

Besides the load-time issue, there’s a second problem. LLDB’s use of Python predates the Limited API, which was introduced by PEP 384 in Python 3.2. This is a subset of Python’s C API that can be compiled once and loaded on multiple versions of Python.

LLDB relies on SWIG to generate the Python bindings for its public Scripting Bridge API. Until SWIG version 4.2, it couldn’t limit the generated code to the Limited API. Later versions generate Limited-API-compatible code by default.

Adopting the Python Limited C API

The first step towards breaking the revlock was adopting the Python Limited C API in LLDB. This work was tracked by issue #151617 and was relatively unglamorous. Most replacements were mechanical, but some APIs needed real surgery, and a few had to be guarded by a minimum Python version when their stable equivalent only landed in a later release.

Adopting the Limited API also requires picking a floor: the Py_LIMITED_API macro sets the oldest Python version the resulting binary can load against. We kept the existing minimum: Python 3.8.

We also had to make a small change to the LLDB Python module’s native extension. The .so file is a symlink to libLLDB. PEP 3149 defines an ABI versioning scheme for this file. For example, on Darwin, when building against Python 3.14, you would end up with _lldb.cpython-314-darwin.so. When building against the Python Limited API, we use the abi3 ABI tag.

The result is LLDB_ENABLE_PYTHON_LIMITED_API, a CMake option that’s now on by default when SWIG is recent enough. By itself, this changes nothing user-visible. LLDB still hard-links the Python library at the build-time path (this is what the next section fixes).

Loading Python at Runtime

With the ABI guarantee in hand, we can now safely load a different version of Python than we linked against at build time. There are several potential ways to support that:

  1. We can continue to have the dynamic loader load Python and rely on search paths to find different versions of the library. Since libraries are identified by name, this requires a known install name. It also requires either knowing the potential search paths (e.g. RPATHs on Darwin) up front, or being able to set them before the LLDB library gets loaded (e.g. by using a shim that sets (DY)LD_LIBRARY_PATH).
  2. We can rely on runtime loading using dlopen and dlsym to load a library after the LLDB library has already been loaded. This gives a lot of flexibility, but requires intrusive code changes to cast the result of dlsym for every symbol.
  3. We can pursue a hybrid between (1) and (2): runtime loading with symbol resolution. This approach eliminates the need for dlsym by using normal header files at compile time and telling the linker to allow unresolved symbols. At runtime, the symbols remain unresolved until the library is loaded with dlopen.

The first solution is the safest because both the compiler and linker verify that all symbols exist. It’s also the least flexible. The main reason it was discarded, though, is that the install name of the Python library can vary significantly.

The second option is by far the most flexible, but also the least safe (no compiler or linker checking) and the most intrusive (all symbols need to go through dlsym). It was discarded because the code generated by SWIG is outside of our control. A common solution to this problem is to build a generated shim library that exports the same symbols, but resolves them lazily at runtime. This works for function symbols, but Python also exports data symbols, which can’t be shimmed this way.

That leaves us with the third option, which offers a nice balance between the other two. It requires no code changes, and you still benefit from compiler checking. However, it poses a major risk: lazy binding crashes. If you call a symbol that was not successfully loaded by dlopen, the dynamic loader will fail to resolve it and crash your program. In other words, what was previously a link-time failure now becomes a runtime crash.

The Script Interpreters as Dynamic Libraries

To limit the blast radius of the hybrid approach, we decided to minimize the surface area that’s built with undefined symbols. The solution is to build the ScriptInterpreterplugins (the parts of LLDB that actually depend on Python) as shared libraries. Despite the name, plugins in LLDB are linked statically, and are more about abstraction than modularity.

This poses its own set of challenges. There is no plugin interface, and all existing plugins build on top of various LLDB and LLVM libraries. Making a plugin a dynamic library means it needs access to these symbols. However, LLDB also needs to call into the script interpreter plugin, creating a cycle. Static libraries paper over this problem, but for dynamic libraries we need to break the cycle.

Luckily, LLDB already supports loading plugins at runtime (i.e. with dlopen). This lets us break the cycle.

Here’s what that ends up looking like:

  • A solid edge represents a link-time dependency
    • ScriptInterpreterPython depends on libLLDB. The plugin uses symbols from LLDB and LLVM, resolved at load time via an exports list.
  • A dotted edge represents a runtime dependency
    • The PluginManager dlopens the plugin instead of pulling it in as a link-time dependency. This runtime edge replaces the link-time dependency that the dynamic-plugin work breaks.
    • The ScriptInterpreter depends on Python. Python symbols are deliberately left undefined at link time and bind to whichever Python library is loaded into the process.

Building the script interpreters as dynamic libraries is tracked by #183791 and controlled by LLDB_ENABLE_DYNAMIC_SCRIPTINTERPRETERS.

The Exports List

As mentioned earlier, the ScriptInterpreter plugins make use of LLVM and LLDB private symbols. When built as a dynamic library, we want to continue using the same symbols, rather than linking in another copy, which would lead to duplicate global state like option registries and llvm::Error type IDs. This requires re-exporting the LLVM and LLDB symbols used by the script interpreters. We want to limit the number of exported symbols, and maintaining an export list by hand is tedious and error-prone. Instead, we use llvm-nm to collect the necessary symbols at build time. New dependencies get picked up automatically.

Delay Load on Windows

So far, the discussion has focused primarily on Unix-like systems, though the revlock isn’t unique to them. Windows has a feature called delay loading that lets you programmatically change the loader search path before a symbol is used. This gives us another hybrid solution that looks more like (1) and eliminates the complexity of dlopen‘ing Python and the dynamic ScriptInterpreter libraries.

The Runtime Loader

The ScriptInterpreterRuntimeLoader, specifically its Python implementation, is the cross-platform abstraction that loads the Python library.

By default, the loader first tries the Python library LLDB was built against, falling back to platform-specific search only if that path is unavailable:

  • On Darwin, we use dlopen to load Python3.framework from Xcode (located viaDEVELOPER_DIR, the Command Line Tools, or xcrun), or Python.framework installed from python.org or Homebrew.
  • On Linux, we use dlopen to load libpython3.so, falling back through stable-ABI SONAMEs in descending order.
  • On Windows, we keep hard-linking Python and rely on the existing delay-load support.

It’s worth noting that loading Python happens in libLLDB, not in the PythonScriptInterpreter plugin. One reason is that the delay-load setup must happen before we load the shared library that uses the symbols. But even in the Unix scenario, there were several reasons to centralize this in LLDB proper, including layering, better error reporting, and avoiding two instances of Python in the same process.

Before searching, the loader checks whether Python is already mapped into the process by probing for Py_IsInitialized. If it is, we skip the search entirely and bind to the existing runtime. This is what makes the LLDB in Python case work: when import lldbruns, libpython is already loaded, and LLDB simply latches onto it.

The Final Result

With the dynamic plugins enabled, the revlock is broken: a single LLDB binary now works against any Python from 3.8 up, regardless of which one it was built against. It’s now possible to import lldb into any Python interpreter (>= 3.8), as well as use it from within LLDB. When no Python runtime can be found, LLDB now reports an error instead of crashing.

Python in LLDB

Here’s what the new design looks like for the Python in LLDB use case, for example, when running lldb -o 'script'.

LLDB in Python

Here’s what the new design looks like for the LLDB in Python use case, for example, when running python3 -c 'import lldb'.


As always, this wouldn’t have been possible without the help of our amazing community. Thank you to everyone who chimed in on the RFC, reviewed the patches and provided feedback!

5 Likes

Great work.

Does this provide all the features that the other platform’s solution does, or is it a subset of that?

Yes, it should. I’d love @charles-zablit to confirm, as he’s been kindly validating all my changes on Windows.

1 Like

Thanks Jonas! I’ve been looking forward to this. When do you expect this to be merged into the upstream codebase?

I’ve told Jonas already, but I tried out the Python Limited API (before the runtime changes detailed here), and managed to get it working downstream on Linux, and successfully ran the API tests using different versions of Python.

2 Likes

Almost all of this has landed. The only outstanding PRs are:

Once those are merged, I’m planning on making LLDB_ENABLE_DYNAMIC_SCRIPTINTERPRETERS the default on Darwin and kick off the discussion if we should do the same on Linux.

Fantastic!

We’ll default to this, both internally and externally.

1 Like

From what I have tested, it does yes. We still get to use the LLDB_PYTHON_RELATIVE_PATH CMake variable to have lldb try to load python from a specific path (allowing us to ship python3.dll in a toolchain distribution, without adding it to the PATH). We can also build lldb with a Python 3.x and run it with Python 3.y with x != y.

Here are the results of what I tried at desk so far:

  1. Python 3.10, with which I built lldb, is not in my PATH.
  2. I import the lldb library from Python 3.11.
  3. I run lldb.exe with Python 3.11 instead of 3.10.
➜  $env:PATH
C:\Program Files\PowerShell\7;c:\Users\charleszablit\.vscode-server\cli\servers\Stable-8761a5560cfd65fdd19ce7e2bd18dab5c0a4d84e\server\bin\remote-cli;C:\Program Files\OpenSSH\;C:\windows\system32;C:\windows;C:\windows\System32\Wbem;C:\windows\System32\WindowsPowerShell\v1.0\;C:\windows\System32\OpenSSH\;C:\Program Files\NVIDIA Corporation\NVIDIA App\NvDLISR;C:\Program Files (x86)\NVIDIA Corporation\PhysX\Common;C:\Program Files\dotnet\;C:\Program Files\Git\cmd;C:\Users\charleszablit\Developer\scripts;C:\Program Files (x86)\Gpg4win\..\GnuPG\bin;C:\Program Files\Git\usr\bin;C:\Program Files\PowerShell\7\;C:\Program Files\CMake-3.30\bin;C:\Program Files\CMake-3.29\bin;C:\Users\charleszablit\scoop\apps\nodejs-lts\current\bin;C:\Users\charleszablit\scoop\apps\nodejs-lts\current;C:\Users\charleszablit\AppData\Local\Programs\Python\Python311\;C:\Users\charleszablit\.cargo\bin;C:\Users\charleszablit\scoop\shims;C:\Users\charleszablit\AppData\Local\Programs\Python\Python39\Scripts\;C:\Users\charleszablit\AppData\Local\Programs\Python\Python39\;C:\Users\charleszablit\AppData\Local\Microsoft\WindowsApps;C:\Users\charleszablit\AppData\Local\Programs\Microsoft VS Code\bin;C:\Users\charleszablit\AppData\Local\Microsoft\WinGet\Links;C:\Program Files (x86)\dxc_2026_02_20\bin\x64;C:\Program Files (x86)\Mutagen;C:\Program Files (x86)\sccache;;c:\Users\charleszablit\.vscode-server\extensions\ms-python.debugpy-2026.6.0-win32-x64\bundled\scripts\noConfigScripts
➜  python
Python 3.11.9 (tags/v3.11.9:de54cf5, Apr  2 2024, 10:12:12) [MSC v.1938 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
Ctrl click to launch VS Code Native REPL
>>> import os
>>> sys.path.insert(0, r'C:\Users\charleszablit\Developer\lldb\llvm-build\release\lib\site-packages')
>>> os.environ['PATH'] = r'C:\Users\charleszablit\Developer\lldb\llvm-build\release\bin' + os.pathsep + os.environ['PATH']
>>> import lldb
>>> print(lldb.SBDebugger.version)
lldb version 23.0.0git (https://github.com/JDevlieghere/llvm-project.git revision 19b01460cdf6c3a72b5e3664d1b68905fe038dc7)
  clang revision 19b01460cdf6c3a72b5e3664d1b68905fe038dc7
  llvm revision 19b01460cdf6c3a72b5e3664d1b68905fe038dc7
>>> exit()
➜  ./llvm-build/release/bin/lldb.exe "C:\Users\charleszablit\Developer\testing\main.exe"
(lldb) target create "C:\\Users\\charleszablit\\Developer\\testing\\main.exe"
Current executable set to 'C:\Users\charleszablit\Developer\testing\main.exe' (x86_64).
(lldb) q
1 Like

I wanted to give another quick update here given that we branched for 23.x last week:

Currently, LLDB_ENABLE_DYNAMIC_SCRIPTINTERPRETERS is the default on Darwin and FreeBSD. Windows doesn’t need the script interpreters to be dynamic libraries but benefits from all the other work. In other words, on Darwin, FreeBSD and Windows, the revlock shouldn’t be an issue anymore.

This prompts the question whether we want to do the same on Linux. I’m aware of one blocking issue, which Ted put up a PR for but got reverted. Whether it’s for the 23 or 24 release, would someone with more familiarity of Linux and its package layouts be willing to help fix and qualify that configuration?

Two concrete questions I’d like to answer here:

  • Do we want to make this the default for Linux?
  • Do we want to pick this up for the 23 release? (*)

[*] Assuming the answer to the first question is yes and we can get the remaining issue(s) fixed wihtin the release timeframe.

Tagging the Linux code owners @DavidSpickett and @labath for visibility.

What exactly is the problem the PR was addressing? @tedwoodward

I haven’t been following the details of this work too closely, so it is not immediately clear to me.

llvm-project’s Github releases should be using it I think.

Default for Linux feels like the right thing but I think we should check with someone who knows about package management first. I wonder if it makes sense to keep lldb locked to a single python version if that python is managed by the same package manager. We can change the default even if that’s true, but need to give them some notice.

@nikic any idea what Redhat would choose?

Which reminds me, I don’t see anything in llvm-project/llvm/docs/ReleaseNotes.md at release/23.x · llvm/llvm-project · GitHub about these option(s) being defaulted on for Windows or Darwin.

We should add a release note about it just in case of issues.

1 Like

LLDB puts its Python module in a directory based on the Python version. If you build with Python 3.12, it gets put in something like lib/python312/site-packages. This can vary based on how you configure lldb. This makes sense if you’re building lldb to run with a specific Python, but if you’re building with LLDB_ENABLE_DYNAMIC_SCRIPTINTERPRETERS to enable this lldb to run with any Python >= 3.8, putting its module in a directory with the Python version doesn’t make sense.

My (reverted) PR changed the behavior on POSIX to match the behavior on Windows - put the module in lib/site-packages. This is what we should do with LLDB_ENABLE_DYNAMIC_SCRIPTINTERPRETERS. But this doesn’t work when an OS vendor is building lldb and expects to put its module in the system python’s site-packages or dist-packages. In the near future I’ll resubmit the PR with the new behavior when setting LLDB_ENABLE_DYNAMIC_SCRIPTINTERPRETERS, and the original behavior when not setting it.

1 Like

I understand the use case now, thanks. I’m not sure what, if anything, is the Python preferred way to do this, so I’ll do my own research to help review that PR.

One more request @JDevlieghere can we get the options documented on Building - 🐛 LLDB? I think a note would be fine like “by default lldb handles python this way, if you don’t want that, see option A and option B”. And we can trust folks to read the descriptions in the cmake files for themselves.

1 Like

I’m happy to take care of both of these.

Edit:

The distros I’ve looked at are all using some versioned path.
Arch: Arch Linux - lldb 22.1.8-1 (x86_64)
Ubuntu: Ubuntu – File list of package python3-lldb-22/resolute/amd64
Fedora: python3-lldb-22.1.1-2.fc44 - Fedora Packages

I do not see any unversioned paths for posix in sysconfig — Provide access to Python’s configuration information — Python 3.12.13 documentation either.

(Windows and Mac look unversioned but in fact the version is much earlier in the path)

As I understand it, limited API solves the problem of having to build the library N times. It does not solve the problem of putting it in a place to be loaded into N different pythons. Distros will install it in a way that the Python they use finds it, pip will do the same for the Python running pip and so on.

It’s up to the thing doing the install - is my point.

We cannot tell users to add the versioned path to the PYTHONPATH of another version, because this will change more than lldb imports if there are other modules in there.

If there’s an empty “site-packages” type folder that just has lldb in it, they can add that though.

So that’s one way to do this. Install to install/whatever/lldb/, and that can be added to the PYTHONPATH or using a .pth file put into the site packages of another version.

For example my ninja install’d local build has local/lib/python3.10/dist-packages/ which is versioned but if I know it’s built with limited API, I can use it in other versions albeit manually.

I’m missing which ways around this needs to work though. I’m primarily thinking about python -c "import lldb". I do not know if putting it in an unversioned location will be a problem for lldb -o script.

It would be useful to write this out in the form of an issue so it’s clear which directions this is supposed to work in.

@tedwoodward can you do that? At least write up the problems for your use case.

@DavidSpickett if you’re building for an OS, you don’t care about running with different Python versions - you’ll be running with the one shipping with that version of your OS. In that case, the current location of local/lib/python3.10/dist-packages/ is correct. But if you’re building a toolset that will run on multiple OSes, you either need to build with the Python internal API and ship your own Python, build with the Python limited API and ship your own Python (since the python .so will be DT_NEEDED), or build with the Python limited API and Dynamic Scriptinterpreters. If you build with the limited API and Dynamic Scriptinterpreters, we don’t want to put the Python version in the site-packages path, since it doesn’t make sense to have local/lib/python3.10/dist-packages/ if you’re running on Ubuntu 24 with Python 3.12. I think in this case we should do what we do on Windows - put it in lib/site-packages.

I will state this more clearly in the commit message when I push up a new PR.