https://github.com/medismailben updated 
https://github.com/llvm/llvm-project/pull/208992

>From 75f0569eb0d23eafdff79b28134d04af326eba94 Mon Sep 17 00:00:00 2001
From: Med Ismail Bennani <[email protected]>
Date: Fri, 10 Jul 2026 15:32:20 -0700
Subject: [PATCH 1/2] [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 <[email protected]>
---
 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/tools/lldb-dap/InstructionBreakpoint.cpp |   1 +
 lldb/tools/lldb-dap/ProtocolUtils.cpp         |   1 +
 lldb/unittests/Utility/PolicyTest.cpp         |   6 +-
 23 files changed, 556 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/tools/lldb-dap/InstructionBreakpoint.cpp 
b/lldb/tools/lldb-dap/InstructionBreakpoint.cpp
index 89a0983b07809..738eee047c2c2 100644
--- a/lldb/tools/lldb-dap/InstructionBreakpoint.cpp
+++ b/lldb/tools/lldb-dap/InstructionBreakpoint.cpp
@@ -12,6 +12,7 @@
 #include "lldb/API/SBBreakpoint.h"
 #include "lldb/API/SBTarget.h"
 #include "llvm/ADT/StringRef.h"
+#include <mutex>
 
 namespace lldb_dap {
 
diff --git a/lldb/tools/lldb-dap/ProtocolUtils.cpp 
b/lldb/tools/lldb-dap/ProtocolUtils.cpp
index fb27859c5726f..d7d583a318d3a 100644
--- a/lldb/tools/lldb-dap/ProtocolUtils.cpp
+++ b/lldb/tools/lldb-dap/ProtocolUtils.cpp
@@ -21,6 +21,7 @@
 #include "lldb/Host/PosixApi.h" // Adds PATH_MAX for windows
 
 #include <iomanip>
+#include <mutex>
 #include <optional>
 #include <sstream>
 
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) {

>From 89bcaf48931b8c650a1c7e1ed0e0173e79c0317b Mon Sep 17 00:00:00 2001
From: Med Ismail Bennani <[email protected]>
Date: Sat, 11 Jul 2026 17:47:30 -0700
Subject: [PATCH 2/2] [lldb] Fix self-deadlock from frame identity aliasing in
 scripted frame providers

ScriptedFrameProvider::GetFrameAtIndex reused the parent list's live
StackFrame object directly whenever a provider's get_frame_at_index
forwarded a frame under its own index (real_frame_index == idx), instead
of wrapping it in a BorrowedStackFrame like every other forwarding case.

SyntheticStackFrameList::FetchFramesUpTo unconditionally re-tags the
returned frame's m_frame_list_id to this (child) list before caching it.
Reusing the parent list's object means this re-tag corrupts the parent
list's own cached frame to claim it belongs to the child list instead.

Later, resolving that frame's identity back to a frame list (e.g. via
ExecutionContextRef::GetFrameSP, used by SBFrame validity checks) follows
the corrupted tag to the child list. If the resolving thread is the one
already fetching frames on that child list -- holding its writer lock
via GetFramesUpTo -- it self-deadlocks trying to take the (non-reentrant)
reader lock on the same list.

Always wrap in BorrowedStackFrame, even when the index is unchanged, so
the object added to the child list is never the same object cached in
the parent list.

Adds a regression test using an identity-forwarding provider (mirroring
the shape that surfaced this bug) to isolate it from the API-mutex
deadlock fixed in the parent commit. Like that test, this is a
best-effort race reproduction, not a guaranteed one.

Signed-off-by: Med Ismail Bennani <[email protected]>
---
 .../ScriptedFrameProvider.cpp                 | 11 ++-
 .../Makefile                                  |  3 +
 ...oviderRegisterCommandFrameAliasDeadlock.py | 94 +++++++++++++++++++
 .../frame_provider.py                         | 30 ++++++
 .../main.c                                    |  7 ++
 5 files changed, 141 insertions(+), 4 deletions(-)
 create mode 100644 
lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/Makefile
 create mode 100644 
lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/TestFrameProviderRegisterCommandFrameAliasDeadlock.py
 create mode 100644 
lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/frame_provider.py
 create mode 100644 
lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/main.c

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/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/Makefile
 
b/lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/Makefile
new file mode 100644
index 0000000000000..0b710c6e298ae
--- /dev/null
+++ 
b/lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_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_frame_alias_deadlock/TestFrameProviderRegisterCommandFrameAliasDeadlock.py
 
b/lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/TestFrameProviderRegisterCommandFrameAliasDeadlock.py
new file mode 100644
index 0000000000000..dee8bf01c7195
--- /dev/null
+++ 
b/lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/TestFrameProviderRegisterCommandFrameAliasDeadlock.py
@@ -0,0 +1,94 @@
+"""
+Test that a scripted frame provider that forwards frames under their own
+index (identity forwarding) does not deadlock when running `bt` from the
+command interpreter.
+
+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 later resolved, 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.
+
+The event-handler thread that can trigger this only runs when commands
+are driven through SBDebugger.RunCommandInterpreter (what the lldb
+driver itself uses), not 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 TestFrameProviderRegisterCommandFrameAliasDeadlock(TestBase):
+    NO_DEBUG_INFO_TESTCASE = True
+
+    def test_register_command_then_bt_no_deadlock(self):
+        """
+        Register a scripted frame provider that identity-forwards every
+        frame, 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.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 the frame-aliasing self-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)
+        self.assertIn("frame3", output)
+        self.assertIn("frame2", output)
+        self.assertIn("frame1", output)
diff --git 
a/lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/frame_provider.py
 
b/lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/frame_provider.py
new file mode 100644
index 0000000000000..e8ed77dc5a985
--- /dev/null
+++ 
b/lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/frame_provider.py
@@ -0,0 +1,30 @@
+"""
+Frame provider that forwards every frame under its own index while also
+touching input_frames.
+
+Returning `index` (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_frame_alias_deadlock/main.c
 
b/lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/main.c
new file mode 100644
index 0000000000000..1aa56e3eddf7a
--- /dev/null
+++ 
b/lldb/test/API/functionalities/scripted_frame_provider/register_command_frame_alias_deadlock/main.c
@@ -0,0 +1,7 @@
+int frame3() { return 3; }
+
+int frame2() { return frame3(); }
+
+int frame1() { return frame2(); }
+
+int main() { return frame1(); }

_______________________________________________
lldb-commits mailing list
[email protected]
https://lists.llvm.org/cgi-bin/mailman/listinfo/lldb-commits

Reply via email to