[Lldb-commits] [lldb] [lldb] Make ProcessRunLock recursive on the read side per-thread (PR #201685)

Med Ismail Bennani via lldb-commits lldb-commits at lists.llvm.org
Fri Jun 5 18:54:58 PDT 2026


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

>From 5b3a5272b1b3ccea9b1f6f3813895f80d968ae89 Mon Sep 17 00:00:00 2001
From: Med Ismail Bennani <ismail at bennani.ma>
Date: Fri, 5 Jun 2026 18:54:41 -0700
Subject: [PATCH] [lldb] Make ProcessRunLock recursive on the read side
 per-thread

When SB API code enters a Python callback (frame provider, breakpoint
callback, ...) and the callback re-enters the SB API, the inner call
tries to re-acquire the ProcessRunLock read lock. Recursive shared
acquisition deadlocks against a queued writer on a write-preferring
rwlock (POSIX), and is UB on Windows SRW.

Each ProcessRunLock now keeps a per-instance map of lldb::thread_t ->
recursion count under its own std::mutex. ProcessRunLocker::TryLock
checks that map first: if this thread already has a non-zero count it
bumps and returns, skipping the rwlock. Unlock decrements; only the
final Unlock releases the rwlock. Per-instance (not thread_local) so
the bookkeeping is colocated with the lock and concurrent readers
from multiple threads each get the fast path.

Thread identity is tracked via lldb::thread_t / Host::GetCurrentThread()
to match the rest of LLDB (HostThread, PrivateStateThread, etc.). That
type already has a DenseMapInfo specialization so DenseMap works out of
the box.

ProcessRunLocker move policy: unlocked moves are always fine; moving
a held locker is only valid on the owning thread. Cross-thread move
asserts in debug and llvm::report_fatal_error in release. The
destructor enforces the same invariant.

rdar://176223894

Signed-off-by: Med Ismail Bennani <ismail at bennani.ma>
---
 lldb/include/lldb/Host/ProcessRunLock.h       | 110 ++++++++++------
 lldb/source/Host/common/ProcessRunLock.cpp    | 118 ++++++++++++++++-
 .../runlock_reentrant_deadlock/Makefile       |   3 +
 .../TestRunLockReentrantDeadlock.py           | 124 ++++++++++++++++++
 .../bkpt_resolver.py                          |  44 +++++++
 .../frame_provider.py                         |  32 +++++
 .../runlock_reentrant_deadlock/main.c         |  18 +++
 lldb/unittests/Host/CMakeLists.txt            |   1 +
 lldb/unittests/Host/ProcessRunLockTest.cpp    | 113 ++++++++++++++++
 9 files changed, 521 insertions(+), 42 deletions(-)
 create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/Makefile
 create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/TestRunLockReentrantDeadlock.py
 create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/bkpt_resolver.py
 create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/frame_provider.py
 create mode 100644 lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/main.c
 create mode 100644 lldb/unittests/Host/ProcessRunLockTest.cpp

diff --git a/lldb/include/lldb/Host/ProcessRunLock.h b/lldb/include/lldb/Host/ProcessRunLock.h
index 27d10942ddb4c..43308fb9d608c 100644
--- a/lldb/include/lldb/Host/ProcessRunLock.h
+++ b/lldb/include/lldb/Host/ProcessRunLock.h
@@ -9,27 +9,59 @@
 #ifndef LLDB_HOST_PROCESSRUNLOCK_H
 #define LLDB_HOST_PROCESSRUNLOCK_H
 
+#include <cassert>
 #include <cstdint>
 #include <ctime>
+#include <mutex>
+
+#include "llvm/ADT/DenseMap.h"
 
 #include "lldb/lldb-defines.h"
+#include "lldb/lldb-types.h"
 
 /// Enumerations for broadcasting.
 namespace lldb_private {
 
 /// \class ProcessRunLock ProcessRunLock.h "lldb/Host/ProcessRunLock.h"
-/// A class used to prevent the process from starting while other
-/// threads are accessing its data, and prevent access to its data while it is
+/// A class used to prevent the process from starting while other threads
+/// are accessing its data, and prevent access to its data while it is
 /// running.
+///
+/// The lock is read/write internally, but readers and writers reach it
+/// through different APIs:
+///   - Readers (any thread that wants the process to stay stopped) use the
+///     RAII ProcessRunLocker. The underlying rwlock primitives are private
+///     because they are not safe to call directly: bypassing the locker
+///     skips the per-instance recursion bookkeeping and can deadlock on
+///     re-entrant acquisition.
+///   - Writers (the private state thread, when transitioning the process
+///     between running and stopped) call SetRunning / SetStopped.
+/// There is intentionally no public TryWriteLock / WriteUnlock entry point;
+/// `ProcessRunLocker` is therefore a read-lock-only helper.
+///
+/// Re-entrant read acquisition on the same thread (e.g. SB API re-entry
+/// from a frame-provider Python callback) is supported via ProcessRunLocker.
+/// Each lock keeps a per-instance map of `lldb::thread_t -> recursion count`
+/// so a nested TryLock on the same lock skips the underlying rwlock and
+/// just bumps the count. The outer reader keeps any writer blocked, so
+/// the process state cannot change while the count is non-zero. The map
+/// is per-instance (not thread_local) so it correctly handles concurrent
+/// readers from multiple threads: each thread has its own entry in the
+/// same lock's map, and a re-entrant acquire from any of them takes the
+/// fast path.
+///
+/// Cross-thread move of a held ProcessRunLocker is forbidden: the
+/// recursion bookkeeping is keyed by the acquiring thread, and POSIX
+/// requires the same thread to call pthread_rwlock_unlock as called
+/// pthread_rwlock_rdlock. Move operations on an unlocked locker are
+/// supported. Move operations on a held locker are valid only on the
+/// owning thread (asserted in debug builds, fatal in release builds).
 
 class ProcessRunLock {
 public:
   ProcessRunLock();
   ~ProcessRunLock();
 
-  bool ReadTryLock();
-  bool ReadUnlock();
-
   /// Set the process to running. Returns true if the process was stopped.
   /// Return false if the process was running.
   bool SetRunning();
@@ -38,51 +70,37 @@ class ProcessRunLock {
   /// Returns false if the process was stopped.
   bool SetStopped();
 
+  /// RAII helper around the read-lock side of ProcessRunLock. Supports
+  /// same-thread recursion (see class doc).
+  ///
+  /// Move policy:
+  ///   - Moving an unlocked locker is always allowed (and free).
+  ///   - Moving a held locker is allowed *only* on the owning thread.
+  ///     Cross-thread move of a held locker is a programming error and
+  ///     triggers an assertion in debug builds / a fatal error in release
+  ///     builds.
   class ProcessRunLocker {
   public:
     ProcessRunLocker() = default;
-    ProcessRunLocker(ProcessRunLocker &&other) : m_lock(other.m_lock) {
-      other.m_lock = nullptr;
-    }
-    ProcessRunLocker &operator=(ProcessRunLocker &&other) {
-      if (this != &other) {
-        Unlock();
-        m_lock = other.m_lock;
-        other.m_lock = nullptr;
-      }
-      return *this;
-    }
-
+    ProcessRunLocker(ProcessRunLocker &&other);
+    ProcessRunLocker &operator=(ProcessRunLocker &&other);
     ~ProcessRunLocker() { Unlock(); }
 
     bool IsLocked() const { return m_lock; }
 
-    // Try to lock the read lock, but only do so if there are no writers.
-    bool TryLock(ProcessRunLock *lock) {
-      if (m_lock) {
-        if (m_lock == lock)
-          return true; // We already have this lock locked
-        else
-          Unlock();
-      }
-      if (lock) {
-        if (lock->ReadTryLock()) {
-          m_lock = lock;
-          return true;
-        }
-      }
-      return false;
-    }
+    /// Try to acquire the read lock. If this thread already holds the
+    /// read lock on this ProcessRunLock, the underlying rwlock is bypassed
+    /// and the per-instance recursion count for this thread is bumped
+    /// instead.
+    bool TryLock(ProcessRunLock *lock);
 
   protected:
-    void Unlock() {
-      if (m_lock) {
-        m_lock->ReadUnlock();
-        m_lock = nullptr;
-      }
-    }
+    void Unlock();
 
     ProcessRunLock *m_lock = nullptr;
+    /// Set in TryLock when m_lock becomes non-null. Used to detect
+    /// cross-thread destruction or move of a locker that holds the lock.
+    lldb::thread_t m_thread = LLDB_INVALID_HOST_THREAD;
 
   private:
     ProcessRunLocker(const ProcessRunLocker &) = delete;
@@ -96,6 +114,20 @@ class ProcessRunLock {
 private:
   ProcessRunLock(const ProcessRunLock &) = delete;
   const ProcessRunLock &operator=(const ProcessRunLock &) = delete;
+
+  /// Low-level rwlock primitives. Private because calling them outside the
+  /// ProcessRunLocker bookkeeping bypasses the per-instance recursion
+  /// count and can deadlock on re-entrant acquisition.
+  bool ReadTryLock();
+  bool ReadUnlock();
+  friend class ProcessRunLocker;
+
+  /// Per-instance recursion bookkeeping. Maps lldb::thread_t -> active
+  /// TryLock count for that thread on this lock. A thread with a non-zero
+  /// count is already holding the rwlock as a reader and may TryLock
+  /// re-entrantly without going through the rwlock.
+  std::mutex m_recursion_mu;
+  llvm::DenseMap<lldb::thread_t, uint32_t> m_recursion;
 };
 
 } // namespace lldb_private
diff --git a/lldb/source/Host/common/ProcessRunLock.cpp b/lldb/source/Host/common/ProcessRunLock.cpp
index 8e7ef45e1e350..e1ec876a09e79 100644
--- a/lldb/source/Host/common/ProcessRunLock.cpp
+++ b/lldb/source/Host/common/ProcessRunLock.cpp
@@ -6,11 +6,19 @@
 //
 //===----------------------------------------------------------------------===//
 
-#ifndef _WIN32
 #include "lldb/Host/ProcessRunLock.h"
 
+#include "lldb/Host/Host.h"
+
+#include "llvm/Support/ErrorHandling.h"
+
+#include <cassert>
+#include <cstdint>
+
 namespace lldb_private {
 
+#ifndef _WIN32
+
 ProcessRunLock::ProcessRunLock() {
   int err = ::pthread_rwlock_init(&m_rwlock, nullptr);
   (void)err;
@@ -51,6 +59,110 @@ bool ProcessRunLock::SetStopped() {
   return was_running;
 }
 
-} // namespace lldb_private
+#endif // !_WIN32
+
+ProcessRunLock::ProcessRunLocker::ProcessRunLocker(ProcessRunLocker &&other)
+    : m_lock(other.m_lock), m_thread(other.m_thread) {
+  if (m_lock && m_thread != Host::GetCurrentThread()) {
+    assert(false && "ProcessRunLocker moved across threads while held");
+    llvm::report_fatal_error(
+        "ProcessRunLocker moved across threads while held");
+  }
+  other.m_lock = nullptr;
+}
+
+ProcessRunLock::ProcessRunLocker &
+ProcessRunLock::ProcessRunLocker::operator=(ProcessRunLocker &&other) {
+  if (this != &other) {
+    if (other.m_lock && other.m_thread != Host::GetCurrentThread()) {
+      assert(false &&
+             "ProcessRunLocker move-assigned across threads while held");
+      llvm::report_fatal_error(
+          "ProcessRunLocker move-assigned across threads while held");
+    }
+    Unlock();
+    m_lock = other.m_lock;
+    m_thread = other.m_thread;
+    other.m_lock = nullptr;
+  }
+  return *this;
+}
+
+bool ProcessRunLock::ProcessRunLocker::TryLock(ProcessRunLock *lock) {
+  if (m_lock) {
+    if (m_lock == lock)
+      return true;
+    Unlock();
+  }
+  if (!lock)
+    return false;
 
-#endif
+  lldb::thread_t self = Host::GetCurrentThread();
+
+  // Fast path: this thread already holds the rwlock as a reader. Bump
+  // the per-instance recursion count for this thread; the outer reader
+  // keeps any writer blocked, so the process state can't change.
+  {
+    std::lock_guard<std::mutex> guard(lock->m_recursion_mu);
+    auto it = lock->m_recursion.find(self);
+    if (it != lock->m_recursion.end()) {
+      ++it->second;
+      m_lock = lock;
+      m_thread = self;
+      return true;
+    }
+  }
+
+  // Real acquire. Done outside m_recursion_mu so a blocking
+  // pthread_rwlock_rdlock doesn't serialize unrelated acquires through
+  // the recursion mutex.
+  if (!lock->ReadTryLock())
+    return false;
+
+  std::lock_guard<std::mutex> guard(lock->m_recursion_mu);
+  lock->m_recursion[self] = 1;
+  m_lock = lock;
+  m_thread = self;
+  return true;
+}
+
+void ProcessRunLock::ProcessRunLocker::Unlock() {
+  if (!m_lock)
+    return;
+
+  lldb::thread_t self = Host::GetCurrentThread();
+  if (m_thread != self) {
+    // Cross-thread destruction defense. pthread_rwlock_unlock from a
+    // different thread than the one that called pthread_rwlock_rdlock
+    // is UB. Move ctor / operator= already trap this case at the
+    // source; this is the last-line check on the destructor path.
+    assert(false &&
+           "ProcessRunLocker destroyed on a different thread while held");
+    llvm::report_fatal_error(
+        "ProcessRunLocker destroyed on a different thread while held");
+  }
+
+  bool release_rwlock = false;
+  {
+    std::lock_guard<std::mutex> guard(m_lock->m_recursion_mu);
+    auto it = m_lock->m_recursion.find(self);
+    assert(it != m_lock->m_recursion.end() &&
+           "ProcessRunLocker released without a matching successful TryLock "
+           "on this thread");
+    if (it == m_lock->m_recursion.end()) {
+      m_lock = nullptr;
+      return;
+    }
+    assert(it->second > 0 && "ProcessRunLocker recursion underflow");
+    if (--it->second == 0) {
+      m_lock->m_recursion.erase(it);
+      release_rwlock = true;
+    }
+  }
+
+  if (release_rwlock)
+    m_lock->ReadUnlock();
+  m_lock = nullptr;
+}
+
+} // namespace lldb_private
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/Makefile b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/Makefile
new file mode 100644
index 0000000000000..0b710c6e298ae
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_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/runlock_reentrant_deadlock/TestRunLockReentrantDeadlock.py b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/TestRunLockReentrantDeadlock.py
new file mode 100644
index 0000000000000..12d6c473954f0
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/TestRunLockReentrantDeadlock.py
@@ -0,0 +1,124 @@
+"""
+Test that ProcessRunLock re-entrant read locks don't deadlock when a frame
+provider's get_frame_at_index calls SB API methods.
+
+This reproduces the deadlock seen in lldb-rpc-server where:
+
+  - An RPC client thread holds a ProcessRunLock read lock (from the outer
+    SB API call) and enters a provider's get_frame_at_index, which calls
+    SBFrame.IsValid -> GetStoppedExecutionContext -> ReadTryLock (re-entrant).
+
+  - The override PST is exiting RunPrivateStateThread and calls SetStopped
+    -> pthread_rwlock_wrlock (blocked by client thread's read lock).
+
+  - The client thread's re-entrant ReadTryLock blocks because the pending
+    writer prevents new readers on a write-preferring rwlock.
+
+  - The original PST is blocked joining the override thread.
+"""
+
+import os
+import threading
+import lldb
+import lldbsuite.test.lldbutil as lldbutil
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+
+
+class TestRunLockReentrantDeadlock(TestBase):
+    NO_DEBUG_INFO_TESTCASE = True
+
+    def test_runlock_reentrant_no_deadlock(self):
+        """
+        Test that a frame provider calling SBFrame.IsValid from
+        get_frame_at_index does not deadlock with the override PST's
+        SetStopped when another thread triggers EvaluateExpression.
+        """
+        self.build()
+
+        target, process, thread, _ = lldbutil.run_to_name_breakpoint(self, "main")
+
+        resolver_path = os.path.join(self.getSourceDir(), "bkpt_resolver.py")
+        provider_path = os.path.join(self.getSourceDir(), "frame_provider.py")
+        self.runCmd("command script import " + resolver_path)
+        self.runCmd("command script import " + provider_path)
+
+        error = lldb.SBError()
+        provider_id = target.RegisterScriptedFrameProvider(
+            "frame_provider.SBAPIAccessInGetFrameProvider",
+            lldb.SBStructuredData(),
+            error,
+        )
+        self.assertTrue(
+            error.Success(),
+            f"Should register frame provider: {error}",
+        )
+        self.assertNotEqual(provider_id, 0, "Provider ID should be non-zero")
+
+        extra_args = lldb.SBStructuredData()
+        stream = lldb.SBStream()
+        stream.Print('{"symbol": "target_func"}')
+        extra_args.SetFromJSON(stream)
+
+        bkpt = target.BreakpointCreateFromScript(
+            "bkpt_resolver.ExprEvalResolver",
+            extra_args,
+            lldb.SBFileSpecList(),
+            lldb.SBFileSpecList(),
+        )
+        self.assertTrue(bkpt.IsValid(), "Scripted breakpoint should be valid")
+        self.assertGreater(
+            bkpt.GetNumLocations(), 0, "Breakpoint should have locations"
+        )
+
+        # Spawn a thread that repeatedly accesses frames through the provider.
+        # This simulates an RPC client thread that holds the ProcessRunLock
+        # read lock and enters get_frame_at_index -> SBFrame.IsValid.
+        stop_frame_access = threading.Event()
+        frame_access_error = [None]
+
+        def access_frames():
+            try:
+                while not stop_frame_access.is_set():
+                    t = process.GetSelectedThread()
+                    if t.IsValid():
+                        for i in range(t.GetNumFrames()):
+                            f = t.GetFrameAtIndex(i)
+                            f.IsValid()
+            except Exception as e:
+                frame_access_error[0] = str(e)
+
+        frame_thread = threading.Thread(target=access_frames, daemon=True)
+        frame_thread.start()
+
+        # Continue into the breakpoint. was_hit calls EvaluateExpression on
+        # the PST, which triggers RunThreadPlan -> override PST.
+        # The frame access thread is concurrently calling GetFrameAtIndex ->
+        # provider get_frame_at_index -> SBFrame.IsValid.
+        # Without the fix, this deadlocks.
+        process.Continue()
+        self.assertState(process.GetState(), lldb.eStateStopped)
+
+        stop_frame_access.set()
+        frame_thread.join(timeout=5)
+        self.assertFalse(frame_thread.is_alive(), "Frame access thread should exit")
+        self.assertIsNone(
+            frame_access_error[0],
+            f"Frame access thread hit an error: {frame_access_error[0]}",
+        )
+
+        thread = process.GetSelectedThread()
+        self.assertTrue(thread.IsValid(), "Thread should be valid")
+        self.assertEqual(
+            thread.GetStopReason(),
+            lldb.eStopReasonBreakpoint,
+            "Should stop at breakpoint",
+        )
+
+        g_value = target.FindFirstGlobalVariable("g_value")
+        self.assertTrue(g_value.IsValid(), "Should find g_value")
+        self.assertGreater(
+            g_value.GetValueAsUnsigned(),
+            0,
+            "g_value should have been incremented by the was_hit callback",
+        )
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/bkpt_resolver.py b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/bkpt_resolver.py
new file mode 100644
index 0000000000000..06641295c534f
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/bkpt_resolver.py
@@ -0,0 +1,44 @@
+"""
+Scripted breakpoint resolver whose was_hit callback calls EvaluateExpression.
+
+Used to trigger the deadlock scenario described in
+TestRunLockReentrantDeadlock.py: was_hit -> EvaluateExpression spins up the
+override PST and starts an expression, setting up the conditions under
+which a re-entrant ReadTryLock from a frame provider would deadlock against
+the pending writer.
+"""
+
+import lldb
+
+
+class ExprEvalResolver:
+    """Scripted breakpoint resolver that evaluates an expression in was_hit."""
+
+    def __init__(self, bkpt, extra_args, dict):
+        self.bkpt = bkpt
+        sym_name = extra_args.GetValueForKey("symbol").GetStringValue(100)
+        self.sym_name = sym_name
+        self.facade_loc = None
+
+    def __callback__(self, sym_ctx):
+        sym = sym_ctx.module.FindSymbol(self.sym_name, lldb.eSymbolTypeCode)
+        if sym.IsValid():
+            self.bkpt.AddLocation(sym.GetStartAddress())
+            self.facade_loc = self.bkpt.AddFacadeLocation()
+
+    def get_short_help(self):
+        return f"ExprEvalResolver for {self.sym_name}"
+
+    def was_hit(self, frame, bp_loc):
+        # This runs on the private state thread. Calling EvaluateExpression
+        # here triggers RunThreadPlan -> Halt -> WaitForProcessToStop, which
+        # holds a mutex and waits for a state change event.
+        options = lldb.SBExpressionOptions()
+        options.SetStopOthers(True)
+        options.SetTryAllThreads(False)
+
+        result = frame.EvaluateExpression("increment()", options)
+        if not result.error.success:
+            return lldb.LLDB_INVALID_BREAK_ID
+
+        return self.facade_loc
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/frame_provider.py b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/frame_provider.py
new file mode 100644
index 0000000000000..8392c90c7b317
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/frame_provider.py
@@ -0,0 +1,32 @@
+"""
+Frame provider whose get_frame_at_index calls SBFrame::IsValid on input frames.
+
+Used to trigger the deadlock scenario described in
+TestRunLockReentrantDeadlock.py: when a client thread accesses frames
+through this provider, get_frame_at_index re-enters the SB API and tries
+to re-acquire the ProcessRunLock read lock, which (without the recursion
+fix in ProcessRunLocker) deadlocks against the override PST's pending
+writer.
+"""
+
+import lldb
+from lldb.plugins.scripted_frame_provider import ScriptedFrameProvider
+
+
+class SBAPIAccessInGetFrameProvider(ScriptedFrameProvider):
+    """Provider that calls SBFrame.IsValid from get_frame_at_index."""
+
+    @staticmethod
+    def get_description():
+        return "Provider that accesses SB API in get_frame_at_index"
+
+    def get_frame_at_index(self, idx):
+        if idx < len(self.input_frames):
+            frame = self.input_frames.GetFrameAtIndex(idx)
+            # This call triggers GetStoppedExecutionContext ->
+            # ProcessRunLock::ReadTryLock. If the current thread already
+            # holds the read lock (from the outer SB API entry point),
+            # and a writer is pending, this re-entrant read lock blocks.
+            frame.IsValid()
+            return idx
+        return None
diff --git a/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/main.c b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/main.c
new file mode 100644
index 0000000000000..9ebeaf3380ed9
--- /dev/null
+++ b/lldb/test/API/functionalities/scripted_frame_provider/runlock_reentrant_deadlock/main.c
@@ -0,0 +1,18 @@
+#include <stdio.h>
+
+int g_value = 0;
+
+int increment() { return ++g_value; }
+
+int target_func() {
+  printf("target_func: %d\n", g_value);
+  return g_value;
+}
+
+int main() {
+  for (int i = 0; i < 10; i++) {
+    target_func();
+  }
+  increment();
+  return 0;
+}
diff --git a/lldb/unittests/Host/CMakeLists.txt b/lldb/unittests/Host/CMakeLists.txt
index 73dfe13ee54ad..87a5ffcb6dbe1 100644
--- a/lldb/unittests/Host/CMakeLists.txt
+++ b/lldb/unittests/Host/CMakeLists.txt
@@ -10,6 +10,7 @@ set (FILES
   NativeProcessProtocolTest.cpp
   PipeTest.cpp
   ProcessLaunchInfoTest.cpp
+  ProcessRunLockTest.cpp
   SocketAddressTest.cpp
   SocketTest.cpp
   StripConPTYSequencesTest.cpp
diff --git a/lldb/unittests/Host/ProcessRunLockTest.cpp b/lldb/unittests/Host/ProcessRunLockTest.cpp
new file mode 100644
index 0000000000000..ace28a01d1129
--- /dev/null
+++ b/lldb/unittests/Host/ProcessRunLockTest.cpp
@@ -0,0 +1,113 @@
+//===-- ProcessRunLockTest.cpp --------------------------------------------===//
+//
+// Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions.
+// See https://llvm.org/LICENSE.txt for license information.
+// SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception
+//
+//===----------------------------------------------------------------------===//
+
+#include "lldb/Host/ProcessRunLock.h"
+
+#include "gtest/gtest.h"
+
+#include <thread>
+#include <utility>
+
+using namespace lldb_private;
+
+TEST(ProcessRunLockTest, BasicLockUnlock) {
+  ProcessRunLock lock;
+  ProcessRunLock::ProcessRunLocker locker;
+  EXPECT_FALSE(locker.IsLocked());
+  EXPECT_TRUE(locker.TryLock(&lock));
+  EXPECT_TRUE(locker.IsLocked());
+  // Destructor releases.
+}
+
+TEST(ProcessRunLockTest, SameThreadRecursion) {
+  ProcessRunLock lock;
+  ProcessRunLock::ProcessRunLocker outer;
+  EXPECT_TRUE(outer.TryLock(&lock));
+
+  // Re-entrant acquisition on the same thread must succeed without
+  // deadlocking on the underlying rwlock.
+  ProcessRunLock::ProcessRunLocker inner;
+  EXPECT_TRUE(inner.TryLock(&lock));
+  EXPECT_TRUE(inner.IsLocked());
+}
+
+TEST(ProcessRunLockTest, ConcurrentReaders) {
+  ProcessRunLock lock;
+  ProcessRunLock::ProcessRunLocker outer;
+  EXPECT_TRUE(outer.TryLock(&lock));
+
+  // Another thread concurrently acquires the same lock — and is also
+  // able to recurse on its own. Both threads must get the same-thread
+  // recursion fast-path; the per-instance map distinguishes them.
+  bool other_outer_ok = false, other_inner_ok = false;
+  std::thread t([&] {
+    ProcessRunLock::ProcessRunLocker thread_outer;
+    other_outer_ok = thread_outer.TryLock(&lock);
+    ProcessRunLock::ProcessRunLocker thread_inner;
+    other_inner_ok = thread_inner.TryLock(&lock);
+  });
+  t.join();
+  EXPECT_TRUE(other_outer_ok);
+  EXPECT_TRUE(other_inner_ok);
+}
+
+TEST(ProcessRunLockTest, MoveUnlocked) {
+  // Moving an unlocked locker is always allowed.
+  ProcessRunLock::ProcessRunLocker a;
+  ProcessRunLock::ProcessRunLocker b(std::move(a));
+  EXPECT_FALSE(b.IsLocked());
+
+  ProcessRunLock::ProcessRunLocker c;
+  c = std::move(b);
+  EXPECT_FALSE(c.IsLocked());
+}
+
+TEST(ProcessRunLockTest, MoveLockedSameThread) {
+  // Moving a held locker on the owning thread is allowed.
+  ProcessRunLock lock;
+  ProcessRunLock::ProcessRunLocker a;
+  EXPECT_TRUE(a.TryLock(&lock));
+  ProcessRunLock::ProcessRunLocker b(std::move(a));
+  EXPECT_TRUE(b.IsLocked());
+  EXPECT_FALSE(a.IsLocked());
+}
+
+TEST(ProcessRunLockTest, MoveUnlockedAcrossThreads) {
+  // Moving an unlocked locker between threads is allowed (no held state
+  // to be inconsistent about).
+  ProcessRunLock::ProcessRunLocker a;
+  std::thread t([locker = std::move(a)]() mutable {
+    EXPECT_FALSE(locker.IsLocked());
+    ProcessRunLock lock;
+    EXPECT_TRUE(locker.TryLock(&lock));
+  });
+  t.join();
+}
+
+#if GTEST_HAS_DEATH_TEST
+// Crossing threads with a held locker is a programming error: the
+// recursion bookkeeping is keyed by the acquiring thread, and POSIX
+// requires the same thread to call pthread_rwlock_unlock as called
+// pthread_rwlock_rdlock. The check fires from whichever path actually
+// runs on the destination thread -- the move ctor (if the move
+// happens cross-thread) or the destructor (if the move happens
+// same-thread but destruction runs on the destination thread, which
+// is the typical lambda-capture pattern).
+TEST(ProcessRunLockDeathTest, MoveLockedAcrossThreads) {
+  ProcessRunLock lock;
+  ProcessRunLock::ProcessRunLocker a;
+  ASSERT_TRUE(a.TryLock(&lock));
+  EXPECT_DEATH(
+      {
+        std::thread t([locker = std::move(a)]() mutable { (void)locker; });
+        t.join();
+      },
+      "ProcessRunLocker (moved|move-assigned|destroyed) (across threads|on a "
+      "different thread)");
+}
+#endif



More information about the lldb-commits mailing list