[Lldb-commits] [lldb] [lldb] Fix scripted frame provider cross-thread re-entrant deadlock (PR #208242)
Med Ismail Bennani via lldb-commits
lldb-commits at lists.llvm.org
Sat Jul 18 11:19:48 PDT 2026
https://github.com/medismailben updated https://github.com/llvm/llvm-project/pull/208242
>From 86ce1e4dba49d7007528ee5860be976b7262a994 Mon Sep 17 00:00:00 2001
From: Med Ismail Bennani <ismail at bennani.ma>
Date: Fri, 10 Jul 2026 15:32:20 -0700
Subject: [PATCH] [lldb] Fix scripted frame provider cross-thread re-entrant
deadlock
`GetStoppedExecutionContext` unconditionally blocked acquiring the
target's API mutex. A thread already holding that mutex (for example a
`bt` command thread, through `CommandObjectParsed`'s
`eCommandTryTargetAPILock`) can end up waiting on a `StackFrameList`
lock held by another thread (for example the debugger's event-handler
thread) that is itself blocked re-acquiring the API mutex from inside a
scripted frame provider's Python code that touches the SB API. This is
a classic AB-BA deadlock.
This patch introduces `Policy::Capabilities::can_bypass_target_api_mutex`,
pushed around every scripted-extension callback in
`ScriptedPythonInterface::Dispatch` and `CallStaticMethod`. A thread
running one of these callbacks isn't servicing a client-facing SB API
entry point; it doesn't need the same locking guarantees a top-level SB
API call does for any of the calls it makes during that window, not
just the one that happens to deadlock.
It encodes the check inside `Target::GetAPIMutex()` itself: when the
current thread's policy says it can bypass, `GetAPIMutex()` hands out a
thread-local mutex instead of the real one. Every existing caller keeps
its own locking code unchanged and becomes deadlock-safe automatically.
`SBTarget::GetAPIMutex()` hands scripts and tools such as `lldb-dap` an
`SBMutex` that can be locked from any thread and held indefinitely, so
it can't just alias whatever mutex was current when it was constructed.
`SBMutex` now wraps a new `Target::APIMutexHandle`, which holds a
`TargetSP` and re-resolves `GetAPIMutex()` fresh on every `lock()`,
`unlock()`, and `try_lock()` call. That keeps ordinary cross-thread use
correct (it resolves to the real mutex) while also making it safe to
call from inside a scripted-extension callback (it resolves to the
bypass mutex there instead).
This patch adds regression tests for both the original deadlock and for
a blocking `SBMutex.lock()` call made from inside a callback.
Signed-off-by: Med Ismail Bennani <ismail at bennani.ma>
---
lldb/include/lldb/API/SBMutex.h | 4 +-
lldb/include/lldb/Target/ExecutionContext.h | 4 +-
lldb/include/lldb/Target/Target.h | 27 ++++
lldb/include/lldb/Utility/Policy.h | 11 ++
lldb/include/lldb/lldb-forward.h | 1 +
lldb/source/API/SBMutex.cpp | 6 +-
.../Interfaces/ScriptedPythonInterface.h | 7 +
lldb/source/Target/ExecutionContext.cpp | 3 +-
lldb/source/Target/Target.cpp | 35 +++++
lldb/source/Utility/Policy.cpp | 7 +
.../Makefile | 2 +
...ProviderRegisterCommandAPIMutexDeadlock.py | 89 +++++++++++++
.../frame_provider.py | 28 ++++
.../main.c | 7 +
.../sbmutex_reflects_target_mutex/Makefile | 2 +
.../TestHoldMutexNoDeadlock.py | 88 ++++++++++++
.../TestSBMutexReflectsTargetMutex.py | 125 ++++++++++++++++++
.../hold_mutex_frame_provider.py | 46 +++++++
.../sbmutex_reflects_target_mutex/main.c | 12 ++
.../sbmutex_frame_provider.py | 56 ++++++++
lldb/unittests/Utility/PolicyTest.cpp | 6 +-
21 files changed, 554 insertions(+), 12 deletions(-)
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/Makefile
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/TestFrameProviderRegisterCommandAPIMutexDeadlock.py
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/frame_provider.py
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/main.c
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/Makefile
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/TestHoldMutexNoDeadlock.py
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/TestSBMutexReflectsTargetMutex.py
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/hold_mutex_frame_provider.py
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/main.c
create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/sbmutex_frame_provider.py
diff --git a/lldb/include/lldb/API/SBMutex.h b/lldb/include/lldb/API/SBMutex.h
index 826ad077f159f..2c7f859abdd71 100644
--- a/lldb/include/lldb/API/SBMutex.h
+++ b/lldb/include/lldb/API/SBMutex.h
@@ -11,7 +11,7 @@
#include "lldb/API/SBDefines.h"
#include "lldb/lldb-forward.h"
-#include <mutex>
+#include <memory>
namespace lldb {
@@ -41,7 +41,7 @@ class LLDB_API SBMutex {
SBMutex(lldb::TargetSP target_sp);
friend class SBTarget;
- std::shared_ptr<std::recursive_mutex> m_opaque_sp;
+ std::shared_ptr<lldb_private::APIMutexHandle> m_opaque_sp;
};
} // namespace lldb
diff --git a/lldb/include/lldb/Target/ExecutionContext.h b/lldb/include/lldb/Target/ExecutionContext.h
index bf976f4db8c87..706fd6f64ba53 100644
--- a/lldb/include/lldb/Target/ExecutionContext.h
+++ b/lldb/include/lldb/Target/ExecutionContext.h
@@ -561,8 +561,8 @@ 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
+/// and Process pointers, a locked ProcessRunLock, and a locked API mutex.
+/// The locks are private by design; to unlock them, destroy the
/// StoppedExecutionContext.
struct StoppedExecutionContext : ExecutionContext {
StoppedExecutionContext(lldb::TargetSP &target_sp,
diff --git a/lldb/include/lldb/Target/Target.h b/lldb/include/lldb/Target/Target.h
index fb43f432a08da..1ecfb99627371 100644
--- a/lldb/include/lldb/Target/Target.h
+++ b/lldb/include/lldb/Target/Target.h
@@ -572,6 +572,28 @@ class EvaluateExpressionOptions {
SymbolContextList m_preferred_lookup_contexts;
};
+/// A movable, sharable handle to a Target's API mutex, safe to lock from any
+/// thread and to hold past the point where the constructing thread's policy
+/// still applies. Every call re-resolves Target::GetAPIMutex() fresh, rather
+/// than binding to whichever mutex was current at construction time, so it
+/// always reflects the calling thread's own, current bypass status.
+///
+/// A handle constructed without a target owns an independent mutex instead,
+/// for callers that just want a general-purpose lockable object.
+class APIMutexHandle {
+public:
+ APIMutexHandle();
+ explicit APIMutexHandle(lldb::TargetSP target_sp);
+
+ void lock();
+ void unlock();
+ bool try_lock();
+
+private:
+ lldb::TargetSP m_target_sp;
+ std::recursive_mutex m_standalone_mutex;
+};
+
// Target
class Target : public std::enable_shared_from_this<Target>,
public TargetProperties,
@@ -761,6 +783,11 @@ class Target : public std::enable_shared_from_this<Target>,
static TargetProperties &GetGlobalProperties();
+ /// Returns the mutex a caller should serialize on before touching the
+ /// target through the SB API. When the current thread's policy says it
+ /// can bypass it, this returns a mutex private to that thread instead, so
+ /// callers can lock it unconditionally without ever contending with
+ /// whichever thread holds the real one.
std::recursive_mutex &GetAPIMutex();
void DeleteCurrentProcess();
diff --git a/lldb/include/lldb/Utility/Policy.h b/lldb/include/lldb/Utility/Policy.h
index afeeab19c2ed0..83248cf61d0c6 100644
--- a/lldb/include/lldb/Utility/Policy.h
+++ b/lldb/include/lldb/Utility/Policy.h
@@ -50,6 +50,11 @@ struct Policy {
bool can_run_breakpoint_actions = true;
bool can_load_frame_providers = true;
bool can_run_frame_recognizers = true;
+ /// Whether the current thread may bypass the target's API mutex
+ /// entirely when it re-enters it, because the thread is already
+ /// running under whatever protections its caller set up rather than
+ /// servicing a top-level SB API entry point itself.
+ bool can_bypass_target_api_mutex = false;
};
/// Why a private-state policy is being pushed. Distinguishes a PST's
@@ -75,6 +80,7 @@ struct Policy {
static Policy CreatePrivateState(
PrivateStatePurpose purpose = PrivateStatePurpose::Default);
static Policy CreatePublicStateRunningExpression();
+ static Policy CreateScriptedExtensionCall();
/// @}
void Dump(Stream &s) const;
@@ -140,6 +146,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/include/lldb/lldb-forward.h b/lldb/include/lldb/lldb-forward.h
index 157aa5743f016..8eb3695d706a9 100644
--- a/lldb/include/lldb/lldb-forward.h
+++ b/lldb/include/lldb/lldb-forward.h
@@ -15,6 +15,7 @@
namespace lldb_private {
class ABI;
+class APIMutexHandle;
class ASTResultSynthesizer;
class ASTStructExtractor;
class Address;
diff --git a/lldb/source/API/SBMutex.cpp b/lldb/source/API/SBMutex.cpp
index c7844dec658cc..1be8d69021554 100644
--- a/lldb/source/API/SBMutex.cpp
+++ b/lldb/source/API/SBMutex.cpp
@@ -11,12 +11,11 @@
#include "lldb/Utility/Instrumentation.h"
#include "lldb/lldb-forward.h"
#include <memory>
-#include <mutex>
using namespace lldb;
using namespace lldb_private;
-SBMutex::SBMutex() : m_opaque_sp(std::make_shared<std::recursive_mutex>()) {
+SBMutex::SBMutex() : m_opaque_sp(std::make_shared<APIMutexHandle>()) {
LLDB_INSTRUMENT_VA(this);
}
@@ -32,8 +31,7 @@ const SBMutex &SBMutex::operator=(const SBMutex &rhs) {
}
SBMutex::SBMutex(lldb::TargetSP target_sp)
- : m_opaque_sp(std::shared_ptr<std::recursive_mutex>(
- target_sp, &target_sp->GetAPIMutex())) {
+ : m_opaque_sp(std::make_shared<APIMutexHandle>(target_sp)) {
LLDB_INSTRUMENT_VA(this, target_sp);
}
diff --git a/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h b/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h
index aaa0b6a0f7a59..ec115081f647f 100644
--- a/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h
+++ b/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h
@@ -17,6 +17,7 @@
#include "lldb/Interpreter/Interfaces/ScriptedInterface.h"
#include "lldb/Utility/DataBufferHeap.h"
+#include "lldb/Utility/Policy.h"
#include "../PythonDataObjects.h"
#include "../SWIGPythonBridge.h"
@@ -420,6 +421,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);
@@ -515,6 +519,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/Target/ExecutionContext.cpp b/lldb/source/Target/ExecutionContext.cpp
index e4b2f07d8d8d1..f2ac6a0bc8e24 100644
--- a/lldb/source/Target/ExecutionContext.cpp
+++ b/lldb/source/Target/ExecutionContext.cpp
@@ -145,8 +145,7 @@ 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(target_sp->GetAPIMutex());
auto process_sp = exe_ctx_ref_ptr->GetProcessSP();
if (!process_sp)
diff --git a/lldb/source/Target/Target.cpp b/lldb/source/Target/Target.cpp
index c98b30bedaaa6..1fec6ec4d6157 100644
--- a/lldb/source/Target/Target.cpp
+++ b/lldb/source/Target/Target.cpp
@@ -5992,8 +5992,43 @@ Target::TargetEventData::GetModuleListFromEvent(const Event *event_ptr) {
return module_list;
}
+APIMutexHandle::APIMutexHandle() = default;
+
+APIMutexHandle::APIMutexHandle(lldb::TargetSP target_sp)
+ : m_target_sp(std::move(target_sp)) {}
+
+void APIMutexHandle::lock() {
+ if (m_target_sp)
+ m_target_sp->GetAPIMutex().lock();
+ else
+ m_standalone_mutex.lock();
+}
+
+void APIMutexHandle::unlock() {
+ if (m_target_sp)
+ m_target_sp->GetAPIMutex().unlock();
+ else
+ m_standalone_mutex.unlock();
+}
+
+bool APIMutexHandle::try_lock() {
+ if (m_target_sp)
+ return m_target_sp->GetAPIMutex().try_lock();
+ return m_standalone_mutex.try_lock();
+}
+
std::recursive_mutex &Target::GetAPIMutex() {
Policy policy = PolicyStack::Get().Current();
+
+ // A thread whose policy says it can bypass the API mutex gets a mutex of
+ // its own instead of a no-op: every caller still locks *something*, but
+ // since it's thread-local, that lock never contends with whatever other
+ // thread holds the real mutex.
+ if (policy.capabilities.can_bypass_target_api_mutex) {
+ static thread_local std::recursive_mutex s_bypass_mutex;
+ return s_bypass_mutex;
+ }
+
if (policy.view == Policy::View::Private)
return m_private_mutex;
diff --git a/lldb/source/Utility/Policy.cpp b/lldb/source/Utility/Policy.cpp
index 4d1999aaf7b92..04293d7a03f85 100644
--- a/lldb/source/Utility/Policy.cpp
+++ b/lldb/source/Utility/Policy.cpp
@@ -64,6 +64,12 @@ Policy Policy::CreatePublicStateRunningExpression() {
return p;
}
+Policy Policy::CreateScriptedExtensionCall() {
+ Policy p = PolicyStack::Get().Current();
+ p.capabilities.can_bypass_target_api_mutex = true;
+ return p;
+}
+
PolicyStack::Guard::~Guard() {
if (!m_active)
return;
@@ -108,6 +114,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 << " bypass_api_mutex=" << capabilities.can_bypass_target_api_mutex;
s << '}';
}
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/Makefile b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/Makefile
new file mode 100644
index 0000000000000..c9319d6e6888a
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/Makefile
@@ -0,0 +1,2 @@
+C_SOURCES := main.c
+include Makefile.rules
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/TestFrameProviderRegisterCommandAPIMutexDeadlock.py b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/TestFrameProviderRegisterCommandAPIMutexDeadlock.py
new file mode 100644
index 0000000000000..09345b11cd84c
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/TestFrameProviderRegisterCommandAPIMutexDeadlock.py
@@ -0,0 +1,89 @@
+"""
+Test that a scripted frame provider whose get_frame_at_index touches SB
+API (self.input_frames) does not deadlock when running `bt` from the
+command interpreter.
+
+GetStoppedExecutionContext (used by SBFrame::IsValid, among others)
+unconditionally blocked acquiring the target's API mutex. The command
+thread running `bt` already holds that mutex (CommandObjectParsed's
+eCommandTryTargetAPILock) and can end up waiting on a StackFrameList
+lock held by the debugger's event-handler thread, which is itself
+blocked re-acquiring the API mutex from inside this provider's Python
+code -- an AB-BA deadlock between the command thread and the
+event-handler thread.
+
+The event-handler thread only runs when commands are driven through
+SBDebugger.RunCommandInterpreter (what the lldb driver itself uses),
+not through plain HandleCommand, so this test drives commands that way.
+
+Note: this is a genuine cross-thread race (the command thread vs. the
+debugger's event-handler thread), not a deterministic sequential
+deadlock, so this test is best-effort -- like the sibling
+runlock_reentrant_deadlock/was_hit_deadlock tests, it raises the odds of
+hitting the race within a single invocation but cannot guarantee it.
+"""
+
+import os
+import lldb
+import lldbsuite.test.lldbutil as lldbutil
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+
+
+class TestFrameProviderRegisterCommandAPIMutexDeadlock(TestBase):
+ NO_DEBUG_INFO_TESTCASE = True
+
+ def test_register_command_then_bt_no_deadlock(self):
+ """
+ Register a scripted frame provider whose get_frame_at_index
+ touches SB API, then repeatedly run `bt` through
+ RunCommandInterpreter. Should complete without 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.DictFrameProvider"
+ )
+ # 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 the API-mutex deadlock regresses, this call 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)
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/frame_provider.py b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/frame_provider.py
new file mode 100644
index 0000000000000..2bfab64b1c4d8
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/frame_provider.py
@@ -0,0 +1,28 @@
+"""
+Frame provider that returns dict-based synthetic frames (never identity
+forwarding), while touching self.input_frames from get_frame_at_index.
+
+Returning a dict keeps this test isolated from the frame-aliasing bug:
+dict-based frames always go through ScriptedFrameProvider's
+create_frame_from_dict helper, which builds a brand new StackFrame and
+never reuses (or wraps via BorrowedStackFrame) the parent list's frame
+object. Only the API-mutex deadlock is reachable through this path.
+"""
+
+from lldb.plugins.scripted_frame_provider import ScriptedFrameProvider
+
+
+class DictFrameProvider(ScriptedFrameProvider):
+ @staticmethod
+ def get_description():
+ return "Provider that returns dict-based synthetic frames"
+
+ def get_frame_at_index(self, index):
+ if index >= len(self.input_frames):
+ return None
+ # __getitem__ calls SBFrame.IsValid() internally, which is what
+ # exercises GetStoppedExecutionContext.
+ frame = self.input_frames[index]
+ if frame is None:
+ return None
+ return {"idx": index, "pc": frame.GetPC()}
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/main.c b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/main.c
new file mode 100644
index 0000000000000..1aa56e3eddf7a
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/main.c
@@ -0,0 +1,7 @@
+int frame3() { return 3; }
+
+int frame2() { return frame3(); }
+
+int frame1() { return frame2(); }
+
+int main() { return frame1(); }
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/Makefile b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/Makefile
new file mode 100644
index 0000000000000..c9319d6e6888a
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/Makefile
@@ -0,0 +1,2 @@
+C_SOURCES := main.c
+include Makefile.rules
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/TestHoldMutexNoDeadlock.py b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/TestHoldMutexNoDeadlock.py
new file mode 100644
index 0000000000000..f50eb9f3ebc88
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/TestHoldMutexNoDeadlock.py
@@ -0,0 +1,88 @@
+"""
+Test that a scripted frame provider can safely call a blocking
+SBMutex.lock() from inside get_frame_at_index without deadlocking.
+
+LLDB's private state thread can reach this callback without already
+holding the target's real API mutex, so a blocking lock() there is a
+genuinely new acquisition attempt, not a safe same-thread recursive
+re-lock. Target::APIMutexHandle re-resolves Target::GetAPIMutex() on
+every call rather than aliasing whatever mutex was current when the
+SBMutex was constructed, so this thread gets the same thread-local
+bypass mutex the internal machinery does, and lock() never contends
+with anyone.
+
+Like the sibling runlock_reentrant_deadlock/was_hit_deadlock/
+register_command_api_mutex_deadlock tests, this drives a genuine
+cross-thread race and is best-effort: it raises the odds of exercising
+the path within a single invocation but the important guarantee is that
+it cannot hang, not that it hits any particular thread ordering.
+"""
+
+import os
+import lldb
+import lldbsuite.test.lldbutil as lldbutil
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+
+
+class TestHoldMutexNoDeadlock(TestBase):
+ NO_DEBUG_INFO_TESTCASE = True
+
+ def test_hold_mutex_no_deadlock(self):
+ """
+ Register a scripted frame provider that locks and holds
+ target.GetAPIMutex() from get_frame_at_index, then run `bt` and
+ `continue` through RunCommandInterpreter. Should complete
+ without deadlocking.
+ """
+ self.build()
+
+ lldbutil.run_to_name_breakpoint(self, "frame3")
+
+ provider_path = os.path.join(
+ self.getSourceDir(), "hold_mutex_frame_provider.py"
+ )
+ commands = ["command script import " + provider_path]
+ commands.append(
+ "target frame-provider register "
+ "-C hold_mutex_frame_provider.HoldMutexFrameProvider"
+ )
+ # Interleave `bt` with `continue` (hitting the same breakpoint
+ # again, via a loop in main.c) so get_frame_at_index runs
+ # repeatedly instead of once, raising the odds of hitting the
+ # race within a single test invocation.
+ commands.extend(["bt", "continue"] * 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 the bypass regresses, this call 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)
+
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/TestSBMutexReflectsTargetMutex.py b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/TestSBMutexReflectsTargetMutex.py
new file mode 100644
index 0000000000000..b596157c4cfd3
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/TestSBMutexReflectsTargetMutex.py
@@ -0,0 +1,125 @@
+"""
+Test that a scripted frame provider calling SBTarget.GetAPIMutex() from
+get_frame_at_index gets a handle that reflects the state of the target's
+real, shared API mutex, even though the callback's own thread is exempt
+from having to serialize on it.
+
+SBMutex is meant to be usable across threads and outlive the call that
+created it (e.g. lldb-dap hands it to background workers), so it must
+always alias the genuine target mutex rather than the thread-local mutex
+the bypass hands out for internal callers. This test drives the same
+kind of command-thread / internal-thread race as
+TestFrameProviderRegisterCommandAPIMutexDeadlock, interleaving `bt` with
+`continue` (hitting the same breakpoint again each time, via a loop in
+main.c) so get_frame_at_index runs many times instead of once, and has
+the provider call target.GetAPIMutex().try_lock() from inside the
+callback: if some other thread happens to hold the real mutex at that
+moment, this should observe it as contended.
+
+Only try_lock() is used, which never blocks, so this cannot deadlock
+regardless of the outcome. An earlier version of this test tried to
+widen the race window by having the callback actually lock() and hold
+the mutex for a short duration, on the assumption that whichever thread
+reaches this callback already holds the real mutex first. That
+assumption is wrong -- LLDB's private state thread can reach this
+callback without already holding it -- so that held mutex.lock() call
+could genuinely block, and reproducibly deadlocked in practice. Do not
+reintroduce a blocking acquisition here.
+
+Observing contention is a genuine cross-thread race, so -- like the
+sibling runlock_reentrant_deadlock/was_hit_deadlock/
+register_command_api_mutex_deadlock tests -- this is best-effort: it
+raises the odds of witnessing it within a single invocation but cannot
+guarantee it, and the test does not require it to pass.
+"""
+
+import os
+import lldb
+import lldbsuite.test.lldbutil as lldbutil
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+
+
+class TestSBMutexReflectsTargetMutex(TestBase):
+ NO_DEBUG_INFO_TESTCASE = True
+
+ def test_sbmutex_reflects_target_mutex(self):
+ """
+ Register a scripted frame provider that checks
+ target.GetAPIMutex().try_lock() from get_frame_at_index, then
+ repeatedly run `bt` and `continue` through RunCommandInterpreter.
+ Should complete without deadlocking, regardless of whether
+ contention is observed.
+ """
+ self.build()
+
+ lldbutil.run_to_name_breakpoint(self, "frame3")
+
+ provider_path = os.path.join(self.getSourceDir(), "sbmutex_frame_provider.py")
+ artifact_path = self.getBuildArtifact("contention.txt")
+ if os.path.exists(artifact_path):
+ os.remove(artifact_path)
+
+ commands = ["command script import " + provider_path]
+ commands.append(
+ "target frame-provider register "
+ "-C sbmutex_frame_provider.ContentionCheckFrameProvider "
+ "-k artifact_path -v " + artifact_path
+ )
+ # `bt` only re-invokes get_frame_at_index when the thread's stack
+ # frame list was invalidated by a new stop, so interleave `bt` with
+ # `continue` (hitting the same breakpoint again, in a loop in
+ # main.c) to get repeated fresh invocations, raising the odds of
+ # hitting the race within a single test invocation.
+ commands.extend(["bt", "continue"] * 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)
+
+ # try_lock() never blocks, so this can only hang if something
+ # else regresses (e.g. a leaked recursive lock count).
+ 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.assertTrue(
+ os.path.exists(artifact_path),
+ "get_frame_at_index should have run and recorded at least one outcome",
+ )
+ with open(artifact_path, "r") as f:
+ outcomes = [line.strip() for line in f if line.strip()]
+
+ self.assertTrue(outcomes, "expected at least one recorded outcome")
+ self.assertTrue(
+ all(o in ("CONTENDED", "UNCONTENDED") for o in outcomes),
+ f"unexpected outcome values: {outcomes}",
+ )
+ # A "CONTENDED" outcome means some other thread held the real
+ # mutex at that moment, which proves SBMutex aliases the genuine,
+ # shared target mutex rather than the bypass mutex. Whether that
+ # race is hit is not guaranteed within a single invocation, so it
+ # isn't asserted on here.
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/hold_mutex_frame_provider.py b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/hold_mutex_frame_provider.py
new file mode 100644
index 0000000000000..a6c36ce161ae4
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/hold_mutex_frame_provider.py
@@ -0,0 +1,46 @@
+"""
+Frame provider whose get_frame_at_index locks the target's real API
+mutex via SBMutex and holds it briefly, from inside the bypassed
+scripted-extension callback.
+
+This is the exact pattern that used to deadlock: LLDB's private state
+thread can reach this callback without already holding the real API
+mutex, so a blocking lock() here is a genuinely new acquisition
+attempt, not a safe same-thread recursive re-lock. Before
+Target::APIMutexHandle re-resolved Target::GetAPIMutex() on every call
+(rather than aliasing a fixed mutex captured at construction time), this
+held mutex.lock() call would block waiting for another thread (e.g. the
+command thread) that was itself waiting on this thread to finish
+processing the stop -- an AB-BA deadlock. Now, because this thread is
+running inside a scripted-extension callback, GetAPIMutex() hands it
+the thread-local bypass mutex instead, so lock() never contends with
+anyone.
+"""
+
+import time
+
+from lldb.plugins.scripted_frame_provider import ScriptedFrameProvider
+
+# How long to hold the real API mutex on each get_frame_at_index(0) call.
+HOLD_DURATION_SECONDS = 0.2
+
+
+class HoldMutexFrameProvider(ScriptedFrameProvider):
+ @staticmethod
+ def get_description():
+ return "Provider that holds the real API mutex via SBMutex from get_frame_at_index"
+
+ def get_frame_at_index(self, index):
+ if index >= len(self.input_frames):
+ return None
+
+ if index == 0:
+ mutex = self.target.GetAPIMutex()
+ mutex.lock()
+ time.sleep(HOLD_DURATION_SECONDS)
+ mutex.unlock()
+
+ frame = self.input_frames[index]
+ if frame is None:
+ return None
+ return {"idx": index, "pc": frame.GetPC()}
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/main.c b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/main.c
new file mode 100644
index 0000000000000..ed95560986ac0
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/main.c
@@ -0,0 +1,12 @@
+int frame3() { return 3; }
+
+int frame2() { return frame3(); }
+
+int frame1() { return frame2(); }
+
+int main() {
+ int result = 0;
+ for (int i = 0; i < 25; ++i)
+ result += frame1();
+ return result;
+}
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/sbmutex_frame_provider.py b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/sbmutex_frame_provider.py
new file mode 100644
index 0000000000000..26a6b7ecfe4ef
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/sbmutex_reflects_target_mutex/sbmutex_frame_provider.py
@@ -0,0 +1,56 @@
+"""
+Frame provider whose get_frame_at_index checks, from inside the bypassed
+scripted-extension callback, whether the target's real API mutex is
+currently held by a different thread.
+
+Used by TestSBMutexReflectsTargetMutex.py to confirm that SBMutex
+(SBTarget::GetAPIMutex()) aliases the real, shared target mutex rather
+than the thread-local mutex the bypass hands out for internal callers.
+
+Only try_lock() is used, and it is never held beyond the immediate
+check: an earlier version of this provider held the mutex for a short
+duration to try to widen the race window, but that meant a genuinely
+blocking acquisition from whichever thread invoked this callback -- not
+every internal caller (e.g. the private state thread) already holds the
+real mutex by the time it gets here, so that held the mutex, which
+caused a real deadlock in practice. try_lock() never blocks, so this
+cannot deadlock regardless of the outcome.
+"""
+
+from lldb.plugins.scripted_frame_provider import ScriptedFrameProvider
+
+
+class ContentionCheckFrameProvider(ScriptedFrameProvider):
+ @staticmethod
+ def get_description():
+ return "Provider that checks SBMutex contention from get_frame_at_index"
+
+ def __init__(self, input_frames, args):
+ super().__init__(input_frames, args)
+ self.artifact_path = None
+ if self.args is not None:
+ value = self.args.GetValueForKey("artifact_path")
+ if value.IsValid():
+ self.artifact_path = value.GetStringValue(4096)
+
+ def get_frame_at_index(self, index):
+ if index >= len(self.input_frames):
+ return None
+
+ if index == 0 and self.artifact_path:
+ mutex = self.target.GetAPIMutex()
+ if mutex.try_lock():
+ # Uncontended: this thread already owned the real mutex
+ # (recursion) or nobody else holds it right now. Undo the
+ # extra recursive lock we just took.
+ mutex.unlock()
+ outcome = "UNCONTENDED"
+ else:
+ outcome = "CONTENDED"
+ with open(self.artifact_path, "a") as f:
+ f.write(outcome + "\n")
+
+ frame = self.input_frames[index]
+ if frame is None:
+ return None
+ return {"idx": index, "pc": frame.GetPC()}
diff --git a/lldb/unittests/Utility/PolicyTest.cpp b/lldb/unittests/Utility/PolicyTest.cpp
index 5ad045a03d30b..29d8bb884285b 100644
--- a/lldb/unittests/Utility/PolicyTest.cpp
+++ b/lldb/unittests/Utility/PolicyTest.cpp
@@ -145,7 +145,8 @@ TEST(PolicyTest, DumpPublicState) {
EXPECT_EQ(s.GetString(),
"policy: view=public, capabilities={"
"eval_expr=true run_all=true try_all=true "
- "bp_actions=true frame_providers=true frame_recognizers=true}");
+ "bp_actions=true frame_providers=true frame_recognizers=true "
+ "bypass_api_mutex=false}");
}
TEST(PolicyTest, DumpPrivateState) {
@@ -154,7 +155,8 @@ TEST(PolicyTest, DumpPrivateState) {
EXPECT_EQ(s.GetString(),
"policy: view=private, capabilities={"
"eval_expr=true run_all=true try_all=true "
- "bp_actions=true frame_providers=true frame_recognizers=true}");
+ "bp_actions=true frame_providers=true frame_recognizers=true "
+ "bypass_api_mutex=false}");
}
TEST(PolicyTest, DumpStack) {
More information about the lldb-commits
mailing list