https://github.com/medismailben created https://github.com/llvm/llvm-project/pull/208242
`GetStoppedExecutionContext` unconditionally blocked acquiring the target's API mutex. A thread already holding that mutex (e.g. a `bt` command thread, via `CommandObjectParsed's eCommandTryTargetAPILock`) can end up waiting on a StackFrameList lock held by another thread (e.g. 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 SB API -- a 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 -- these callbacks are already running under whatever protections the caller set up, and blocking again is both redundant and, in this case, deadlock-prone. This also required making `PolicyStack::Get()` out-of-line: `Dispatch`/`CallStaticMethod` live in a header included by both liblldb and the Python script interpreter plugin, and LLDB builds with hidden visibility by default. An inline function's function-local static is NOT shared across shared library boundaries, so each dylib that included Policy.h 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 two. `Push`/`Pop` calls from the plugin would then operate on a different object than Guard's destructor's Pop (already out-of-line, compiled into liblldb), draining liblldb's copy out from under callers that never pushed to it and tripping the "can't pop the base policy" assert. Making `Get()` out-of-line ensures every dylib resolves to the single instance defined in Policy.cpp. Adds a regression test for the deadlock. It's a genuine cross-thread race (the command thread vs. the debugger's event-handler thread), so like the sibling `runlock_reentrant_deadlock`/`was_hit_deadlock` tests, it raises the odds of hitting it within a single invocation but can't guarantee it. Fixes https://github.com/llvm/llvm-project/issues/194108 >From 3e336b4958d69d804ddc452dbffd7b1ccf661e85 Mon Sep 17 00:00:00 2001 From: Med Ismail Bennani <[email protected]> Date: Wed, 8 Jul 2026 07:19:07 -0700 Subject: [PATCH] [lldb] Fix deadlock between the command thread and event-handler thread in scripted frame providers GetStoppedExecutionContext unconditionally blocked acquiring the target's API mutex. A thread already holding that mutex (e.g. a `bt` command thread, via CommandObjectParsed's eCommandTryTargetAPILock) can end up waiting on a StackFrameList lock held by another thread (e.g. 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 SB API -- a 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 -- these callbacks are already running under whatever protections the caller set up, and blocking again is both redundant and, in this case, deadlock-prone. This also required making PolicyStack::Get() out-of-line: Dispatch/CallStaticMethod live in a header included by both liblldb and the Python script interpreter plugin, and LLDB builds with hidden visibility by default. An inline function's function-local static is NOT shared across shared library boundaries, so each dylib that included Policy.h 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 two. Push/Pop calls from the plugin would then operate on a different object than Guard's destructor's Pop (already out-of-line, compiled into liblldb), draining liblldb's copy out from under callers that never pushed to it and tripping the "can't pop the base policy" assert. Making Get() out-of-line ensures every dylib resolves to the single instance defined in Policy.cpp. Adds a regression test for the deadlock. It's a genuine cross-thread race (the command thread vs. the debugger's event-handler thread), so like the sibling runlock_reentrant_deadlock/was_hit_deadlock tests, it raises the odds of hitting it within a single invocation but can't guarantee it. The frame provider deliberately returns dict-based synthetic frames rather than forwarding a frame under its own index, to keep this test isolated from the separate frame-aliasing bug fixed on top of this commit. --- lldb/include/lldb/Target/ExecutionContext.h | 12 ++- lldb/include/lldb/Utility/Policy.h | 28 +++++- .../Interfaces/ScriptedPythonInterface.h | 7 ++ lldb/source/Target/ExecutionContext.cpp | 5 +- lldb/source/Utility/Policy.cpp | 12 +++ .../Makefile | 3 + ...ProviderRegisterCommandAPIMutexDeadlock.py | 91 +++++++++++++++++++ .../frame_provider.py | 30 ++++++ .../main.c | 7 ++ 9 files changed, 185 insertions(+), 10 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 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 c841daf47a97d..aeee52379b38f 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; }; /// Why a private-state policy is being pushed. Distinguishes a PST's @@ -75,6 +82,7 @@ struct Policy { static Policy CreatePrivateState( PrivateStatePurpose purpose = PrivateStatePurpose::Default); static Policy CreatePublicStateRunningExpression(); + static Policy CreateScriptedExtensionCall(); /// @} void Dump(Stream &s) const; @@ -95,10 +103,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; @@ -143,6 +158,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/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h b/lldb/source/Plugins/ScriptInterpreter/Python/Interfaces/ScriptedPythonInterface.h index 7d0d4cdd3c6d1..79ca76577ffa9 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" @@ -415,6 +416,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); @@ -510,6 +514,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..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 3c2fa8f7fc867..19aaeb3bcadfa 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)) { @@ -51,6 +56,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; @@ -95,6 +106,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_api_mutex_deadlock/Makefile b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/Makefile new file mode 100644 index 0000000000000..0b710c6e298ae --- /dev/null +++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_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_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..0b066607827f8 --- /dev/null +++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/TestFrameProviderRegisterCommandAPIMutexDeadlock.py @@ -0,0 +1,91 @@ +""" +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..e38d964cb32c2 --- /dev/null +++ b/lldb/test/API/functionalities/scripted_frame_provider/register_command_api_mutex_deadlock/frame_provider.py @@ -0,0 +1,30 @@ +""" +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(); } _______________________________________________ lldb-commits mailing list [email protected] https://lists.llvm.org/cgi-bin/mailman/listinfo/lldb-commits
