[Lldb-commits] [lldb] [lldb/Interpreter] Surface Python exceptions from scripted extensions (PR #198153)
Med Ismail Bennani via lldb-commits
lldb-commits at lists.llvm.org
Mon Jul 27 00:18:21 PDT 2026
https://github.com/medismailben updated https://github.com/llvm/llvm-project/pull/198153
>From bcaa3548cc8e509ed886eece73c3f94f9db5edd7 Mon Sep 17 00:00:00 2001
From: Med Ismail Bennani <ismail at bennani.ma>
Date: Mon, 6 Jul 2026 18:51:51 -0700
Subject: [PATCH 1/3] [lldb/Interpreter] Surface Python exceptions from
scripted extensions
Python exceptions in scripted affordance methods were silently consumed
by llvm::consumeError, leaving users with a generic "Failed to create
script object." or just a null result.
Dispatch and CallStaticMethod now extract the Python backtrace via
PythonException::ReadBacktrace and populate the Status out-parameter
with "Python exception in <script-class> method '<method>':\n<traceback>"
(class name pulled from ScriptedInterface's metadata accessor, or
"<unknown>" if unset). ErrorWithMessage no longer overwrites a Status
that already carries a detailed message.
For entry points with no error-return channel - ScriptedProcess and
OperatingSystemPython CreateInstance/ctor, ScriptedThreadPlan::DidPush,
BreakpointResolverScripted ctor - the detailed error is broadcast via
Debugger::ReportError. ScriptedThread::Create propagates it through its
existing llvm::Expected return via llvm::toString instead of a generic
message.
ScriptedProcess::CreateInstance returns nullptr quietly when the launch
info doesn't request a scripted process, so the diagnostic only fires
on real failures. ScriptedProcess::DoAttach / DoReadMemory /
DoWriteMemory wrap their affordance Status with the failing operation.
ScriptedInterface gains an optional ErrorCallback for command handlers
that want to forward errors directly to a CommandReturnObject; if unset,
Status / ReportError remain the surfacing path.
ScriptedBreakpointInterface, ScriptedFrameProviderInterface and
ScriptedStopHookInterface now inherit virtually from ScriptedInterface
to disambiguate the metadata subobject in their *PythonInterface
subclasses.
A new test under lldb/test/API/functionalities/scripted_extensions/
listens for eBroadcastBitError and asserts each malformed extension
produces a user-visible error.
Signed-off-by: Med Ismail Bennani <ismail at bennani.ma>
---
.../Interfaces/ScriptedBreakpointInterface.h | 2 +-
.../ScriptedFrameProviderInterface.h | 2 +-
.../Interfaces/ScriptedInterface.h | 35 +++-
.../Interfaces/ScriptedStopHookInterface.h | 2 +-
.../lldb/Interpreter/ScriptInterpreter.h | 4 +
.../Breakpoint/BreakpointResolverScripted.cpp | 8 +-
.../Python/OperatingSystemPython.cpp | 8 +-
.../Process/scripted/ScriptedProcess.cpp | 60 +++++-
.../Process/scripted/ScriptedProcess.h | 14 +-
.../Process/scripted/ScriptedThread.cpp | 7 +-
.../Interfaces/ScriptedPythonInterface.h | 132 +++++++++++-
lldb/source/Target/ScriptedThreadPlan.cpp | 5 +
.../scripted_extensions/Makefile | 3 +
.../TestScriptedExtensionsDiagnostics.py | 124 +++++++++++
.../scripted_extensions/main.c | 1 +
.../malformed_scripted_extensions.py | 195 ++++++++++++++++++
.../scripted_process/TestScriptedProcess.py | 6 +-
17 files changed, 571 insertions(+), 37 deletions(-)
create mode 100644 lldb/test/API/functionalities/scripted_extensions/Makefile
create mode 100644 lldb/test/API/functionalities/scripted_extensions/TestScriptedExtensionsDiagnostics.py
create mode 100644 lldb/test/API/functionalities/scripted_extensions/main.c
create mode 100644 lldb/test/API/functionalities/scripted_extensions/malformed_scripted_extensions.py
diff --git a/lldb/include/lldb/Interpreter/Interfaces/ScriptedBreakpointInterface.h b/lldb/include/lldb/Interpreter/Interfaces/ScriptedBreakpointInterface.h
index 982f231d9f0b2..7328aa26db65d 100644
--- a/lldb/include/lldb/Interpreter/Interfaces/ScriptedBreakpointInterface.h
+++ b/lldb/include/lldb/Interpreter/Interfaces/ScriptedBreakpointInterface.h
@@ -15,7 +15,7 @@
#include "lldb/lldb-private.h"
namespace lldb_private {
-class ScriptedBreakpointInterface : public ScriptedInterface {
+class ScriptedBreakpointInterface : virtual public ScriptedInterface {
public:
virtual llvm::Expected<StructuredData::GenericSP>
CreatePluginObject(const ScriptedMetadata &scripted_metadata,
diff --git a/lldb/include/lldb/Interpreter/Interfaces/ScriptedFrameProviderInterface.h b/lldb/include/lldb/Interpreter/Interfaces/ScriptedFrameProviderInterface.h
index 757a99cb2387b..8c94df430c103 100644
--- a/lldb/include/lldb/Interpreter/Interfaces/ScriptedFrameProviderInterface.h
+++ b/lldb/include/lldb/Interpreter/Interfaces/ScriptedFrameProviderInterface.h
@@ -14,7 +14,7 @@
#include "ScriptedInterface.h"
namespace lldb_private {
-class ScriptedFrameProviderInterface : public ScriptedInterface {
+class ScriptedFrameProviderInterface : virtual public ScriptedInterface {
public:
virtual bool AppliesToThread(llvm::StringRef class_name,
lldb::ThreadSP thread_sp) {
diff --git a/lldb/include/lldb/Interpreter/Interfaces/ScriptedInterface.h b/lldb/include/lldb/Interpreter/Interfaces/ScriptedInterface.h
index 3dbc009a58311..4fc1e5860702b 100644
--- a/lldb/include/lldb/Interpreter/Interfaces/ScriptedInterface.h
+++ b/lldb/include/lldb/Interpreter/Interfaces/ScriptedInterface.h
@@ -20,6 +20,7 @@
#include "llvm/Support/Compiler.h"
+#include <functional>
#include <optional>
#include <string>
@@ -37,6 +38,17 @@ class ScriptedInterface {
return m_scripted_metadata;
}
+ /// Set error callback to surface Python exceptions directly to users.
+ ///
+ /// This allows command handlers to receive Python exception details
+ /// immediately rather than relying on diagnostic broadcasts.
+ ///
+ /// \param callback Function to call with Status containing exception details.
+ virtual void SetErrorCallback(std::function<void(const Status &)> callback) {}
+
+ /// Clear the error callback.
+ virtual void ClearErrorCallback() {}
+
struct AbstractMethodRequirement {
llvm::StringLiteral name;
size_t min_arg_count = 0;
@@ -62,18 +74,20 @@ class ScriptedInterface {
static Ret ErrorWithMessage(llvm::StringRef caller_name,
llvm::StringRef error_msg, Status &error,
LLDBLog log_category = LLDBLog::Process) {
+ // Log the error for debugging (includes function signature for context).
LLDB_LOGF(GetLog(log_category), "%s ERROR = %s", caller_name.data(),
error_msg.data());
- std::string full_error_message =
- llvm::Twine(caller_name + llvm::Twine(" ERROR = ") +
- llvm::Twine(error_msg))
- .str();
- if (const char *detailed_error = error.AsCString())
- full_error_message +=
- llvm::Twine(llvm::Twine(" (") + llvm::Twine(detailed_error) +
- llvm::Twine(")"))
- .str();
- error = Status(std::move(full_error_message));
+
+ // For user-facing messages, just pass through the Status if it already
+ // has detailed information (like Python tracebacks); otherwise set it.
+ llvm::StringRef existing_error = error.AsCString();
+ if (!error.Fail() || existing_error.empty()) {
+ // Status is empty, populate it with the simple error message.
+ error = Status::FromErrorString(error_msg.data());
+ }
+ // If Status already has content, leave it as-is (it has the Python
+ // traceback).
+
return {};
}
@@ -105,4 +119,5 @@ class ScriptedInterface {
std::optional<ScriptedMetadata> m_scripted_metadata;
};
} // namespace lldb_private
+
#endif // LLDB_INTERPRETER_INTERFACES_SCRIPTEDINTERFACE_H
diff --git a/lldb/include/lldb/Interpreter/Interfaces/ScriptedStopHookInterface.h b/lldb/include/lldb/Interpreter/Interfaces/ScriptedStopHookInterface.h
index c68498cba1632..ba325cfdf38b2 100644
--- a/lldb/include/lldb/Interpreter/Interfaces/ScriptedStopHookInterface.h
+++ b/lldb/include/lldb/Interpreter/Interfaces/ScriptedStopHookInterface.h
@@ -14,7 +14,7 @@
#include "ScriptedInterface.h"
namespace lldb_private {
-class ScriptedStopHookInterface : public ScriptedInterface {
+class ScriptedStopHookInterface : virtual public ScriptedInterface {
public:
virtual llvm::Expected<StructuredData::GenericSP>
CreatePluginObject(const ScriptedMetadata &scripted_metadata,
diff --git a/lldb/include/lldb/Interpreter/ScriptInterpreter.h b/lldb/include/lldb/Interpreter/ScriptInterpreter.h
index 58af82fb48390..2cc3890d86916 100644
--- a/lldb/include/lldb/Interpreter/ScriptInterpreter.h
+++ b/lldb/include/lldb/Interpreter/ScriptInterpreter.h
@@ -663,6 +663,10 @@ class ScriptInterpreter : public PluginInterface {
lldb::TargetSP GetOpaqueTypeFromSBTarget(const lldb::SBTarget &target) const;
+ /// Get the debugger associated with this script interpreter.
+ Debugger &GetDebugger() { return m_debugger; }
+ const Debugger &GetDebugger() const { return m_debugger; }
+
protected:
Debugger &m_debugger;
lldb::ScriptLanguage m_script_lang;
diff --git a/lldb/source/Breakpoint/BreakpointResolverScripted.cpp b/lldb/source/Breakpoint/BreakpointResolverScripted.cpp
index 0719c8b634cd3..0fdb185537c9a 100644
--- a/lldb/source/Breakpoint/BreakpointResolverScripted.cpp
+++ b/lldb/source/Breakpoint/BreakpointResolverScripted.cpp
@@ -20,6 +20,7 @@
#include "lldb/Target/Target.h"
#include "lldb/Utility/Log.h"
#include "lldb/Utility/StreamString.h"
+#include "llvm/Support/FormatVariadic.h"
using namespace lldb;
using namespace lldb_private;
@@ -94,7 +95,12 @@ void BreakpointResolverScripted::CreateImplementationIfNeeded(
m_interface_sp->CreatePluginObject(scripted_metadata, breakpoint_sp);
if (!obj_or_err) {
m_interface_sp.reset();
- m_error = Status::FromError(obj_or_err.takeError());
+ std::string msg = llvm::toString(obj_or_err.takeError());
+ Debugger::ReportError(
+ llvm::formatv("Failed to create BreakpointResolverScripted: {0}", msg)
+ .str(),
+ target.GetDebugger().GetID());
+ m_error = Status(msg);
return;
}
StructuredData::ObjectSP object_sp = *obj_or_err;
diff --git a/lldb/source/Plugins/OperatingSystem/Python/OperatingSystemPython.cpp b/lldb/source/Plugins/OperatingSystem/Python/OperatingSystemPython.cpp
index 49df19906929f..662b8117041bc 100644
--- a/lldb/source/Plugins/OperatingSystem/Python/OperatingSystemPython.cpp
+++ b/lldb/source/Plugins/OperatingSystem/Python/OperatingSystemPython.cpp
@@ -33,6 +33,7 @@
#include "lldb/Utility/StreamString.h"
#include "lldb/Utility/StructuredData.h"
#include "lldb/ValueObject/ValueObjectVariable.h"
+#include "llvm/Support/FormatVariadic.h"
#include <memory>
@@ -120,7 +121,12 @@ OperatingSystemPython::OperatingSystemPython(lldb_private::Process *process,
scripted_metadata, exe_ctx, nullptr);
if (!obj_or_err) {
- llvm::consumeError(obj_or_err.takeError());
+ std::string msg = llvm::toString(obj_or_err.takeError());
+ if (process)
+ Debugger::ReportError(
+ llvm::formatv("Failed to create OperatingSystemPython: {0}", msg)
+ .str(),
+ process->GetTarget().GetDebugger().GetID());
return;
}
diff --git a/lldb/source/Plugins/Process/scripted/ScriptedProcess.cpp b/lldb/source/Plugins/Process/scripted/ScriptedProcess.cpp
index 842eef9524328..b0418307cdebb 100644
--- a/lldb/source/Plugins/Process/scripted/ScriptedProcess.cpp
+++ b/lldb/source/Plugins/Process/scripted/ScriptedProcess.cpp
@@ -27,6 +27,10 @@
#include "Plugins/ObjectFile/Placeholder/ObjectFilePlaceholder.h"
+#include "llvm/Support/Error.h"
+
+#include <string>
+
using namespace lldb;
using namespace lldb_private;
@@ -57,11 +61,27 @@ lldb::ProcessSP ScriptedProcess::CreateInstance(lldb::TargetSP target_sp,
ScriptedMetadata scripted_metadata(target_sp->GetProcessLaunchInfo());
+ // CreateInstance is invoked for every process plugin during process
+ // creation; if the launch info doesn't request a scripted process, bail
+ // out silently rather than treating the missing class name as an error.
+ if (!scripted_metadata)
+ return nullptr;
+
Status error;
auto process_sp = std::shared_ptr<ScriptedProcess>(
new ScriptedProcess(target_sp, listener_sp, scripted_metadata, error));
if (error.Fail() || !process_sp || !process_sp->m_interface_up) {
+ // CreateInstance returns nullptr on failure with no Status output
+ // parameter, so we must report the error via the diagnostic system for
+ // users to see it.
+ if (error.Fail()) {
+ Debugger::ReportError(
+ llvm::formatv("Failed to create ScriptedProcess: {0}",
+ error.AsCString())
+ .str(),
+ target_sp->GetDebugger().GetID());
+ }
LLDB_LOGF(GetLog(LLDBLog::Process), "%s", error.AsCString());
return nullptr;
}
@@ -112,8 +132,10 @@ ScriptedProcess::ScriptedProcess(lldb::TargetSP target_sp,
GetInterface().CreatePluginObject(m_scripted_metadata, exe_ctx);
if (!obj_or_err) {
- llvm::consumeError(obj_or_err.takeError());
- error = Status::FromErrorString("Failed to create script object.");
+ // Extract the detailed error message including the Python backtrace.
+ std::string error_msg = llvm::toString(obj_or_err.takeError());
+ error = Status::FromErrorStringWithFormatv(
+ "Failed to create script object: {0}", error_msg);
return;
}
@@ -189,10 +211,13 @@ Status ScriptedProcess::DoResume(RunDirection direction) {
Status ScriptedProcess::DoAttach(const ProcessAttachInfo &attach_info) {
Status error = GetInterface().Attach(attach_info);
+ if (error.Fail()) {
+ error = Status::FromErrorStringWithFormatv(
+ "Failed to attach to scripted process: {0}", error.AsCString());
+ return error;
+ }
SetPrivateState(eStateRunning);
SetPrivateState(eStateStopped);
- if (error.Fail())
- return error;
// NOTE: We need to set the PID before finishing to attach otherwise we will
// hit an assert when calling the attach completion handler.
DidLaunch();
@@ -224,8 +249,14 @@ size_t ScriptedProcess::DoReadMemory(lldb::addr_t addr, void *buf, size_t size,
lldb::DataExtractorSP data_extractor_sp =
GetInterface().ReadMemoryAtAddress(addr, size, error);
- if (!data_extractor_sp || !data_extractor_sp->HasData() || error.Fail())
+ if (!data_extractor_sp || !data_extractor_sp->HasData() || error.Fail()) {
+ if (error.Fail()) {
+ error = Status::FromErrorStringWithFormatv(
+ "Failed to read memory from scripted process at 0x{0:x-}: {1}", addr,
+ error.AsCString());
+ }
return 0;
+ }
offset_t bytes_copied = data_extractor_sp->CopyByteOrderedData(
0, data_extractor_sp->GetByteSize(), buf, size, GetByteOrder());
@@ -251,9 +282,15 @@ size_t ScriptedProcess::DoWriteMemory(lldb::addr_t vm_addr, const void *buf,
lldb::offset_t bytes_written =
GetInterface().WriteMemoryAtAddress(vm_addr, data_extractor_sp, error);
- if (!bytes_written || bytes_written == LLDB_INVALID_OFFSET)
+ if (!bytes_written || bytes_written == LLDB_INVALID_OFFSET) {
+ if (error.Fail()) {
+ error = Status::FromErrorStringWithFormatv(
+ "Failed to write memory to scripted process at 0x{0:x-}: {1}", vm_addr,
+ error.AsCString());
+ }
return ScriptedInterface::ErrorWithMessage<size_t>(
LLVM_PRETTY_FUNCTION, "Failed to copy write buffer to memory.", error);
+ }
// FIXME: We should use the diagnostic system to report a warning if the
// `bytes_written` is different from `size`.
@@ -566,3 +603,14 @@ void *ScriptedProcess::GetImplementation() {
return object_instance_sp->GetAsGeneric()->GetValue();
return nullptr;
}
+
+void ScriptedProcess::SetScriptedInterfaceErrorCallback(
+ std::function<void(const Status &)> callback) {
+ if (m_interface_up)
+ m_interface_up->SetErrorCallback(std::move(callback));
+}
+
+void ScriptedProcess::ClearScriptedInterfaceErrorCallback() {
+ if (m_interface_up)
+ m_interface_up->ClearErrorCallback();
+}
diff --git a/lldb/source/Plugins/Process/scripted/ScriptedProcess.h b/lldb/source/Plugins/Process/scripted/ScriptedProcess.h
index 8371180734217..9ce4a3f8d3cc9 100644
--- a/lldb/source/Plugins/Process/scripted/ScriptedProcess.h
+++ b/lldb/source/Plugins/Process/scripted/ScriptedProcess.h
@@ -17,8 +17,6 @@
#include "ScriptedThread.h"
-#include <mutex>
-
namespace lldb_private {
class ScriptedProcess : public Process {
public:
@@ -93,6 +91,18 @@ class ScriptedProcess : public Process {
void *GetImplementation() override;
+ /// Set error callback to surface Python exceptions directly to users.
+ ///
+ /// This allows command handlers to receive Python exception details
+ /// immediately rather than relying on diagnostic broadcasts.
+ ///
+ /// \param callback Function to call with Status containing exception details.
+ void SetScriptedInterfaceErrorCallback(
+ std::function<void(const Status &)> callback);
+
+ /// Clear the error callback.
+ void ClearScriptedInterfaceErrorCallback();
+
void ForceScriptedState(lldb::StateType state) override {
// If we're about to stop, we should fetch the loaded dynamic libraries
// dictionary before emitting the private stop event to avoid having the
diff --git a/lldb/source/Plugins/Process/scripted/ScriptedThread.cpp b/lldb/source/Plugins/Process/scripted/ScriptedThread.cpp
index f87b37ca67e09..ac5dd00483f3b 100644
--- a/lldb/source/Plugins/Process/scripted/ScriptedThread.cpp
+++ b/lldb/source/Plugins/Process/scripted/ScriptedThread.cpp
@@ -69,11 +69,8 @@ ScriptedThread::Create(ScriptedProcess &process,
auto obj_or_err = scripted_thread_interface->CreatePluginObject(
thread_metadata, exe_ctx, script_object);
- if (!obj_or_err) {
- llvm::consumeError(obj_or_err.takeError());
- return llvm::createStringError(llvm::inconvertibleErrorCode(),
- "Failed to create script object.");
- }
+ if (!obj_or_err)
+ return obj_or_err.takeError();
StructuredData::GenericSP owned_script_object_sp = *obj_or_err;
diff --git a/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h b/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h
index 7d0d4cdd3c6d1..5608d5d35d654 100644
--- a/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h
+++ b/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h
@@ -9,12 +9,14 @@
#ifndef LLDB_SOURCE_PLUGINS_SCRIPTINTERPRETER_PYTHON_INTERFACES_SCRIPTEDPYTHONINTERFACE_H
#define LLDB_SOURCE_PLUGINS_SCRIPTINTERPRETER_PYTHON_INTERFACES_SCRIPTEDPYTHONINTERFACE_H
+#include <functional>
#include <optional>
#include <sstream>
#include <tuple>
#include <type_traits>
#include <utility>
+#include "lldb/Core/Debugger.h"
#include "lldb/Interpreter/Interfaces/ScriptedInterface.h"
#include "lldb/Utility/DataBufferHeap.h"
@@ -29,6 +31,22 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
ScriptedPythonInterface(ScriptInterpreterPythonImpl &interpreter);
~ScriptedPythonInterface() override = default;
+ /// Set callback to surface Python exceptions to CommandReturnObject.
+ ///
+ /// When set, this callback will be invoked whenever a Python exception occurs
+ /// in scripting affordance methods, allowing errors to be surfaced directly
+ /// to the user via CommandReturnObject::AppendError().
+ ///
+ /// If no callback is registered, errors will be reported via
+ /// Debugger::ReportError() instead.
+ ///
+ /// \param callback Function to call with Status containing exception details.
+ using ErrorCallback = std::function<void(const Status &)>;
+ void SetErrorCallback(ErrorCallback callback) override {
+ m_error_callback = std::move(callback);
+ }
+ void ClearErrorCallback() override { m_error_callback = nullptr; }
+
enum class AbstractMethodCheckerCases {
eNotImplemented,
eNotAllocated,
@@ -130,7 +148,15 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
llvm::Expected<PythonObject> callable_or_err =
class_dict.GetItem(method_name);
if (!callable_or_err) {
- llvm::consumeError(callable_or_err.takeError());
+ Log *log = GetLog(LLDBLog::Script);
+ if (log) {
+ std::string error_msg =
+ ExtractPythonError(callable_or_err.takeError());
+ LLDB_LOGF(log, "Failed to get method '%s': %s", method_name.data(),
+ error_msg.c_str());
+ } else {
+ llvm::consumeError(callable_or_err.takeError());
+ }
SET_CASE_AND_CONTINUE(method_name,
AbstractMethodCheckerCases::eNotAllocated)
}
@@ -145,7 +171,15 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
auto arg_info_or_err = callable.GetArgInfo();
if (!arg_info_or_err) {
- llvm::consumeError(arg_info_or_err.takeError());
+ Log *log = GetLog(LLDBLog::Script);
+ if (log) {
+ std::string error_msg =
+ ExtractPythonError(arg_info_or_err.takeError());
+ LLDB_LOGF(log, "Failed to get arg info for method '%s': %s",
+ method_name.data(), error_msg.c_str());
+ } else {
+ llvm::consumeError(arg_info_or_err.takeError());
+ }
SET_CASE_AND_CONTINUE(method_name,
AbstractMethodCheckerCases::eUnknownArgumentCount)
}
@@ -258,14 +292,18 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
std::apply(
[&init, &expected_return_object](auto &&...args) {
- llvm::consumeError(expected_return_object.takeError());
+ // Consume placeholder error (expected initial state).
+ if (!expected_return_object)
+ llvm::consumeError(expected_return_object.takeError());
expected_return_object = init(args...);
},
std::tuple_cat(transformed_args, std::make_tuple(dict)));
} else {
std::apply(
[&init, &expected_return_object](auto &&...args) {
- llvm::consumeError(expected_return_object.takeError());
+ // Consume placeholder error (expected initial state).
+ if (!expected_return_object)
+ llvm::consumeError(expected_return_object.takeError());
expected_return_object = init(args...);
},
transformed_args);
@@ -467,13 +505,41 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
llvm::createStringError("not initialized");
std::apply(
[&method, &expected_return_object](auto &&...args) {
- llvm::consumeError(expected_return_object.takeError());
+ // Consume placeholder error (expected initial state).
+ if (!expected_return_object)
+ llvm::consumeError(expected_return_object.takeError());
expected_return_object = method(args...);
},
transformed_args);
if (llvm::Error e = expected_return_object.takeError()) {
- error = Status::FromError(std::move(e));
+ // Extract Python backtrace and log it.
+ std::string detailed_error = ExtractPythonError(std::move(e));
+
+ Log *log = GetLog(LLDBLog::Script);
+ if (log) {
+ LLDB_LOGF(log, "%s: Python exception in static method %s:\n%s",
+ caller_signature.c_str(), method_name.data(),
+ detailed_error.c_str());
+ }
+
+ // Create Status with full context including the interface type.
+ // TODO: Stringify `args` and include them in the message so users
+ // can see what was passed to the failing call (e.g.
+ // `read_memory_at_address(0x500000000, 4)`). Requires a SFINAE
+ // helper that falls back to a placeholder for types without a
+ // format_provider / operator<<.
+ error = Status::FromErrorStringWithFormatv(
+ "Python exception in {0} method '{1}':\n{2}",
+ GetScriptedMetadata() ? GetScriptedMetadata()->GetClassName()
+ : "<unknown>",
+ method_name, detailed_error);
+
+ // Surface error to user: use callback if available.
+ if (m_error_callback) {
+ m_error_callback(error);
+ }
+
return ErrorWithMessage<T>(
caller_signature, "python static method could not be called", error);
}
@@ -492,6 +558,23 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
}
protected:
+ /// Extract detailed error message including Python backtrace if available.
+ ///
+ /// This helper processes llvm::Error objects that may contain PythonException
+ /// instances, extracting full Python backtraces when available.
+ ///
+ /// \param error The llvm::Error to extract information from.
+ /// \return A string containing the error message, including full Python
+ /// backtrace if the error was a PythonException.
+ static std::string ExtractPythonError(llvm::Error error) {
+ std::string error_msg;
+ llvm::handleAllErrors(
+ std::move(error),
+ [&](python::PythonException &E) { error_msg = E.ReadBacktrace(); },
+ [&](const llvm::ErrorInfoBase &E) { error_msg = E.message(); });
+ return error_msg;
+ }
+
template <typename T = StructuredData::ObjectSP>
T ExtractValueFromPythonObject(python::PythonObject &p, Status &error) {
return p.CreateStructuredObject();
@@ -530,14 +613,42 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
llvm::createStringError("not initialized");
std::apply(
[&implementor, &method_name, &expected_return_object](auto &&...args) {
- llvm::consumeError(expected_return_object.takeError());
+ // Consume placeholder error (expected initial state).
+ if (!expected_return_object)
+ llvm::consumeError(expected_return_object.takeError());
expected_return_object =
implementor.CallMethod(method_name.data(), args...);
},
transformed_args);
if (llvm::Error e = expected_return_object.takeError()) {
- error = Status::FromError(std::move(e));
+ // Extract Python backtrace and log it.
+ std::string detailed_error = ExtractPythonError(std::move(e));
+
+ Log *log = GetLog(LLDBLog::Script);
+ if (log) {
+ LLDB_LOGF(log, "%s: Python exception in %s:\n%s",
+ caller_signature.c_str(), method_name.data(),
+ detailed_error.c_str());
+ }
+
+ // Create Status with full context including the interface type.
+ // TODO: Stringify `args` and include them in the message so users
+ // can see what was passed to the failing call (e.g.
+ // `read_memory_at_address(0x500000000, 4)`). Requires a SFINAE
+ // helper that falls back to a placeholder for types without a
+ // format_provider / operator<<.
+ error = Status::FromErrorStringWithFormatv(
+ "Python exception in {0} method '{1}':\n{2}",
+ GetScriptedMetadata() ? GetScriptedMetadata()->GetClassName()
+ : "<unknown>",
+ method_name, detailed_error);
+
+ // Surface error to user: use callback if available.
+ if (m_error_callback) {
+ m_error_callback(error);
+ }
+
return ErrorWithMessage<T>(caller_signature,
"python method could not be called", error);
}
@@ -738,6 +849,11 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
// The lifetime is managed by the ScriptInterpreter
ScriptInterpreterPythonImpl &m_interpreter;
+
+ // Optional callback for surfacing errors to users (e.g.,
+ // CommandReturnObject). If not set, errors are reported via
+ // Debugger::ReportError().
+ ErrorCallback m_error_callback;
};
template <>
diff --git a/lldb/source/Target/ScriptedThreadPlan.cpp b/lldb/source/Target/ScriptedThreadPlan.cpp
index 7b0a6bd3a7279..ffdd3cc018acb 100644
--- a/lldb/source/Target/ScriptedThreadPlan.cpp
+++ b/lldb/source/Target/ScriptedThreadPlan.cpp
@@ -20,6 +20,7 @@
#include "lldb/Utility/LLDBLog.h"
#include "lldb/Utility/Log.h"
#include "lldb/Utility/State.h"
+#include "llvm/Support/FormatVariadic.h"
using namespace lldb;
using namespace lldb_private;
@@ -89,6 +90,10 @@ void ScriptedThreadPlan::DidPush() {
this->shared_from_this());
if (!obj_or_err) {
m_error_str = llvm::toString(obj_or_err.takeError());
+ Debugger::ReportError(
+ llvm::formatv("Failed to create ScriptedThreadPlan: {0}", m_error_str)
+ .str(),
+ m_process.GetTarget().GetDebugger().GetID());
SetPlanComplete(false);
} else
m_implementation_sp = *obj_or_err;
diff --git a/lldb/test/API/functionalities/scripted_extensions/Makefile b/lldb/test/API/functionalities/scripted_extensions/Makefile
new file mode 100644
index 0000000000000..10495940055b6
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_extensions/Makefile
@@ -0,0 +1,3 @@
+C_SOURCES := main.c
+
+include Makefile.rules
diff --git a/lldb/test/API/functionalities/scripted_extensions/TestScriptedExtensionsDiagnostics.py b/lldb/test/API/functionalities/scripted_extensions/TestScriptedExtensionsDiagnostics.py
new file mode 100644
index 0000000000000..5ffe7c316546f
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_extensions/TestScriptedExtensionsDiagnostics.py
@@ -0,0 +1,124 @@
+"""
+Verify that exceptions raised inside scripted extension affordance methods
+(or missing required abstract methods) are surfaced to the user.
+
+For entry points with no return-channel for errors
+(`ScriptedProcess::CreateInstance`, `OperatingSystemPython` ctor,
+`ScriptedThreadPlan::DidPush`, `BreakpointResolverScripted` ctor) the
+diagnostic is broadcast via `Debugger::ReportError` and asserted on a
+listener.
+
+Entry points that already return an `llvm::Expected` / `Status`
+(`ScriptedThread::Create`, `ScriptedFrameProvider::CreateInstance`,
+`StopHookScripted::SetScriptCallback`) propagate the detailed error
+through their return type; tests for those are tracked as follow-up.
+"""
+
+import os
+
+import lldb
+import lldbsuite.test.lldbutil as lldbutil
+from lldbsuite.test.decorators import expectedFailureAll
+from lldbsuite.test.lldbtest import TestBase
+
+
+class TestScriptedExtensionsDiagnostics(TestBase):
+ NO_DEBUG_INFO_TESTCASE = True
+
+ def setUp(self):
+ TestBase.setUp(self)
+ self.broadcaster = self.dbg.GetBroadcaster()
+ self.listener = lldbutil.start_listening_from(
+ self.broadcaster,
+ lldb.SBDebugger.eBroadcastBitWarning | lldb.SBDebugger.eBroadcastBitError,
+ )
+ script_path = os.path.join(
+ self.getSourceDir(), "malformed_scripted_extensions.py"
+ )
+ self.runCmd("command script import " + script_path)
+
+ def assert_diagnostic(self, expected_substring):
+ event = lldbutil.fetch_next_event(self, self.listener, self.broadcaster)
+ data = lldb.SBDebugger.GetDiagnosticFromEvent(event)
+ self.assertTrue(data.IsValid(), "event has diagnostic data")
+ message = data.GetValueForKey("message").GetStringValue(4096)
+ self.assertIn(expected_substring, message)
+
+ def create_target(self):
+ self.build()
+ target = self.dbg.CreateTarget(self.getBuildArtifact("a.out"))
+ self.assertTrue(target, "valid target")
+ return target
+
+ # ------------------------------------------------------------------
+ # ScriptedProcess - reports via ScriptedProcess::CreateInstance
+ # ------------------------------------------------------------------
+
+ def test_scripted_process_missing_methods(self):
+ """A ScriptedProcess missing abstract methods should emit a
+ diagnostic naming the missing method."""
+ target = self.create_target()
+ launch_info = lldb.SBLaunchInfo(None)
+ launch_info.SetProcessPluginName("ScriptedProcess")
+ launch_info.SetScriptedProcessClassName(
+ "malformed_scripted_extensions.MissingMethodsScriptedProcess"
+ )
+ error = lldb.SBError()
+ target.Launch(launch_info, error)
+ self.assertTrue(error.Fail(), "launch should fail")
+ self.assert_diagnostic("read_memory_at_address")
+
+ # ------------------------------------------------------------------
+ # BreakpointResolverScripted - reports via
+ # BreakpointResolverScripted::CreateImplementationIfNeeded.
+ # `m_error` is set but never surfaced to the user, so ReportError is
+ # the only user-visible channel.
+ # ------------------------------------------------------------------
+
+ # TODO: malformed_scripted_extensions needs a class whose __init__
+ # raises (the current ExceptionScriptedBreakpointResolver only raises
+ # from __callback__, which goes through Dispatch/Status). Tracked as
+ # follow-up.
+ @expectedFailureAll(bugnumber="needs an init-raising malformed class")
+ def test_scripted_breakpoint_resolver_init_failure(self):
+ target = self.create_target()
+ target.BreakpointCreateFromScript(
+ "malformed_scripted_extensions.ExceptionScriptedBreakpointResolver",
+ lldb.SBStructuredData(),
+ lldb.SBFileSpecList(),
+ lldb.SBFileSpecList(),
+ )
+ self.assert_diagnostic("ExceptionScriptedBreakpointResolver")
+
+ # ------------------------------------------------------------------
+ # ScriptedThreadPlan - reports via ScriptedThreadPlan::DidPush. The
+ # plan stores the error in m_error_str but never surfaces it; the
+ # diagnostic is the user-visible channel.
+ # ------------------------------------------------------------------
+
+ @expectedFailureAll(bugnumber="needs an init-raising malformed class")
+ def test_scripted_thread_plan_init_failure(self):
+ target = self.create_target()
+ process = target.LaunchSimple(None, None, self.get_process_working_directory())
+ self.assertTrue(process, "valid process")
+ thread = process.GetSelectedThread()
+ self.assertTrue(thread, "valid thread")
+ thread.StepUsingScriptedThreadPlan(
+ "malformed_scripted_extensions.ExceptionScriptedThreadPlan"
+ )
+ self.assert_diagnostic("ExceptionScriptedThreadPlan")
+
+ # ------------------------------------------------------------------
+ # The remaining entry points need a live inferior process to trigger
+ # their creation paths, or have no plugin implementation yet.
+ # ------------------------------------------------------------------
+
+ @expectedFailureAll(
+ bugnumber="OperatingSystemPython needs a live process to load the OS plugin"
+ )
+ def test_operating_system_missing_methods(self):
+ self.assert_diagnostic("get_thread_info")
+
+ @expectedFailureAll(bugnumber="ScriptedPlatform has no plugin implementation yet")
+ def test_scripted_platform_missing_methods(self):
+ self.assert_diagnostic("list_processes")
diff --git a/lldb/test/API/functionalities/scripted_extensions/main.c b/lldb/test/API/functionalities/scripted_extensions/main.c
new file mode 100644
index 0000000000000..237c8ce181774
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_extensions/main.c
@@ -0,0 +1 @@
+int main() {}
diff --git a/lldb/test/API/functionalities/scripted_extensions/malformed_scripted_extensions.py b/lldb/test/API/functionalities/scripted_extensions/malformed_scripted_extensions.py
new file mode 100644
index 0000000000000..e9ff0fd17a57c
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_extensions/malformed_scripted_extensions.py
@@ -0,0 +1,195 @@
+"""
+Intentionally malformed scripted extensions used by
+TestScriptedExtensionsDiagnostics.
+
+Each class either omits a required abstract method or raises a Python
+exception from one of its affordance methods. The corresponding test asserts
+that LLDB surfaces those errors to the user instead of silently swallowing
+them.
+"""
+
+# ---------------------------------------------------------------------------
+# Scripted Process
+# ---------------------------------------------------------------------------
+
+
+class MissingMethodsScriptedProcess:
+ """Missing required abstract method `read_memory_at_address`."""
+
+ def __init__(self, exe_ctx, args):
+ self.exe_ctx = exe_ctx
+ self.args = args
+
+ def get_scripted_thread_plugin(self):
+ return None
+
+ def is_alive(self):
+ return True
+
+
+class ExceptionScriptedProcess:
+ """All abstract methods present, but `launch` raises."""
+
+ def __init__(self, exe_ctx, args):
+ self.exe_ctx = exe_ctx
+ self.args = args
+
+ def get_scripted_thread_plugin(self):
+ return None
+
+ def is_alive(self):
+ return True
+
+ def read_memory_at_address(self, addr, size, error):
+ return None
+
+ def launch(self):
+ raise RuntimeError("intentional exception from launch()")
+
+
+# ---------------------------------------------------------------------------
+# Scripted Thread
+# ---------------------------------------------------------------------------
+
+
+class ExceptionScriptedThread:
+ def __init__(self, process, args):
+ self.process = process
+ self.args = args
+
+ def get_thread_id(self):
+ raise ValueError("intentional exception from get_thread_id()")
+
+ def get_register_context(self):
+ return ""
+
+ def get_name(self):
+ return "ExceptionScriptedThread"
+
+ def get_state(self):
+ return 0
+
+
+# ---------------------------------------------------------------------------
+# Scripted Platform
+# ---------------------------------------------------------------------------
+
+
+class MissingMethodsScriptedPlatform:
+ """Missing required abstract method `list_processes`."""
+
+ def __init__(self, exe_ctx, args):
+ self.exe_ctx = exe_ctx
+ self.args = args
+
+
+class ExceptionScriptedPlatform:
+ def __init__(self, exe_ctx, args):
+ self.exe_ctx = exe_ctx
+ self.args = args
+
+ def list_processes(self):
+ raise RuntimeError("intentional exception from list_processes()")
+
+ def get_process_info(self, pid):
+ return None
+
+ def launch_process(self, launch_info):
+ return None
+
+ def kill_process(self, pid):
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Scripted Frame Provider
+# ---------------------------------------------------------------------------
+
+
+class ExceptionScriptedFrameProvider:
+ def __init__(self, frames, args):
+ self.frames = frames
+ self.args = args
+
+ def get_num_frames(self):
+ raise RuntimeError("intentional exception from get_num_frames()")
+
+ def get_frame_at_index(self, idx):
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Scripted Thread Plan
+# ---------------------------------------------------------------------------
+
+
+class ExceptionScriptedThreadPlan:
+ def __init__(self, thread_plan, args):
+ self.thread_plan = thread_plan
+ self.args = args
+
+ def explains_stop(self, event):
+ raise RuntimeError("intentional exception from explains_stop()")
+
+ def should_stop(self, event):
+ return True
+
+ def is_stale(self):
+ return False
+
+
+# ---------------------------------------------------------------------------
+# Scripted Breakpoint Resolver
+# ---------------------------------------------------------------------------
+
+
+class ExceptionScriptedBreakpointResolver:
+ def __init__(self, bkpt, args):
+ self.bkpt = bkpt
+ self.args = args
+
+ def __callback__(self, sym_ctx):
+ raise RuntimeError("intentional exception from __callback__()")
+
+ def get_short_help(self):
+ return "Exception breakpoint resolver"
+
+
+# ---------------------------------------------------------------------------
+# Scripted Stop Hook
+# ---------------------------------------------------------------------------
+
+
+class ExceptionScriptedStopHook:
+ def __init__(self, target, args):
+ self.target = target
+ self.args = args
+
+ def handle_stop(self, exe_ctx, stream):
+ raise RuntimeError("intentional exception from handle_stop()")
+
+
+# ---------------------------------------------------------------------------
+# Operating System
+# ---------------------------------------------------------------------------
+
+
+class MissingMethodsOperatingSystem:
+ """Missing required abstract method `get_thread_info`."""
+
+ def __init__(self, process):
+ self.process = process
+
+
+class ExceptionOperatingSystem:
+ def __init__(self, process):
+ self.process = process
+
+ def get_thread_info(self):
+ raise RuntimeError("intentional exception from get_thread_info()")
+
+ def get_register_info(self):
+ return {}
+
+ def get_register_data(self, tid):
+ return b""
diff --git a/lldb/test/API/functionalities/scripted_process/TestScriptedProcess.py b/lldb/test/API/functionalities/scripted_process/TestScriptedProcess.py
index e8449c0faa928..f90a7422d270a 100644
--- a/lldb/test/API/functionalities/scripted_process/TestScriptedProcess.py
+++ b/lldb/test/API/functionalities/scripted_process/TestScriptedProcess.py
@@ -158,7 +158,11 @@ def cleanup():
buff = process.ReadMemory(addr, 4, error)
self.assertEqual(buff, None)
self.assertTrue(error.Fail())
- self.assertEqual(error.GetCString(), "This is an invalid scripted process!")
+ self.assertEqual(
+ error.GetCString(),
+ "Failed to read memory from scripted process at 0x500000000: "
+ "This is an invalid scripted process!",
+ )
with open(log_file, "r") as f:
log = f.read()
>From fdd790cb02b4c6efaf88f22950de9e6f4c9ba1b4 Mon Sep 17 00:00:00 2001
From: Med Ismail Bennani <ismail at bennani.ma>
Date: Wed, 8 Jul 2026 06:11:17 -0700
Subject: [PATCH 2/3] [lldb] Fix assert and deadlocks when registering a
scripted frame provider
"target frame-provider register" never called
CommandReturnObject::SetStatus() on its success path, tripping the
DoExecuteStatusCheck assert in CommandObject.cpp. This went unnoticed
because every existing scripted_frame_provider test uses
SBTarget::RegisterScriptedFrameProvider directly, bypassing the command
entirely.
Fixing that assert exposed two locking bugs that only manifest when the
command interpreter's event-handler thread is running concurrently with
a "bt" that touches SB API from inside a frame provider callback:
- GetStoppedExecutionContext unconditionally blocked acquiring the
target's API mutex. A thread already holding that mutex (e.g. the "bt"
command thread) could be waiting on a StackFrameList lock held by
another thread that is itself blocked re-entering the API mutex from a
frame provider's Python code -- classic AB-BA deadlock. Introduce
Policy::Capabilities::can_reenter_target_api_mutex, pushed around every
scripted-extension callback in ScriptedPythonInterface::Dispatch and
CallStaticMethod, so GetStoppedExecutionContext can skip the mutex
instead of blocking when running under a scripted callback.
- PolicyStack::Get() was an inline function with a function-local static
thread_local variable. Since LLDB builds with hidden visibility, each
shared library that included Policy.h got its own private copy of the
thread-local stack instead of sharing liblldb's instance, silently
splitting Push/Pop calls issued from a plugin dylib from the Guard
destructor's Pop (compiled into liblldb), draining the wrong stack.
Made Get() out-of-line so every dylib resolves to the same instance.
- ScriptedFrameProvider::GetFrameAtIndex reused the parent list's live
StackFrame object directly whenever a provider forwarded a frame under
its own index, instead of wrapping it in a BorrowedStackFrame. Frame
construction unconditionally re-tags the returned frame as belonging
to the child list, corrupting the parent list's cached frame. Once
that frame's corrupted list identity is resolved later, it points back
at the (possibly still-being-built) child list, and a thread already
holding that list's writer lock can self-deadlock taking the reader
lock. Always wrap in BorrowedStackFrame to avoid the aliasing.
Adds a regression test that drives commands through
SBDebugger.RunCommandInterpreter (which is what actually starts the
event-handler thread, unlike plain HandleCommand) to exercise the
"target frame-provider register" command path none of the other tests
cover.
Signed-off-by: Med Ismail Bennani <ismail at bennani.ma>
---
lldb/include/lldb/Target/ExecutionContext.h | 12 ++-
lldb/include/lldb/Utility/Policy.h | 28 +++++-
lldb/source/Commands/CommandObjectTarget.cpp | 1 +
.../Interfaces/ScriptedPythonInterface.h | 7 ++
.../ScriptedFrameProvider.cpp | 11 ++-
lldb/source/Target/ExecutionContext.cpp | 5 +-
lldb/source/Utility/Policy.cpp | 12 +++
.../register_command_deadlock/Makefile | 3 +
...estFrameProviderRegisterCommandDeadlock.py | 96 +++++++++++++++++++
.../frame_provider.py | 40 ++++++++
.../register_command_deadlock/main.c | 7 ++
11 files changed, 208 insertions(+), 14 deletions(-)
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/Makefile
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/TestFrameProviderRegisterCommandDeadlock.py
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/frame_provider.py
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/main.c
diff --git a/lldb/include/lldb/Target/ExecutionContext.h b/lldb/include/lldb/Target/ExecutionContext.h
index bf976f4db8c87..3a53a7755ee72 100644
--- a/lldb/include/lldb/Target/ExecutionContext.h
+++ b/lldb/include/lldb/Target/ExecutionContext.h
@@ -14,6 +14,7 @@
#include "lldb/Host/ProcessRunLock.h"
#include "lldb/Target/StackID.h"
#include "lldb/Target/SyntheticFrameProvider.h"
+#include "lldb/Utility/Policy.h"
#include "lldb/lldb-private.h"
namespace lldb_private {
@@ -561,9 +562,10 @@ class ExecutionContext {
};
/// A wrapper class representing an execution context with non-null Target
-/// and Process pointers, a locked API mutex and a locked ProcessRunLock.
-/// The locks are private by design: to unlock them, destroy the
-/// StoppedExecutionContext.
+/// and Process pointers, a locked ProcessRunLock, and (unless
+/// Policy::Capabilities::can_reenter_target_api_mutex is set for the
+/// current thread) a locked API mutex. The locks are private by design: to
+/// unlock them, destroy the StoppedExecutionContext.
struct StoppedExecutionContext : ExecutionContext {
StoppedExecutionContext(lldb::TargetSP &target_sp,
lldb::ProcessSP &process_sp,
@@ -574,7 +576,9 @@ struct StoppedExecutionContext : ExecutionContext {
: m_api_lock(std::move(api_lock)), m_stop_locker(std::move(stop_locker)) {
assert(target_sp);
assert(process_sp);
- assert(m_api_lock.owns_lock());
+ assert(
+ m_api_lock.owns_lock() ||
+ PolicyStack::Get().Current().capabilities.can_reenter_target_api_mutex);
assert(m_stop_locker.IsLocked());
SetTargetSP(target_sp);
SetProcessSP(process_sp);
diff --git a/lldb/include/lldb/Utility/Policy.h b/lldb/include/lldb/Utility/Policy.h
index 0435bdd4a0e15..cbe8f31bb89e8 100644
--- a/lldb/include/lldb/Utility/Policy.h
+++ b/lldb/include/lldb/Utility/Policy.h
@@ -50,6 +50,13 @@ struct Policy {
bool can_run_breakpoint_actions = true;
bool can_load_frame_providers = true;
bool can_run_frame_recognizers = true;
+ /// Whether SB API calls made on this thread may skip re-acquiring the
+ /// target's API mutex. Set while running a scripted extension callback
+ /// (e.g. a scripted frame provider's get_frame_at_index), which may
+ /// already be running under a lock (like StackFrameList's) that a
+ /// blocking re-acquisition of the API mutex could deadlock against, from
+ /// a thread that holds the API mutex and is waiting on that same lock.
+ bool can_reenter_target_api_mutex = false;
};
View view = View::Public;
@@ -64,6 +71,7 @@ struct Policy {
static Policy CreatePublicState();
static Policy CreatePrivateState();
static Policy CreatePublicStateRunningExpression();
+ static Policy CreateScriptedExtensionCall();
/// @}
void Dump(Stream &s) const;
@@ -84,10 +92,17 @@ struct Policy {
/// thread's stack when the task starts.
class PolicyStack {
public:
- static PolicyStack &Get() {
- static thread_local PolicyStack s_stack;
- return s_stack;
- }
+ /// Out-of-line so every shared library resolves to the single instance
+ /// defined in Policy.cpp (part of liblldb). LLDB builds with hidden
+ /// visibility by default, so an inline function's function-local static
+ /// is NOT shared across shared library boundaries: each dylib that
+ /// included this header and called an inline Get() would get its own
+ /// private copy of the thread_local stack, silently splitting a single
+ /// logical per-thread stack into several. Push/Pop calls emitted from
+ /// different dylibs would then operate on different objects while
+ /// Guard's destructor (already out-of-line) always pops liblldb's
+ /// instance, draining it out from under callers that never pushed to it.
+ static PolicyStack &Get();
Policy Current() const;
@@ -130,6 +145,11 @@ class PolicyStack {
return Guard();
}
+ [[nodiscard]] Guard PushScriptedExtensionCall() {
+ Push(Policy::CreateScriptedExtensionCall());
+ return Guard();
+ }
+
private:
void Push(Policy policy) { m_stack.push_back(std::move(policy)); }
diff --git a/lldb/source/Commands/CommandObjectTarget.cpp b/lldb/source/Commands/CommandObjectTarget.cpp
index 28065a47fd413..4ef3a6fe82115 100644
--- a/lldb/source/Commands/CommandObjectTarget.cpp
+++ b/lldb/source/Commands/CommandObjectTarget.cpp
@@ -6175,6 +6175,7 @@ class CommandObjectTargetFrameProviderRegister : public CommandObjectParsed {
result.AppendMessageWithFormatv(
"successfully registered scripted frame provider '{0}' for target",
m_class_options.GetName().c_str());
+ result.SetStatus(eReturnStatusSuccessFinishResult);
}
OptionGroupPythonClassWithDict m_class_options;
diff --git a/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h b/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h
index 5608d5d35d654..90263856915ea 100644
--- a/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h
+++ b/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h
@@ -19,6 +19,7 @@
#include "lldb/Core/Debugger.h"
#include "lldb/Interpreter/Interfaces/ScriptedInterface.h"
#include "lldb/Utility/DataBufferHeap.h"
+#include "lldb/Utility/Policy.h"
#include "../PythonDataObjects.h"
#include "../SWIGPythonBridge.h"
@@ -453,6 +454,9 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
return ErrorWithMessage<T>(caller_signature, "missing script class name",
error);
+ PolicyStack::Guard policy_guard =
+ PolicyStack::Get().PushScriptedExtensionCall();
+
Locker py_lock(&m_interpreter, Locker::AcquireLock | Locker::NoSTDIN,
Locker::FreeLock);
@@ -593,6 +597,9 @@ class ScriptedPythonInterface : virtual public ScriptedInterface {
return ErrorWithMessage<T>(caller_signature, "python object ill-formed",
error);
+ PolicyStack::Guard policy_guard =
+ PolicyStack::Get().PushScriptedExtensionCall();
+
Locker py_lock(&m_interpreter, Locker::AcquireLock | Locker::NoSTDIN,
Locker::FreeLock);
diff --git a/lldb/source/Plugins/SyntheticFrameProvider/ScriptedFrameProvider/ScriptedFrameProvider.cpp b/lldb/source/Plugins/SyntheticFrameProvider/ScriptedFrameProvider/ScriptedFrameProvider.cpp
index ab09d86b24d95..977ea5e0548e4 100644
--- a/lldb/source/Plugins/SyntheticFrameProvider/ScriptedFrameProvider/ScriptedFrameProvider.cpp
+++ b/lldb/source/Plugins/SyntheticFrameProvider/ScriptedFrameProvider/ScriptedFrameProvider.cpp
@@ -179,10 +179,13 @@ ScriptedFrameProvider::GetFrameAtIndex(uint32_t idx) {
if (real_frame_index < m_input_frames->GetNumFrames()) {
StackFrameSP real_frame_sp =
m_input_frames->GetFrameAtIndex(real_frame_index);
- synth_frame_sp =
- (real_frame_index == idx)
- ? real_frame_sp
- : std::make_shared<BorrowedStackFrame>(real_frame_sp, idx);
+ // Always wrap in a BorrowedStackFrame, even when the index is
+ // unchanged. FetchFramesUpTo below unconditionally overwrites
+ // frame_sp->m_frame_list_id to tag the frame as belonging to this
+ // synthetic list; reusing real_frame_sp directly would corrupt the
+ // parent list's cached frame (still m_input_frames' object) to claim
+ // it belongs to this list instead.
+ synth_frame_sp = std::make_shared<BorrowedStackFrame>(real_frame_sp, idx);
}
} else if (StructuredData::Dictionary *dict = obj_sp->GetAsDictionary()) {
// Check if it's a dictionary describing a frame.
diff --git a/lldb/source/Target/ExecutionContext.cpp b/lldb/source/Target/ExecutionContext.cpp
index e4b2f07d8d8d1..ec0f45cfa404d 100644
--- a/lldb/source/Target/ExecutionContext.cpp
+++ b/lldb/source/Target/ExecutionContext.cpp
@@ -145,8 +145,9 @@ lldb_private::GetStoppedExecutionContext(
return llvm::createStringError(
"StoppedExecutionContext created with a null target");
- auto api_lock =
- std::unique_lock<std::recursive_mutex>(target_sp->GetAPIMutex());
+ std::unique_lock<std::recursive_mutex> api_lock;
+ if (!PolicyStack::Get().Current().capabilities.can_reenter_target_api_mutex)
+ api_lock = std::unique_lock<std::recursive_mutex>(target_sp->GetAPIMutex());
auto process_sp = exe_ctx_ref_ptr->GetProcessSP();
if (!process_sp)
diff --git a/lldb/source/Utility/Policy.cpp b/lldb/source/Utility/Policy.cpp
index 104df17df7a97..903d8370dc067 100644
--- a/lldb/source/Utility/Policy.cpp
+++ b/lldb/source/Utility/Policy.cpp
@@ -15,6 +15,11 @@
using namespace lldb_private;
+PolicyStack &PolicyStack::Get() {
+ static thread_local PolicyStack s_stack;
+ return s_stack;
+}
+
Policy PolicyStack::Current() const {
Policy p = m_stack.back();
if (Log *log = GetLog(LLDBLog::Process)) {
@@ -45,6 +50,12 @@ Policy Policy::CreatePublicStateRunningExpression() {
return p;
}
+Policy Policy::CreateScriptedExtensionCall() {
+ Policy p = PolicyStack::Get().Current();
+ p.capabilities.can_reenter_target_api_mutex = true;
+ return p;
+}
+
PolicyStack::Guard::~Guard() {
if (!m_active)
return;
@@ -89,6 +100,7 @@ void Policy::Dump(Stream &s) const {
s << " bp_actions=" << capabilities.can_run_breakpoint_actions;
s << " frame_providers=" << capabilities.can_load_frame_providers;
s << " frame_recognizers=" << capabilities.can_run_frame_recognizers;
+ s << " reenter_api_mutex=" << capabilities.can_reenter_target_api_mutex;
s << '}';
}
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/Makefile b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/Makefile
new file mode 100644
index 0000000000000..0b710c6e298ae
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/Makefile
@@ -0,0 +1,3 @@
+C_SOURCES := main.c
+CFLAGS_EXTRAS := -std=c99
+include Makefile.rules
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/TestFrameProviderRegisterCommandDeadlock.py b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/TestFrameProviderRegisterCommandDeadlock.py
new file mode 100644
index 0000000000000..e4c5c591cba18
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/TestFrameProviderRegisterCommandDeadlock.py
@@ -0,0 +1,96 @@
+"""
+Test that `target frame-provider register` (the command, not the
+SBTarget::RegisterScriptedFrameProvider API used by every other frame
+provider test) works correctly when run through the command interpreter.
+
+This is a regression test for two related bugs found while investigating a
+user-reported crash/deadlock report:
+
+1. CommandObjectTargetFrameProviderRegister::DoExecute never called
+ result.SetStatus() on its success path, tripping the
+ DoExecuteStatusCheck assert in CommandObject.cpp. None of the existing
+ scripted_frame_provider tests exercise the `target frame-provider
+ register` command itself (they all call the SBTarget API directly),
+ which is why this went unnoticed.
+
+2. Once the command actually returns success, running `bt` right after
+ racing with the debugger's own event-handler thread deadlocked: that
+ thread independently calls Thread::GetStatus (e.g. in response to the
+ stack-changed event broadcast by registering a provider), which loads
+ the frame provider and can end up blocked on the same locks the `bt`
+ command thread holds, and vice versa. This only happens when the
+ command interpreter's event-handler thread is actually running, which
+ requires driving commands through SBDebugger.RunCommandInterpreter
+ (what the lldb driver itself uses), not plain HandleCommand.
+"""
+
+import os
+import lldb
+import lldbsuite.test.lldbutil as lldbutil
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+
+
+class TestFrameProviderRegisterCommandDeadlock(TestBase):
+ NO_DEBUG_INFO_TESTCASE = True
+
+ def test_register_command_then_bt_no_deadlock(self):
+ """
+ Register a scripted frame provider via the `target frame-provider
+ register` command and repeatedly run `bt` through
+ RunCommandInterpreter. Should complete without asserting or
+ deadlocking.
+ """
+ self.build()
+
+ lldbutil.run_to_name_breakpoint(self, "frame3")
+
+ provider_path = os.path.join(self.getSourceDir(), "frame_provider.py")
+
+ commands = ["command script import " + provider_path]
+ commands.append(
+ "target frame-provider register -C frame_provider.IdentityProvider"
+ )
+ # Run `bt` several times to raise the odds of hitting the race
+ # between the command thread and the debugger's event-handler
+ # thread within a single test invocation.
+ commands.extend(["bt"] * 20)
+ commands.append("quit")
+
+ stdin_path = self.getBuildArtifact("stdin.txt")
+ stdout_path = self.getBuildArtifact("stdout.txt")
+ with open(stdin_path, "w") as f:
+ f.write("\n".join(commands) + "\n")
+
+ with open(stdin_path, "r") as in_fileH, open(stdout_path, "w") as out_fileH:
+ in_sbf = lldb.SBFile(in_fileH.fileno(), "r", False)
+ out_sbf = lldb.SBFile(out_fileH.fileno(), "w", False)
+ self.assertSuccess(self.dbg.SetInputFile(in_sbf))
+ self.assertSuccess(self.dbg.SetOutputFile(out_sbf))
+ self.assertSuccess(self.dbg.SetErrorFile(out_sbf))
+
+ options = lldb.SBCommandInterpreterRunOptions()
+ options.SetEchoCommands(False)
+ options.SetPrintResults(True)
+ options.SetStopOnError(False)
+ options.SetStopOnCrash(False)
+
+ # If either bug regresses, this call either asserts (crashing
+ # the test) or hangs forever (timing out the test run).
+ n_errors, quit_requested, has_crashed = self.dbg.RunCommandInterpreter(
+ True, False, options, 0, False, False
+ )
+
+ with open(stdout_path, "r") as out_fileH:
+ output = out_fileH.read()
+
+ self.assertFalse(has_crashed, "lldb should not have crashed")
+ self.assertTrue(quit_requested, "quit command should have been processed")
+ self.assertEqual(n_errors, 0, f"unexpected errors in output:\n{output}")
+
+ self.assertIn(
+ "successfully registered scripted frame provider", output
+ )
+ self.assertIn("frame3", output)
+ self.assertIn("frame2", output)
+ self.assertIn("frame1", output)
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/frame_provider.py b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/frame_provider.py
new file mode 100644
index 0000000000000..b5e85431e2429
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/frame_provider.py
@@ -0,0 +1,40 @@
+"""
+Frame provider that forwards every frame under its own index while also
+touching input_frames, reproducing two bugs at once:
+
+- Registering and running `target frame-provider register` from the
+ command interpreter starts the debugger's event-handler thread, which
+ independently calls Thread::GetStatus -> GetStackFrameList while the
+ command thread runs `bt`.
+
+- get_frame_at_index touching self.input_frames triggers
+ GetStoppedExecutionContext, which (before the fix) blocked on the
+ target's API mutex, which the other thread could be holding while
+ waiting on this StackFrameList's lock -> deadlock.
+
+- Returning `index` for every frame (identity forwarding) means the
+ provider reuses the same StackFrame object that is cached in the
+ parent (input) frame list instead of wrapping it in a
+ BorrowedStackFrame. Frame construction unconditionally re-tags that
+ frame as belonging to this (child) list, corrupting the parent list's
+ cached frame. When that corrupted frame is later resolved back to a
+ frame list, it resolves to this list -- which, if the resolving thread
+ is the one already fetching frames on this list, self-deadlocks trying
+ to take a reader lock on the writer lock it already holds.
+"""
+
+from lldb.plugins.scripted_frame_provider import ScriptedFrameProvider
+
+
+class IdentityProvider(ScriptedFrameProvider):
+ @staticmethod
+ def get_description():
+ return "Provider that forwards each frame under its own index"
+
+ def get_frame_at_index(self, index):
+ if index < len(self.input_frames):
+ # __getitem__ calls SBFrame.IsValid() internally, which is what
+ # exercises GetStoppedExecutionContext.
+ self.input_frames[index]
+ return index
+ return None
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/main.c b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/main.c
new file mode 100644
index 0000000000000..1aa56e3eddf7a
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/main.c
@@ -0,0 +1,7 @@
+int frame3() { return 3; }
+
+int frame2() { return frame3(); }
+
+int frame1() { return frame2(); }
+
+int main() { return frame1(); }
>From de2a1b5d4e3b49a2b04b9276267764c7b2374672 Mon Sep 17 00:00:00 2001
From: Med Ismail Bennani <ismail at bennani.ma>
Date: Thu, 9 Jul 2026 03:19:45 -0400
Subject: [PATCH 3/3] [lldb] Reformat
TestFrameProviderRegisterCommandDeadlock.py with darker
No functional change.
---
.../TestFrameProviderRegisterCommandDeadlock.py | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/TestFrameProviderRegisterCommandDeadlock.py b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/TestFrameProviderRegisterCommandDeadlock.py
index e4c5c591cba18..f3e1205549f54 100644
--- a/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/TestFrameProviderRegisterCommandDeadlock.py
+++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_deadlock/TestFrameProviderRegisterCommandDeadlock.py
@@ -88,9 +88,7 @@ def test_register_command_then_bt_no_deadlock(self):
self.assertTrue(quit_requested, "quit command should have been processed")
self.assertEqual(n_errors, 0, f"unexpected errors in output:\n{output}")
- self.assertIn(
- "successfully registered scripted frame provider", output
- )
+ self.assertIn("successfully registered scripted frame provider", output)
self.assertIn("frame3", output)
self.assertIn("frame2", output)
self.assertIn("frame1", output)
More information about the lldb-commits
mailing list