[lldb] [llvm] [lldb][GNUstep] Objective-C debugging support for the libobjc2 runtime (overview, do not merge) (PR #216709)

via llvm-commits llvm-commits at lists.llvm.org
Tue Aug 18 06:21:05 PDT 2026


https://github.com/robk-dev updated https://github.com/llvm/llvm-project/pull/216709

>From 1e92afb2ac485d152fbbaf4e0c70f32260db224e Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 11 Aug 2026 20:10:00 +0100
Subject: [PATCH 01/38] [lldb] Guard CRT debug report calls with _MSC_VER

_CrtSetReportMode/_CrtSetReportFile are Microsoft C runtime debug APIs
that do not exist when building LLDB on Windows with MinGW; guard them so
the LLDB_DISABLE_CRASH_DIALOG path compiles there.

Assisted-by: Claude Fable 5
---
 lldb/source/Initialization/SystemInitializerCommon.cpp | 7 +++++++
 1 file changed, 7 insertions(+)

diff --git a/lldb/source/Initialization/SystemInitializerCommon.cpp b/lldb/source/Initialization/SystemInitializerCommon.cpp
index b5d1c25e15008..97aad944ece93 100644
--- a/lldb/source/Initialization/SystemInitializerCommon.cpp
+++ b/lldb/source/Initialization/SystemInitializerCommon.cpp
@@ -53,12 +53,19 @@ llvm::Error SystemInitializerCommon::Initialize() {
     ::SetErrorMode(GetErrorMode() | SEM_FAILCRITICALERRORS |
                    SEM_NOGPFAULTERRORBOX);
 
+#ifdef _MSC_VER
+    // crtdbg.h expands these to no-ops unless _DEBUG is set, in which case
+    // they become real calls into the debug CRT. That macro means both "this
+    // code is built in debug mode" and "a debug CRT is linked", which go
+    // together for MSVC but not for MinGW: a debug MinGW build normally still
+    // links the release CRT, which does not export them.
     _CrtSetReportMode(_CRT_ASSERT, _CRTDBG_MODE_FILE | _CRTDBG_MODE_DEBUG);
     _CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_FILE | _CRTDBG_MODE_DEBUG);
     _CrtSetReportMode(_CRT_ERROR, _CRTDBG_MODE_FILE | _CRTDBG_MODE_DEBUG);
     _CrtSetReportFile(_CRT_ASSERT, _CRTDBG_FILE_STDERR);
     _CrtSetReportFile(_CRT_WARN, _CRTDBG_FILE_STDERR);
     _CrtSetReportFile(_CRT_ERROR, _CRTDBG_FILE_STDERR);
+#endif // _MSC_VER
   }
 #endif
 

>From b3fda2465ac454e020f06d7ac9ddc9dfad398fa4 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 11 Aug 2026 20:58:28 +0100
Subject: [PATCH 02/38] [lldb][GNUstep] Add class descriptors, ISA map, and
 dynamic type resolution

Implement runtime introspection for the GNUstep libobjc2 runtime using
only memory reads - no code is ever executed in the inferior:

- GNUstepObjCClassDescriptor parses libobjc2's struct objc_class
  (metaclass, superclass, name, instance size) directly from memory.
- GNUstepTaggedPointerVendor mirrors libobjc2's classForObject(): tagged
  ("small object") pointers are detected via the low tag bits and their
  class resolved by reading the runtime's SmallObjectClasses table,
  located by symbol.
- UpdateISAToDescriptorMapIfNeeded seeds the ISA-to-descriptor map from
  the `._OBJC_CLASS_<name>` data symbols the gnustep-2.x ABI emits for
  every compiled class; GetClassDescriptorFromISA falls back to parsing
  unknown ISAs so runtime-registered classes resolve too.
- GetDynamicTypeAndAddress resolves dynamic types via the descriptors,
  upgrading the class name to a real type through the complete-class
  cache when the inferior's debug info defines the class.

Assisted-by: Claude Fable 5
---
 .../ObjC/GNUstepObjCRuntime/CMakeLists.txt    |   1 +
 .../GNUstepObjCClassDescriptor.cpp            | 197 ++++++++++++++++++
 .../GNUstepObjCClassDescriptor.h              | 144 +++++++++++++
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 121 ++++++++++-
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  15 ++
 .../Shell/Expr/objc-gnustep-dynamic-types.m   |  55 +++++
 6 files changed, 530 insertions(+), 3 deletions(-)
 create mode 100644 lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
 create mode 100644 lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
 create mode 100644 lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
index 05caad3e7d220..8fd8ffb4bc57f 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
@@ -1,4 +1,5 @@
 add_lldb_library(lldbPluginGNUstepObjCRuntime PLUGIN
+  GNUstepObjCClassDescriptor.cpp
   GNUstepObjCRuntime.cpp
 
   LINK_COMPONENTS
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
new file mode 100644
index 0000000000000..816b2cae1ade8
--- /dev/null
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
@@ -0,0 +1,197 @@
+//===-- GNUstepObjCClassDescriptor.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 "GNUstepObjCClassDescriptor.h"
+
+#include "lldb/Core/Module.h"
+#include "lldb/Core/ModuleList.h"
+#include "lldb/Symbol/Symbol.h"
+#include "lldb/Symbol/SymbolContext.h"
+#include "lldb/Target/Process.h"
+#include "lldb/Target/Target.h"
+#include "lldb/Utility/ConstString.h"
+#include "lldb/Utility/LLDBLog.h"
+#include "lldb/Utility/Log.h"
+#include "lldb/Utility/Status.h"
+
+using namespace lldb;
+using namespace lldb_private;
+
+// Field indices into libobjc2's `struct objc_class` (see class documentation
+// in the header).
+static constexpr uint64_t kClassFieldIsa = 0;
+static constexpr uint64_t kClassFieldSuperclass = 1;
+static constexpr uint64_t kClassFieldName = 2;
+static constexpr uint64_t kClassFieldInstanceSize = 5;
+
+// An upper bound for plausible class names; longer strings indicate that the
+// name pointer does not actually point at a class name.
+static constexpr size_t kMaxClassNameLength = 512;
+
+GNUstepObjCClassDescriptor::GNUstepObjCClassDescriptor(
+    ProcessSP process_sp, ObjCLanguageRuntime::ObjCISA isa)
+    : m_process_wp(process_sp), m_isa(isa) {
+  Read();
+}
+
+void GNUstepObjCClassDescriptor::Read() {
+  ProcessSP process_sp = m_process_wp.lock();
+  if (!process_sp || m_isa == 0 || m_isa == LLDB_INVALID_ADDRESS)
+    return;
+
+  const uint32_t addr_size = process_sp->GetAddressByteSize();
+  // Class objects are at least pointer-aligned.
+  if (m_isa % addr_size != 0)
+    return;
+
+  Status error;
+  auto read_field = [&](uint64_t index) -> addr_t {
+    addr_t value = process_sp->ReadPointerFromMemory(
+        m_isa + index * addr_size, error);
+    return error.Fail() ? LLDB_INVALID_ADDRESS : value;
+  };
+
+  const addr_t metaclass = read_field(kClassFieldIsa);
+  if (metaclass == LLDB_INVALID_ADDRESS)
+    return;
+  const addr_t superclass = read_field(kClassFieldSuperclass);
+  if (superclass == LLDB_INVALID_ADDRESS)
+    return;
+  const addr_t name_ptr = read_field(kClassFieldName);
+  if (name_ptr == LLDB_INVALID_ADDRESS || name_ptr == 0)
+    return;
+
+  std::string name;
+  process_sp->ReadCStringFromMemory(name_ptr, name, error);
+  if (error.Fail() || name.empty() || name.size() >= kMaxClassNameLength)
+    return;
+
+  // `instance_size` is a signed `long`. With the non-fragile ABI it is
+  // negative until the runtime registers the class; take the magnitude so a
+  // not-yet-registered class still yields a usable size.
+  const int64_t instance_size = process_sp->ReadSignedIntegerFromMemory(
+      m_isa + kClassFieldInstanceSize * addr_size, addr_size, 0, error);
+  if (error.Fail())
+    return;
+
+  m_metaclass_isa = metaclass;
+  m_superclass_isa = superclass;
+  m_name = ConstString(name);
+  m_instance_size = static_cast<uint64_t>(
+      instance_size < 0 ? -instance_size : instance_size);
+  m_valid = true;
+}
+
+ObjCLanguageRuntime::ClassDescriptorSP
+GNUstepObjCClassDescriptor::GetSuperclass() {
+  if (!m_valid || m_superclass_isa == 0)
+    return ObjCLanguageRuntime::ClassDescriptorSP();
+  // A class that is its own superclass would make any walk up the chain spin
+  // forever. The chain comes from inferior memory, so refuse the edge here
+  // rather than leaving every caller to remember. Longer cycles cannot be
+  // seen from a single descriptor and still need the caller's depth bound.
+  if (m_superclass_isa == m_isa)
+    return ObjCLanguageRuntime::ClassDescriptorSP();
+  ProcessSP process_sp = m_process_wp.lock();
+  if (!process_sp)
+    return ObjCLanguageRuntime::ClassDescriptorSP();
+  return std::make_shared<GNUstepObjCClassDescriptor>(process_sp,
+                                                      m_superclass_isa);
+}
+
+std::unique_ptr<ObjCLanguageRuntime::ClassDescriptor>
+GNUstepObjCClassDescriptor::GetMetaclass() const {
+  if (!m_valid || m_metaclass_isa == 0)
+    return nullptr;
+  ProcessSP process_sp = m_process_wp.lock();
+  if (!process_sp)
+    return nullptr;
+  return std::make_unique<GNUstepObjCClassDescriptor>(process_sp,
+                                                      m_metaclass_isa);
+}
+
+bool GNUstepObjCTaggedPointerClassDescriptor::GetTaggedPointerInfo(
+    uint64_t *info_bits, uint64_t *value_bits, uint64_t *payload) {
+  if (info_bits)
+    *info_bits = m_tag;
+  if (value_bits)
+    *value_bits = m_pointer_value >> m_payload_shift;
+  if (payload)
+    *payload = m_pointer_value;
+  return true;
+}
+
+bool GNUstepObjCTaggedPointerClassDescriptor::GetTaggedPointerInfoSigned(
+    uint64_t *info_bits, int64_t *value_bits, uint64_t *payload) {
+  if (info_bits)
+    *info_bits = m_tag;
+  if (value_bits)
+    *value_bits =
+        static_cast<int64_t>(m_pointer_value) >> m_payload_shift;
+  if (payload)
+    *payload = m_pointer_value;
+  return true;
+}
+
+bool GNUstepTaggedPointerVendor::IsPossibleTaggedPointer(lldb::addr_t ptr) {
+  const uint64_t mask = m_process.GetAddressByteSize() == 8 ? 7 : 1;
+  return (ptr & mask) != 0;
+}
+
+std::unique_ptr<ObjCLanguageRuntime::ClassDescriptor>
+GNUstepTaggedPointerVendor::GetClassDescriptor(lldb::addr_t ptr) {
+  const bool is_64_bit = m_process.GetAddressByteSize() == 8;
+  const uint64_t mask = is_64_bit ? 7 : 1;
+  const uint32_t payload_shift = is_64_bit ? 3 : 1;
+  const uint64_t tag = ptr & mask;
+  if (tag == 0)
+    return nullptr;
+
+  // Mirror libobjc2's classForObject(): 32-bit targets have a single small
+  // object class at index 0; 64-bit targets index the table by the tag. The
+  // table has 7 entries, so reject out-of-range tags.
+  const uint64_t index = is_64_bit ? tag : 0;
+  if (index > 6)
+    return nullptr;
+
+  if (!m_table_addr) {
+    m_table_addr = LLDB_INVALID_ADDRESS;
+    Target &target = m_process.GetTarget();
+    SymbolContextList sc_list;
+    target.GetImages().FindSymbolsWithNameAndType(
+        ConstString("SmallObjectClasses"), eSymbolTypeAny, sc_list);
+    for (const SymbolContext &sc : sc_list) {
+      if (!sc.symbol)
+        continue;
+      const addr_t table = sc.symbol->GetAddress().GetLoadAddress(&target);
+      if (table != LLDB_INVALID_ADDRESS) {
+        m_table_addr = table;
+        break;
+      }
+    }
+    if (*m_table_addr == LLDB_INVALID_ADDRESS)
+      LLDB_LOG(GetLog(LLDBLog::Language),
+               "GNUstepTaggedPointerVendor: SmallObjectClasses symbol not "
+               "found (stripped libobjc?); tagged pointer classes unknown");
+  }
+  if (*m_table_addr == LLDB_INVALID_ADDRESS)
+    return nullptr;
+
+  Status error;
+  const addr_t isa = m_process.ReadPointerFromMemory(
+      *m_table_addr + index * m_process.GetAddressByteSize(), error);
+  if (error.Fail() || isa == 0 || isa == LLDB_INVALID_ADDRESS)
+    return nullptr;
+
+  auto descriptor_up =
+      std::make_unique<GNUstepObjCTaggedPointerClassDescriptor>(
+          m_process.shared_from_this(), isa, ptr, tag, payload_shift);
+  if (!descriptor_up->IsValid())
+    return nullptr;
+  return descriptor_up;
+}
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
new file mode 100644
index 0000000000000..949b9f98ca9d6
--- /dev/null
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
@@ -0,0 +1,144 @@
+//===-- GNUstepObjCClassDescriptor.h ----------------------------*- C++ -*-===//
+//
+// 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
+//
+//===----------------------------------------------------------------------===//
+
+#ifndef LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPOBJCCLASSDESCRIPTOR_H
+#define LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPOBJCCLASSDESCRIPTOR_H
+
+#include "Plugins/LanguageRuntime/ObjC/ObjCLanguageRuntime.h"
+
+#include "lldb/lldb-forward.h"
+#include "lldb/lldb-types.h"
+
+#include <optional>
+
+namespace lldb_private {
+
+/// A class descriptor for classes of the GNUstep libobjc2 runtime, backed
+/// entirely by reads of the inferior's memory - no code is ever executed in
+/// the inferior.
+///
+/// The layout parsed here is libobjc2's `struct objc_class` (class.h), whose
+/// leading fields have been stable across the gnustep-2.x ABI:
+///
+///   Class isa;              // metaclass          [index 0]
+///   Class super_class;      //                    [index 1]
+///   const char *name;       //                    [index 2]
+///   long version;           //                    [index 3]
+///   unsigned long info;     // flag bits          [index 4]
+///   long instance_size;     //                    [index 5]
+///
+/// Note: with the non-fragile ABI the compiler emits a negative
+/// instance_size; the runtime replaces it with the real size when the class
+/// is registered, so debug-time reads of loaded classes see the real value.
+class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
+public:
+  GNUstepObjCClassDescriptor(lldb::ProcessSP process_sp,
+                             ObjCLanguageRuntime::ObjCISA isa);
+
+  ~GNUstepObjCClassDescriptor() override = default;
+
+  ConstString GetClassName() override { return m_name; }
+
+  ObjCLanguageRuntime::ClassDescriptorSP GetSuperclass() override;
+
+  std::unique_ptr<ObjCLanguageRuntime::ClassDescriptor>
+  GetMetaclass() const override;
+
+  bool IsValid() override { return m_valid; }
+
+  bool GetTaggedPointerInfo(uint64_t *info_bits = nullptr,
+                            uint64_t *value_bits = nullptr,
+                            uint64_t *payload = nullptr) override {
+    return false;
+  }
+
+  bool GetTaggedPointerInfoSigned(uint64_t *info_bits = nullptr,
+                                  int64_t *value_bits = nullptr,
+                                  uint64_t *payload = nullptr) override {
+    return false;
+  }
+
+  uint64_t GetInstanceSize() override { return m_instance_size; }
+
+  ObjCLanguageRuntime::ObjCISA GetISA() override { return m_isa; }
+
+protected:
+  /// Parse `struct objc_class` at m_isa. Called from the constructor;
+  /// sets m_valid on success.
+  void Read();
+
+  lldb::ProcessWP m_process_wp;
+  ObjCLanguageRuntime::ObjCISA m_isa = 0;
+  ConstString m_name;
+  ObjCLanguageRuntime::ObjCISA m_superclass_isa = 0;
+  ObjCLanguageRuntime::ObjCISA m_metaclass_isa = 0;
+  uint64_t m_instance_size = 0;
+  bool m_valid = false;
+};
+
+/// Class descriptor for libobjc2 "small objects" (tagged pointers). The
+/// pointed-to class is the entry of the runtime's `SmallObjectClasses` table
+/// selected by the low tag bits; the descriptor additionally exposes the
+/// payload via GetTaggedPointerInfo.
+class GNUstepObjCTaggedPointerClassDescriptor
+    : public GNUstepObjCClassDescriptor {
+public:
+  GNUstepObjCTaggedPointerClassDescriptor(lldb::ProcessSP process_sp,
+                                          ObjCLanguageRuntime::ObjCISA isa,
+                                          lldb::addr_t pointer_value,
+                                          uint64_t tag, uint32_t payload_shift)
+      : GNUstepObjCClassDescriptor(std::move(process_sp), isa),
+        m_pointer_value(pointer_value), m_tag(tag),
+        m_payload_shift(payload_shift) {}
+
+  bool GetTaggedPointerInfo(uint64_t *info_bits = nullptr,
+                            uint64_t *value_bits = nullptr,
+                            uint64_t *payload = nullptr) override;
+
+  bool GetTaggedPointerInfoSigned(uint64_t *info_bits = nullptr,
+                                  int64_t *value_bits = nullptr,
+                                  uint64_t *payload = nullptr) override;
+
+private:
+  lldb::addr_t m_pointer_value;
+  uint64_t m_tag;
+  uint32_t m_payload_shift;
+};
+
+/// Resolves tagged pointers by mirroring libobjc2's `classForObject()`
+/// (class.h): a pointer with any of the low tag bits set (3 bits on 64-bit
+/// targets, 1 bit on 32-bit targets) is a small object whose class is
+/// `SmallObjectClasses[tag]` (index 0 on 32-bit targets).
+///
+/// `SmallObjectClasses` has hidden visibility, so resolving it requires the
+/// library's .symtab (present in unstripped builds). If it cannot be
+/// resolved, tagged pointers are still detected but their class is unknown.
+class GNUstepTaggedPointerVendor
+    : public ObjCLanguageRuntime::TaggedPointerVendor {
+public:
+  explicit GNUstepTaggedPointerVendor(Process &process) : m_process(process) {}
+
+  ~GNUstepTaggedPointerVendor() override = default;
+
+  bool IsPossibleTaggedPointer(lldb::addr_t ptr) override;
+
+  std::unique_ptr<ObjCLanguageRuntime::ClassDescriptor>
+  GetClassDescriptor(lldb::addr_t ptr) override;
+
+private:
+  /// Load address of libobjc2's `SmallObjectClasses` table, resolved lazily
+  /// and cached. LLDB_INVALID_ADDRESS inside the optional means resolution
+  /// was attempted and failed.
+  std::optional<lldb::addr_t> m_table_addr;
+
+  Process &m_process;
+};
+
+} // namespace lldb_private
+
+#endif // LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPOBJCCLASSDESCRIPTOR_H
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index c317f6478fe74..512f43101404d 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -7,17 +7,25 @@
 //===----------------------------------------------------------------------===//
 
 #include "GNUstepObjCRuntime.h"
+#include "GNUstepObjCClassDescriptor.h"
 
 #include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
 
 #include "lldb/Core/Module.h"
+#include "lldb/Core/ModuleList.h"
 #include "lldb/Core/PluginManager.h"
 #include "lldb/Expression/UtilityFunction.h"
+#include "lldb/Symbol/DeclVendor.h"
+#include "lldb/Symbol/Symbol.h"
+#include "lldb/Symbol/SymbolContext.h"
 #include "lldb/Target/ExecutionContext.h"
 #include "lldb/Target/Process.h"
 #include "lldb/Target/Target.h"
 #include "lldb/Utility/ArchSpec.h"
 #include "lldb/Utility/ConstString.h"
+#include "lldb/Utility/LLDBLog.h"
+#include "lldb/Utility/Log.h"
+#include "lldb/Utility/RegularExpression.h"
 #include "lldb/ValueObject/ValueObject.h"
 
 using namespace lldb;
@@ -100,7 +108,9 @@ LanguageRuntime *GNUstepObjCRuntime::CreateInstance(Process *process,
 GNUstepObjCRuntime::~GNUstepObjCRuntime() = default;
 
 GNUstepObjCRuntime::GNUstepObjCRuntime(Process *process)
-    : ObjCLanguageRuntime(process), m_objc_module_sp(nullptr) {
+    : ObjCLanguageRuntime(process), m_objc_module_sp(nullptr),
+      m_tagged_pointer_vendor_up(
+          std::make_unique<GNUstepTaggedPointerVendor>(*process)) {
   ReadObjCLibraryIfNeeded(process->GetTarget().GetImages());
 }
 
@@ -128,7 +138,42 @@ bool GNUstepObjCRuntime::GetDynamicTypeAndAddress(
     ValueObject &in_value, DynamicValueType use_dynamic,
     TypeAndOrName &class_type_or_name, Address &address,
     Value::ValueType &value_type, llvm::ArrayRef<uint8_t> &local_buffer) {
-  return false;
+  class_type_or_name.Clear();
+  value_type = Value::ValueType::Scalar;
+
+  if (!CouldHaveDynamicValue(in_value))
+    return false;
+
+  ClassDescriptorSP objc_class_sp(GetNonKVOClassDescriptor(in_value));
+  if (!objc_class_sp)
+    return false;
+
+  ConstString class_name(objc_class_sp->GetClassName());
+  if (!class_name)
+    return false;
+
+  const addr_t object_ptr = in_value.GetPointerValue().address;
+  address.SetRawAddress(object_ptr);
+  class_type_or_name.SetName(class_name);
+
+  // Try to upgrade the bare name to a real type: first from the cache of
+  // classes already realized from debug info, then - should a decl vendor
+  // exist one day - from that.
+  TypeSP type_sp(objc_class_sp->GetType());
+  if (!type_sp) {
+    type_sp = LookupInCompleteClassCache(class_name);
+    if (type_sp)
+      objc_class_sp->SetType(type_sp);
+  }
+  if (type_sp)
+    class_type_or_name.SetTypeSP(type_sp);
+  else if (auto *vendor = GetDeclVendor()) {
+    auto types = vendor->FindTypes(class_name, /*max_matches*/ 1);
+    if (!types.empty())
+      class_type_or_name.SetCompilerType(types.front());
+  }
+
+  return !class_type_or_name.IsEmpty();
 }
 
 TypeAndOrName
@@ -205,7 +250,76 @@ GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
 }
 
 void GNUstepObjCRuntime::UpdateISAToDescriptorMapIfNeeded() {
-  // TODO: Support lazily named and dynamically loaded Objective-C classes
+  if (!m_process)
+    return;
+  const uint32_t stop_id = m_process->GetStopID();
+  if (!m_isa_map_dirty) {
+    m_isa_to_descriptor_stop_id = stop_id;
+    return;
+  }
+
+  // The gnustep-2.x ABI emits every compiled class as a `._OBJC_CLASS_<name>`
+  // data symbol whose address is the class object itself (the ISA of its
+  // instances), so the map can be seeded from symbol tables alone - without
+  // running any code in the inferior. Classes created dynamically at runtime
+  // are handled by the create-on-miss path in GetClassDescriptorFromISA.
+  Target &target = GetTargetRef();
+  const ModuleList &images = target.GetImages();
+
+  SymbolContextList sc_list;
+  RegularExpression regex(llvm::StringRef("^\\._OBJC_CLASS_"));
+  images.FindSymbolsMatchingRegExAndType(regex, eSymbolTypeAny, sc_list);
+
+  static constexpr llvm::StringLiteral g_class_prefix("._OBJC_CLASS_");
+  for (const SymbolContext &sc : sc_list) {
+    if (!sc.symbol)
+      continue;
+    const addr_t isa = sc.symbol->GetAddress().GetLoadAddress(&target);
+    if (isa == 0 || isa == LLDB_INVALID_ADDRESS || ISAIsCached(isa))
+      continue;
+    llvm::StringRef name = sc.symbol->GetName().GetStringRef();
+    name.consume_front(g_class_prefix);
+    auto descriptor_sp = std::make_shared<GNUstepObjCClassDescriptor>(
+        m_process->shared_from_this(), isa);
+    if (descriptor_sp->IsValid())
+      AddClass(isa, descriptor_sp, name.str().c_str());
+  }
+
+  m_isa_map_dirty = false;
+  m_isa_to_descriptor_stop_id = stop_id;
+}
+
+ObjCLanguageRuntime::TaggedPointerVendor *
+GNUstepObjCRuntime::GetTaggedPointerVendor() {
+  return m_tagged_pointer_vendor_up.get();
+}
+
+ObjCLanguageRuntime::ClassDescriptorSP
+GNUstepObjCRuntime::GetClassDescriptor(ValueObject &in_value) {
+  const addr_t ptr = in_value.GetPointerValue().address;
+  if (ptr != LLDB_INVALID_ADDRESS && m_tagged_pointer_vendor_up &&
+      m_tagged_pointer_vendor_up->IsPossibleTaggedPointer(ptr))
+    return m_tagged_pointer_vendor_up->GetClassDescriptor(ptr);
+  return ObjCLanguageRuntime::GetClassDescriptor(in_value);
+}
+
+ObjCLanguageRuntime::ClassDescriptorSP
+GNUstepObjCRuntime::GetClassDescriptorFromISA(ObjCISA isa) {
+  if (ClassDescriptorSP descriptor_sp =
+          ObjCLanguageRuntime::GetClassDescriptorFromISA(isa))
+    return descriptor_sp;
+
+  // The symbol sweep only sees classes with static definitions. Fall back to
+  // parsing the class structure directly so classes registered at runtime
+  // (e.g. via objc_allocateClassPair) resolve as well.
+  if (!m_process || isa == 0 || isa == LLDB_INVALID_ADDRESS)
+    return ClassDescriptorSP();
+  auto descriptor_sp = std::make_shared<GNUstepObjCClassDescriptor>(
+      m_process->shared_from_this(), isa);
+  if (!descriptor_sp->IsValid())
+    return ClassDescriptorSP();
+  AddClass(isa, descriptor_sp, descriptor_sp->GetClassName().GetCString());
+  return descriptor_sp;
 }
 
 bool GNUstepObjCRuntime::IsModuleObjCLibrary(const ModuleSP &module_sp) {
@@ -224,4 +338,5 @@ bool GNUstepObjCRuntime::ReadObjCLibrary(const ModuleSP &module_sp) {
 
 void GNUstepObjCRuntime::ModulesDidLoad(const ModuleList &module_list) {
   ReadObjCLibraryIfNeeded(module_list);
+  m_isa_map_dirty = true;
 }
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 94a5c9e1261a8..2b340f17df462 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -17,10 +17,13 @@
 #include "llvm/ADT/StringRef.h"
 #include "llvm/Support/Error.h"
 
+#include <memory>
 #include <optional>
 
 namespace lldb_private {
 
+class GNUstepTaggedPointerVendor;
+
 class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
 public:
   ~GNUstepObjCRuntime() override;
@@ -99,11 +102,23 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
 
   void UpdateISAToDescriptorMapIfNeeded() override;
 
+  TaggedPointerVendor *GetTaggedPointerVendor() override;
+
+  ClassDescriptorSP GetClassDescriptor(ValueObject &in_value) override;
+
+  ClassDescriptorSP GetClassDescriptorFromISA(ObjCISA isa) override;
+
 protected:
   // Call CreateInstance instead.
   GNUstepObjCRuntime(Process *process);
 
   lldb::ModuleSP m_objc_module_sp;
+
+  std::unique_ptr<GNUstepTaggedPointerVendor> m_tagged_pointer_vendor_up;
+
+  /// Set when new modules arrive; cleared once the ISA-to-descriptor map has
+  /// been refreshed, so the symbol sweep only reruns after module changes.
+  bool m_isa_map_dirty = true;
 };
 
 } // namespace lldb_private
diff --git a/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m b/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m
new file mode 100644
index 0000000000000..6c43f2df26223
--- /dev/null
+++ b/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m
@@ -0,0 +1,55 @@
+// REQUIRES: objc-gnustep
+// XFAIL: system-windows
+//
+// RUN: %build %s --compiler=clang --objc-gnustep --output=%t
+
+#import "objc/runtime.h"
+
+ at protocol NSCoding
+ at end
+
+#ifdef __has_attribute
+#if __has_attribute(objc_root_class)
+__attribute__((objc_root_class))
+#endif
+#endif
+ at interface NSObject <NSCoding> {
+  id isa;
+  int refcount;
+}
+ at end
+ at implementation NSObject
++ (id)new {
+  return class_createInstance(self, 0);
+}
+ at end
+
+ at interface Base : NSObject
+ at end
+ at implementation Base
+ at end
+
+ at interface Derived : Base
+ at end
+ at implementation Derived
+ at end
+
+// The static type of `object` is Base, but the dynamic type is Derived. The
+// GNUstep runtime resolves the dynamic type by reading the class structure
+// from the inferior's memory (no code is run in the inferior).
+//
+// RUN: %lldb -b -o "b objc-gnustep-dynamic-types.m:48" -o "run" \
+// RUN:          -o "frame variable -d run-target object" \
+// RUN:          -o "frame variable -d no-dynamic-values object" -- %t | FileCheck %s
+//
+int main() {
+  Base *object = [Derived new];
+  (void)object;
+  return 0;
+}
+//
+// CHECK: (lldb) frame variable -d run-target object
+// CHECK: (Derived *) object = 0x
+//
+// CHECK: (lldb) frame variable -d no-dynamic-values object
+// CHECK: (Base *) object = 0x

>From 16b227567271c547855bb39674ccdddb774fee54 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 11 Aug 2026 21:01:27 +0100
Subject: [PATCH 03/38] [lldb][GNUstep] Implement object description via
 _NSPrintForDebugger

gnustep-base ships the same `const char *_NSPrintForDebugger(id)`
debugger hook that AppleObjCRuntime already uses on Darwin, so `po` can
follow the exact same proven mechanics: resolve the hook by symbol, call
it through a cached FunctionCaller with utility-expression options, and
read back the returned C string.

The call thunk is compiled as plain C with the object passed as void *,
so this works without any Objective-C support in the expression parser.
When gnustep-base is not loaded (bare libobjc2 inferiors), po reports a
clear error instead of failing obscurely.

Assisted-by: Claude Fable 5
---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 145 +++++++++++++++++-
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |   9 ++
 lldb/test/Shell/Expr/objc-gnustep-print.m     |  15 ++
 3 files changed, 165 insertions(+), 4 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 512f43101404d..49c1707008f29 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -11,9 +11,13 @@
 
 #include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
 
+#include "lldb/Core/Address.h"
 #include "lldb/Core/Module.h"
 #include "lldb/Core/ModuleList.h"
 #include "lldb/Core/PluginManager.h"
+#include "lldb/Core/Value.h"
+#include "lldb/Expression/DiagnosticManager.h"
+#include "lldb/Expression/FunctionCaller.h"
 #include "lldb/Expression/UtilityFunction.h"
 #include "lldb/Symbol/DeclVendor.h"
 #include "lldb/Symbol/Symbol.h"
@@ -21,6 +25,7 @@
 #include "lldb/Target/ExecutionContext.h"
 #include "lldb/Target/Process.h"
 #include "lldb/Target/Target.h"
+#include "lldb/Target/Thread.h"
 #include "lldb/Utility/ArchSpec.h"
 #include "lldb/Utility/ConstString.h"
 #include "lldb/Utility/LLDBLog.h"
@@ -114,17 +119,149 @@ GNUstepObjCRuntime::GNUstepObjCRuntime(Process *process)
   ReadObjCLibraryIfNeeded(process->GetTarget().GetImages());
 }
 
+Address *GNUstepObjCRuntime::GetPrintForDebuggerAddr() {
+  if (!m_print_for_debugger_addr_up) {
+    SymbolContextList sc_list;
+    GetTargetRef().GetImages().FindSymbolsWithNameAndType(
+        ConstString("_NSPrintForDebugger"), eSymbolTypeCode, sc_list);
+    for (const SymbolContext &sc : sc_list) {
+      if (!sc.symbol)
+        continue;
+      m_print_for_debugger_addr_up =
+          std::make_unique<Address>(sc.symbol->GetAddress());
+      break;
+    }
+  }
+  return m_print_for_debugger_addr_up.get();
+}
+
 llvm::Error GNUstepObjCRuntime::GetObjectDescription(Stream &str,
                                                      ValueObject &valobj) {
-  return llvm::createStringError(
-      "LLDB's GNUStep runtime does not support object description");
+  CompilerType compiler_type(valobj.GetCompilerType());
+  bool is_signed;
+  // ObjC objects can only be pointers (or numbers that actually represent
+  // pointers but haven't been typecast).
+  if (!compiler_type.IsIntegerType(is_signed) && !compiler_type.IsPointerType())
+    return llvm::createStringError("not a pointer type");
+
+  Value val;
+  if (!valobj.ResolveValue(val.GetScalar()))
+    return llvm::createStringError("pointer value could not be resolved");
+
+  // Value objects may not have a process in their ExecutionContextRef. But
+  // we need one in the context we pass down to eventually call description.
+  ExecutionContext exe_ctx;
+  if (valobj.GetProcessSP()) {
+    exe_ctx = ExecutionContext(valobj.GetExecutionContextRef());
+  } else {
+    exe_ctx.SetContext(valobj.GetTargetSP(), true);
+    if (!exe_ctx.HasProcessScope())
+      return llvm::createStringError("no process");
+  }
+  return GetObjectDescription(str, val, exe_ctx.GetBestExecutionContextScope());
 }
 
 llvm::Error
 GNUstepObjCRuntime::GetObjectDescription(Stream &strm, Value &value,
                                          ExecutionContextScope *exe_scope) {
-  return llvm::createStringError(
-      "LLDB's GNUStep runtime does not support object description");
+  // The libobjc2 runtime alone cannot describe objects; the hook lives in
+  // gnustep-base (Foundation), just like on Darwin.
+  Address *function_address = GetPrintForDebuggerAddr();
+  if (!function_address)
+    return llvm::createStringError(
+        "gnustep-base is not loaded: _NSPrintForDebugger not found");
+
+  ExecutionContext exe_ctx;
+  exe_scope->CalculateExecutionContext(exe_ctx);
+  Process *process = exe_ctx.GetProcessPtr();
+  if (!process)
+    return llvm::createStringError("no process");
+
+  Target *target = exe_ctx.GetTargetPtr();
+  TypeSystemClangSP scratch_ts_sp =
+      ScratchTypeSystemClang::GetForTarget(*target);
+  if (!scratch_ts_sp)
+    return llvm::createStringError("no scratch type system");
+
+  // The call thunk is compiled as plain C (no ObjC machinery needed in the
+  // expression parser), so pass the object as `void *` and read back a
+  // `const char *`.
+  CompilerType void_ptr_type =
+      scratch_ts_sp->GetBasicType(eBasicTypeVoid).GetPointerType();
+  value.SetCompilerType(void_ptr_type);
+
+  ValueList arg_value_list;
+  arg_value_list.PushValue(value);
+
+  CompilerType return_compiler_type = scratch_ts_sp->GetCStringType(true);
+  Value ret;
+  ret.SetCompilerType(return_compiler_type);
+
+  if (!exe_ctx.GetFramePtr()) {
+    Thread *thread = exe_ctx.GetThreadPtr();
+    if (thread == nullptr) {
+      exe_ctx.SetThreadSP(process->GetThreadList().GetSelectedThread());
+      thread = exe_ctx.GetThreadPtr();
+    }
+    if (thread)
+      exe_ctx.SetFrameSP(thread->GetSelectedFrame(DoNoSelectMostRelevantFrame));
+  }
+
+  DiagnosticManager diagnostics;
+  lldb::addr_t wrapper_struct_addr = LLDB_INVALID_ADDRESS;
+
+  if (!m_print_object_caller_up) {
+    Status error;
+    m_print_object_caller_up.reset(
+        exe_scope->CalculateTarget()->GetFunctionCallerForLanguage(
+            eLanguageTypeC, return_compiler_type, *function_address,
+            arg_value_list, "gnustep-object-description", error));
+    if (error.Fail()) {
+      m_print_object_caller_up.reset();
+      return llvm::createStringError(
+          llvm::Twine("could not get function runner to call "
+                      "_NSPrintForDebugger: ") +
+          error.AsCString());
+    }
+    m_print_object_caller_up->InsertFunction(exe_ctx, wrapper_struct_addr,
+                                             diagnostics);
+  } else {
+    m_print_object_caller_up->WriteFunctionArguments(
+        exe_ctx, wrapper_struct_addr, arg_value_list, diagnostics);
+  }
+
+  EvaluateExpressionOptions options;
+  options.SetUnwindOnError(true);
+  options.SetTryAllThreads(true);
+  options.SetStopOthers(true);
+  options.SetIgnoreBreakpoints(true);
+  options.SetTimeout(process->GetUtilityExpressionTimeout());
+  options.SetIsForUtilityExpr(true);
+
+  ExpressionResults results = m_print_object_caller_up->ExecuteFunction(
+      exe_ctx, &wrapper_struct_addr, options, diagnostics, ret);
+  if (results != eExpressionCompleted)
+    return llvm::createStringError(
+        "could not evaluate _NSPrintForDebugger in the inferior");
+
+  addr_t result_ptr = ret.GetScalar().ULongLong(LLDB_INVALID_ADDRESS);
+  if (result_ptr == 0 || result_ptr == LLDB_INVALID_ADDRESS)
+    return llvm::createStringError("object returned no description");
+
+  char buf[512];
+  size_t cstr_len = 0;
+  size_t full_buffer_len = sizeof(buf) - 1;
+  size_t curr_len = full_buffer_len;
+  while (curr_len == full_buffer_len) {
+    Status error;
+    curr_len = process->ReadCStringFromMemory(result_ptr + cstr_len, buf,
+                                              sizeof(buf), error);
+    strm.Write(buf, curr_len);
+    cstr_len += curr_len;
+  }
+  if (cstr_len > 0)
+    return llvm::Error::success();
+  return llvm::createStringError("empty object description");
 }
 
 bool GNUstepObjCRuntime::CouldHaveDynamicValue(ValueObject &in_value) {
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 2b340f17df462..abc0848dd4d24 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -112,8 +112,17 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   // Call CreateInstance instead.
   GNUstepObjCRuntime(Process *process);
 
+  /// Address of gnustep-base's `const char *_NSPrintForDebugger(id)`, the
+  /// same debugger hook AppleObjCRuntime uses. Resolved lazily; nullptr when
+  /// gnustep-base is not loaded in the inferior.
+  Address *GetPrintForDebuggerAddr();
+
   lldb::ModuleSP m_objc_module_sp;
 
+  std::unique_ptr<Address> m_print_for_debugger_addr_up;
+
+  std::unique_ptr<FunctionCaller> m_print_object_caller_up;
+
   std::unique_ptr<GNUstepTaggedPointerVendor> m_tagged_pointer_vendor_up;
 
   /// Set when new modules arrive; cleared once the ISA-to-descriptor map has
diff --git a/lldb/test/Shell/Expr/objc-gnustep-print.m b/lldb/test/Shell/Expr/objc-gnustep-print.m
index 3f13bf1234cbd..70144a5829854 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-print.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-print.m
@@ -105,3 +105,18 @@ int main() {
   [t set_ivars];
   return 0;
 }
+
+// LLDB resolves `_NSPrintForDebugger` by symbol in any loaded module and
+// calls it to implement `po`. In a full GNUstep environment gnustep-base
+// provides it; this hermetic stand-in exercises the same machinery.
+const char *_NSPrintForDebugger(id object) {
+  if (!object)
+    return 0;
+  return object_getClassName(object);
+}
+
+// RUN: %lldb -b -o "b objc-gnustep-print.m:106" -o "run" -o "po t" \
+// RUN:     -- %t | FileCheck %s --check-prefix=PO
+//
+// PO: (lldb) po t
+// PO: TestObj

>From cdc111cb7e97c6a6388075be5479ad778921fa8f Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 11 Aug 2026 21:43:04 +0100
Subject: [PATCH 04/38] [lldb][GNUstep] Implement step-through for ObjC
 dispatch trampolines

When a step lands on the first instruction of a libobjc2 dispatch entry
point (objc_msgSend{,_fpret,_stret}, objc_msg_lookup{,_sender}), read the
receiver and selector from the argument registers, then resolve the IMP
by calling `objc_msg_lookup(receiver, selector)` in the inferior - the
same lookup the trampoline itself is about to perform - from a nested
function-call plan, and run to the returned address. This is the same
plan shape as AppleThreadPlanStepThroughObjCTrampoline, without the
Apple-specific dispatch-table machinery.

Results are stored in ObjCLanguageRuntime's method cache keyed by
(isa, selector), so repeated steps through the same send skip the
inferior call entirely. Messages to nil and unknown entry points fall
back to normal stepping.

Assisted-by: Claude Fable 5
---
 .../ObjC/GNUstepObjCRuntime/CMakeLists.txt    |   1 +
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 151 ++++++++++++++++-
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  11 ++
 ...tepThreadPlanStepThroughObjCTrampoline.cpp | 155 ++++++++++++++++++
 ...UstepThreadPlanStepThroughObjCTrampoline.h |  79 +++++++++
 lldb/test/Shell/Expr/objc-gnustep-print.m     |  10 ++
 6 files changed, 405 insertions(+), 2 deletions(-)
 create mode 100644 lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
 create mode 100644 lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.h

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
index 8fd8ffb4bc57f..14364c12dfcde 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
@@ -1,6 +1,7 @@
 add_lldb_library(lldbPluginGNUstepObjCRuntime PLUGIN
   GNUstepObjCClassDescriptor.cpp
   GNUstepObjCRuntime.cpp
+  GNUstepThreadPlanStepThroughObjCTrampoline.cpp
 
   LINK_COMPONENTS
     Support
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 49c1707008f29..814204a5ef892 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -8,6 +8,7 @@
 
 #include "GNUstepObjCRuntime.h"
 #include "GNUstepObjCClassDescriptor.h"
+#include "GNUstepThreadPlanStepThroughObjCTrampoline.h"
 
 #include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
 
@@ -22,10 +23,13 @@
 #include "lldb/Symbol/DeclVendor.h"
 #include "lldb/Symbol/Symbol.h"
 #include "lldb/Symbol/SymbolContext.h"
+#include "lldb/Target/ABI.h"
 #include "lldb/Target/ExecutionContext.h"
 #include "lldb/Target/Process.h"
+#include "lldb/Target/RegisterContext.h"
 #include "lldb/Target/Target.h"
 #include "lldb/Target/Thread.h"
+#include "lldb/Target/ThreadPlanRunToAddress.h"
 #include "lldb/Utility/ArchSpec.h"
 #include "lldb/Utility/ConstString.h"
 #include "lldb/Utility/LLDBLog.h"
@@ -382,8 +386,151 @@ GNUstepObjCRuntime::CreateObjectChecker(std::string name,
 ThreadPlanSP
 GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
                                                  bool stop_others) {
-  // TODO: Implement this properly to avoid stepping into things like PLT stubs
-  return nullptr;
+  // Only act when stopped at the first instruction of a known libobjc2
+  // dispatch entry point (where the argument registers still hold the
+  // receiver and selector).
+  Process *process = thread.GetProcess().get();
+  if (!process)
+    return {};
+  const addr_t pc = thread.GetRegisterContext()->GetPC();
+  Target &target = GetTargetRef();
+  Address pc_addr;
+  if (!target.ResolveLoadAddress(pc, pc_addr))
+    return {};
+  Symbol *symbol = pc_addr.CalculateSymbolContextSymbol();
+  if (!symbol || symbol->GetAddress().GetLoadAddress(&target) != pc)
+    return {};
+
+  // Dispatch entry points exported by libobjc2 (objc_msgSend.S, sendmsg2.c).
+  // The `_super` variants are omitted: super sends compile to a lookup plus
+  // a direct call, and the direct call steps normally.
+  llvm::StringRef name = symbol->GetName().GetStringRef();
+  bool is_stret = false, is_sender = false;
+  if (name == "objc_msgSend" || name == "objc_msgSend_fpret" ||
+      name == "objc_msg_lookup") {
+  } else if (name == "objc_msgSend_stret") {
+    is_stret = true;
+  } else if (name == "objc_msg_lookup_sender") {
+    is_sender = true;
+  } else {
+    return {};
+  }
+
+  ABISP abi_sp = process->GetABI();
+  if (!abi_sp)
+    return {};
+  TypeSystemClangSP scratch_ts_sp =
+      ScratchTypeSystemClang::GetForTarget(target);
+  if (!scratch_ts_sp)
+    return {};
+  CompilerType void_ptr_type =
+      scratch_ts_sp->GetBasicType(eBasicTypeVoid).GetPointerType();
+  Value void_ptr_value;
+  void_ptr_value.SetValueType(Value::ValueType::Scalar);
+  void_ptr_value.SetCompilerType(void_ptr_type);
+
+  ValueList argument_values;
+  argument_values.PushValue(void_ptr_value);
+  argument_values.PushValue(void_ptr_value);
+  argument_values.PushValue(void_ptr_value);
+  if (!abi_sp->GetArgumentValues(thread, argument_values))
+    return {};
+
+  // With struct return the sret pointer occupies the first argument slot.
+  const uint32_t receiver_idx = is_stret ? 1 : 0;
+  const uint32_t sel_idx = is_stret ? 2 : 1;
+  addr_t receiver =
+      argument_values.GetValueAtIndex(receiver_idx)->GetScalar().ULongLong();
+  const addr_t selector =
+      argument_values.GetValueAtIndex(sel_idx)->GetScalar().ULongLong();
+
+  if (is_sender) {
+    // objc_msg_lookup_sender takes `id *receiver`.
+    Status error;
+    receiver = process->ReadPointerFromMemory(receiver, error);
+    if (error.Fail())
+      return {};
+  }
+
+  // A message to nil does not dispatch anywhere.
+  if (receiver == 0 || receiver == LLDB_INVALID_ADDRESS)
+    return {};
+
+  // Consult the method cache before running anything in the inferior.
+  // Tagged pointers skip the cache: their ISA is not the object's first word.
+  addr_t isa = LLDB_INVALID_ADDRESS;
+  if (!(m_tagged_pointer_vendor_up &&
+        m_tagged_pointer_vendor_up->IsPossibleTaggedPointer(receiver))) {
+    Status error;
+    const addr_t isa_candidate = process->ReadPointerFromMemory(receiver, error);
+    if (error.Success())
+      isa = isa_candidate;
+  }
+  if (isa != LLDB_INVALID_ADDRESS) {
+    const addr_t cached_imp = LookupInMethodCache(isa, selector);
+    if (cached_imp != LLDB_INVALID_ADDRESS) {
+      Address imp_addr;
+      imp_addr.SetOpcodeLoadAddress(cached_imp, &target);
+      return std::make_shared<ThreadPlanRunToAddress>(thread, imp_addr,
+                                                      stop_others);
+    }
+  }
+
+  if (!GetMsgLookupFunctionCaller())
+    return {};
+
+  ValueList lookup_args;
+  Value receiver_value = void_ptr_value;
+  receiver_value.GetScalar() = receiver;
+  lookup_args.PushValue(receiver_value);
+  Value selector_value = void_ptr_value;
+  selector_value.GetScalar() = selector;
+  lookup_args.PushValue(selector_value);
+
+  return std::make_shared<GNUstepThreadPlanStepThroughObjCTrampoline>(
+      thread, *this, lookup_args, isa, selector);
+}
+
+FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller() {
+  if (m_msg_lookup_caller_up)
+    return m_msg_lookup_caller_up.get();
+
+  Target &target = GetTargetRef();
+  SymbolContextList sc_list;
+  target.GetImages().FindSymbolsWithNameAndType(ConstString("objc_msg_lookup"),
+                                                eSymbolTypeCode, sc_list);
+  Address lookup_addr;
+  for (const SymbolContext &sc : sc_list) {
+    if (sc.symbol) {
+      lookup_addr = sc.symbol->GetAddress();
+      break;
+    }
+  }
+  if (!lookup_addr.IsValid())
+    return nullptr;
+
+  TypeSystemClangSP scratch_ts_sp =
+      ScratchTypeSystemClang::GetForTarget(target);
+  if (!scratch_ts_sp)
+    return nullptr;
+  CompilerType void_ptr_type =
+      scratch_ts_sp->GetBasicType(eBasicTypeVoid).GetPointerType();
+  Value void_ptr_value;
+  void_ptr_value.SetValueType(Value::ValueType::Scalar);
+  void_ptr_value.SetCompilerType(void_ptr_type);
+  ValueList args;
+  args.PushValue(void_ptr_value);
+  args.PushValue(void_ptr_value);
+
+  Status error;
+  m_msg_lookup_caller_up.reset(target.GetFunctionCallerForLanguage(
+      eLanguageTypeC, void_ptr_type, lookup_addr, args, "gnustep-msg-lookup",
+      error));
+  if (error.Fail()) {
+    m_msg_lookup_caller_up.reset();
+    return nullptr;
+  }
+  return m_msg_lookup_caller_up.get();
 }
 
 void GNUstepObjCRuntime::UpdateISAToDescriptorMapIfNeeded() {
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index abc0848dd4d24..99488a16b8213 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -117,12 +117,23 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   /// gnustep-base is not loaded in the inferior.
   Address *GetPrintForDebuggerAddr();
 
+public:
+  /// Lazily-built FunctionCaller for libobjc2's
+  /// `IMP objc_msg_lookup(id receiver, SEL selector)`, used by the
+  /// step-through-trampoline plan. Returns nullptr if the symbol cannot be
+  /// resolved.
+  FunctionCaller *GetMsgLookupFunctionCaller();
+
+protected:
+
   lldb::ModuleSP m_objc_module_sp;
 
   std::unique_ptr<Address> m_print_for_debugger_addr_up;
 
   std::unique_ptr<FunctionCaller> m_print_object_caller_up;
 
+  std::unique_ptr<FunctionCaller> m_msg_lookup_caller_up;
+
   std::unique_ptr<GNUstepTaggedPointerVendor> m_tagged_pointer_vendor_up;
 
   /// Set when new modules arrive; cleared once the ISA-to-descriptor map has
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
new file mode 100644
index 0000000000000..89ea5835a0b2c
--- /dev/null
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
@@ -0,0 +1,155 @@
+//===-- GNUstepThreadPlanStepThroughObjCTrampoline.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 "GNUstepThreadPlanStepThroughObjCTrampoline.h"
+#include "GNUstepObjCRuntime.h"
+
+#include "lldb/Expression/DiagnosticManager.h"
+#include "lldb/Expression/FunctionCaller.h"
+#include "lldb/Target/ABI.h"
+#include "lldb/Target/ExecutionContext.h"
+#include "lldb/Target/Process.h"
+#include "lldb/Target/Target.h"
+#include "lldb/Target/Thread.h"
+#include "lldb/Target/ThreadPlanRunToAddress.h"
+#include "lldb/Utility/LLDBLog.h"
+#include "lldb/Utility/Log.h"
+#include "lldb/Utility/Stream.h"
+
+using namespace lldb;
+using namespace lldb_private;
+
+GNUstepThreadPlanStepThroughObjCTrampoline::
+    GNUstepThreadPlanStepThroughObjCTrampoline(Thread &thread,
+                                               GNUstepObjCRuntime &runtime,
+                                               ValueList &input_values,
+                                               lldb::addr_t isa_addr,
+                                               lldb::addr_t sel_addr)
+    : ThreadPlan(ThreadPlan::eKindGeneric,
+                 "GNUstep step through ObjC trampoline", thread, eVoteNoOpinion,
+                 eVoteNoOpinion),
+      m_runtime(runtime), m_input_values(input_values), m_isa_addr(isa_addr),
+      m_sel_addr(sel_addr) {}
+
+GNUstepThreadPlanStepThroughObjCTrampoline::
+    ~GNUstepThreadPlanStepThroughObjCTrampoline() = default;
+
+void GNUstepThreadPlanStepThroughObjCTrampoline::DidPush() {
+  // Setting up the called function might require allocations in the
+  // inferior, i.e. a nested function call. This needs to be done as a
+  // PreResumeAction.
+  m_process.AddPreResumeAction(PreResumeInitializeFunctionCaller,
+                               (void *)this);
+}
+
+bool GNUstepThreadPlanStepThroughObjCTrampoline::
+    PreResumeInitializeFunctionCaller(void *void_myself) {
+  auto *myself =
+      static_cast<GNUstepThreadPlanStepThroughObjCTrampoline *>(void_myself);
+  return myself->InitializeFunctionCaller();
+}
+
+bool GNUstepThreadPlanStepThroughObjCTrampoline::InitializeFunctionCaller() {
+  if (m_func_sp)
+    return true;
+
+  m_lookup_function = m_runtime.GetMsgLookupFunctionCaller();
+  if (!m_lookup_function)
+    return false;
+
+  ExecutionContext exe_ctx;
+  GetThread().CalculateExecutionContext(exe_ctx);
+
+  DiagnosticManager diagnostics;
+  if (!m_lookup_function->InsertFunction(exe_ctx, m_args_addr, diagnostics))
+    return false;
+  if (!m_lookup_function->WriteFunctionArguments(exe_ctx, m_args_addr,
+                                                 m_input_values, diagnostics))
+    return false;
+
+  EvaluateExpressionOptions options;
+  options.SetUnwindOnError(true);
+  options.SetIgnoreBreakpoints(true);
+  options.SetStopOthers(false);
+
+  m_func_sp = m_lookup_function->GetThreadPlanToCallFunction(
+      exe_ctx, m_args_addr, options, diagnostics);
+  if (!m_func_sp)
+    return false;
+  m_func_sp->SetOkayToDiscard(true);
+  PushPlan(m_func_sp);
+  return true;
+}
+
+void GNUstepThreadPlanStepThroughObjCTrampoline::GetDescription(
+    Stream *s, lldb::DescriptionLevel level) {
+  if (level == lldb::eDescriptionLevelBrief) {
+    s->Printf("Step through GNUstep ObjC trampoline");
+    return;
+  }
+  s->Printf("Stepping to implementation of ObjC method - obj: 0x%" PRIx64
+            ", isa: 0x%" PRIx64 ", sel: 0x%" PRIx64,
+            m_input_values.GetValueAtIndex(0)->GetScalar().ULongLong(),
+            m_isa_addr, m_sel_addr);
+}
+
+bool GNUstepThreadPlanStepThroughObjCTrampoline::ShouldStop(Event *event_ptr) {
+  // First stage: the nested "call objc_msg_lookup" plan is still running.
+  if (m_func_sp) {
+    if (!m_func_sp->IsPlanComplete())
+      return false;
+    if (!m_func_sp->PlanSucceeded()) {
+      SetPlanComplete(false);
+      return true;
+    }
+    m_func_sp.reset();
+  }
+
+  Log *log = GetLog(LLDBLog::Step);
+
+  // Second stage: fetch the IMP the lookup returned and run to it.
+  if (!m_run_to_sp) {
+    Value target_addr_value;
+    ExecutionContext exe_ctx;
+    GetThread().CalculateExecutionContext(exe_ctx);
+    m_lookup_function->FetchFunctionResults(exe_ctx, m_args_addr,
+                                            target_addr_value);
+    m_lookup_function->DeallocateFunctionResults(exe_ctx, m_args_addr);
+    lldb::addr_t target_addr = target_addr_value.GetScalar().ULongLong();
+
+    if (ABISP abi_sp = GetThread().GetProcess()->GetABI())
+      target_addr = abi_sp->FixCodeAddress(target_addr);
+
+    if (target_addr == 0 || target_addr == LLDB_INVALID_ADDRESS) {
+      LLDB_LOG(log, "objc_msg_lookup returned {0:x}, stopping.", target_addr);
+      SetPlanComplete();
+      return true;
+    }
+
+    LLDB_LOG(log, "Running to GNUstep ObjC method implementation: {0:x}",
+             target_addr);
+
+    if (m_isa_addr != LLDB_INVALID_ADDRESS &&
+        m_sel_addr != LLDB_INVALID_ADDRESS)
+      m_runtime.AddToMethodCache(m_isa_addr, m_sel_addr, target_addr);
+
+    Address target_so_addr;
+    target_so_addr.SetOpcodeLoadAddress(target_addr, exe_ctx.GetTargetPtr());
+    m_run_to_sp = std::make_shared<ThreadPlanRunToAddress>(
+        GetThread(), target_so_addr, false);
+    PushPlan(m_run_to_sp);
+    return false;
+  }
+
+  // Third stage: wait for the run-to-implementation plan.
+  if (GetThread().IsThreadPlanDone(m_run_to_sp.get())) {
+    SetPlanComplete();
+    return true;
+  }
+  return false;
+}
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.h
new file mode 100644
index 0000000000000..8b61a4d8c26d6
--- /dev/null
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.h
@@ -0,0 +1,79 @@
+//===-- GNUstepThreadPlanStepThroughObjCTrampoline.h ------------*- C++ -*-===//
+//
+// 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
+//
+//===----------------------------------------------------------------------===//
+
+#ifndef LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPTHREADPLANSTEPTHROUGHOBJCTRAMPOLINE_H
+#define LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPTHREADPLANSTEPTHROUGHOBJCTRAMPOLINE_H
+
+#include "lldb/Core/Value.h"
+#include "lldb/Target/ThreadPlan.h"
+#include "lldb/lldb-types.h"
+
+namespace lldb_private {
+
+class GNUstepObjCRuntime;
+
+/// Steps from a libobjc2 dispatch entry point (objc_msgSend,
+/// objc_msg_lookup, ...) to the method implementation it is about to
+/// dispatch to. The IMP is resolved by calling `objc_msg_lookup(receiver,
+/// selector)` in the inferior - the same lookup the trampoline itself
+/// performs - from a nested function-call plan, then running to the
+/// returned address. This is the same shape as
+/// AppleThreadPlanStepThroughObjCTrampoline.
+class GNUstepThreadPlanStepThroughObjCTrampoline : public ThreadPlan {
+public:
+  GNUstepThreadPlanStepThroughObjCTrampoline(Thread &thread,
+                                             GNUstepObjCRuntime &runtime,
+                                             ValueList &input_values,
+                                             lldb::addr_t isa_addr,
+                                             lldb::addr_t sel_addr);
+
+  ~GNUstepThreadPlanStepThroughObjCTrampoline() override;
+
+  static bool PreResumeInitializeFunctionCaller(void *myself);
+
+  void GetDescription(Stream *s, lldb::DescriptionLevel level) override;
+
+  bool ValidatePlan(Stream *error) override { return true; }
+
+  lldb::StateType GetPlanRunState() override { return lldb::eStateRunning; }
+
+  bool ShouldStop(Event *event_ptr) override;
+
+  // The lookup might have to fill dispatch caches, so it is not safe to run
+  // only one thread.
+  bool StopOthers() override { return false; }
+
+  bool MischiefManaged() override { return IsPlanComplete(); }
+
+  void DidPush() override;
+
+  bool WillStop() override { return true; }
+
+protected:
+  bool DoPlanExplainsStop(Event *event_ptr) override { return true; }
+
+private:
+  bool InitializeFunctionCaller();
+
+  GNUstepObjCRuntime &m_runtime;
+  /// Address of the argument struct of the msg-lookup function call.
+  lldb::addr_t m_args_addr = LLDB_INVALID_ADDRESS;
+  ValueList m_input_values;
+  /// Keys for the method cache filled in when the lookup completes.
+  lldb::addr_t m_isa_addr;
+  lldb::addr_t m_sel_addr;
+  /// The nested function-call plan; reset once it completes.
+  lldb::ThreadPlanSP m_func_sp;
+  /// The run-to-implementation plan queued after the lookup.
+  lldb::ThreadPlanSP m_run_to_sp;
+  FunctionCaller *m_lookup_function = nullptr;
+};
+
+} // namespace lldb_private
+
+#endif // LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPTHREADPLANSTEPTHROUGHOBJCTRAMPOLINE_H
diff --git a/lldb/test/Shell/Expr/objc-gnustep-print.m b/lldb/test/Shell/Expr/objc-gnustep-print.m
index 70144a5829854..6e119cefc459b 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-print.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-print.m
@@ -120,3 +120,13 @@ int main() {
 //
 // PO: (lldb) po t
 // PO: TestObj
+
+// Stepping at a message send goes through the objc_msgSend trampoline into
+// the method implementation.
+//
+// RUN: %lldb -b -o "b objc-gnustep-print.m:104" -o "run" -o "step" \
+// RUN:     -- %t | FileCheck %s --check-prefix=STEP
+//
+// STEP: (lldb) step
+// STEP: stop reason = step in
+// STEP: check_ivars_zeroed

>From f95fb5d2b7681086b18fc64a56aef987464f2ca5 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 11 Aug 2026 23:45:07 +0100
Subject: [PATCH 05/38] [lldb][GNUstep] Enable Objective-C expression
 evaluation

The expression parser already compiles in native gnustep-2.x mode when
this runtime reports eGNUstep_libobjc2, and the ABI lowers message
sends, class references, and constant strings to ordinary symbols that
resolve from the target's symbol tables. The one missing piece is
selector registration: JIT'd expression modules never run __objc_load,
so their `.objc_selector_*` structures reach objc_msgSend unregistered
and every send fails in the runtime's forwarding path.

Provide the missing piece as a module IR pass via the existing
LanguageRuntime::GetIRPasses hook: rewrite each use of a selector
global in the __objc_selectors section into a call to
sel_registerTypedName_np() (or sel_registerName() for untyped
selectors), reusing the name and type-encoding string constants from
the selector's own initializer. No shared expression-parser code is
touched.

Also override CalculateHasNewLiteralsAndIndexing: gnustep-base
implements the container-literal and boxed-expression protocol, so
@[...], @{...} and @(...) work out of the box.

Assisted-by: Claude Fable 5
---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 104 ++++++++++++++++++
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  10 ++
 lldb/test/Shell/Expr/objc-gnustep-expr.m      |  54 +++++++++
 3 files changed, 168 insertions(+)
 create mode 100644 lldb/test/Shell/Expr/objc-gnustep-expr.m

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 814204a5ef892..8f63fad1c71ef 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -37,11 +37,108 @@
 #include "lldb/Utility/RegularExpression.h"
 #include "lldb/ValueObject/ValueObject.h"
 
+#include "llvm/IR/IRBuilder.h"
+#include "llvm/IR/LegacyPassManager.h"
+#include "llvm/IR/Module.h"
+#include "llvm/Pass.h"
+
 using namespace lldb;
 using namespace lldb_private;
 
 LLDB_PLUGIN_DEFINE(GNUstepObjCRuntime)
 
+namespace {
+/// Registers the Objective-C selectors of a JIT'd expression module with the
+/// libobjc2 runtime.
+///
+/// clang emits each selector as a `.objc_selector_<name>_<types>` global in
+/// the `__objc_selectors` section: a {name, types} string pair that the
+/// runtime's __objc_load rewrites into a registered selector when a module
+/// is loaded. Expression modules are never loaded that way, so passing the
+/// raw structure to objc_msgSend dispatches an unregistered selector (which
+/// gnustep-base reports as e.g. "-[NSSmallInt ]"). Replace every use with
+/// the result of sel_registerTypedName_np()/sel_registerName(), which
+/// resolve against libobjc2 at expression link time.
+class GNUstepObjCSelectorRegistrationPass : public llvm::ModulePass {
+public:
+  static char ID;
+
+  GNUstepObjCSelectorRegistrationPass() : llvm::ModulePass(ID) {}
+
+  llvm::StringRef getPassName() const override {
+    return "GNUstep ObjC selector registration";
+  }
+
+  bool runOnModule(llvm::Module &module) override {
+    llvm::SmallVector<llvm::GlobalVariable *, 8> sel_globals;
+    for (llvm::GlobalVariable &gv : module.globals())
+      if (gv.hasSection() &&
+          llvm::StringRef(gv.getSection()).starts_with("__objc_selectors"))
+        sel_globals.push_back(&gv);
+    if (sel_globals.empty())
+      return false;
+
+    llvm::LLVMContext &ctx = module.getContext();
+    llvm::PointerType *ptr_ty = llvm::PointerType::get(ctx, 0);
+    llvm::FunctionCallee typed_reg;
+    llvm::FunctionCallee untyped_reg;
+
+    bool changed = false;
+    for (llvm::GlobalVariable *gv : sel_globals) {
+      if (!gv->hasInitializer())
+        continue;
+      auto *init = llvm::dyn_cast<llvm::ConstantStruct>(gv->getInitializer());
+      if (!init || init->getNumOperands() < 1)
+        continue;
+      llvm::Constant *name_ptr = init->getOperand(0);
+      llvm::Constant *types_ptr =
+          init->getNumOperands() > 1 ? init->getOperand(1) : nullptr;
+      const bool has_types = types_ptr && !types_ptr->isNullValue();
+
+      // One registration call per function; the entry block dominates all
+      // uses, including PHI incoming edges.
+      llvm::SmallDenseMap<llvm::Function *, llvm::Value *, 4> call_per_fn;
+      llvm::SmallVector<llvm::Use *, 8> uses;
+      for (llvm::Use &use : gv->uses())
+        uses.push_back(&use);
+      for (llvm::Use *use : uses) {
+        auto *inst = llvm::dyn_cast<llvm::Instruction>(use->getUser());
+        if (!inst)
+          continue;
+        llvm::Function *func = inst->getFunction();
+        llvm::Value *&reg_call = call_per_fn[func];
+        if (!reg_call) {
+          llvm::IRBuilder<> builder(
+              &*func->getEntryBlock().getFirstInsertionPt());
+          if (has_types) {
+            if (!typed_reg)
+              typed_reg = module.getOrInsertFunction(
+                  "sel_registerTypedName_np",
+                  llvm::FunctionType::get(ptr_ty, {ptr_ty, ptr_ty},
+                                          /*isVarArg=*/false));
+            reg_call = builder.CreateCall(typed_reg, {name_ptr, types_ptr},
+                                          "lldb.objc.sel");
+          } else {
+            if (!untyped_reg)
+              untyped_reg = module.getOrInsertFunction(
+                  "sel_registerName",
+                  llvm::FunctionType::get(ptr_ty, {ptr_ty},
+                                          /*isVarArg=*/false));
+            reg_call =
+                builder.CreateCall(untyped_reg, {name_ptr}, "lldb.objc.sel");
+          }
+        }
+        use->set(reg_call);
+        changed = true;
+      }
+    }
+    return changed;
+  }
+};
+
+char GNUstepObjCSelectorRegistrationPass::ID = 0;
+} // namespace
+
 char GNUstepObjCRuntime::ID = 0;
 
 void GNUstepObjCRuntime::Initialize() {
@@ -573,6 +670,13 @@ void GNUstepObjCRuntime::UpdateISAToDescriptorMapIfNeeded() {
   m_isa_to_descriptor_stop_id = stop_id;
 }
 
+bool GNUstepObjCRuntime::GetIRPasses(
+    LLVMUserExpression::IRPasses &custom_passes) {
+  custom_passes.EarlyPasses = std::make_shared<llvm::legacy::PassManager>();
+  custom_passes.EarlyPasses->add(new GNUstepObjCSelectorRegistrationPass());
+  return true;
+}
+
 ObjCLanguageRuntime::TaggedPointerVendor *
 GNUstepObjCRuntime::GetTaggedPointerVendor() {
   return m_tagged_pointer_vendor_up.get();
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 99488a16b8213..91e70db7f94c1 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -102,6 +102,16 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
 
   void UpdateISAToDescriptorMapIfNeeded() override;
 
+  /// Provides an IR pass that registers the expression module's Objective-C
+  /// selectors with the runtime. JIT'd expression modules never run
+  /// __objc_load, so their selector structures would otherwise reach
+  /// objc_msgSend unregistered.
+  bool GetIRPasses(LLVMUserExpression::IRPasses &custom_passes) override;
+
+  /// gnustep-base implements the container-literal and boxed-expression
+  /// protocol methods, so @[...], @{...} and @(...) are available.
+  bool CalculateHasNewLiteralsAndIndexing() override { return true; }
+
   TaggedPointerVendor *GetTaggedPointerVendor() override;
 
   ClassDescriptorSP GetClassDescriptor(ValueObject &in_value) override;
diff --git a/lldb/test/Shell/Expr/objc-gnustep-expr.m b/lldb/test/Shell/Expr/objc-gnustep-expr.m
new file mode 100644
index 0000000000000..c58b13e1238fb
--- /dev/null
+++ b/lldb/test/Shell/Expr/objc-gnustep-expr.m
@@ -0,0 +1,54 @@
+// REQUIRES: objc-gnustep
+// XFAIL: system-windows
+//
+// RUN: %build %s --compiler=clang --objc-gnustep --output=%t
+
+#import "objc/runtime.h"
+
+ at protocol NSCoding
+ at end
+
+#ifdef __has_attribute
+#if __has_attribute(objc_root_class)
+__attribute__((objc_root_class))
+#endif
+#endif
+ at interface NSObject <NSCoding> {
+  id isa;
+  int refcount;
+}
+ at end
+ at implementation NSObject
++ (id)new {
+  return class_createInstance(self, 0);
+}
+ at end
+
+ at interface Calc : NSObject
+- (int)addFourtyTwoTo:(int)value;
+ at end
+ at implementation Calc
+- (int)addFourtyTwoTo:(int)value {
+  return value + 42;
+}
+ at end
+
+// Message sends in expressions require the JIT'd module's selectors to be
+// registered with the runtime (the GNUstep plugin's IR pass does this);
+// without it the dispatch reaches the runtime with an unregistered selector.
+//
+// RUN: %lldb -b -o "b objc-gnustep-expr.m:47" -o "run" \
+// RUN:          -o "expr [c addFourtyTwoTo:100]" \
+// RUN:          -o "expr (int)[[Calc new] addFourtyTwoTo:1]" -- %t | FileCheck %s
+//
+int main() {
+  Calc *c = [Calc new];
+  (void)[c addFourtyTwoTo:0];
+  return 0;
+}
+//
+// CHECK: (lldb) expr [c addFourtyTwoTo:100]
+// CHECK: (int) {{.*}} = 142
+//
+// CHECK: (lldb) expr (int)[[Calc new] addFourtyTwoTo:1]
+// CHECK: (int) {{.*}} = 43

>From ef4baceeb600807648384507e10f9c214eaf8de8 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 00:13:22 +0100
Subject: [PATCH 06/38] [lldb][GNUstep] Match the expression result variable by
 pattern

An expression's result is named $0, $1 and so on depending on how many
have been evaluated before it, so match it with a pattern rather than a
literal, and avoid an unbalanced bracket in a check line.

Assisted-by: Claude Opus 5
---
 lldb/test/Shell/Expr/objc-gnustep-expr.m | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/lldb/test/Shell/Expr/objc-gnustep-expr.m b/lldb/test/Shell/Expr/objc-gnustep-expr.m
index c58b13e1238fb..bc20422d817bc 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-expr.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-expr.m
@@ -48,7 +48,7 @@ int main() {
 }
 //
 // CHECK: (lldb) expr [c addFourtyTwoTo:100]
-// CHECK: (int) {{.*}} = 142
+// CHECK: (int) {{\$[0-9]+}} = 142
 //
-// CHECK: (lldb) expr (int)[[Calc new] addFourtyTwoTo:1]
-// CHECK: (int) {{.*}} = 43
+// CHECK: addFourtyTwoTo:1]
+// CHECK: (int) {{\$[0-9]+}} = 43

>From 3555065b8c5d7651ea2866668fa62bb9df78d117 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 00:13:22 +0100
Subject: [PATCH 07/38] [lldb][GNUstep] Assert the dynamic type through the
 command interpreter

`frame variable -d run-target` is what a user actually sees, so check
its output rather than only the value the API returns, and check that
the static type is still reported when dynamic values are turned off.

Assisted-by: Claude Opus 5
---
 lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m b/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m
index 6c43f2df26223..2d0caf1c69426 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m
@@ -36,9 +36,9 @@ @implementation Derived
 
 // The static type of `object` is Base, but the dynamic type is Derived. The
 // GNUstep runtime resolves the dynamic type by reading the class structure
-// from the inferior's memory (no code is run in the inferior).
+// from the inferior's memory and attaching the matching type from debug info.
 //
-// RUN: %lldb -b -o "b objc-gnustep-dynamic-types.m:48" -o "run" \
+// RUN: %lldb -b -o "b objc-gnustep-dynamic-types.m:47" -o "run" \
 // RUN:          -o "frame variable -d run-target object" \
 // RUN:          -o "frame variable -d no-dynamic-values object" -- %t | FileCheck %s
 //

>From 8ef8b4214e5f51742bd79d341e56174014664e30 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 00:16:39 +0100
Subject: [PATCH 08/38] [lldb][GNUstep] Attach a type from debug info to a
 dynamic value

Reporting only a class name leaves LLDB without a type to display, so it
falls back to the static type. The base class's cache is keyed on a
symbol named after the class, which the Apple ABI emits but the
gnustep-2.x ABI does not - its class symbol is `._OBJC_CLASS_<name>` -
so on a miss, query the debug info directly for the interface type.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 28 +++++++++++++------
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  6 ++++
 2 files changed, 26 insertions(+), 8 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 8f63fad1c71ef..cf1dfd6f51409 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -394,26 +394,38 @@ bool GNUstepObjCRuntime::GetDynamicTypeAndAddress(
   address.SetRawAddress(object_ptr);
   class_type_or_name.SetName(class_name);
 
-  // Try to upgrade the bare name to a real type: first from the cache of
-  // classes already realized from debug info, then - should a decl vendor
-  // exist one day - from that.
+  // Upgrade the bare class name to a real type when the inferior's debug
+  // info defines the class. LookupInCompleteClassCache keys on an
+  // eSymbolTypeObjCClass symbol named exactly after the class, which the
+  // Apple ABI emits but the gnustep-2.x ABI does not (its class symbol is
+  // "._OBJC_CLASS_<name>"), so on a cache miss query the debug info directly.
   TypeSP type_sp(objc_class_sp->GetType());
   if (!type_sp) {
     type_sp = LookupInCompleteClassCache(class_name);
+    if (!type_sp)
+      type_sp = LookupClassTypeInDebugInfo(class_name);
     if (type_sp)
       objc_class_sp->SetType(type_sp);
   }
   if (type_sp)
     class_type_or_name.SetTypeSP(type_sp);
-  else if (auto *vendor = GetDeclVendor()) {
-    auto types = vendor->FindTypes(class_name, /*max_matches*/ 1);
-    if (!types.empty())
-      class_type_or_name.SetCompilerType(types.front());
-  }
 
   return !class_type_or_name.IsEmpty();
 }
 
+lldb::TypeSP
+GNUstepObjCRuntime::LookupClassTypeInDebugInfo(ConstString class_name) {
+  TypeQuery query(class_name.GetStringRef(), TypeQueryOptions::e_exact_match);
+  TypeResults results;
+  GetTargetRef().GetImages().FindTypes(nullptr, query, results);
+  for (const TypeSP &type_sp : results.GetTypeMap().Types()) {
+    if (type_sp && TypeSystemClang::IsObjCObjectOrInterfaceType(
+                       type_sp->GetForwardCompilerType()))
+      return type_sp;
+  }
+  return TypeSP();
+}
+
 TypeAndOrName
 GNUstepObjCRuntime::FixUpDynamicType(const TypeAndOrName &type_and_or_name,
                                      ValueObject &static_value) {
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 91e70db7f94c1..fc9a928de8608 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -122,6 +122,12 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   // Call CreateInstance instead.
   GNUstepObjCRuntime(Process *process);
 
+  /// Finds a complete Objective-C interface type named \p class_name in the
+  /// target's debug info. Used to attach a real type to a dynamic value when
+  /// the base class's symbol-name-keyed cache misses (the gnustep-2.x class
+  /// symbol is not named after the class).
+  lldb::TypeSP LookupClassTypeInDebugInfo(ConstString class_name);
+
   /// Address of gnustep-base's `const char *_NSPrintForDebugger(id)`, the
   /// same debugger hook AppleObjCRuntime uses. Resolved lazily; nullptr when
   /// gnustep-base is not loaded in the inferior.

>From 81ce03830b921cfa77fcd3228449b573f989d7d5 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 00:16:39 +0100
Subject: [PATCH 09/38] [lldb][GNUstep] Identify dispatch entry points by name,
 not by address

libobjc2's hand-written assembly places local labels at the same address
as objc_msgSend, so the symbol found at an address is not reliably the
dispatch symbol. Resolve each entry point's address from its name
instead, and add the struct-return and lookup variants.

Build the call that resolves a method implementation as a utility
function, the way AppleObjCTrampolineHandler does, rather than from a
bare function address: the latter cannot be compiled from inside a
step's pre-resume action.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 125 ++++++++++++------
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  31 ++++-
 ...tepThreadPlanStepThroughObjCTrampoline.cpp |   8 +-
 3 files changed, 115 insertions(+), 49 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index cf1dfd6f51409..17f19a0b14097 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -497,33 +497,22 @@ GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
                                                  bool stop_others) {
   // Only act when stopped at the first instruction of a known libobjc2
   // dispatch entry point (where the argument registers still hold the
-  // receiver and selector).
+  // receiver and selector). Match by resolving each entry point's address by
+  // name rather than by the symbol at the PC: libobjc2's hand-written
+  // assembly places local labels (e.g. __objc_block_trampoline_end_sret) at
+  // the same address as objc_msgSend, so the symbol found at an address is
+  // not reliably the dispatch symbol.
   Process *process = thread.GetProcess().get();
   if (!process)
     return {};
   const addr_t pc = thread.GetRegisterContext()->GetPC();
   Target &target = GetTargetRef();
-  Address pc_addr;
-  if (!target.ResolveLoadAddress(pc, pc_addr))
-    return {};
-  Symbol *symbol = pc_addr.CalculateSymbolContextSymbol();
-  if (!symbol || symbol->GetAddress().GetLoadAddress(&target) != pc)
-    return {};
 
-  // Dispatch entry points exported by libobjc2 (objc_msgSend.S, sendmsg2.c).
-  // The `_super` variants are omitted: super sends compile to a lookup plus
-  // a direct call, and the direct call steps normally.
-  llvm::StringRef name = symbol->GetName().GetStringRef();
-  bool is_stret = false, is_sender = false;
-  if (name == "objc_msgSend" || name == "objc_msgSend_fpret" ||
-      name == "objc_msg_lookup") {
-  } else if (name == "objc_msgSend_stret") {
-    is_stret = true;
-  } else if (name == "objc_msg_lookup_sender") {
-    is_sender = true;
-  } else {
+  const DispatchEntryPoint *entry = FindDispatchEntryPoint(pc);
+  if (!entry)
     return {};
-  }
+  const bool is_stret = entry->is_stret;
+  const bool is_sender = entry->is_sender;
 
   ABISP abi_sp = process->GetABI();
   if (!abi_sp)
@@ -585,7 +574,7 @@ GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
     }
   }
 
-  if (!GetMsgLookupFunctionCaller())
+  if (!GetMsgLookupFunctionCaller(thread))
     return {};
 
   ValueList lookup_args;
@@ -600,30 +589,82 @@ GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
       thread, *this, lookup_args, isa, selector);
 }
 
-FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller() {
-  if (m_msg_lookup_caller_up)
-    return m_msg_lookup_caller_up.get();
-
-  Target &target = GetTargetRef();
-  SymbolContextList sc_list;
-  target.GetImages().FindSymbolsWithNameAndType(ConstString("objc_msg_lookup"),
-                                                eSymbolTypeCode, sc_list);
-  Address lookup_addr;
-  for (const SymbolContext &sc : sc_list) {
-    if (sc.symbol) {
-      lookup_addr = sc.symbol->GetAddress();
-      break;
+const GNUstepObjCRuntime::DispatchEntryPoint *
+GNUstepObjCRuntime::FindDispatchEntryPoint(lldb::addr_t pc) {
+  if (!m_dispatch_entry_points_resolved) {
+    m_dispatch_entry_points_resolved = true;
+    // Dispatch entry points exported by libobjc2 (objc_msgSend.S,
+    // sendmsg2.c). The `_super` variants are omitted: super sends compile to
+    // a lookup plus a direct call, and the direct call steps normally.
+    static const struct {
+      const char *name;
+      bool is_stret;
+      bool is_sender;
+    } kEntryPoints[] = {
+        {"objc_msgSend", false, false},
+        {"objc_msgSend_fpret", false, false},
+        {"objc_msgSend_stret", true, false},
+        {"objc_msg_lookup", false, false},
+        {"objc_msg_lookup_sender", false, true},
+    };
+    Target &target = GetTargetRef();
+    for (const auto &ep : kEntryPoints) {
+      SymbolContextList sc_list;
+      target.GetImages().FindSymbolsWithNameAndType(ConstString(ep.name),
+                                                    eSymbolTypeCode, sc_list);
+      for (const SymbolContext &sc : sc_list) {
+        if (!sc.symbol)
+          continue;
+        const addr_t addr = sc.symbol->GetLoadAddress(&target);
+        if (addr != LLDB_INVALID_ADDRESS) {
+          m_dispatch_entry_points.push_back({addr, ep.is_stret, ep.is_sender});
+          break;
+        }
+      }
     }
   }
-  if (!lookup_addr.IsValid())
+
+  for (const DispatchEntryPoint &ep : m_dispatch_entry_points)
+    if (ep.address == pc)
+      return &ep;
+  return nullptr;
+}
+
+FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller(Thread &thread) {
+  // Build (once) a utility function that resolves a method implementation by
+  // calling libobjc2's objc_msg_lookup, and a FunctionCaller to invoke it.
+  // This mirrors AppleObjCTrampolineHandler's dispatch-lookup utility and is
+  // the JIT path that works from inside a step's PreResume action.
+  static const char *g_lookup_name = "$__lldb_gnustep_objc_msg_lookup";
+  static const char *g_lookup_code =
+      "void *objc_msg_lookup(void *receiver, void *selector);\n"
+      "void *$__lldb_gnustep_objc_msg_lookup(void *receiver, void *selector) {\n"
+      "  return objc_msg_lookup(receiver, selector);\n"
+      "}\n";
+
+  if (m_msg_lookup_caller)
+    return m_msg_lookup_caller;
+
+  ThreadSP thread_sp(thread.shared_from_this());
+  ExecutionContext exe_ctx(thread_sp);
+  Log *log = GetLog(LLDBLog::Step);
+
+  auto utility_fn_or_error = exe_ctx.GetTargetRef().CreateUtilityFunction(
+      g_lookup_code, g_lookup_name, eLanguageTypeC, exe_ctx);
+  if (!utility_fn_or_error) {
+    LLDB_LOG_ERROR(log, utility_fn_or_error.takeError(),
+                   "[GNUstep] failed to build objc_msg_lookup utility: {0}");
     return nullptr;
+  }
+  m_msg_lookup_utility_up = std::move(*utility_fn_or_error);
 
   TypeSystemClangSP scratch_ts_sp =
-      ScratchTypeSystemClang::GetForTarget(target);
+      ScratchTypeSystemClang::GetForTarget(GetTargetRef());
   if (!scratch_ts_sp)
     return nullptr;
   CompilerType void_ptr_type =
       scratch_ts_sp->GetBasicType(eBasicTypeVoid).GetPointerType();
+
   Value void_ptr_value;
   void_ptr_value.SetValueType(Value::ValueType::Scalar);
   void_ptr_value.SetCompilerType(void_ptr_type);
@@ -632,14 +673,16 @@ FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller() {
   args.PushValue(void_ptr_value);
 
   Status error;
-  m_msg_lookup_caller_up.reset(target.GetFunctionCallerForLanguage(
-      eLanguageTypeC, void_ptr_type, lookup_addr, args, "gnustep-msg-lookup",
-      error));
+  m_msg_lookup_caller =
+      m_msg_lookup_utility_up->MakeFunctionCaller(void_ptr_type, args,
+                                                  thread_sp, error);
   if (error.Fail()) {
-    m_msg_lookup_caller_up.reset();
+    LLDB_LOG(log, "[GNUstep] failed to make objc_msg_lookup caller: {0}",
+             error.AsCString());
+    m_msg_lookup_caller = nullptr;
     return nullptr;
   }
-  return m_msg_lookup_caller_up.get();
+  return m_msg_lookup_caller;
 }
 
 void GNUstepObjCRuntime::UpdateISAToDescriptorMapIfNeeded() {
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index fc9a928de8608..2d3c28e7040e3 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -14,6 +14,7 @@
 
 #include "Plugins/LanguageRuntime/ObjC/ObjCLanguageRuntime.h"
 
+#include "llvm/ADT/SmallVector.h"
 #include "llvm/ADT/StringRef.h"
 #include "llvm/Support/Error.h"
 
@@ -122,6 +123,20 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   // Call CreateInstance instead.
   GNUstepObjCRuntime(Process *process);
 
+  /// A libobjc2 message dispatch entry point, identified by the load address
+  /// of its first instruction (resolved by symbol name, so it is robust to
+  /// local labels sharing the address).
+  struct DispatchEntryPoint {
+    lldb::addr_t address;
+    bool is_stret;
+    bool is_sender;
+  };
+
+  /// Returns the dispatch entry point whose first instruction is at \p pc, or
+  /// nullptr. The entry-point address table is resolved and cached on first
+  /// use.
+  const DispatchEntryPoint *FindDispatchEntryPoint(lldb::addr_t pc);
+
   /// Finds a complete Objective-C interface type named \p class_name in the
   /// target's debug info. Used to attach a real type to a dynamic value when
   /// the base class's symbol-name-keyed cache misses (the gnustep-2.x class
@@ -134,11 +149,12 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   Address *GetPrintForDebuggerAddr();
 
 public:
-  /// Lazily-built FunctionCaller for libobjc2's
+  /// Lazily-built FunctionCaller for a utility function that resolves a
+  /// method implementation via libobjc2's
   /// `IMP objc_msg_lookup(id receiver, SEL selector)`, used by the
-  /// step-through-trampoline plan. Returns nullptr if the symbol cannot be
-  /// resolved.
-  FunctionCaller *GetMsgLookupFunctionCaller();
+  /// step-through-trampoline plan. Returns nullptr on failure. The caller is
+  /// owned by the utility function and stays valid for the runtime's life.
+  FunctionCaller *GetMsgLookupFunctionCaller(Thread &thread);
 
 protected:
 
@@ -148,7 +164,12 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
 
   std::unique_ptr<FunctionCaller> m_print_object_caller_up;
 
-  std::unique_ptr<FunctionCaller> m_msg_lookup_caller_up;
+  /// Utility function wrapping objc_msg_lookup; owns m_msg_lookup_caller.
+  std::unique_ptr<UtilityFunction> m_msg_lookup_utility_up;
+  FunctionCaller *m_msg_lookup_caller = nullptr;
+
+  llvm::SmallVector<DispatchEntryPoint, 5> m_dispatch_entry_points;
+  bool m_dispatch_entry_points_resolved = false;
 
   std::unique_ptr<GNUstepTaggedPointerVendor> m_tagged_pointer_vendor_up;
 
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
index 89ea5835a0b2c..318f34cceee68 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
@@ -58,16 +58,18 @@ bool GNUstepThreadPlanStepThroughObjCTrampoline::InitializeFunctionCaller() {
   if (m_func_sp)
     return true;
 
-  m_lookup_function = m_runtime.GetMsgLookupFunctionCaller();
+  m_lookup_function = m_runtime.GetMsgLookupFunctionCaller(GetThread());
   if (!m_lookup_function)
     return false;
 
   ExecutionContext exe_ctx;
   GetThread().CalculateExecutionContext(exe_ctx);
 
+  // The wrapper was already compiled into the inferior when the caller was
+  // built (GetMsgLookupFunctionCaller); only write a fresh argument struct
+  // here. m_args_addr starts invalid so WriteFunctionArguments allocates one.
   DiagnosticManager diagnostics;
-  if (!m_lookup_function->InsertFunction(exe_ctx, m_args_addr, diagnostics))
-    return false;
+  m_args_addr = LLDB_INVALID_ADDRESS;
   if (!m_lookup_function->WriteFunctionArguments(exe_ctx, m_args_addr,
                                                  m_input_values, diagnostics))
     return false;

>From 137879d6eb7133e6b4bfab1e10e06765126d6cb5 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 10:24:42 +0100
Subject: [PATCH 10/38] [lldb][GNUstep] Harden runtime for all libobjc2
 configurations

Correctness and robustness work across the plugin:

Detection: identify the runtime by a defined __objc_load rather than by
library file name, so statically linked (BUILD_STATIC_LIBOBJC) and
renamed (LIBOBJC_NAME) builds are recognized too. Undefined references
are ignored, since every module compiled against libobjc2 carries one.

Data model: compute struct objc_class field offsets from the target's
data model instead of assuming pointer stride. The trailing fields are
`long`, which is 32 bits on Windows, so instance_size lives at a
different offset there. Likewise use the COFF symbol prefix ($_) and
section names (.objcrt$SEL) on Windows, and recognize
objc_msgSend_stret2.

Memory safety: bound the class-name read, and validate a candidate class
by cross-checking the meta flag against its metaclass. Only report a
superclass and instance size for resolved classes, since before the
runtime resolves a class those fields hold a name pointer and the
negated size of the class's own ivars.

Correct 32-bit signed tagged payloads by sign-extending from the
target's pointer width.

Feature probing: only claim literal/subscripting support when the
Foundation classes it lowers to are present, so a bare libobjc2 process
reports a clean error at compile time instead of failing inside the
inferior.

Caching: invalidate the dispatch entry points, tagged pointer table and
negative type lookups when modules load, scan only newly loaded modules
for classes rather than re-walking every symbol table, and cache classes
that have no debug info.

Step-through: guard against a lookup that was never set up, stop instead
of stepping into the runtime's forwarding machinery, clear the
pre-resume action when the plan is popped, and defer building the call
wrapper to the pre-resume action. Serialize its construction and latch
failures.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCClassDescriptor.cpp            | 138 ++++++++---
 .../GNUstepObjCClassDescriptor.h              |  41 +++-
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 231 +++++++++++++-----
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  60 +++--
 ...tepThreadPlanStepThroughObjCTrampoline.cpp |  26 ++
 ...UstepThreadPlanStepThroughObjCTrampoline.h |   2 +
 6 files changed, 361 insertions(+), 137 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
index 816b2cae1ade8..f310bff13aa51 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
@@ -14,6 +14,7 @@
 #include "lldb/Symbol/SymbolContext.h"
 #include "lldb/Target/Process.h"
 #include "lldb/Target/Target.h"
+#include "lldb/Utility/ArchSpec.h"
 #include "lldb/Utility/ConstString.h"
 #include "lldb/Utility/LLDBLog.h"
 #include "lldb/Utility/Log.h"
@@ -22,16 +23,44 @@
 using namespace lldb;
 using namespace lldb_private;
 
-// Field indices into libobjc2's `struct objc_class` (see class documentation
-// in the header).
-static constexpr uint64_t kClassFieldIsa = 0;
-static constexpr uint64_t kClassFieldSuperclass = 1;
-static constexpr uint64_t kClassFieldName = 2;
-static constexpr uint64_t kClassFieldInstanceSize = 5;
-
-// An upper bound for plausible class names; longer strings indicate that the
-// name pointer does not actually point at a class name.
-static constexpr size_t kMaxClassNameLength = 512;
+// Flags from libobjc2's `enum objc_class_flags` (class.h).
+static constexpr uint64_t g_class_flag_meta = 1ULL << 0;
+static constexpr uint64_t g_class_flag_resolved = 1ULL << 9;
+
+// An upper bound for plausible class names. A string that does not terminate
+// within this many bytes is not a class name, and stopping there keeps a
+// stray pointer from dragging in arbitrary amounts of inferior memory.
+static constexpr size_t g_max_class_name_length = 256;
+
+namespace {
+/// Offsets of the `struct objc_class` fields this descriptor reads. The first
+/// three fields are pointers; the rest are `long`, which is not always the
+/// same width (see the class documentation).
+struct ClassLayout {
+  uint32_t pointer_size;
+  uint32_t long_size;
+  uint64_t superclass_offset;
+  uint64_t name_offset;
+  uint64_t info_offset;
+  uint64_t instance_size_offset;
+};
+
+ClassLayout GetClassLayout(Process &process) {
+  ClassLayout layout;
+  layout.pointer_size = process.GetAddressByteSize();
+  // Windows is LLP64, so `long` stays 32 bits there while pointers are 64.
+  const llvm::Triple &triple =
+      process.GetTarget().GetArchitecture().GetTriple();
+  layout.long_size = (triple.isOSWindows() && layout.pointer_size == 8)
+                         ? 4
+                         : layout.pointer_size;
+  layout.superclass_offset = layout.pointer_size;
+  layout.name_offset = 2 * layout.pointer_size;
+  layout.info_offset = 3 * layout.pointer_size + layout.long_size;
+  layout.instance_size_offset = 3 * layout.pointer_size + 2 * layout.long_size;
+  return layout;
+}
+} // namespace
 
 GNUstepObjCClassDescriptor::GNUstepObjCClassDescriptor(
     ProcessSP process_sp, ObjCLanguageRuntime::ObjCISA isa)
@@ -44,46 +73,66 @@ void GNUstepObjCClassDescriptor::Read() {
   if (!process_sp || m_isa == 0 || m_isa == LLDB_INVALID_ADDRESS)
     return;
 
-  const uint32_t addr_size = process_sp->GetAddressByteSize();
+  const ClassLayout layout = GetClassLayout(*process_sp);
   // Class objects are at least pointer-aligned.
-  if (m_isa % addr_size != 0)
+  if (m_isa % layout.pointer_size != 0)
     return;
 
   Status error;
-  auto read_field = [&](uint64_t index) -> addr_t {
-    addr_t value = process_sp->ReadPointerFromMemory(
-        m_isa + index * addr_size, error);
+  auto read_pointer = [&](uint64_t offset) -> addr_t {
+    addr_t value = process_sp->ReadPointerFromMemory(m_isa + offset, error);
     return error.Fail() ? LLDB_INVALID_ADDRESS : value;
   };
 
-  const addr_t metaclass = read_field(kClassFieldIsa);
-  if (metaclass == LLDB_INVALID_ADDRESS)
+  const addr_t metaclass = read_pointer(0);
+  if (metaclass == 0 || metaclass == LLDB_INVALID_ADDRESS)
     return;
-  const addr_t superclass = read_field(kClassFieldSuperclass);
+  const addr_t superclass = read_pointer(layout.superclass_offset);
   if (superclass == LLDB_INVALID_ADDRESS)
     return;
-  const addr_t name_ptr = read_field(kClassFieldName);
-  if (name_ptr == LLDB_INVALID_ADDRESS || name_ptr == 0)
+  const addr_t name_ptr = read_pointer(layout.name_offset);
+  if (name_ptr == 0 || name_ptr == LLDB_INVALID_ADDRESS)
     return;
 
-  std::string name;
-  process_sp->ReadCStringFromMemory(name_ptr, name, error);
-  if (error.Fail() || name.empty() || name.size() >= kMaxClassNameLength)
+  char name_buffer[g_max_class_name_length];
+  const size_t name_length = process_sp->ReadCStringFromMemory(
+      name_ptr, name_buffer, sizeof(name_buffer), error);
+  // A string that fills the buffer was truncated, so it is not a class name.
+  if (error.Fail() || name_length == 0 ||
+      name_length >= sizeof(name_buffer) - 1)
     return;
 
-  // `instance_size` is a signed `long`. With the non-fragile ABI it is
-  // negative until the runtime registers the class; take the magnitude so a
-  // not-yet-registered class still yields a usable size.
-  const int64_t instance_size = process_sp->ReadSignedIntegerFromMemory(
-      m_isa + kClassFieldInstanceSize * addr_size, addr_size, 0, error);
+  const uint64_t info = process_sp->ReadUnsignedIntegerFromMemory(
+      m_isa + layout.info_offset, layout.long_size, 0, error);
   if (error.Fail())
     return;
 
+  // A class and its metaclass must disagree about the meta flag. Checking
+  // both directions is what keeps an arbitrary readable address from being
+  // accepted as a class.
+  const bool is_meta = (info & g_class_flag_meta) != 0;
+  if (!is_meta) {
+    const uint64_t metaclass_info = process_sp->ReadUnsignedIntegerFromMemory(
+        metaclass + layout.info_offset, layout.long_size, 0, error);
+    if (error.Fail() || (metaclass_info & g_class_flag_meta) == 0)
+      return;
+  }
+
+  // Only a resolved class has a real superclass pointer and instance size;
+  // see the class documentation.
+  const bool resolved = (info & g_class_flag_resolved) != 0;
+  if (resolved) {
+    const int64_t instance_size = process_sp->ReadSignedIntegerFromMemory(
+        m_isa + layout.instance_size_offset, layout.long_size, 0, error);
+    if (error.Fail())
+      return;
+    m_instance_size = static_cast<uint64_t>(
+        instance_size < 0 ? -instance_size : instance_size);
+    m_superclass_isa = superclass;
+  }
+
   m_metaclass_isa = metaclass;
-  m_superclass_isa = superclass;
-  m_name = ConstString(name);
-  m_instance_size = static_cast<uint64_t>(
-      instance_size < 0 ? -instance_size : instance_size);
+  m_name = ConstString(name_buffer);
   m_valid = true;
 }
 
@@ -130,9 +179,15 @@ bool GNUstepObjCTaggedPointerClassDescriptor::GetTaggedPointerInfoSigned(
     uint64_t *info_bits, int64_t *value_bits, uint64_t *payload) {
   if (info_bits)
     *info_bits = m_tag;
-  if (value_bits)
-    *value_bits =
-        static_cast<int64_t>(m_pointer_value) >> m_payload_shift;
+  if (value_bits) {
+    // Sign-extend from the target's pointer width before shifting, so that a
+    // negative payload in a 32-bit pointer is not read as a large positive.
+    const uint32_t pointer_bits = m_pointer_size * 8;
+    int64_t signed_value = static_cast<int64_t>(m_pointer_value)
+                           << (64 - pointer_bits);
+    signed_value >>= (64 - pointer_bits);
+    *value_bits = signed_value >> m_payload_shift;
+  }
   if (payload)
     *payload = m_pointer_value;
   return true;
@@ -145,7 +200,8 @@ bool GNUstepTaggedPointerVendor::IsPossibleTaggedPointer(lldb::addr_t ptr) {
 
 std::unique_ptr<ObjCLanguageRuntime::ClassDescriptor>
 GNUstepTaggedPointerVendor::GetClassDescriptor(lldb::addr_t ptr) {
-  const bool is_64_bit = m_process.GetAddressByteSize() == 8;
+  const uint32_t pointer_size = m_process.GetAddressByteSize();
+  const bool is_64_bit = pointer_size == 8;
   const uint64_t mask = is_64_bit ? 7 : 1;
   const uint32_t payload_shift = is_64_bit ? 3 : 1;
   const uint64_t tag = ptr & mask;
@@ -153,8 +209,9 @@ GNUstepTaggedPointerVendor::GetClassDescriptor(lldb::addr_t ptr) {
     return nullptr;
 
   // Mirror libobjc2's classForObject(): 32-bit targets have a single small
-  // object class at index 0; 64-bit targets index the table by the tag. The
-  // table has 7 entries, so reject out-of-range tags.
+  // object class at index 0; 64-bit targets index the table by the tag.
+  // `SmallObjectClasses` has 7 entries (class_table.c), so a tag of 7 has no
+  // corresponding class.
   const uint64_t index = is_64_bit ? tag : 0;
   if (index > 6)
     return nullptr;
@@ -184,13 +241,14 @@ GNUstepTaggedPointerVendor::GetClassDescriptor(lldb::addr_t ptr) {
 
   Status error;
   const addr_t isa = m_process.ReadPointerFromMemory(
-      *m_table_addr + index * m_process.GetAddressByteSize(), error);
+      *m_table_addr + index * pointer_size, error);
   if (error.Fail() || isa == 0 || isa == LLDB_INVALID_ADDRESS)
     return nullptr;
 
   auto descriptor_up =
       std::make_unique<GNUstepObjCTaggedPointerClassDescriptor>(
-          m_process.shared_from_this(), isa, ptr, tag, payload_shift);
+          m_process.shared_from_this(), isa, ptr, tag, payload_shift,
+          pointer_size);
   if (!descriptor_up->IsValid())
     return nullptr;
   return descriptor_up;
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
index 949b9f98ca9d6..e1b2ee240d931 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
@@ -25,16 +25,23 @@ namespace lldb_private {
 /// The layout parsed here is libobjc2's `struct objc_class` (class.h), whose
 /// leading fields have been stable across the gnustep-2.x ABI:
 ///
-///   Class isa;              // metaclass          [index 0]
-///   Class super_class;      //                    [index 1]
-///   const char *name;       //                    [index 2]
-///   long version;           //                    [index 3]
-///   unsigned long info;     // flag bits          [index 4]
-///   long instance_size;     //                    [index 5]
+///   Class isa;              // metaclass
+///   Class super_class;
+///   const char *name;
+///   long version;
+///   unsigned long info;     // enum objc_class_flags
+///   long instance_size;
 ///
-/// Note: with the non-fragile ABI the compiler emits a negative
-/// instance_size; the runtime replaces it with the real size when the class
-/// is registered, so debug-time reads of loaded classes see the real value.
+/// Note that the last three fields are `long`, which is 32 bits on Windows
+/// (LLP64) and pointer-sized on the LP64 and ILP32 targets libobjc2 supports,
+/// so field offsets are computed from the target's data model rather than from
+/// the pointer size alone.
+///
+/// Classes emitted by the compiler are only fully formed once the runtime has
+/// resolved them (`objc_class_flag_resolved`): before that, `super_class` may
+/// still hold the superclass *name* rather than a Class, and `instance_size`
+/// holds the negated size of just this class's own ivars. Both are therefore
+/// only reported for resolved classes.
 class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
 public:
   GNUstepObjCClassDescriptor(lldb::ProcessSP process_sp,
@@ -68,8 +75,9 @@ class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
   ObjCLanguageRuntime::ObjCISA GetISA() override { return m_isa; }
 
 protected:
-  /// Parse `struct objc_class` at m_isa. Called from the constructor;
-  /// sets m_valid on success.
+  /// Parse `struct objc_class` at m_isa. Called from the constructor; sets
+  /// m_valid only if the structure passes the consistency checks that keep a
+  /// stray pointer into readable memory from being reported as a class.
   void Read();
 
   lldb::ProcessWP m_process_wp;
@@ -91,10 +99,11 @@ class GNUstepObjCTaggedPointerClassDescriptor
   GNUstepObjCTaggedPointerClassDescriptor(lldb::ProcessSP process_sp,
                                           ObjCLanguageRuntime::ObjCISA isa,
                                           lldb::addr_t pointer_value,
-                                          uint64_t tag, uint32_t payload_shift)
+                                          uint64_t tag, uint32_t payload_shift,
+                                          uint32_t pointer_size)
       : GNUstepObjCClassDescriptor(std::move(process_sp), isa),
         m_pointer_value(pointer_value), m_tag(tag),
-        m_payload_shift(payload_shift) {}
+        m_payload_shift(payload_shift), m_pointer_size(pointer_size) {}
 
   bool GetTaggedPointerInfo(uint64_t *info_bits = nullptr,
                             uint64_t *value_bits = nullptr,
@@ -108,6 +117,7 @@ class GNUstepObjCTaggedPointerClassDescriptor
   lldb::addr_t m_pointer_value;
   uint64_t m_tag;
   uint32_t m_payload_shift;
+  uint32_t m_pointer_size;
 };
 
 /// Resolves tagged pointers by mirroring libobjc2's `classForObject()`
@@ -130,6 +140,11 @@ class GNUstepTaggedPointerVendor
   std::unique_ptr<ObjCLanguageRuntime::ClassDescriptor>
   GetClassDescriptor(lldb::addr_t ptr) override;
 
+  /// Forget where (or whether) the small object class table was found, so a
+  /// newly loaded runtime is picked up and a negative result is not cached
+  /// for the lifetime of the process.
+  void ModulesDidLoad() { m_table_addr.reset(); }
+
 private:
   /// Load address of libobjc2's `SmallObjectClasses` table, resolved lazily
   /// and cached. LLDB_INVALID_ADDRESS inside the optional means resolution
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 17f19a0b14097..b960e80a10404 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -41,6 +41,7 @@
 #include "llvm/IR/LegacyPassManager.h"
 #include "llvm/IR/Module.h"
 #include "llvm/Pass.h"
+#include "llvm/Support/Regex.h"
 
 using namespace lldb;
 using namespace lldb_private;
@@ -70,11 +71,18 @@ class GNUstepObjCSelectorRegistrationPass : public llvm::ModulePass {
   }
 
   bool runOnModule(llvm::Module &module) override {
+    // Section names differ by object format: "__objc_selectors" everywhere
+    // except COFF, which sorts the runtime metadata into ".objcrt$SEL"
+    // subsections (CGObjCGNU.cpp).
     llvm::SmallVector<llvm::GlobalVariable *, 8> sel_globals;
-    for (llvm::GlobalVariable &gv : module.globals())
-      if (gv.hasSection() &&
-          llvm::StringRef(gv.getSection()).starts_with("__objc_selectors"))
+    for (llvm::GlobalVariable &gv : module.globals()) {
+      if (!gv.hasSection())
+        continue;
+      llvm::StringRef section(gv.getSection());
+      if (section.starts_with("__objc_selectors") ||
+          section.starts_with(".objcrt$SEL"))
         sel_globals.push_back(&gv);
+    }
     if (sel_globals.empty())
       return false;
 
@@ -103,8 +111,14 @@ class GNUstepObjCSelectorRegistrationPass : public llvm::ModulePass {
         uses.push_back(&use);
       for (llvm::Use *use : uses) {
         auto *inst = llvm::dyn_cast<llvm::Instruction>(use->getUser());
-        if (!inst)
+        if (!inst) {
+          // A constant expression has no instruction to anchor the call to;
+          // such a selector stays unregistered.
+          LLDB_LOG(GetLog(LLDBLog::Expressions),
+                   "not registering selector used by a constant expression: {0}",
+                   gv->getName());
           continue;
+        }
         llvm::Function *func = inst->getFunction();
         llvm::Value *&reg_call = call_per_fn[func];
         if (!reg_call) {
@@ -151,31 +165,40 @@ void GNUstepObjCRuntime::Terminate() {
   PluginManager::UnregisterPlugin(CreateInstance);
 }
 
-static bool CanModuleBeGNUstepObjCLibrary(const ModuleSP &module_sp,
-                                          const llvm::Triple &TT) {
+/// Returns true if \p module_sp defines (rather than merely references) a
+/// function named \p name.
+static bool ModuleDefinesFunction(const ModuleSP &module_sp,
+                                  llvm::StringRef name) {
   if (!module_sp)
     return false;
-  const FileSpec &module_file_spec = module_sp->GetFileSpec();
-  if (!module_file_spec)
-    return false;
-  llvm::StringRef filename = module_file_spec.GetFilename();
-  if (TT.isOSBinFormatELF())
-    return filename.starts_with("libobjc.so");
-  if (TT.isOSWindows())
-    return filename == "objc.dll";
+  SymbolContextList sc_list;
+  module_sp->FindSymbolsWithNameAndType(ConstString(name), eSymbolTypeCode,
+                                        sc_list);
+  for (const SymbolContext &sc : sc_list) {
+    // Every module compiled against libobjc2 carries an undefined reference
+    // to __objc_load from its .objc_init constructor, so only a definition
+    // identifies the runtime itself.
+    if (sc.symbol && sc.symbol->GetAddress().IsValid())
+      return true;
+  }
   return false;
 }
 
-static bool ScanForGNUstepObjCLibraryCandidate(const ModuleList &modules,
-                                               const llvm::Triple &TT) {
+/// Finds the module implementing the libobjc2 runtime, identified by its
+/// loader entry point. __objc_load is exported by every libobjc2 build on
+/// every platform and does not exist in GCC's Objective-C runtime, so this
+/// both avoids activating for an unrelated runtime and recognizes builds the
+/// file name does not identify: a renamed library (LIBOBJC_NAME) or a static
+/// libobjc2, whose symbols land in the executable itself.
+static ModuleSP FindGNUstepObjCRuntimeModule(const ModuleList &modules) {
   std::lock_guard<std::recursive_mutex> guard(modules.GetMutex());
-  size_t num_modules = modules.GetSize();
+  const size_t num_modules = modules.GetSize();
   for (size_t i = 0; i < num_modules; i++) {
-    auto mod = modules.GetModuleAtIndex(i);
-    if (CanModuleBeGNUstepObjCLibrary(mod, TT))
-      return true;
+    ModuleSP module_sp = modules.GetModuleAtIndex(i);
+    if (ModuleDefinesFunction(module_sp, "__objc_load"))
+      return module_sp;
   }
-  return false;
+  return ModuleSP();
 }
 
 LanguageRuntime *GNUstepObjCRuntime::CreateInstance(Process *process,
@@ -190,24 +213,9 @@ LanguageRuntime *GNUstepObjCRuntime::CreateInstance(Process *process,
   if (TT.getVendor() == llvm::Triple::VendorType::Apple)
     return nullptr;
 
-  const ModuleList &images = target.GetImages();
-  if (!ScanForGNUstepObjCLibraryCandidate(images, TT))
+  if (!FindGNUstepObjCRuntimeModule(target.GetImages()))
     return nullptr;
 
-  if (TT.isOSBinFormatELF()) {
-    SymbolContextList eh_pers;
-    RegularExpression regex("__gnustep_objc[x]*_personality_v[0-9]+");
-    images.FindSymbolsMatchingRegExAndType(regex, eSymbolTypeCode, eh_pers);
-    if (eh_pers.GetSize() == 0)
-      return nullptr;
-  } else if (TT.isOSWindows()) {
-    SymbolContextList objc_mandatory;
-    images.FindSymbolsWithNameAndType(ConstString("__objc_load"),
-                                      eSymbolTypeCode, objc_mandatory);
-    if (objc_mandatory.GetSize() == 0)
-      return nullptr;
-  }
-
   return new GNUstepObjCRuntime(process);
 }
 
@@ -415,6 +423,12 @@ bool GNUstepObjCRuntime::GetDynamicTypeAndAddress(
 
 lldb::TypeSP
 GNUstepObjCRuntime::LookupClassTypeInDebugInfo(ConstString class_name) {
+  // Searching every module's debug info is expensive and happens for each
+  // value on each stop, so remember the classes that have no debug info. The
+  // cache is dropped whenever new modules arrive.
+  if (m_negative_type_cache.count(class_name))
+    return TypeSP();
+
   TypeQuery query(class_name.GetStringRef(), TypeQueryOptions::e_exact_match);
   TypeResults results;
   GetTargetRef().GetImages().FindTypes(nullptr, query, results);
@@ -423,9 +437,30 @@ GNUstepObjCRuntime::LookupClassTypeInDebugInfo(ConstString class_name) {
                        type_sp->GetForwardCompilerType()))
       return type_sp;
   }
+  m_negative_type_cache.insert(class_name);
   return TypeSP();
 }
 
+bool GNUstepObjCRuntime::CalculateHasNewLiteralsAndIndexing() {
+  // The literal and subscripting syntax lowers to calls on Foundation
+  // classes, which live in gnustep-base rather than in the runtime itself.
+  // Claiming support without them makes such expressions compile and then
+  // fail inside the inferior, so require the classes to be present.
+  static constexpr llvm::StringLiteral g_required_classes[] = {
+      "NSArray", "NSDictionary", "NSNumber", "NSString"};
+
+  const llvm::StringRef prefix = GetClassSymbolPrefix();
+  const ModuleList &images = GetTargetRef().GetImages();
+  for (llvm::StringRef class_name : g_required_classes) {
+    SymbolContextList sc_list;
+    images.FindSymbolsWithNameAndType(ConstString(prefix.str() + class_name.str()),
+                                      eSymbolTypeAny, sc_list);
+    if (sc_list.GetSize() == 0)
+      return false;
+  }
+  return true;
+}
+
 TypeAndOrName
 GNUstepObjCRuntime::FixUpDynamicType(const TypeAndOrName &type_and_or_name,
                                      ValueObject &static_value) {
@@ -508,7 +543,7 @@ GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
   const addr_t pc = thread.GetRegisterContext()->GetPC();
   Target &target = GetTargetRef();
 
-  const DispatchEntryPoint *entry = FindDispatchEntryPoint(pc);
+  std::optional<DispatchEntryPoint> entry = FindDispatchEntryPoint(pc);
   if (!entry)
     return {};
   const bool is_stret = entry->is_stret;
@@ -574,7 +609,10 @@ GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
     }
   }
 
-  if (!GetMsgLookupFunctionCaller(thread))
+  // Only claim the step if the runtime actually exports the lookup function.
+  // Building the call wrapper is deliberately left to the plan's pre-resume
+  // action, which is where running code in the inferior is safe.
+  if (!ModuleDefinesFunction(m_objc_module_sp, "objc_msg_lookup"))
     return {};
 
   ValueList lookup_args;
@@ -589,7 +627,7 @@ GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
       thread, *this, lookup_args, isa, selector);
 }
 
-const GNUstepObjCRuntime::DispatchEntryPoint *
+std::optional<GNUstepObjCRuntime::DispatchEntryPoint>
 GNUstepObjCRuntime::FindDispatchEntryPoint(lldb::addr_t pc) {
   if (!m_dispatch_entry_points_resolved) {
     m_dispatch_entry_points_resolved = true;
@@ -600,41 +638,43 @@ GNUstepObjCRuntime::FindDispatchEntryPoint(lldb::addr_t pc) {
       const char *name;
       bool is_stret;
       bool is_sender;
-    } kEntryPoints[] = {
+    } g_entry_points[] = {
         {"objc_msgSend", false, false},
         {"objc_msgSend_fpret", false, false},
         {"objc_msgSend_stret", true, false},
+        // Windows on ARM64 dispatches struct returns through this variant.
+        {"objc_msgSend_stret2", true, false},
         {"objc_msg_lookup", false, false},
         {"objc_msg_lookup_sender", false, true},
     };
     Target &target = GetTargetRef();
-    for (const auto &ep : kEntryPoints) {
+    for (const auto &ep : g_entry_points) {
       SymbolContextList sc_list;
       target.GetImages().FindSymbolsWithNameAndType(ConstString(ep.name),
                                                     eSymbolTypeCode, sc_list);
       for (const SymbolContext &sc : sc_list) {
         if (!sc.symbol)
           continue;
-        const addr_t addr = sc.symbol->GetLoadAddress(&target);
-        if (addr != LLDB_INVALID_ADDRESS) {
+        // Use the opcode address so the comparison against the PC is correct
+        // on targets where the symbol address carries an ISA bit (Thumb).
+        const addr_t addr =
+            sc.symbol->GetAddress().GetOpcodeLoadAddress(&target);
+        if (addr != LLDB_INVALID_ADDRESS)
           m_dispatch_entry_points.push_back({addr, ep.is_stret, ep.is_sender});
-          break;
-        }
       }
     }
   }
 
   for (const DispatchEntryPoint &ep : m_dispatch_entry_points)
     if (ep.address == pc)
-      return &ep;
-  return nullptr;
+      return ep;
+  return std::nullopt;
 }
 
 FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller(Thread &thread) {
   // Build (once) a utility function that resolves a method implementation by
   // calling libobjc2's objc_msg_lookup, and a FunctionCaller to invoke it.
-  // This mirrors AppleObjCTrampolineHandler's dispatch-lookup utility and is
-  // the JIT path that works from inside a step's PreResume action.
+  // This mirrors AppleObjCTrampolineHandler's dispatch-lookup utility.
   static const char *g_lookup_name = "$__lldb_gnustep_objc_msg_lookup";
   static const char *g_lookup_code =
       "void *objc_msg_lookup(void *receiver, void *selector);\n"
@@ -642,8 +682,13 @@ FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller(Thread &thread) {
       "  return objc_msg_lookup(receiver, selector);\n"
       "}\n";
 
+  std::lock_guard<std::mutex> guard(m_msg_lookup_mutex);
   if (m_msg_lookup_caller)
     return m_msg_lookup_caller;
+  // Don't pay for compiling the wrapper again on every step once it is known
+  // not to work in this process.
+  if (m_msg_lookup_failed)
+    return nullptr;
 
   ThreadSP thread_sp(thread.shared_from_this());
   ExecutionContext exe_ctx(thread_sp);
@@ -653,15 +698,18 @@ FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller(Thread &thread) {
       g_lookup_code, g_lookup_name, eLanguageTypeC, exe_ctx);
   if (!utility_fn_or_error) {
     LLDB_LOG_ERROR(log, utility_fn_or_error.takeError(),
-                   "[GNUstep] failed to build objc_msg_lookup utility: {0}");
+                   "failed to build objc_msg_lookup utility: {0}");
+    m_msg_lookup_failed = true;
     return nullptr;
   }
   m_msg_lookup_utility_up = std::move(*utility_fn_or_error);
 
   TypeSystemClangSP scratch_ts_sp =
       ScratchTypeSystemClang::GetForTarget(GetTargetRef());
-  if (!scratch_ts_sp)
+  if (!scratch_ts_sp) {
+    m_msg_lookup_failed = true;
     return nullptr;
+  }
   CompilerType void_ptr_type =
       scratch_ts_sp->GetBasicType(eBasicTypeVoid).GetPointerType();
 
@@ -677,9 +725,10 @@ FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller(Thread &thread) {
       m_msg_lookup_utility_up->MakeFunctionCaller(void_ptr_type, args,
                                                   thread_sp, error);
   if (error.Fail()) {
-    LLDB_LOG(log, "[GNUstep] failed to make objc_msg_lookup caller: {0}",
+    LLDB_LOG(log, "failed to make objc_msg_lookup caller: {0}",
              error.AsCString());
     m_msg_lookup_caller = nullptr;
+    m_msg_lookup_failed = true;
     return nullptr;
   }
   return m_msg_lookup_caller;
@@ -694,19 +743,58 @@ void GNUstepObjCRuntime::UpdateISAToDescriptorMapIfNeeded() {
     return;
   }
 
-  // The gnustep-2.x ABI emits every compiled class as a `._OBJC_CLASS_<name>`
+  // The first update has to look at everything already loaded; afterwards only
+  // the modules that arrived since need scanning, so a dlopen does not re-walk
+  // every symbol table in the process.
+  if (m_swept_all_modules) {
+    for (const ModuleSP &module_sp : m_pending_modules)
+      AddClassesFromModule(module_sp);
+  } else {
+    const ModuleList &images = GetTargetRef().GetImages();
+    std::lock_guard<std::recursive_mutex> guard(images.GetMutex());
+    const size_t num_modules = images.GetSize();
+    for (size_t i = 0; i < num_modules; i++)
+      AddClassesFromModule(images.GetModuleAtIndex(i));
+    m_swept_all_modules = true;
+  }
+
+  m_pending_modules.clear();
+  m_isa_map_dirty = false;
+  m_isa_to_descriptor_stop_id = stop_id;
+}
+
+bool GNUstepObjCRuntime::IsRuntimeInternalAddress(lldb::addr_t addr) {
+  if (!m_objc_module_sp || addr == 0 || addr == LLDB_INVALID_ADDRESS)
+    return false;
+  Address resolved;
+  if (!GetTargetRef().ResolveLoadAddress(addr, resolved))
+    return false;
+  return resolved.GetModule() == m_objc_module_sp;
+}
+
+llvm::StringRef GNUstepObjCRuntime::GetClassSymbolPrefix() {
+  // clang mangles the public runtime symbols with a leading "._" on every
+  // object format except COFF, which uses "$_" (CGObjCGNU.cpp).
+  return GetTargetRef().GetArchitecture().GetTriple().isOSBinFormatCOFF()
+             ? "$_OBJC_CLASS_"
+             : "._OBJC_CLASS_";
+}
+
+void GNUstepObjCRuntime::AddClassesFromModule(const ModuleSP &module_sp) {
+  if (!module_sp || !m_process)
+    return;
+
+  // The gnustep-2.x ABI emits every compiled class as a `<prefix>OBJC_CLASS_`
   // data symbol whose address is the class object itself (the ISA of its
   // instances), so the map can be seeded from symbol tables alone - without
   // running any code in the inferior. Classes created dynamically at runtime
   // are handled by the create-on-miss path in GetClassDescriptorFromISA.
-  Target &target = GetTargetRef();
-  const ModuleList &images = target.GetImages();
-
+  const llvm::StringRef prefix = GetClassSymbolPrefix();
+  RegularExpression regex("^" + llvm::Regex::escape(prefix));
   SymbolContextList sc_list;
-  RegularExpression regex(llvm::StringRef("^\\._OBJC_CLASS_"));
-  images.FindSymbolsMatchingRegExAndType(regex, eSymbolTypeAny, sc_list);
+  module_sp->FindSymbolsMatchingRegExAndType(regex, eSymbolTypeAny, sc_list);
 
-  static constexpr llvm::StringLiteral g_class_prefix("._OBJC_CLASS_");
+  Target &target = GetTargetRef();
   for (const SymbolContext &sc : sc_list) {
     if (!sc.symbol)
       continue;
@@ -714,15 +802,12 @@ void GNUstepObjCRuntime::UpdateISAToDescriptorMapIfNeeded() {
     if (isa == 0 || isa == LLDB_INVALID_ADDRESS || ISAIsCached(isa))
       continue;
     llvm::StringRef name = sc.symbol->GetName().GetStringRef();
-    name.consume_front(g_class_prefix);
+    name.consume_front(prefix);
     auto descriptor_sp = std::make_shared<GNUstepObjCClassDescriptor>(
         m_process->shared_from_this(), isa);
     if (descriptor_sp->IsValid())
       AddClass(isa, descriptor_sp, name.str().c_str());
   }
-
-  m_isa_map_dirty = false;
-  m_isa_to_descriptor_stop_id = stop_id;
 }
 
 bool GNUstepObjCRuntime::GetIRPasses(
@@ -766,8 +851,7 @@ GNUstepObjCRuntime::GetClassDescriptorFromISA(ObjCISA isa) {
 }
 
 bool GNUstepObjCRuntime::IsModuleObjCLibrary(const ModuleSP &module_sp) {
-  const llvm::Triple &TT = GetTargetRef().GetArchitecture().GetTriple();
-  return CanModuleBeGNUstepObjCLibrary(module_sp, TT);
+  return ModuleDefinesFunction(module_sp, "__objc_load");
 }
 
 bool GNUstepObjCRuntime::ReadObjCLibrary(const ModuleSP &module_sp) {
@@ -781,5 +865,20 @@ bool GNUstepObjCRuntime::ReadObjCLibrary(const ModuleSP &module_sp) {
 
 void GNUstepObjCRuntime::ModulesDidLoad(const ModuleList &module_list) {
   ReadObjCLibraryIfNeeded(module_list);
+
+  // Everything cached from a symbol lookup can be invalidated by new modules:
+  // classes to add to the map, dispatch entry points that may only now exist,
+  // and negative results that may now resolve.
+  {
+    std::lock_guard<std::recursive_mutex> guard(module_list.GetMutex());
+    const size_t num_modules = module_list.GetSize();
+    for (size_t i = 0; i < num_modules; i++)
+      m_pending_modules.push_back(module_list.GetModuleAtIndex(i));
+  }
   m_isa_map_dirty = true;
+  m_dispatch_entry_points.clear();
+  m_dispatch_entry_points_resolved = false;
+  m_negative_type_cache.clear();
+  if (m_tagged_pointer_vendor_up)
+    m_tagged_pointer_vendor_up->ModulesDidLoad();
 }
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 2d3c28e7040e3..042ed2e25a48c 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -19,7 +19,10 @@
 #include "llvm/Support/Error.h"
 
 #include <memory>
+#include <mutex>
 #include <optional>
+#include <set>
+#include <vector>
 
 namespace lldb_private {
 
@@ -109,9 +112,7 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   /// objc_msgSend unregistered.
   bool GetIRPasses(LLVMUserExpression::IRPasses &custom_passes) override;
 
-  /// gnustep-base implements the container-literal and boxed-expression
-  /// protocol methods, so @[...], @{...} and @(...) are available.
-  bool CalculateHasNewLiteralsAndIndexing() override { return true; }
+  bool CalculateHasNewLiteralsAndIndexing() override;
 
   TaggedPointerVendor *GetTaggedPointerVendor() override;
 
@@ -119,6 +120,19 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
 
   ClassDescriptorSP GetClassDescriptorFromISA(ObjCISA isa) override;
 
+  /// Lazily-built FunctionCaller for a utility function that resolves a
+  /// method implementation via libobjc2's
+  /// `IMP objc_msg_lookup(id receiver, SEL selector)`, used by the
+  /// step-through-trampoline plan. Returns nullptr on failure. The caller is
+  /// owned by the utility function and stays valid for the runtime's life.
+  FunctionCaller *GetMsgLookupFunctionCaller(Thread &thread);
+
+  /// Returns true if \p addr belongs to the module implementing the ObjC
+  /// runtime. Method lookups that resolve there reached either the forwarding
+  /// machinery or one of the runtime's own methods, neither of which has user
+  /// source to step into.
+  bool IsRuntimeInternalAddress(lldb::addr_t addr);
+
 protected:
   // Call CreateInstance instead.
   GNUstepObjCRuntime(Process *process);
@@ -132,10 +146,18 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
     bool is_sender;
   };
 
-  /// Returns the dispatch entry point whose first instruction is at \p pc, or
-  /// nullptr. The entry-point address table is resolved and cached on first
-  /// use.
-  const DispatchEntryPoint *FindDispatchEntryPoint(lldb::addr_t pc);
+  /// Returns the dispatch entry point whose first instruction is at \p pc, if
+  /// any. The entry-point address table is resolved on first use and dropped
+  /// when modules are loaded.
+  std::optional<DispatchEntryPoint> FindDispatchEntryPoint(lldb::addr_t pc);
+
+  /// The prefix clang gives the runtime's public class symbols, which differs
+  /// between object formats.
+  llvm::StringRef GetClassSymbolPrefix();
+
+  /// Adds every class statically defined by \p module_sp to the
+  /// ISA-to-descriptor map.
+  void AddClassesFromModule(const lldb::ModuleSP &module_sp);
 
   /// Finds a complete Objective-C interface type named \p class_name in the
   /// target's debug info. Used to attach a real type to a dynamic value when
@@ -148,16 +170,6 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   /// gnustep-base is not loaded in the inferior.
   Address *GetPrintForDebuggerAddr();
 
-public:
-  /// Lazily-built FunctionCaller for a utility function that resolves a
-  /// method implementation via libobjc2's
-  /// `IMP objc_msg_lookup(id receiver, SEL selector)`, used by the
-  /// step-through-trampoline plan. Returns nullptr on failure. The caller is
-  /// owned by the utility function and stays valid for the runtime's life.
-  FunctionCaller *GetMsgLookupFunctionCaller(Thread &thread);
-
-protected:
-
   lldb::ModuleSP m_objc_module_sp;
 
   std::unique_ptr<Address> m_print_for_debugger_addr_up;
@@ -165,14 +177,26 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   std::unique_ptr<FunctionCaller> m_print_object_caller_up;
 
   /// Utility function wrapping objc_msg_lookup; owns m_msg_lookup_caller.
+  /// Guarded by m_msg_lookup_mutex, which also latches a failed build so it
+  /// is not retried on every step.
+  std::mutex m_msg_lookup_mutex;
   std::unique_ptr<UtilityFunction> m_msg_lookup_utility_up;
   FunctionCaller *m_msg_lookup_caller = nullptr;
+  bool m_msg_lookup_failed = false;
 
-  llvm::SmallVector<DispatchEntryPoint, 5> m_dispatch_entry_points;
+  llvm::SmallVector<DispatchEntryPoint, 6> m_dispatch_entry_points;
   bool m_dispatch_entry_points_resolved = false;
 
   std::unique_ptr<GNUstepTaggedPointerVendor> m_tagged_pointer_vendor_up;
 
+  /// Classes named here have no debug info, so the search is not repeated.
+  std::set<ConstString> m_negative_type_cache;
+
+  /// Modules seen since the last ISA-to-descriptor map update, so only new
+  /// symbol tables have to be scanned.
+  std::vector<lldb::ModuleSP> m_pending_modules;
+  bool m_swept_all_modules = false;
+
   /// Set when new modules arrive; cleared once the ISA-to-descriptor map has
   /// been refreshed, so the symbol sweep only reruns after module changes.
   bool m_isa_map_dirty = true;
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
index 318f34cceee68..0705ae1ee12f4 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
@@ -47,6 +47,13 @@ void GNUstepThreadPlanStepThroughObjCTrampoline::DidPush() {
                                (void *)this);
 }
 
+void GNUstepThreadPlanStepThroughObjCTrampoline::DidPop() {
+  // The action holds a bare pointer to this plan, so it must not outlive it -
+  // the plan can be discarded before the process ever resumes.
+  m_process.ClearPreResumeAction(PreResumeInitializeFunctionCaller,
+                                 (void *)this);
+}
+
 bool GNUstepThreadPlanStepThroughObjCTrampoline::
     PreResumeInitializeFunctionCaller(void *void_myself) {
   auto *myself =
@@ -114,6 +121,14 @@ bool GNUstepThreadPlanStepThroughObjCTrampoline::ShouldStop(Event *event_ptr) {
 
   Log *log = GetLog(LLDBLog::Step);
 
+  // Setting up the call can fail after the plan is already on the stack, in
+  // which case there is nothing to collect a result from.
+  if (!m_lookup_function || m_args_addr == LLDB_INVALID_ADDRESS) {
+    LLDB_LOG(log, "objc_msg_lookup call was never set up, stopping.");
+    SetPlanComplete(false);
+    return true;
+  }
+
   // Second stage: fetch the IMP the lookup returned and run to it.
   if (!m_run_to_sp) {
     Value target_addr_value;
@@ -133,6 +148,17 @@ bool GNUstepThreadPlanStepThroughObjCTrampoline::ShouldStop(Event *event_ptr) {
       return true;
     }
 
+    // A selector the class does not implement resolves to the runtime's
+    // forwarding machinery, which lives inside libobjc itself - as do the
+    // runtime's own internal method implementations. There is no user code to
+    // step into in either case, so stop here instead.
+    if (m_runtime.IsRuntimeInternalAddress(target_addr)) {
+      LLDB_LOG(log, "objc_msg_lookup resolved into the runtime itself "
+                    "(forwarding or an internal method), stopping.");
+      SetPlanComplete();
+      return true;
+    }
+
     LLDB_LOG(log, "Running to GNUstep ObjC method implementation: {0:x}",
              target_addr);
 
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.h
index 8b61a4d8c26d6..a477050271ff4 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.h
@@ -52,6 +52,8 @@ class GNUstepThreadPlanStepThroughObjCTrampoline : public ThreadPlan {
 
   void DidPush() override;
 
+  void DidPop() override;
+
   bool WillStop() override { return true; }
 
 protected:

>From 673b6bfac57701610837a40c225dfabe448ead5e Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 10:28:59 +0100
Subject: [PATCH 11/38] [lldb][GNUstep] Add unit tests for class structure
 parsing

Cover the memory parsing that backs class descriptors with a fake
process serving byte buffers, so the tests need no Objective-C runtime
and run everywhere - which matters because the Shell tests are only
enabled when a GNUstep installation is configured.

The tests are parameterized over the three data models libobjc2
supports, which pins down the field offsets: on Windows `long` is 32
bits while pointers are 64, so every field after the class name sits at
a different offset than on the LP64 and ILP32 targets.

Also cover tagged pointer payload decoding, including sign extension
from the target's pointer width, and the rejection of memory that is
not a class: unmapped or misaligned addresses, missing metaclasses,
absent or unterminated names, and a metaclass whose meta flag disagrees
with its class.

Assisted-by: Claude Opus 5
---
 lldb/unittests/CMakeLists.txt                 |   1 +
 lldb/unittests/LanguageRuntime/CMakeLists.txt |   1 +
 .../LanguageRuntime/ObjC/CMakeLists.txt       |   1 +
 .../ObjC/GNUstepObjCRuntime/CMakeLists.txt    |  15 +
 .../GNUstepObjCClassDescriptorTest.cpp        | 335 ++++++++++++++++++
 5 files changed, 353 insertions(+)
 create mode 100644 lldb/unittests/LanguageRuntime/CMakeLists.txt
 create mode 100644 lldb/unittests/LanguageRuntime/ObjC/CMakeLists.txt
 create mode 100644 lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
 create mode 100644 lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp

diff --git a/lldb/unittests/CMakeLists.txt b/lldb/unittests/CMakeLists.txt
index b0b7f68a7dcd6..76f2fa8fca198 100644
--- a/lldb/unittests/CMakeLists.txt
+++ b/lldb/unittests/CMakeLists.txt
@@ -90,6 +90,7 @@ add_subdirectory(Host)
 add_subdirectory(Instruction)
 add_subdirectory(Interpreter)
 add_subdirectory(Language)
+add_subdirectory(LanguageRuntime)
 add_subdirectory(ObjectContainer)
 add_subdirectory(ObjectFile)
 add_subdirectory(Platform)
diff --git a/lldb/unittests/LanguageRuntime/CMakeLists.txt b/lldb/unittests/LanguageRuntime/CMakeLists.txt
new file mode 100644
index 0000000000000..7115e09686740
--- /dev/null
+++ b/lldb/unittests/LanguageRuntime/CMakeLists.txt
@@ -0,0 +1 @@
+add_subdirectory(ObjC)
diff --git a/lldb/unittests/LanguageRuntime/ObjC/CMakeLists.txt b/lldb/unittests/LanguageRuntime/ObjC/CMakeLists.txt
new file mode 100644
index 0000000000000..7cda9782a9278
--- /dev/null
+++ b/lldb/unittests/LanguageRuntime/ObjC/CMakeLists.txt
@@ -0,0 +1 @@
+add_subdirectory(GNUstepObjCRuntime)
diff --git a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
new file mode 100644
index 0000000000000..39b22ebd2816e
--- /dev/null
+++ b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
@@ -0,0 +1,15 @@
+add_lldb_unittest(LanguageRuntimeObjCGNUstepTests
+  GNUstepObjCClassDescriptorTest.cpp
+
+  LINK_COMPONENTS
+    Support
+  LINK_LIBS
+    lldbCore
+    lldbHost
+    lldbSymbol
+    lldbTarget
+    lldbUtility
+    lldbPluginGNUstepObjCRuntime
+    lldbPluginPlatformLinux
+    lldbPluginPlatformWindows
+  )
diff --git a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
new file mode 100644
index 0000000000000..5d06a61a9acb2
--- /dev/null
+++ b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
@@ -0,0 +1,335 @@
+//===-- GNUstepObjCClassDescriptorTest.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 "Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h"
+#include "Plugins/Platform/Linux/PlatformLinux.h"
+#include "Plugins/Platform/Windows/PlatformWindows.h"
+#include "TestingSupport/SubsystemRAII.h"
+#include "lldb/Core/Debugger.h"
+#include "lldb/Core/PluginManager.h"
+#include "lldb/Host/FileSystem.h"
+#include "lldb/Host/HostInfo.h"
+#include "lldb/Target/Process.h"
+#include "lldb/Target/Target.h"
+#include "lldb/Utility/ArchSpec.h"
+#include "lldb/Utility/Listener.h"
+
+#include "gtest/gtest.h"
+
+#include <cstring>
+
+using namespace lldb;
+using namespace lldb_private;
+
+namespace {
+
+/// Serves memory reads out of one contiguous block of fake inferior memory, so
+/// that class structures can be laid out byte by byte and handed to the
+/// descriptor without a live process.
+class FakeProcess : public Process {
+public:
+  static constexpr addr_t g_base_addr = 0x100000;
+  static constexpr size_t g_size = 0x2000;
+
+  FakeProcess(TargetSP target_sp, ListenerSP listener_sp)
+      : Process(target_sp, listener_sp), m_memory(g_size, 0) {}
+
+  bool CanDebug(TargetSP, bool) override { return true; }
+  Status DoDestroy() override { return {}; }
+  void RefreshStateAfterStop() override {}
+  bool IsAlive() override { return true; }
+  bool DoUpdateThreadList(ThreadList &, ThreadList &) override { return false; }
+  llvm::StringRef GetPluginName() override { return "fake"; }
+
+  size_t DoReadMemory(const ProcessAddress &process_addr, void *buf,
+                      size_t size, Status &error) override {
+    const addr_t vm_addr = process_addr.GetValue();
+    if (vm_addr < g_base_addr || vm_addr >= g_base_addr + m_memory.size()) {
+      error = Status::FromErrorString("address is not mapped");
+      return 0;
+    }
+    const size_t offset = vm_addr - g_base_addr;
+    const size_t bytes = std::min(size, m_memory.size() - offset);
+    std::memcpy(buf, m_memory.data() + offset, bytes);
+    return bytes;
+  }
+
+  // The targets libobjc2 supports are all little-endian.
+  void WriteInteger(addr_t addr, uint64_t value, uint32_t byte_size) {
+    const size_t offset = addr - g_base_addr;
+    for (uint32_t i = 0; i < byte_size; i++)
+      m_memory[offset + i] = (value >> (8 * i)) & 0xff;
+  }
+
+  void WriteCString(addr_t addr, llvm::StringRef str) {
+    const size_t offset = addr - g_base_addr;
+    std::memcpy(m_memory.data() + offset, str.data(), str.size());
+    m_memory[offset + str.size()] = '\0';
+  }
+
+  void Fill(addr_t addr, uint8_t byte, size_t count) {
+    const size_t offset = addr - g_base_addr;
+    std::memset(m_memory.data() + offset, byte, count);
+  }
+
+  std::vector<uint8_t> m_memory;
+};
+
+/// The parts of libobjc2's `struct objc_class` this test lays out. The first
+/// three fields are pointers and the rest are `long`, which is why the two
+/// sizes are tracked separately.
+struct DataModel {
+  const char *triple;
+  uint32_t pointer_size;
+  uint32_t long_size;
+};
+
+class GNUstepClassDescriptorTest : public ::testing::TestWithParam<DataModel> {
+public:
+  void SetUp() override {
+    ArchSpec arch(GetParam().triple);
+    PlatformSP platform_sp =
+        arch.GetTriple().isOSWindows()
+            ? PlatformWindows::CreateInstance(true, &arch)
+            : platform_linux::PlatformLinux::CreateInstance(true, &arch);
+    Platform::SetHostPlatform(platform_sp);
+
+    m_debugger_sp = Debugger::CreateInstance();
+    m_debugger_sp->GetTargetList().CreateTarget(
+        *m_debugger_sp, "", arch, eLoadDependentsNo, platform_sp, m_target_sp);
+    ASSERT_TRUE(m_target_sp);
+
+    ListenerSP listener_sp(Listener::MakeListener("fake"));
+    m_process_sp = std::make_shared<FakeProcess>(m_target_sp, listener_sp);
+    struct TargetHack : public Target {
+      void SetProcess(ProcessSP process) { m_process_sp = process; }
+    };
+    static_cast<TargetHack *>(m_target_sp.get())->SetProcess(m_process_sp);
+  }
+
+  void TearDown() override {
+    m_process_sp.reset();
+    m_target_sp.reset();
+    m_debugger_sp.reset();
+  }
+
+  FakeProcess &GetProcess() {
+    return *static_cast<FakeProcess *>(m_process_sp.get());
+  }
+
+  uint32_t PointerSize() const { return GetParam().pointer_size; }
+  uint32_t LongSize() const { return GetParam().long_size; }
+
+  addr_t InfoOffset() const { return 3 * PointerSize() + LongSize(); }
+  addr_t InstanceSizeOffset() const {
+    return 3 * PointerSize() + 2 * LongSize();
+  }
+  addr_t ClassSize() const { return 3 * PointerSize() + 6 * LongSize(); }
+
+  /// Lays out a class structure, returning its address.
+  addr_t WriteClass(addr_t addr, addr_t metaclass, addr_t superclass,
+                    addr_t name_addr, uint64_t info, int64_t instance_size) {
+    FakeProcess &process = GetProcess();
+    process.WriteInteger(addr, metaclass, PointerSize());
+    process.WriteInteger(addr + PointerSize(), superclass, PointerSize());
+    process.WriteInteger(addr + 2 * PointerSize(), name_addr, PointerSize());
+    process.WriteInteger(addr + InfoOffset(), info, LongSize());
+    process.WriteInteger(addr + InstanceSizeOffset(),
+                         static_cast<uint64_t>(instance_size), LongSize());
+    return addr;
+  }
+
+  // Flags from libobjc2's enum objc_class_flags.
+  static constexpr uint64_t g_flag_meta = 1ULL << 0;
+  static constexpr uint64_t g_flag_resolved = 1ULL << 9;
+
+  SubsystemRAII<FileSystem, HostInfo, platform_linux::PlatformLinux,
+                PlatformWindows>
+      m_subsystems;
+  DebuggerSP m_debugger_sp;
+  TargetSP m_target_sp;
+  ProcessSP m_process_sp;
+};
+
+// Addresses inside the fake memory block. Kept far from its edges so a cache
+// read around a structure stays mapped.
+constexpr addr_t g_class_addr = FakeProcess::g_base_addr + 0x100;
+constexpr addr_t g_metaclass_addr = FakeProcess::g_base_addr + 0x200;
+constexpr addr_t g_superclass_addr = FakeProcess::g_base_addr + 0x300;
+constexpr addr_t g_superclass_meta_addr = FakeProcess::g_base_addr + 0x380;
+constexpr addr_t g_name_addr = FakeProcess::g_base_addr + 0x400;
+constexpr addr_t g_super_name_addr = FakeProcess::g_base_addr + 0x480;
+
+/// A well-formed class parses on every data model. This is what proves the
+/// field offsets track the target's `long` size rather than its pointer size:
+/// on Windows the instance size sits four bytes earlier than on Linux.
+TEST_P(GNUstepClassDescriptorTest, ParsesWellFormedClass) {
+  FakeProcess &process = GetProcess();
+  process.WriteCString(g_name_addr, "Derived");
+  WriteClass(g_class_addr, g_metaclass_addr, g_superclass_addr, g_name_addr,
+             g_flag_resolved, 42);
+  WriteClass(g_metaclass_addr, g_metaclass_addr, 0, g_name_addr,
+             g_flag_meta | g_flag_resolved, 0);
+
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, g_class_addr);
+  ASSERT_TRUE(descriptor.IsValid());
+  EXPECT_EQ(descriptor.GetClassName(), ConstString("Derived"));
+  EXPECT_EQ(descriptor.GetInstanceSize(), 42u);
+  EXPECT_EQ(descriptor.GetISA(), g_class_addr);
+}
+
+TEST_P(GNUstepClassDescriptorTest, WalksSuperclassChain) {
+  FakeProcess &process = GetProcess();
+  process.WriteCString(g_name_addr, "Derived");
+  process.WriteCString(g_super_name_addr, "Base");
+  WriteClass(g_class_addr, g_metaclass_addr, g_superclass_addr, g_name_addr,
+             g_flag_resolved, 42);
+  WriteClass(g_metaclass_addr, g_metaclass_addr, 0, g_name_addr,
+             g_flag_meta | g_flag_resolved, 0);
+  WriteClass(g_superclass_addr, g_superclass_meta_addr, 0, g_super_name_addr,
+             g_flag_resolved, 16);
+  WriteClass(g_superclass_meta_addr, g_superclass_meta_addr, 0,
+             g_super_name_addr, g_flag_meta | g_flag_resolved, 0);
+
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, g_class_addr);
+  ASSERT_TRUE(descriptor.IsValid());
+  auto superclass_sp = descriptor.GetSuperclass();
+  ASSERT_TRUE(superclass_sp);
+  EXPECT_EQ(superclass_sp->GetClassName(), ConstString("Base"));
+  EXPECT_EQ(superclass_sp->GetInstanceSize(), 16u);
+}
+
+/// Before the runtime resolves a class, `super_class` still holds a name
+/// pointer and `instance_size` the negated size of only this class's ivars,
+/// so neither may be reported.
+TEST_P(GNUstepClassDescriptorTest, UnresolvedClassHidesSuperclassAndSize) {
+  FakeProcess &process = GetProcess();
+  process.WriteCString(g_name_addr, "Derived");
+  WriteClass(g_class_addr, g_metaclass_addr, g_superclass_addr, g_name_addr,
+             /*info=*/0, -8);
+  WriteClass(g_metaclass_addr, g_metaclass_addr, 0, g_name_addr, g_flag_meta,
+             0);
+
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, g_class_addr);
+  ASSERT_TRUE(descriptor.IsValid());
+  EXPECT_EQ(descriptor.GetClassName(), ConstString("Derived"));
+  EXPECT_EQ(descriptor.GetInstanceSize(), 0u);
+  EXPECT_FALSE(descriptor.GetSuperclass());
+}
+
+TEST_P(GNUstepClassDescriptorTest, RejectsUnmappedAddress) {
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, 0xdead0000);
+  EXPECT_FALSE(descriptor.IsValid());
+}
+
+TEST_P(GNUstepClassDescriptorTest, RejectsMisalignedAddress) {
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, g_class_addr + 1);
+  EXPECT_FALSE(descriptor.IsValid());
+}
+
+TEST_P(GNUstepClassDescriptorTest, RejectsNullNamePointer) {
+  WriteClass(g_class_addr, g_metaclass_addr, g_superclass_addr, /*name=*/0,
+             g_flag_resolved, 42);
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, g_class_addr);
+  EXPECT_FALSE(descriptor.IsValid());
+}
+
+/// A name that never terminates is not a class name, and must not drag in
+/// unbounded amounts of inferior memory.
+TEST_P(GNUstepClassDescriptorTest, RejectsUnterminatedName) {
+  FakeProcess &process = GetProcess();
+  process.Fill(g_name_addr, 'A', 0x800);
+  WriteClass(g_class_addr, g_metaclass_addr, g_superclass_addr, g_name_addr,
+             g_flag_resolved, 42);
+  WriteClass(g_metaclass_addr, g_metaclass_addr, 0, g_name_addr,
+             g_flag_meta | g_flag_resolved, 0);
+
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, g_class_addr);
+  EXPECT_FALSE(descriptor.IsValid());
+}
+
+/// Arbitrary readable memory must not be accepted as a class: a class and its
+/// metaclass have to disagree about the meta flag.
+TEST_P(GNUstepClassDescriptorTest, RejectsClassWhoseMetaclassIsNotMeta) {
+  FakeProcess &process = GetProcess();
+  process.WriteCString(g_name_addr, "NotAClass");
+  WriteClass(g_class_addr, g_metaclass_addr, g_superclass_addr, g_name_addr,
+             g_flag_resolved, 42);
+  // The "metaclass" is missing the meta flag.
+  WriteClass(g_metaclass_addr, g_metaclass_addr, 0, g_name_addr,
+             g_flag_resolved, 0);
+
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, g_class_addr);
+  EXPECT_FALSE(descriptor.IsValid());
+}
+
+TEST_P(GNUstepClassDescriptorTest, RejectsNullMetaclass) {
+  FakeProcess &process = GetProcess();
+  process.WriteCString(g_name_addr, "Derived");
+  WriteClass(g_class_addr, /*metaclass=*/0, g_superclass_addr, g_name_addr,
+             g_flag_resolved, 42);
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, g_class_addr);
+  EXPECT_FALSE(descriptor.IsValid());
+}
+
+/// Tagged pointer payloads are shifted by the tag width, and a signed payload
+/// has to be sign-extended from the target's pointer width.
+TEST_P(GNUstepClassDescriptorTest, DecodesTaggedPointerPayload) {
+  FakeProcess &process = GetProcess();
+  process.WriteCString(g_name_addr, "NSSmallInt");
+  WriteClass(g_class_addr, g_metaclass_addr, 0, g_name_addr, g_flag_resolved,
+             0);
+  WriteClass(g_metaclass_addr, g_metaclass_addr, 0, g_name_addr,
+             g_flag_meta | g_flag_resolved, 0);
+
+  const bool is_64_bit = PointerSize() == 8;
+  const uint64_t tag = is_64_bit ? 3 : 1;
+  const uint32_t shift = is_64_bit ? 3 : 1;
+  const uint64_t pointer_mask =
+      is_64_bit ? UINT64_MAX : std::numeric_limits<uint32_t>::max();
+
+  // A positive payload of 42.
+  const addr_t positive = ((42ULL << shift) | tag) & pointer_mask;
+  GNUstepObjCTaggedPointerClassDescriptor positive_descriptor(
+      m_process_sp, g_class_addr, positive, tag, shift, PointerSize());
+  ASSERT_TRUE(positive_descriptor.IsValid());
+  uint64_t info_bits = 0;
+  uint64_t value_bits = 0;
+  ASSERT_TRUE(
+      positive_descriptor.GetTaggedPointerInfo(&info_bits, &value_bits));
+  EXPECT_EQ(info_bits, tag);
+  EXPECT_EQ(value_bits, 42u);
+
+  // A negative payload of -42 encoded in the target's pointer width.
+  const addr_t negative =
+      ((static_cast<uint64_t>(-42LL) << shift) | tag) & pointer_mask;
+  GNUstepObjCTaggedPointerClassDescriptor negative_descriptor(
+      m_process_sp, g_class_addr, negative, tag, shift, PointerSize());
+  int64_t signed_value = 0;
+  ASSERT_TRUE(negative_descriptor.GetTaggedPointerInfoSigned(&info_bits,
+                                                             &signed_value));
+  EXPECT_EQ(info_bits, tag);
+  EXPECT_EQ(signed_value, -42);
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    DataModels, GNUstepClassDescriptorTest,
+    ::testing::Values(DataModel{"x86_64-pc-linux", 8, 8},
+                      // Windows is LLP64: pointers are 64 bits but long stays
+                      // 32, moving every field after the class name.
+                      DataModel{"x86_64-pc-windows-msvc", 8, 4},
+                      DataModel{"i386-pc-linux", 4, 4}),
+    [](const ::testing::TestParamInfo<DataModel> &info) {
+      std::string name = info.param.triple;
+      for (char &c : name)
+        if (!std::isalnum(static_cast<unsigned char>(c)))
+          c = '_';
+      return name;
+    });
+
+} // namespace

>From f2c31b6b569412567ab2b92ea07f747acd39578e Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 10:30:22 +0100
Subject: [PATCH 12/38] [lldb][GNUstep] Formatting and include cleanup

Apply clang-format, drop the DeclVendor include left behind when the
decl vendor fallback was removed, include Symbol/Type.h for the type
query it uses rather than relying on it arriving transitively, and fix
a misspelled selector in the expression test.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCClassDescriptor.cpp            |  4 ++--
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 24 ++++++++++---------
 ...tepThreadPlanStepThroughObjCTrampoline.cpp |  3 +--
 lldb/test/Shell/Expr/objc-gnustep-expr.m      | 14 +++++------
 4 files changed, 23 insertions(+), 22 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
index f310bff13aa51..3a00f1abb3c4a 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
@@ -126,8 +126,8 @@ void GNUstepObjCClassDescriptor::Read() {
         m_isa + layout.instance_size_offset, layout.long_size, 0, error);
     if (error.Fail())
       return;
-    m_instance_size = static_cast<uint64_t>(
-        instance_size < 0 ? -instance_size : instance_size);
+    m_instance_size = static_cast<uint64_t>(instance_size < 0 ? -instance_size
+                                                              : instance_size);
     m_superclass_isa = superclass;
   }
 
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index b960e80a10404..174ae738cffe2 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -20,9 +20,9 @@
 #include "lldb/Expression/DiagnosticManager.h"
 #include "lldb/Expression/FunctionCaller.h"
 #include "lldb/Expression/UtilityFunction.h"
-#include "lldb/Symbol/DeclVendor.h"
 #include "lldb/Symbol/Symbol.h"
 #include "lldb/Symbol/SymbolContext.h"
+#include "lldb/Symbol/Type.h"
 #include "lldb/Target/ABI.h"
 #include "lldb/Target/ExecutionContext.h"
 #include "lldb/Target/Process.h"
@@ -114,9 +114,10 @@ class GNUstepObjCSelectorRegistrationPass : public llvm::ModulePass {
         if (!inst) {
           // A constant expression has no instruction to anchor the call to;
           // such a selector stays unregistered.
-          LLDB_LOG(GetLog(LLDBLog::Expressions),
-                   "not registering selector used by a constant expression: {0}",
-                   gv->getName());
+          LLDB_LOG(
+              GetLog(LLDBLog::Expressions),
+              "not registering selector used by a constant expression: {0}",
+              gv->getName());
           continue;
         }
         llvm::Function *func = inst->getFunction();
@@ -453,8 +454,8 @@ bool GNUstepObjCRuntime::CalculateHasNewLiteralsAndIndexing() {
   const ModuleList &images = GetTargetRef().GetImages();
   for (llvm::StringRef class_name : g_required_classes) {
     SymbolContextList sc_list;
-    images.FindSymbolsWithNameAndType(ConstString(prefix.str() + class_name.str()),
-                                      eSymbolTypeAny, sc_list);
+    images.FindSymbolsWithNameAndType(
+        ConstString(prefix.str() + class_name.str()), eSymbolTypeAny, sc_list);
     if (sc_list.GetSize() == 0)
       return false;
   }
@@ -595,7 +596,8 @@ GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
   if (!(m_tagged_pointer_vendor_up &&
         m_tagged_pointer_vendor_up->IsPossibleTaggedPointer(receiver))) {
     Status error;
-    const addr_t isa_candidate = process->ReadPointerFromMemory(receiver, error);
+    const addr_t isa_candidate =
+        process->ReadPointerFromMemory(receiver, error);
     if (error.Success())
       isa = isa_candidate;
   }
@@ -678,7 +680,8 @@ FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller(Thread &thread) {
   static const char *g_lookup_name = "$__lldb_gnustep_objc_msg_lookup";
   static const char *g_lookup_code =
       "void *objc_msg_lookup(void *receiver, void *selector);\n"
-      "void *$__lldb_gnustep_objc_msg_lookup(void *receiver, void *selector) {\n"
+      "void *$__lldb_gnustep_objc_msg_lookup(void *receiver, void *selector) "
+      "{\n"
       "  return objc_msg_lookup(receiver, selector);\n"
       "}\n";
 
@@ -721,9 +724,8 @@ FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller(Thread &thread) {
   args.PushValue(void_ptr_value);
 
   Status error;
-  m_msg_lookup_caller =
-      m_msg_lookup_utility_up->MakeFunctionCaller(void_ptr_type, args,
-                                                  thread_sp, error);
+  m_msg_lookup_caller = m_msg_lookup_utility_up->MakeFunctionCaller(
+      void_ptr_type, args, thread_sp, error);
   if (error.Fail()) {
     LLDB_LOG(log, "failed to make objc_msg_lookup caller: {0}",
              error.AsCString());
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
index 0705ae1ee12f4..f96bd0eb3d31a 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepThreadPlanStepThroughObjCTrampoline.cpp
@@ -43,8 +43,7 @@ void GNUstepThreadPlanStepThroughObjCTrampoline::DidPush() {
   // Setting up the called function might require allocations in the
   // inferior, i.e. a nested function call. This needs to be done as a
   // PreResumeAction.
-  m_process.AddPreResumeAction(PreResumeInitializeFunctionCaller,
-                               (void *)this);
+  m_process.AddPreResumeAction(PreResumeInitializeFunctionCaller, (void *)this);
 }
 
 void GNUstepThreadPlanStepThroughObjCTrampoline::DidPop() {
diff --git a/lldb/test/Shell/Expr/objc-gnustep-expr.m b/lldb/test/Shell/Expr/objc-gnustep-expr.m
index bc20422d817bc..8a511d57395e1 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-expr.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-expr.m
@@ -25,10 +25,10 @@ + (id)new {
 @end
 
 @interface Calc : NSObject
-- (int)addFourtyTwoTo:(int)value;
+- (int)addFortyTwoTo:(int)value;
 @end
 @implementation Calc
-- (int)addFourtyTwoTo:(int)value {
+- (int)addFortyTwoTo:(int)value {
   return value + 42;
 }
 @end
@@ -38,17 +38,17 @@ - (int)addFourtyTwoTo:(int)value {
 // without it the dispatch reaches the runtime with an unregistered selector.
 //
 // RUN: %lldb -b -o "b objc-gnustep-expr.m:47" -o "run" \
-// RUN:          -o "expr [c addFourtyTwoTo:100]" \
-// RUN:          -o "expr (int)[[Calc new] addFourtyTwoTo:1]" -- %t | FileCheck %s
+// RUN:          -o "expr [c addFortyTwoTo:100]" \
+// RUN:          -o "expr (int)[[Calc new] addFortyTwoTo:1]" -- %t | FileCheck %s
 //
 int main() {
   Calc *c = [Calc new];
-  (void)[c addFourtyTwoTo:0];
+  (void)[c addFortyTwoTo:0];
   return 0;
 }
 //
-// CHECK: (lldb) expr [c addFourtyTwoTo:100]
+// CHECK: (lldb) expr [c addFortyTwoTo:100]
 // CHECK: (int) {{\$[0-9]+}} = 142
 //
-// CHECK: addFourtyTwoTo:1]
+// CHECK: addFortyTwoTo:1]
 // CHECK: (int) {{\$[0-9]+}} = 43

>From 3fe0f6e50b0c715fa58b6f8e3d893695f187f559 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 10:34:16 +0100
Subject: [PATCH 13/38] [lldb][test] Allow API tests to build against a GNUstep
 libobjc2 runtime

The Shell tests can already be pointed at a GNUstep libobjc2
installation through LLDB_TEST_OBJC_GNUSTEP, but the API test suite had
no equivalent, so Objective-C behaviour could only be tested there on
Darwin.

Thread the configured directory through to dotest the same way a custom
libc++ is handled, and have Makefile.rules build Objective-C sources
against it, using the same flags as the Shell test helper. Tests opt in
with a new "objc-gnustep" category, which is skipped unless a runtime
directory was given, so nothing changes for builds that do not configure
one. The existing Darwin-only "objc" category is untouched.

Add a first test under lang/objc-gnustep covering dynamic type
resolution through both the SB API and the command interpreter.

Assisted-by: Claude Opus 5
---
 .../Python/lldbsuite/test/builders/builder.py |  6 +++
 .../Python/lldbsuite/test/configuration.py    |  4 ++
 lldb/packages/Python/lldbsuite/test/dotest.py | 14 ++++++
 .../Python/lldbsuite/test/dotest_args.py      |  8 ++++
 .../Python/lldbsuite/test/make/Makefile.rules | 16 +++++++
 .../Python/lldbsuite/test/test_categories.py  |  1 +
 lldb/test/API/lang/objc-gnustep/categories    |  1 +
 .../lang/objc-gnustep/dynamic-value/Makefile  |  3 ++
 .../dynamic-value/TestGNUstepDynamicValue.py  | 47 +++++++++++++++++++
 .../lang/objc-gnustep/dynamic-value/main.m    | 36 ++++++++++++++
 lldb/test/API/lit.cfg.py                      |  5 ++
 lldb/test/API/lit.site.cfg.py.in              |  1 +
 12 files changed, 142 insertions(+)
 create mode 100644 lldb/test/API/lang/objc-gnustep/categories
 create mode 100644 lldb/test/API/lang/objc-gnustep/dynamic-value/Makefile
 create mode 100644 lldb/test/API/lang/objc-gnustep/dynamic-value/TestGNUstepDynamicValue.py
 create mode 100644 lldb/test/API/lang/objc-gnustep/dynamic-value/main.m

diff --git a/lldb/packages/Python/lldbsuite/test/builders/builder.py b/lldb/packages/Python/lldbsuite/test/builders/builder.py
index 47ef61030fa16..3ed463ae76049 100644
--- a/lldb/packages/Python/lldbsuite/test/builders/builder.py
+++ b/lldb/packages/Python/lldbsuite/test/builders/builder.py
@@ -243,6 +243,11 @@ def getLibCxxArgs(self):
             return libcpp_args
         return []
 
+    def getObjcGnustepArgs(self):
+        if configuration.objc_gnustep_dir:
+            return ["OBJC_GNUSTEP_DIR={}".format(configuration.objc_gnustep_dir)]
+        return []
+
     def getLLDBObjRoot(self):
         if configuration.lldb_obj_root:
             return [f"LLDB_OBJ_ROOT={configuration.lldb_obj_root}"]
@@ -303,6 +308,7 @@ def getBuildCommand(
             self.getExtraMakeArgs(),
             self.getModuleCacheSpec(),
             self.getLibCxxArgs(),
+            self.getObjcGnustepArgs(),
             self.getLLDBObjRoot(),
             self.getResourceDirArgs(),
             self.getCmdLine(dictionary),
diff --git a/lldb/packages/Python/lldbsuite/test/configuration.py b/lldb/packages/Python/lldbsuite/test/configuration.py
index af069adf9c69e..2c7a4f5f12afe 100644
--- a/lldb/packages/Python/lldbsuite/test/configuration.py
+++ b/lldb/packages/Python/lldbsuite/test/configuration.py
@@ -148,6 +148,10 @@
 libcxx_include_target_dir = None
 libcxx_library_dir = None
 
+# GNUstep libobjc2 installation directory used to build Objective-C tests on
+# non-Apple platforms.
+objc_gnustep_dir = None
+
 # A plugin whose tests will be enabled, like intel-pt.
 enabled_plugins = []
 
diff --git a/lldb/packages/Python/lldbsuite/test/dotest.py b/lldb/packages/Python/lldbsuite/test/dotest.py
index dec46c7715e40..3e4279dc9d2ac 100644
--- a/lldb/packages/Python/lldbsuite/test/dotest.py
+++ b/lldb/packages/Python/lldbsuite/test/dotest.py
@@ -291,6 +291,9 @@ def parseOptionsAndInitTestdirs():
         logging.warning("No valid FileCheck executable; some tests may fail...")
         logging.warning("(Double-check the --llvm-tools-dir argument to dotest.py)")
 
+    if args.objc_gnustep_dir:
+        configuration.objc_gnustep_dir = args.objc_gnustep_dir
+
     if args.libcxx_include_dir or args.libcxx_library_dir:
         if args.lldb_platform_name:
             logging.warning(
@@ -1018,6 +1021,16 @@ def checkObjcSupport():
         configuration.skip_categories.append("objc")
 
 
+def checkObjcGnustepSupport():
+    """The GNUstep libobjc2 runtime is not part of any platform's SDK, so its
+    tests only run when a build of it has been pointed at."""
+    if not configuration.objc_gnustep_dir:
+        if configuration.verbose:
+            print("objc-gnustep tests will be skipped because no GNUstep")
+            print("libobjc2 installation was specified")
+        configuration.skip_categories.append("objc-gnustep")
+
+
 def checkExpressionSupport():
     from lldbsuite.test import lldbplatformutil
 
@@ -1217,6 +1230,7 @@ def run_suite():
     checkDebugInfoSupport()
     checkDebugServerSupport()
     checkObjcSupport()
+    checkObjcGnustepSupport()
     checkExpressionSupport()
     checkForkVForkSupport()
     checkPexpectSupport()
diff --git a/lldb/packages/Python/lldbsuite/test/dotest_args.py b/lldb/packages/Python/lldbsuite/test/dotest_args.py
index 516559fb6268d..b3dbd1c378bb2 100644
--- a/lldb/packages/Python/lldbsuite/test/dotest_args.py
+++ b/lldb/packages/Python/lldbsuite/test/dotest_args.py
@@ -85,6 +85,14 @@ def create_parser():
             "Specify the path to a custom libc++ library directory. Must be used in conjunction with --libcxx-include-dir."
         ),
     )
+    group.add_argument(
+        "--objc-gnustep-dir",
+        metavar="dir",
+        dest="objc_gnustep_dir",
+        help=textwrap.dedent(
+            "Specify the path to a GNUstep libobjc2 installation to build Objective-C tests against on non-Apple platforms."
+        ),
+    )
     # FIXME? This won't work for different extra flags according to each triple.
     group.add_argument(
         "-E",
diff --git a/lldb/packages/Python/lldbsuite/test/make/Makefile.rules b/lldb/packages/Python/lldbsuite/test/make/Makefile.rules
index feb0f3aa36856..387ce0d21cda9 100644
--- a/lldb/packages/Python/lldbsuite/test/make/Makefile.rules
+++ b/lldb/packages/Python/lldbsuite/test/make/Makefile.rules
@@ -546,6 +546,22 @@ ifneq "$(strip $(OBJCXX_SOURCES))" ""
 	endif
 endif
 
+#----------------------------------------------------------------------
+# Build Objective-C sources against a GNUstep libobjc2 installation when one
+# has been configured, which is how Objective-C tests run on platforms with
+# no system runtime. Mirrors the flags the Shell test helper uses.
+#----------------------------------------------------------------------
+ifneq "$(strip $(OBJC_GNUSTEP_DIR))" ""
+	ifneq "$(strip $(OBJC_SOURCES)$(OBJCXX_SOURCES))" ""
+		OBJCFLAGS +=-fobjc-runtime=gnustep-2.0 -I$(OBJC_GNUSTEP_DIR)/include
+		CFLAGS +=-fobjc-runtime=gnustep-2.0 -I$(OBJC_GNUSTEP_DIR)/include
+		LDFLAGS +=-L$(OBJC_GNUSTEP_DIR)/lib
+		ifeq "$(OS)" "Linux"
+			LDFLAGS +=-Wl,-rpath,$(OBJC_GNUSTEP_DIR)/lib
+		endif
+	endif
+endif
+
 ifeq ($(CC_TYPE), clang)
 	CXXFLAGS += --driver-mode=g++
 endif
diff --git a/lldb/packages/Python/lldbsuite/test/test_categories.py b/lldb/packages/Python/lldbsuite/test/test_categories.py
index b8a764fb3349a..efc55d4284e24 100644
--- a/lldb/packages/Python/lldbsuite/test/test_categories.py
+++ b/lldb/packages/Python/lldbsuite/test/test_categories.py
@@ -43,6 +43,7 @@
     "pdb": "Tests that can be run with PDB debug information",
     "pexpect": "Tests requiring the pexpect library to be available",
     "objc": "Tests related to the Objective-C programming language support",
+    "objc-gnustep": "Tests requiring the GNUstep libobjc2 Objective-C runtime",
     "pyapi": "Tests related to the Python API",
     "std-module": "Tests related to importing the std module",
     "stresstest": "Tests related to stressing lldb limits",
diff --git a/lldb/test/API/lang/objc-gnustep/categories b/lldb/test/API/lang/objc-gnustep/categories
new file mode 100644
index 0000000000000..3ef065a2dd25d
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/categories
@@ -0,0 +1 @@
+objc-gnustep
diff --git a/lldb/test/API/lang/objc-gnustep/dynamic-value/Makefile b/lldb/test/API/lang/objc-gnustep/dynamic-value/Makefile
new file mode 100644
index 0000000000000..845553d5e3f2f
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/dynamic-value/Makefile
@@ -0,0 +1,3 @@
+OBJC_SOURCES := main.m
+
+include Makefile.rules
diff --git a/lldb/test/API/lang/objc-gnustep/dynamic-value/TestGNUstepDynamicValue.py b/lldb/test/API/lang/objc-gnustep/dynamic-value/TestGNUstepDynamicValue.py
new file mode 100644
index 0000000000000..ae06f0c661ad0
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/dynamic-value/TestGNUstepDynamicValue.py
@@ -0,0 +1,47 @@
+"""
+Test the dynamic type of an Objective-C object with the GNUstep runtime.
+"""
+
+import lldb
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+from lldbsuite.test import lldbutil
+
+
+class TestGNUstepDynamicValue(TestBase):
+    def test_dynamic_value_from_api(self):
+        """The dynamic type is read out of the runtime's class structures."""
+        self.build()
+        lldbutil.run_to_source_breakpoint(
+            self, "// break here", lldb.SBFileSpec("main.m")
+        )
+
+        frame = self.frame()
+        static_value = frame.FindVariable("object", lldb.eNoDynamicValues)
+        self.assertTrue(static_value.IsValid(), "found the variable")
+        self.assertEqual(static_value.GetTypeName(), "Base *")
+
+        dynamic_value = static_value.GetDynamicValue(lldb.eDynamicCanRunTarget)
+        self.assertTrue(dynamic_value.IsValid(), "resolved a dynamic value")
+        self.assertEqual(dynamic_value.GetTypeName(), "Derived *")
+
+        # A variable whose dynamic and static types agree stays unchanged.
+        base = frame.FindVariable("base", lldb.eNoDynamicValues).GetDynamicValue(
+            lldb.eDynamicCanRunTarget
+        )
+        self.assertEqual(base.GetTypeName(), "Base *")
+
+    def test_dynamic_value_from_command(self):
+        """`frame variable` reports the same dynamic type as the API."""
+        self.build()
+        lldbutil.run_to_source_breakpoint(
+            self, "// break here", lldb.SBFileSpec("main.m")
+        )
+
+        self.expect(
+            "frame variable -d run-target object", substrs=["(Derived *) object"]
+        )
+        self.expect(
+            "frame variable -d no-dynamic-values object",
+            substrs=["(Base *) object"],
+        )
diff --git a/lldb/test/API/lang/objc-gnustep/dynamic-value/main.m b/lldb/test/API/lang/objc-gnustep/dynamic-value/main.m
new file mode 100644
index 0000000000000..4b2b7bce0722c
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/dynamic-value/main.m
@@ -0,0 +1,36 @@
+#import "objc/runtime.h"
+
+ at protocol NSCoding
+ at end
+
+#ifdef __has_attribute
+#if __has_attribute(objc_root_class)
+__attribute__((objc_root_class))
+#endif
+#endif
+ at interface NSObject <NSCoding> {
+  id isa;
+  int refcount;
+}
+ at end
+ at implementation NSObject
++ (id)new {
+  return class_createInstance(self, 0);
+}
+ at end
+
+ at interface Base : NSObject
+ at end
+ at implementation Base
+ at end
+
+ at interface Derived : Base
+ at end
+ at implementation Derived
+ at end
+
+int main() {
+  Base *object = [Derived new];
+  Base *base = [Base new];
+  return object != base; // break here
+}
diff --git a/lldb/test/API/lit.cfg.py b/lldb/test/API/lit.cfg.py
index 41603a1e6f5af..3f32433a3beec 100644
--- a/lldb/test/API/lit.cfg.py
+++ b/lldb/test/API/lit.cfg.py
@@ -237,6 +237,11 @@ def delete_module_cache(path):
                 ]
             dotest_cmd += ["--libcxx-library-dir", config.libcxx_libs_dir]
 
+# If a GNUstep libobjc2 installation is available, build Objective-C tests
+# against it on non-Apple platforms.
+if is_configured("objc_gnustep_dir"):
+    dotest_cmd += ["--objc-gnustep-dir", config.objc_gnustep_dir]
+
 # Forward ASan-specific environment variables to tests, as a test may load an
 # ASan-ified dylib.
 for env_var in ("ASAN_OPTIONS", "DYLD_INSERT_LIBRARIES"):
diff --git a/lldb/test/API/lit.site.cfg.py.in b/lldb/test/API/lit.site.cfg.py.in
index 44c62414f8bdd..30bb3733121c3 100644
--- a/lldb/test/API/lit.site.cfg.py.in
+++ b/lldb/test/API/lit.site.cfg.py.in
@@ -42,6 +42,7 @@ config.has_libcxx = @LLDB_HAS_LIBCXX@
 config.libcxx_libs_dir = "@LIBCXX_LIBRARY_DIR@"
 config.libcxx_include_dir = "@LIBCXX_GENERATED_INCLUDE_DIR@"
 config.libcxx_include_target_dir = "@LIBCXX_GENERATED_INCLUDE_TARGET_DIR@"
+config.objc_gnustep_dir = "@LLDB_TEST_OBJC_GNUSTEP_DIR@"
 config.lldb_launcher = "@LLDB_LAUNCHER@"
 config.test_resource_dir = "@LLDB_TEST_RESOURCE_DIR@"
 config.lldb_enable_mte = @LLDB_ENABLE_MTE@

>From 84651aa9bee4ec7e216aba3db4b738b13bc88412 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 10:50:42 +0100
Subject: [PATCH 14/38] [lldb][GNUstep] Add tests for tagged pointers and
 stepping

Cover two runtime features that had no test: resolving the class of a
tagged pointer, and stepping through the runtime's dispatch function
into a method implementation.

The tagged pointer test registers its own small object class rather than
relying on Foundation, so it runs against a bare libobjc2, and also
checks that an ordinary pointer is still resolved by reading its class
structure. The stepping test covers both landing in the implementation
and a message to nil, which dispatches nowhere and must simply carry on.

Assisted-by: Claude Opus 5
---
 lldb/test/Shell/Expr/objc-gnustep-stepping.m  | 62 +++++++++++++++++++
 .../Shell/Expr/objc-gnustep-tagged-pointers.m | 60 ++++++++++++++++++
 2 files changed, 122 insertions(+)
 create mode 100644 lldb/test/Shell/Expr/objc-gnustep-stepping.m
 create mode 100644 lldb/test/Shell/Expr/objc-gnustep-tagged-pointers.m

diff --git a/lldb/test/Shell/Expr/objc-gnustep-stepping.m b/lldb/test/Shell/Expr/objc-gnustep-stepping.m
new file mode 100644
index 0000000000000..0a35f1945ded5
--- /dev/null
+++ b/lldb/test/Shell/Expr/objc-gnustep-stepping.m
@@ -0,0 +1,62 @@
+// REQUIRES: objc-gnustep
+// XFAIL: system-windows
+//
+// RUN: %build %s --compiler=clang --objc-gnustep --output=%t
+
+#import "objc/runtime.h"
+
+ at protocol NSCoding
+ at end
+
+#ifdef __has_attribute
+#if __has_attribute(objc_root_class)
+__attribute__((objc_root_class))
+#endif
+#endif
+ at interface NSObject <NSCoding> {
+  id isa;
+  int refcount;
+}
+ at end
+ at implementation NSObject
++ (id)new {
+  return class_createInstance(self, 0);
+}
+ at end
+
+ at interface Doubler : NSObject
+- (int)twice:(int)value;
+ at end
+ at implementation Doubler
+- (int)twice:(int)value {
+  return value * 2;
+}
+ at end
+
+// Stepping at a message send has to run through the runtime's dispatch
+// function and land in the method implementation.
+//
+// RUN: %lldb -b -o "b objc-gnustep-stepping.m:50" -o "run" -o "step" \
+// RUN:     -- %t | FileCheck %s --check-prefix=STEP_IN
+//
+// A message to nil dispatches nowhere, so the step must simply move on
+// instead of trying to run to an implementation.
+//
+// RUN: %lldb -b -o "b objc-gnustep-stepping.m:52" -o "run" -o "step" \
+// RUN:     -- %t | FileCheck %s --check-prefix=STEP_OVER_NIL
+//
+int main() {
+  Doubler *doubler = [Doubler new];
+  int value = [doubler twice:21];
+  Doubler *nothing = (Doubler *)0;
+  int none = [nothing twice:1];
+  return value + none;
+}
+//
+// STEP_IN: (lldb) step
+// STEP_IN: stop reason = step in
+// STEP_IN: -[Doubler twice:]
+//
+// STEP_OVER_NIL: (lldb) step
+// STEP_OVER_NIL: stop reason = step in
+// STEP_OVER_NIL: main at objc-gnustep-stepping.m:53
diff --git a/lldb/test/Shell/Expr/objc-gnustep-tagged-pointers.m b/lldb/test/Shell/Expr/objc-gnustep-tagged-pointers.m
new file mode 100644
index 0000000000000..fc7d1103ff310
--- /dev/null
+++ b/lldb/test/Shell/Expr/objc-gnustep-tagged-pointers.m
@@ -0,0 +1,60 @@
+// REQUIRES: objc-gnustep
+// XFAIL: system-windows
+//
+// RUN: %build %s --compiler=clang --objc-gnustep --output=%t
+
+#import "objc/runtime.h"
+
+ at protocol NSCoding
+ at end
+
+#ifdef __has_attribute
+#if __has_attribute(objc_root_class)
+__attribute__((objc_root_class))
+#endif
+#endif
+ at interface NSObject <NSCoding> {
+  id isa;
+  int refcount;
+}
+ at end
+ at implementation NSObject
++ (id)new {
+  return class_createInstance(self, 0);
+}
+ at end
+
+// libobjc2 calls these "small objects": a pointer with any of its low bits set
+// holds a value inline rather than pointing at an object, and its class comes
+// from the runtime's table of registered small object classes.
+ at interface TinyNumber : NSObject
+ at end
+ at implementation TinyNumber
+ at end
+
+ at interface Ordinary : NSObject
+ at end
+ at implementation Ordinary
+ at end
+
+// RUN: %lldb -b -o "b objc-gnustep-tagged-pointers.m:50" -o "run" \
+// RUN:          -o "frame variable -d run-target tagged" \
+// RUN:          -o "frame variable -d run-target ordinary" -- %t | FileCheck %s
+//
+int main() {
+  objc_registerSmallObjectClass_np(objc_getClass("TinyNumber"), 1);
+
+  // A payload of 3 in the slot registered above.
+  id tagged = (id)(uintptr_t)((3 << 3) | 1);
+  id ordinary = [Ordinary new];
+  return tagged == ordinary;
+}
+//
+// The tagged value's class comes from the runtime's small object table, while
+// an ordinary pointer is still resolved by reading its class structure.
+//
+// CHECK: (lldb) frame variable -d run-target tagged
+// CHECK: (TinyNumber *) tagged = 0x{{0*}}19
+//
+// CHECK: (lldb) frame variable -d run-target ordinary
+// CHECK: (Ordinary *) ordinary = 0x

>From 3ae32e650c3dae50b65861aed8688b6d4811457d Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 10:56:59 +0100
Subject: [PATCH 15/38] [lldb][docs] Document testing Objective-C against a
 GNUstep runtime

LLDB_TEST_OBJC_GNUSTEP and LLDB_TEST_OBJC_GNUSTEP_DIR were not mentioned
anywhere, so there was no way to discover how to run the Objective-C
tests on a platform without a system runtime. Describe them alongside
the other test-related CMake options, note the new API test category,
and add a release note for the GNUstep runtime support.

Assisted-by: Claude Opus 5
---
 lldb/docs/resources/build.md | 17 +++++++++++++++++
 lldb/docs/resources/test.md  |  7 +++++++
 llvm/docs/ReleaseNotes.md    |  5 +++++
 3 files changed, 29 insertions(+)

diff --git a/lldb/docs/resources/build.md b/lldb/docs/resources/build.md
index e3c3250006051..d01586a0f18d7 100644
--- a/lldb/docs/resources/build.md
+++ b/lldb/docs/resources/build.md
@@ -287,6 +287,23 @@ When both of these options are enabled, LLDB can use, and be used from, a
 different version of Python (3.8 or later) than it was built against. Note that
 on Windows, `LLDB_ENABLE_DYNAMIC_SCRIPTINTERPRETERS` is not required.
 
+#### Testing Objective-C without a system runtime
+
+Linux and Windows have no Objective-C runtime of their own, so the tests for
+Objective-C language support are only run when LLDB is pointed at a build of
+the GNUstep [libobjc2](https://github.com/gnustep/libobjc2) runtime:
+
+```
+-DLLDB_TEST_OBJC_GNUSTEP=On
+-DLLDB_TEST_OBJC_GNUSTEP_DIR=/path/to/libobjc2/install
+```
+
+The directory is the install prefix of libobjc2, containing `lib` and
+`include`. With these set, the Shell tests that require the `objc-gnustep`
+feature and the API tests in the `objc-gnustep` category are enabled; without
+them those tests are skipped. Foundation is not required: the tests run
+against libobjc2 alone.
+
 #### Windows
 
 On Windows the LLDB test suite requires lld. Either add `lld` to
diff --git a/lldb/docs/resources/test.md b/lldb/docs/resources/test.md
index 5097b972db9f6..c38f474d187e0 100644
--- a/lldb/docs/resources/test.md
+++ b/lldb/docs/resources/test.md
@@ -166,6 +166,13 @@ Reach for `require*` when the test is tied to a platform-specific file format,
 API, or OS feature. If the test is merely untested or broken somewhere, keep
 `skipIf*` so nobody mistakes a bug for a design decision.
 
+Some tests instead depend on something the build was pointed at rather than on
+the platform. Objective-C tests are an example: on Linux and Windows there is
+no system runtime, so tests that need one go in the `objc-gnustep` category
+(by adding a `categories` file next to them) and only run when the build was
+configured with `LLDB_TEST_OBJC_GNUSTEP_DIR`. The Darwin-only `objc` category
+is unaffected.
+
 In addition to providing a lot more flexibility when it comes to writing the
 test, the API test also allow for much more complex scenarios when it comes to
 building inferiors. Every test has its own `Makefile`, most of them only a
diff --git a/llvm/docs/ReleaseNotes.md b/llvm/docs/ReleaseNotes.md
index 72e1b2a12ee1d..289fe50374fd5 100644
--- a/llvm/docs/ReleaseNotes.md
+++ b/llvm/docs/ReleaseNotes.md
@@ -141,6 +141,11 @@ Makes programs 10x faster by doing Special New Thing.
 
 ### Changes to LLDB
 
+* Debugging Objective-C with the GNUstep libobjc2 runtime is now supported on
+  Linux and Windows: dynamic types, tagged pointers, printing objects with
+  `po`, stepping through message dispatch, evaluating expressions that send
+  messages, and data formatters for the gnustep-base Foundation classes.
+
 #### SBAPI
 
 * A [bug](https://github.com/llvm/llvm-project/issues/211787) involving SBValues

>From 47f393a230238b24ca2749855c88064a7edb6090 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 23:37:16 +0100
Subject: [PATCH 16/38] [lldb] Guard MS inheritance model against
 non-CXXRecordDecl DWARF types

CompleteRecordType() computes the MSInheritanceAttr for the Microsoft
C++ ABI by calling calculateInheritanceModel() on the record's
CXXRecordDecl. Objective-C interface types complete through the same
path but are ObjCInterfaceDecls, so GetAsCXXRecordDecl() returns null
and the Microsoft-ABI block crashed on every Objective-C type
completion for *-windows-msvc targets. Guard it the same way as the
SetRecordLayout call above.

Found debugging GNUstep Objective-C programs on Windows, where any
`frame variable` touching an object type crashed LLDB.

Assisted-by: Claude Fable 5
---
 .../Plugins/SymbolFile/DWARF/DWARFASTParserClang.cpp   | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/lldb/source/Plugins/SymbolFile/DWARF/DWARFASTParserClang.cpp b/lldb/source/Plugins/SymbolFile/DWARF/DWARFASTParserClang.cpp
index b60f1d9e41958..8609e0b2b0388 100644
--- a/lldb/source/Plugins/SymbolFile/DWARF/DWARFASTParserClang.cpp
+++ b/lldb/source/Plugins/SymbolFile/DWARF/DWARFASTParserClang.cpp
@@ -2239,8 +2239,14 @@ bool DWARFASTParserClang::CompleteRecordType(const DWARFDIE &die,
 
   clang::CXXRecordDecl *record_decl =
       m_ast.GetAsCXXRecordDecl(clang_type.GetOpaqueQualType());
-  if (record_decl)
-    GetClangASTImporter().SetRecordLayout(record_decl, layout_info);
+  // Objective-C interfaces are completed through this path as well, but are
+  // not CXXRecordDecls. Nothing that follows applies to them: they have no
+  // record layout to hand to the importer, no pointer-to-member
+  // representation to infer, and no nested types to resolve.
+  if (!record_decl)
+    return clang_type.IsValid();
+
+  GetClangASTImporter().SetRecordLayout(record_decl, layout_info);
 
   // DWARF doesn't have the attribute, but we can infer the value the same way
   // as Clang Sema does. It's required to calculate the size of pointers to

>From dfd82c90be1f948e50fb290a6bd0e6a312e4a2a3 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 23:37:31 +0100
Subject: [PATCH 17/38] [lldb][GNUstep] Ignore PE import thunks when locating
 the runtime

ModuleDefinesFunction() treats any __objc_load symbol with a valid
address as a definition. On PE/COFF every module linked against
libobjc2 contains an import thunk for __objc_load that carries the
plain symbol name and a valid code address, so the executable itself
was identified as the runtime module. The step-through gate then looked
for objc_msg_lookup in the executable and refused to create a plan,
which broke stepping through message sends on Windows, and
IsRuntimeInternalAddress() classified every executable address as
runtime-internal.

Only the importing module also has the IAT pointer symbol
`__imp_<name>`; the module that implements the function does not. Use
that to reject importers.

Assisted-by: Claude Fable 5
---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 21 ++++++++++++++++---
 1 file changed, 18 insertions(+), 3 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 174ae738cffe2..7e272da352be0 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -175,14 +175,29 @@ static bool ModuleDefinesFunction(const ModuleSP &module_sp,
   SymbolContextList sc_list;
   module_sp->FindSymbolsWithNameAndType(ConstString(name), eSymbolTypeCode,
                                         sc_list);
+  bool defines_function = false;
   for (const SymbolContext &sc : sc_list) {
     // Every module compiled against libobjc2 carries an undefined reference
     // to __objc_load from its .objc_init constructor, so only a definition
     // identifies the runtime itself.
-    if (sc.symbol && sc.symbol->GetAddress().IsValid())
-      return true;
+    if (sc.symbol && sc.symbol->GetAddress().IsValid()) {
+      defines_function = true;
+      break;
+    }
   }
-  return false;
+  if (!defines_function)
+    return false;
+  // On PE/COFF an importing module contains an import thunk that carries the
+  // imported function's plain name and a valid code address, which the check
+  // above cannot tell apart from a definition. Only the importer also has the
+  // IAT pointer symbol `__imp_<name>`; the implementing module does not.
+  SymbolContextList imp_list;
+  module_sp->FindSymbolsWithNameAndType(ConstString(("__imp_" + name).str()),
+                                        eSymbolTypeAny, imp_list);
+  for (const SymbolContext &sc : imp_list)
+    if (sc.symbol && sc.symbol->GetAddress().IsValid())
+      return false;
+  return true;
 }
 
 /// Finds the module implementing the libobjc2 runtime, identified by its

>From 226b7defcce6aa96cd394aeaaac1f98e951f43a7 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Wed, 12 Aug 2026 23:37:32 +0100
Subject: [PATCH 18/38] [lldb][GNUstep] Build Windows GNUstep tests with DWARF
 and un-XFAIL them

CodeView cannot represent Objective-C types, so test binaries built
with -gcodeview present plain C++ record types to the debugger: dynamic
type resolution silently falls back to the static type, ivar values
read from wrong offsets, and expression evaluation rejects message
sends. Compile the Shell and API test binaries with -gdwarf instead and
link with lld-link /debug:dwarf, which keeps the DWARF sections and
also writes a COFF symbol table - required for LLDB to see the runtime
metadata symbols ($_OBJC_CLASS_..., selector references) that the
executable image otherwise loses.

The API harness additionally copies objc.dll next to the test binary,
because lldbtest launches inferiors with the shared-library search path
scrubbed (it clears PATH on Windows).

With this, all five objc-gnustep Shell tests and the dynamic-value API
test pass on Windows against libobjc2 v2.3, so drop the XFAILs. The
in-file breakpoint line numbers shift by one for the removed XFAIL
lines.

Assisted-by: Claude Fable 5
---
 .../Python/lldbsuite/test/make/Makefile.rules  | 18 ++++++++++++++++++
 .../Shell/Expr/objc-gnustep-dynamic-types.m    |  3 +--
 lldb/test/Shell/Expr/objc-gnustep-expr.m       |  3 +--
 lldb/test/Shell/Expr/objc-gnustep-print.m      | 11 +++++------
 lldb/test/Shell/Expr/objc-gnustep-stepping.m   |  7 +++----
 .../Shell/Expr/objc-gnustep-tagged-pointers.m  |  3 +--
 lldb/test/Shell/helper/build.py                | 14 ++++++++------
 7 files changed, 37 insertions(+), 22 deletions(-)

diff --git a/lldb/packages/Python/lldbsuite/test/make/Makefile.rules b/lldb/packages/Python/lldbsuite/test/make/Makefile.rules
index 387ce0d21cda9..33c2b0788f87d 100644
--- a/lldb/packages/Python/lldbsuite/test/make/Makefile.rules
+++ b/lldb/packages/Python/lldbsuite/test/make/Makefile.rules
@@ -559,6 +559,12 @@ ifneq "$(strip $(OBJC_GNUSTEP_DIR))" ""
 		ifeq "$(OS)" "Linux"
 			LDFLAGS +=-Wl,-rpath,$(OBJC_GNUSTEP_DIR)/lib
 		endif
+		ifeq "$(OS)" "Windows_NT"
+			# Keep the DWARF sections and emit a COFF symbol table; the
+			# default CodeView route cannot represent Objective-C types.
+			LDFLAGS +=-Wl,/debug:dwarf
+			GNUSTEP_NEEDS_DLL_COPY := 1
+		endif
 	endif
 endif
 
@@ -745,6 +751,18 @@ print-%:
 	@echo '  flavor = $(flavor $*)'
 	@echo '   value = $(value  $*)'
 
+# The test harness launches inferiors with the shared-library search path
+# scrubbed (lldbtest clears PATH on Windows), so the runtime DLL must sit
+# next to the test binary. This lives at the end of the file because make
+# would otherwise fold the tab-indented lines of the next conditional block
+# into this rule's recipe.
+ifeq "$(GNUSTEP_NEEDS_DLL_COPY)" "1"
+all: objc.dll
+
+objc.dll: $(OBJC_GNUSTEP_DIR)/lib/objc.dll
+	cp $< $@
+endif
+
 ### Local Variables: ###
 ### mode:makefile ###
 ### End: ###
diff --git a/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m b/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m
index 2d0caf1c69426..3da7aeb01ad06 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-dynamic-types.m
@@ -1,5 +1,4 @@
 // REQUIRES: objc-gnustep
-// XFAIL: system-windows
 //
 // RUN: %build %s --compiler=clang --objc-gnustep --output=%t
 
@@ -38,7 +37,7 @@ @implementation Derived
 // GNUstep runtime resolves the dynamic type by reading the class structure
 // from the inferior's memory and attaching the matching type from debug info.
 //
-// RUN: %lldb -b -o "b objc-gnustep-dynamic-types.m:47" -o "run" \
+// RUN: %lldb -b -o "b objc-gnustep-dynamic-types.m:46" -o "run" \
 // RUN:          -o "frame variable -d run-target object" \
 // RUN:          -o "frame variable -d no-dynamic-values object" -- %t | FileCheck %s
 //
diff --git a/lldb/test/Shell/Expr/objc-gnustep-expr.m b/lldb/test/Shell/Expr/objc-gnustep-expr.m
index 8a511d57395e1..3605bebc9cbe2 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-expr.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-expr.m
@@ -1,5 +1,4 @@
 // REQUIRES: objc-gnustep
-// XFAIL: system-windows
 //
 // RUN: %build %s --compiler=clang --objc-gnustep --output=%t
 
@@ -37,7 +36,7 @@ - (int)addFortyTwoTo:(int)value {
 // registered with the runtime (the GNUstep plugin's IR pass does this);
 // without it the dispatch reaches the runtime with an unregistered selector.
 //
-// RUN: %lldb -b -o "b objc-gnustep-expr.m:47" -o "run" \
+// RUN: %lldb -b -o "b objc-gnustep-expr.m:46" -o "run" \
 // RUN:          -o "expr [c addFortyTwoTo:100]" \
 // RUN:          -o "expr (int)[[Calc new] addFortyTwoTo:1]" -- %t | FileCheck %s
 //
diff --git a/lldb/test/Shell/Expr/objc-gnustep-print.m b/lldb/test/Shell/Expr/objc-gnustep-print.m
index 6e119cefc459b..873ac3092df1f 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-print.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-print.m
@@ -1,5 +1,4 @@
 // REQUIRES: objc-gnustep
-// XFAIL: system-windows
 //
 // RUN: %build %s --compiler=clang --objc-gnustep --output=%t
 
@@ -52,9 +51,9 @@ - (void)set_ivars {
 }
 @end
 
-// RUN: %lldb -b -o "b objc-gnustep-print.m:43" -o "run" -o "p self" -o "p *self" -- %t | FileCheck %s --check-prefix=SELF
+// RUN: %lldb -b -o "b objc-gnustep-print.m:42" -o "run" -o "p self" -o "p *self" -- %t | FileCheck %s --check-prefix=SELF
 //
-// SELF: (lldb) b objc-gnustep-print.m:43
+// SELF: (lldb) b objc-gnustep-print.m:42
 // SELF: Breakpoint {{.*}} at objc-gnustep-print.m
 //
 // SELF: (lldb) run
@@ -78,7 +77,7 @@ - (void)set_ivars {
 // SELF:   _id_objc = nil
 // SELF: }
 
-// RUN: %lldb -b -o "b objc-gnustep-print.m:106" -o "run" -o "p t->_int" -o "p t->_float" -o "p t->_char" \
+// RUN: %lldb -b -o "b objc-gnustep-print.m:105" -o "run" -o "p t->_int" -o "p t->_float" -o "p t->_char" \
 // RUN:          -o "p t->_ptr_void" -o "p t->_ptr_nsobject" -o "p t->_id_objc" -- %t | FileCheck %s --check-prefix=IVARS_SET
 //
 // IVARS_SET: (lldb) p t->_int
@@ -115,7 +114,7 @@ int main() {
   return object_getClassName(object);
 }
 
-// RUN: %lldb -b -o "b objc-gnustep-print.m:106" -o "run" -o "po t" \
+// RUN: %lldb -b -o "b objc-gnustep-print.m:105" -o "run" -o "po t" \
 // RUN:     -- %t | FileCheck %s --check-prefix=PO
 //
 // PO: (lldb) po t
@@ -124,7 +123,7 @@ int main() {
 // Stepping at a message send goes through the objc_msgSend trampoline into
 // the method implementation.
 //
-// RUN: %lldb -b -o "b objc-gnustep-print.m:104" -o "run" -o "step" \
+// RUN: %lldb -b -o "b objc-gnustep-print.m:103" -o "run" -o "step" \
 // RUN:     -- %t | FileCheck %s --check-prefix=STEP
 //
 // STEP: (lldb) step
diff --git a/lldb/test/Shell/Expr/objc-gnustep-stepping.m b/lldb/test/Shell/Expr/objc-gnustep-stepping.m
index 0a35f1945ded5..d7f0278d46044 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-stepping.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-stepping.m
@@ -1,5 +1,4 @@
 // REQUIRES: objc-gnustep
-// XFAIL: system-windows
 //
 // RUN: %build %s --compiler=clang --objc-gnustep --output=%t
 
@@ -36,13 +35,13 @@ - (int)twice:(int)value {
 // Stepping at a message send has to run through the runtime's dispatch
 // function and land in the method implementation.
 //
-// RUN: %lldb -b -o "b objc-gnustep-stepping.m:50" -o "run" -o "step" \
+// RUN: %lldb -b -o "b objc-gnustep-stepping.m:49" -o "run" -o "step" \
 // RUN:     -- %t | FileCheck %s --check-prefix=STEP_IN
 //
 // A message to nil dispatches nowhere, so the step must simply move on
 // instead of trying to run to an implementation.
 //
-// RUN: %lldb -b -o "b objc-gnustep-stepping.m:52" -o "run" -o "step" \
+// RUN: %lldb -b -o "b objc-gnustep-stepping.m:51" -o "run" -o "step" \
 // RUN:     -- %t | FileCheck %s --check-prefix=STEP_OVER_NIL
 //
 int main() {
@@ -59,4 +58,4 @@ int main() {
 //
 // STEP_OVER_NIL: (lldb) step
 // STEP_OVER_NIL: stop reason = step in
-// STEP_OVER_NIL: main at objc-gnustep-stepping.m:53
+// STEP_OVER_NIL: main at objc-gnustep-stepping.m:52
diff --git a/lldb/test/Shell/Expr/objc-gnustep-tagged-pointers.m b/lldb/test/Shell/Expr/objc-gnustep-tagged-pointers.m
index fc7d1103ff310..55744570a2dcd 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-tagged-pointers.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-tagged-pointers.m
@@ -1,5 +1,4 @@
 // REQUIRES: objc-gnustep
-// XFAIL: system-windows
 //
 // RUN: %build %s --compiler=clang --objc-gnustep --output=%t
 
@@ -37,7 +36,7 @@ @interface Ordinary : NSObject
 @implementation Ordinary
 @end
 
-// RUN: %lldb -b -o "b objc-gnustep-tagged-pointers.m:50" -o "run" \
+// RUN: %lldb -b -o "b objc-gnustep-tagged-pointers.m:49" -o "run" \
 // RUN:          -o "frame variable -d run-target tagged" \
 // RUN:          -o "frame variable -d run-target ordinary" -- %t | FileCheck %s
 //
diff --git a/lldb/test/Shell/helper/build.py b/lldb/test/Shell/helper/build.py
index fb88c1f2f44c8..d09b28c9bc17b 100755
--- a/lldb/test/Shell/helper/build.py
+++ b/lldb/test/Shell/helper/build.py
@@ -782,9 +782,10 @@ def _get_compilation_command(self, source, obj):
             if source.endswith(".m") or source.endswith(".mm"):
                 args.extend(["-fobjc-runtime=gnustep-2.0", "-I", self.objc_gnustep_inc])
                 if sys.platform == "win32":
-                    args.extend(
-                        ["-Xclang", "-gcodeview", "-Xclang", "--dependent-lib=msvcrtd"]
-                    )
+                    # CodeView cannot represent Objective-C types, so force
+                    # DWARF even though the target is MSVC. The debugger needs
+                    # it to recognize classes and resolve dynamic types.
+                    args.extend(["-gdwarf", "-Xclang", "--dependent-lib=msvcrtd"])
         elif self.sysroot:
             args.extend(["--sysroot", self.sysroot])
 
@@ -832,9 +833,10 @@ def _get_link_command(self):
             if sys.platform == "linux":
                 args.extend(["-Wl,-rpath," + self.objc_gnustep_lib])
             elif sys.platform == "win32":
-                args.extend(
-                    ["-fuse-ld=lld-link", "-g", "-Xclang", "--dependent-lib=msvcrtd"]
-                )
+                # /debug:dwarf keeps the DWARF sections in the image and, unlike
+                # the PDB route, writes a COFF symbol table, which the debugger
+                # needs to find the runtime metadata symbols ($_OBJC_CLASS_...).
+                args.extend(["-fuse-ld=lld-link", "-Wl,/debug:dwarf"])
         elif self.sysroot:
             args.extend(["--sysroot", self.sysroot])
 

>From 6e6825e23e37371793812e91062ee1f8219168fc Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Sun, 16 Aug 2026 01:04:05 +0100
Subject: [PATCH 19/38] [lldb][GNUstep] Do not give class objects a dynamic
 type

A root class may declare its isa ivar as `id` rather than `Class`, which
libobjc2 programs and the GNUstep tests do. `id` values are offered to
the runtime for dynamic typing, so GetDynamicTypeAndAddress ran on the
class object an isa points at, read its first word - the metaclass - and
built a descriptor from that. libobjc2 names a metaclass after its class,
so the class object was reported as an instance of its own class:
`isa` rendered as `(Derived *)`, its "ivars" were struct objc_class
fields, and expanding it recursed forever through the same isa. Apple's
runtime never sees this because Apple's NSObject declares `Class isa`
and Class is not dynamic-typeable.

Record whether a descriptor was built from a metaclass and refuse to
report a dynamic type in that case; such a value is a Class, not an
object, and keeps its static type.

Assisted-by: Claude Fable 5
---
 .../GNUstepObjCClassDescriptor.cpp            |  1 +
 .../GNUstepObjCClassDescriptor.h              |  7 ++
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 12 ++++
 .../Shell/Expr/objc-gnustep-class-objects.m   | 72 +++++++++++++++++++
 .../GNUstepObjCClassDescriptorTest.cpp        | 22 ++++++
 5 files changed, 114 insertions(+)
 create mode 100644 lldb/test/Shell/Expr/objc-gnustep-class-objects.m

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
index 3a00f1abb3c4a..1261aa347ab41 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
@@ -132,6 +132,7 @@ void GNUstepObjCClassDescriptor::Read() {
   }
 
   m_metaclass_isa = metaclass;
+  m_is_meta = is_meta;
   m_name = ConstString(name_buffer);
   m_valid = true;
 }
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
index e1b2ee240d931..25ef25c22400b 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
@@ -74,6 +74,12 @@ class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
 
   ObjCLanguageRuntime::ObjCISA GetISA() override { return m_isa; }
 
+  /// True if this descriptor describes a metaclass, i.e. the ISA it was
+  /// built from is itself the class pointer of a class object rather than of
+  /// an instance. Instances never have a metaclass as their ISA, so a value
+  /// that resolves to one is a Class, not an object.
+  bool IsMetaclass() const { return m_is_meta; }
+
 protected:
   /// Parse `struct objc_class` at m_isa. Called from the constructor; sets
   /// m_valid only if the structure passes the consistency checks that keep a
@@ -86,6 +92,7 @@ class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
   ObjCLanguageRuntime::ObjCISA m_superclass_isa = 0;
   ObjCLanguageRuntime::ObjCISA m_metaclass_isa = 0;
   uint64_t m_instance_size = 0;
+  bool m_is_meta = false;
   bool m_valid = false;
 };
 
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 7e272da352be0..fe70bd20f2916 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -410,6 +410,18 @@ bool GNUstepObjCRuntime::GetDynamicTypeAndAddress(
   if (!objc_class_sp)
     return false;
 
+  // The descriptor was built from the first word of the pointed-to memory.
+  // For an instance that word is its class; for a class object it is the
+  // metaclass, which libobjc2 gives the same name as the class. Reporting
+  // that name here would present the class object as an instance of itself
+  // (and, since a root class may declare `id isa`, recurse through it), so
+  // values that turn out to be Class have no dynamic type. Every descriptor
+  // this runtime creates derives from GNUstepObjCClassDescriptor, so the
+  // cast is safe.
+  if (static_cast<GNUstepObjCClassDescriptor *>(objc_class_sp.get())
+          ->IsMetaclass())
+    return false;
+
   ConstString class_name(objc_class_sp->GetClassName());
   if (!class_name)
     return false;
diff --git a/lldb/test/Shell/Expr/objc-gnustep-class-objects.m b/lldb/test/Shell/Expr/objc-gnustep-class-objects.m
new file mode 100644
index 0000000000000..05c135b72bc4a
--- /dev/null
+++ b/lldb/test/Shell/Expr/objc-gnustep-class-objects.m
@@ -0,0 +1,72 @@
+// REQUIRES: objc-gnustep
+//
+// RUN: %build %s --compiler=clang --objc-gnustep --output=%t
+
+#import "objc/runtime.h"
+
+ at protocol NSCoding
+ at end
+
+// A root class may declare its `isa` as `id` rather than `Class` (the GNUstep
+// tests and many libobjc2 programs do). Because `id` can carry a dynamic
+// type, the value of such a field - a class object - is offered to the
+// runtime for dynamic typing. libobjc2 names a metaclass after its class, so
+// a naive runtime would then report the class object as an instance of the
+// class, and expanding it would recurse forever through the same `isa`.
+#ifdef __has_attribute
+#if __has_attribute(objc_root_class)
+__attribute__((objc_root_class))
+#endif
+#endif
+ at interface NSObject <NSCoding> {
+  id isa;
+  int refcount;
+}
+ at end
+ at implementation NSObject
++ (id)new {
+  return class_createInstance(self, 0);
+}
+ at end
+
+ at interface Base : NSObject {
+  int base_ivar;
+}
+ at end
+ at implementation Base
+ at end
+
+ at interface Derived : Base {
+  int derived_ivar;
+}
+ at end
+ at implementation Derived
+ at end
+
+// RUN: %lldb -b -o "b objc-gnustep-class-objects.m:54" -o "run" \
+// RUN:          -o "frame variable -d run-target -T object" \
+// RUN:          -o "frame variable -d run-target -T *object" \
+// RUN:          -o "frame variable -d run-target -T object->isa" \
+// RUN:          -- %t | FileCheck %s
+//
+int main() {
+  Base *object = [Derived new];
+  (void)object;
+  return object == 0;
+}
+//
+// The object itself gets its dynamic type...
+// CHECK: (lldb) frame variable -d run-target -T object
+// CHECK: (Derived *) object = 0x
+//
+// ...and its `isa` stays a plain `id`: it points at the class object, which
+// must not be presented as an instance.
+// CHECK: (lldb) frame variable -d run-target -T *object
+// CHECK: (Derived) *object = {
+// CHECK: (id) isa = 0x
+// CHECK-NOT: (Derived *) isa
+// CHECK-NOT: (Base *) isa
+// CHECK: (int) refcount
+//
+// CHECK: (lldb) frame variable -d run-target -T object->isa
+// CHECK: (id) object->isa = 0x
diff --git a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
index 5d06a61a9acb2..bc3ace1cc96f7 100644
--- a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
+++ b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
@@ -183,6 +183,28 @@ TEST_P(GNUstepClassDescriptorTest, ParsesWellFormedClass) {
   EXPECT_EQ(descriptor.GetISA(), g_class_addr);
 }
 
+/// A descriptor built from a class object's own ISA (its metaclass) must say
+/// so. GetDynamicTypeAndAddress relies on this to refuse a dynamic type for
+/// values that are Class rather than instances: libobjc2 gives a metaclass the
+/// same name as its class, so nothing else distinguishes the two.
+TEST_P(GNUstepClassDescriptorTest, IdentifiesMetaclass) {
+  FakeProcess &process = GetProcess();
+  process.WriteCString(g_name_addr, "Derived");
+  WriteClass(g_class_addr, g_metaclass_addr, g_superclass_addr, g_name_addr,
+             g_flag_resolved, 42);
+  WriteClass(g_metaclass_addr, g_metaclass_addr, 0, g_name_addr,
+             g_flag_meta | g_flag_resolved, 0);
+
+  GNUstepObjCClassDescriptor instance_class(m_process_sp, g_class_addr);
+  ASSERT_TRUE(instance_class.IsValid());
+  EXPECT_FALSE(instance_class.IsMetaclass());
+
+  GNUstepObjCClassDescriptor metaclass(m_process_sp, g_metaclass_addr);
+  ASSERT_TRUE(metaclass.IsValid());
+  EXPECT_TRUE(metaclass.IsMetaclass());
+  EXPECT_EQ(metaclass.GetClassName(), ConstString("Derived"));
+}
+
 TEST_P(GNUstepClassDescriptorTest, WalksSuperclassChain) {
   FakeProcess &process = GetProcess();
   process.WriteCString(g_name_addr, "Derived");

>From d8d53db03c4ab2492c8ec2228d75576985c04fe9 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Sun, 16 Aug 2026 01:25:04 +0100
Subject: [PATCH 20/38] [lldb][GNUstep] Add data formatters for gnustep-base
 Foundation classes

The Objective-C data formatters recognize only Apple's Foundation: they
dispatch on class names like __NSCFString and read CoreFoundation
layouts, and the container synthetic children bail out for any runtime
that is not AppleObjCRuntime. Programs using the GNUstep libobjc2
runtime with gnustep-base therefore showed only pointer values.

Add summary providers and synthetic children for gnustep-base's
concrete classes - strings (tagged GSTinyString, NSConstantString, the
GSString family, GSMutableString), numbers (tagged and boxed), arrays,
dictionaries, sets, data, dates and NSNull - producing the same output
as the Apple formatters (@"...", @"N elements", N key/value pairs,
(int)N, YES/NO). They are registered under the concrete class names, so
ObjCLanguage's runtime-name candidate selects them for dynamic values;
for static values the Apple providers hand over to them when the process
runs the GNUstep runtime.

Two rules keep the formatters correct across configurations. Nothing
runs code in the inferior: small objects are decoded from the pointer
bits alone (GSString.m, NSNumber.m, NSDate.m, and clang's CGObjCGNU.cpp
which emits short literals as tagged strings), and everything else is
read from memory. No ivar offset is hardcoded: libobjc2 packs instance
sizes and long-typed ivars differ between LP64 and LLP64, so ivars are
found by name through the debug info of the value's dynamic type, the
way the libc++ formatters read their members. GSIMap tables are walked
through their typed nodes for the same reason, which also handles sets
whose nodes carry no value member.

Not covered: NSURL, whose ivars gnustep-base hides behind GS_EXPOSE and
which therefore appear in neither debug info nor ivar-offset symbols.

Assisted-by: Claude Fable 5
---
 .../Plugins/Language/ObjC/CMakeLists.txt      |   6 +
 lldb/source/Plugins/Language/ObjC/Cocoa.cpp   |  13 +
 .../Language/ObjC/GNUstepFormatters.cpp       | 285 ++++++++++++++++++
 .../Plugins/Language/ObjC/GNUstepFormatters.h | 104 +++++++
 .../Plugins/Language/ObjC/GNUstepNSArray.cpp  | 130 ++++++++
 .../Language/ObjC/GNUstepNSDictionary.cpp     | 264 ++++++++++++++++
 .../Plugins/Language/ObjC/GNUstepNSNumber.cpp | 126 ++++++++
 .../Plugins/Language/ObjC/GNUstepNSString.cpp | 239 +++++++++++++++
 lldb/source/Plugins/Language/ObjC/NSArray.cpp |  12 +-
 .../Plugins/Language/ObjC/NSDictionary.cpp    |  14 +-
 .../Plugins/Language/ObjC/NSDictionary.h      |   6 +
 lldb/source/Plugins/Language/ObjC/NSSet.cpp   |   9 +
 .../source/Plugins/Language/ObjC/NSString.cpp |   5 +
 .../Plugins/Language/ObjC/ObjCLanguage.cpp    |   2 +
 14 files changed, 1210 insertions(+), 5 deletions(-)
 create mode 100644 lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp
 create mode 100644 lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h
 create mode 100644 lldb/source/Plugins/Language/ObjC/GNUstepNSArray.cpp
 create mode 100644 lldb/source/Plugins/Language/ObjC/GNUstepNSDictionary.cpp
 create mode 100644 lldb/source/Plugins/Language/ObjC/GNUstepNSNumber.cpp
 create mode 100644 lldb/source/Plugins/Language/ObjC/GNUstepNSString.cpp

diff --git a/lldb/source/Plugins/Language/ObjC/CMakeLists.txt b/lldb/source/Plugins/Language/ObjC/CMakeLists.txt
index 5e554b22030fa..c2be0216daaa3 100644
--- a/lldb/source/Plugins/Language/ObjC/CMakeLists.txt
+++ b/lldb/source/Plugins/Language/ObjC/CMakeLists.txt
@@ -4,6 +4,11 @@ add_lldb_library(lldbPluginObjCLanguage PLUGIN
   CFBasicHash.cpp
   Cocoa.cpp
   CoreMedia.cpp
+  GNUstepFormatters.cpp
+  GNUstepNSArray.cpp
+  GNUstepNSDictionary.cpp
+  GNUstepNSNumber.cpp
+  GNUstepNSString.cpp
   NSArray.cpp
   NSDictionary.cpp
   NSError.cpp
@@ -22,6 +27,7 @@ add_lldb_library(lldbPluginObjCLanguage PLUGIN
     lldbUtility
     lldbValueObject
     lldbPluginAppleObjCRuntime
+    lldbPluginGNUstepObjCRuntime
     lldbPluginTypeSystemClang
   CLANG_LIBS
     clangAST
diff --git a/lldb/source/Plugins/Language/ObjC/Cocoa.cpp b/lldb/source/Plugins/Language/ObjC/Cocoa.cpp
index 4a8d6f1ea75ce..69f5c96351b70 100644
--- a/lldb/source/Plugins/Language/ObjC/Cocoa.cpp
+++ b/lldb/source/Plugins/Language/ObjC/Cocoa.cpp
@@ -7,6 +7,7 @@
 //===----------------------------------------------------------------------===//
 
 #include "Cocoa.h"
+#include "GNUstepFormatters.h"
 #include "NSString.h"
 #include "ObjCConstants.h"
 
@@ -445,6 +446,10 @@ bool lldb_private::formatters::NSNumberSummaryProvider(
 
   if (!runtime)
     return false;
+  // gnustep-base lays its classes out differently and names them
+  // differently; hand those over.
+  if (llvm::isa<GNUstepObjCRuntime>(runtime))
+    return GNUstepNSNumberSummaryProvider(valobj, stream, options);
 
   ObjCLanguageRuntime::ClassDescriptorSP descriptor(
       runtime->GetClassDescriptor(valobj));
@@ -910,6 +915,10 @@ bool lldb_private::formatters::NSDateSummaryProvider(
 
   if (!runtime)
     return false;
+  // gnustep-base lays its classes out differently and names them
+  // differently; hand those over.
+  if (llvm::isa<GNUstepObjCRuntime>(runtime))
+    return GNUstepNSDateSummaryProvider(valobj, stream, options);
 
   ObjCLanguageRuntime::ClassDescriptorSP descriptor(
       runtime->GetClassDescriptor(valobj));
@@ -1072,6 +1081,10 @@ bool lldb_private::formatters::NSDataSummaryProvider(
 
   if (!runtime)
     return false;
+  // gnustep-base lays its classes out differently and names them
+  // differently; hand those over.
+  if (llvm::isa<GNUstepObjCRuntime>(runtime))
+    return GNUstepNSDataSummaryProvider(valobj, stream, options);
 
   ObjCLanguageRuntime::ClassDescriptorSP descriptor(
       runtime->GetClassDescriptor(valobj));
diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp
new file mode 100644
index 0000000000000..93995df8da654
--- /dev/null
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp
@@ -0,0 +1,285 @@
+//===-- GNUstepFormatters.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 "GNUstepFormatters.h"
+
+#include "Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h"
+#include "Plugins/LanguageRuntime/ObjC/ObjCLanguageRuntime.h"
+#include "lldb/DataFormatters/FormattersHelpers.h"
+#include "lldb/DataFormatters/TypeSummary.h"
+#include "lldb/Target/Language.h"
+#include "lldb/Target/Process.h"
+#include "lldb/Target/Target.h"
+#include "lldb/Utility/Stream.h"
+#include "lldb/Utility/StreamString.h"
+#include "llvm/ADT/APFloat.h"
+#include "llvm/Support/Error.h"
+
+#include <cmath>
+#include <cstring>
+#include <ctime>
+
+using namespace lldb;
+using namespace lldb_private;
+using namespace lldb_private::formatters;
+
+bool lldb_private::formatters::IsGNUstepObjCRuntime(ValueObject &valobj) {
+  ProcessSP process_sp = valobj.GetProcessSP();
+  if (!process_sp)
+    return false;
+  return llvm::isa_and_nonnull<GNUstepObjCRuntime>(
+      ObjCLanguageRuntime::Get(*process_sp));
+}
+
+ValueObjectSP lldb_private::formatters::GNUstepGetIvar(ValueObject &valobj,
+                                                       llvm::StringRef name) {
+  // Formatters usually receive the dynamic value already, but the static
+  // value arrives when a provider registered under an abstract class name
+  // (NSArray) dispatches here; the ivars live on the concrete class, so ask
+  // for the dynamic value in that case.
+  ValueObjectSP object_sp = valobj.GetSP();
+  if (!object_sp)
+    return {};
+  // A summary runs on the value the synthetic children were attached to,
+  // whose "children" are the elements; the ivars are on the value beneath.
+  if (ValueObjectSP non_synthetic_sp = object_sp->GetNonSyntheticValue())
+    object_sp = non_synthetic_sp;
+  if (ValueObjectSP dynamic_sp =
+          object_sp->GetDynamicValue(lldb::eDynamicDontRunTarget))
+    object_sp = dynamic_sp;
+  return object_sp->GetChildMemberWithName(name);
+}
+
+std::optional<double>
+lldb_private::formatters::GNUstepGetFloatValue(ValueObject &valobj) {
+  llvm::Expected<llvm::APFloat> value = valobj.GetValueAsAPFloat();
+  if (!value) {
+    llvm::consumeError(value.takeError());
+    return std::nullopt;
+  }
+  bool ignored = false;
+  llvm::APFloat as_double(*value);
+  as_double.convert(llvm::APFloat::IEEEdouble(),
+                    llvm::APFloat::rmNearestTiesToEven, &ignored);
+  return as_double.convertToDouble();
+}
+
+// --- Small object decoding -------------------------------------------------
+
+std::optional<std::string>
+lldb_private::formatters::GNUstepDecodeTinyString(uint64_t ptr) {
+  if ((ptr & g_gnustep_small_object_mask) != 4)
+    return std::nullopt;
+  // struct { uintptr_t char0..char7 : 7 each; length : 5; tag : 3; }: the
+  // characters occupy the high bits, character i at bits [57-7i, 64-7i).
+  const uint64_t length = (ptr >> 3) & 0x1f;
+  // Nine means eight characters and an implicit terminator.
+  if (length > 9)
+    return std::nullopt;
+  std::string result;
+  for (uint64_t i = 0; i < length && i < 8; ++i)
+    result.push_back(static_cast<char>((ptr >> (57 - 7 * i)) & 0x7f));
+  return result;
+}
+
+int64_t lldb_private::formatters::GNUstepDecodeSmallInt(uint64_t ptr) {
+  return static_cast<int64_t>(ptr) >> 3;
+}
+
+double
+lldb_private::formatters::GNUstepDecodeSmallExtendedDouble(uint64_t ptr) {
+  // The tag displaced the low three mantissa bits, which were all equal to
+  // bit 3; restore them from it.
+  const uint64_t low_bit = ptr & 8;
+  const uint64_t bits = (ptr & ~g_gnustep_small_object_mask) | (low_bit >> 1) |
+                        (low_bit >> 2) | (low_bit >> 3);
+  double value;
+  std::memcpy(&value, &bits, sizeof(value));
+  return value;
+}
+
+double
+lldb_private::formatters::GNUstepDecodeSmallRepeatingDouble(uint64_t ptr) {
+  // Bits 3-5 hold the three mantissa bits displaced by the tag.
+  const uint64_t moved = ptr & 56;
+  const uint64_t bits = (ptr & ~g_gnustep_small_object_mask) | (moved >> 3);
+  double value;
+  std::memcpy(&value, &bits, sizeof(value));
+  return value;
+}
+
+double lldb_private::formatters::GNUstepDecodeSmallDate(uint64_t ptr) {
+  // union CompressedDouble { tag:3; fraction:52; exponent:8 (signed); sign:1 }
+  // with the exponent rebased on 0x3EF (Source/NSDate.m).
+  const uint64_t fraction = (ptr >> 3) & ((1ULL << 52) - 1);
+  const int64_t exponent = static_cast<int8_t>((ptr >> 55) & 0xff);
+  const uint64_t sign = (ptr >> 63) & 1;
+  const uint64_t bits =
+      (sign << 63) | ((static_cast<uint64_t>(exponent + 0x3EF) & 0x7ff) << 52) |
+      fraction;
+  double value;
+  std::memcpy(&value, &bits, sizeof(value));
+  return value;
+}
+
+// --- Small providers -------------------------------------------------------
+
+bool lldb_private::formatters::GNUstepNSNullSummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  stream.PutCString("<null>");
+  return true;
+}
+
+bool lldb_private::formatters::GNUstepNSDataSummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  // NSDataStatic and its subclasses keep `NSUInteger length` (Source/NSData.m).
+  ValueObjectSP length_sp = GNUstepGetIvar(valobj, "length");
+  if (!length_sp)
+    return false;
+  bool success = false;
+  const uint64_t length = length_sp->GetValueAsUnsigned(0, &success);
+  if (!success)
+    return false;
+  stream.Printf("%" PRIu64 " byte%s", length, length == 1 ? "" : "s");
+  return true;
+}
+
+bool lldb_private::formatters::GNUstepNSDateSummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  const uint64_t ptr = valobj.GetValueAsUnsigned(0);
+  double seconds_since_2001 = 0.0;
+  if ((ptr & g_gnustep_small_object_mask) == 6) {
+    seconds_since_2001 = GNUstepDecodeSmallDate(ptr);
+  } else {
+    // NSCalendarDate (and NSGDate on targets without small objects) keep the
+    // interval in _seconds_since_ref.
+    ValueObjectSP seconds_sp = GNUstepGetIvar(valobj, "_seconds_since_ref");
+    if (!seconds_sp)
+      return false;
+    std::optional<double> seconds = GNUstepGetFloatValue(*seconds_sp);
+    if (!seconds)
+      return false;
+    seconds_since_2001 = *seconds;
+  }
+  // Same rendering as the Apple NSDate summary: seconds since 2001-01-01
+  // converted through the Unix epoch, printed as UTC.
+  constexpr time_t g_seconds_from_1970_to_2001 = 978307200;
+  time_t epoch = g_seconds_from_1970_to_2001 +
+                 static_cast<time_t>(std::floor(seconds_since_2001));
+  tm *tm_date = gmtime(&epoch);
+  if (!tm_date)
+    return false;
+  stream.Printf("%04d-%02d-%02d %02d:%02d:%02d UTC", tm_date->tm_year + 1900,
+                tm_date->tm_mon + 1, tm_date->tm_mday, tm_date->tm_hour,
+                tm_date->tm_min, tm_date->tm_sec);
+  return true;
+}
+
+// --- Registration ----------------------------------------------------------
+
+void lldb_private::formatters::LoadGNUstepFormatters(
+    TypeCategoryImplSP objc_category_sp) {
+  if (!objc_category_sp)
+    return;
+
+  TypeSummaryImpl::Flags summary_flags;
+  summary_flags.SetCascades(true)
+      .SetSkipPointers(false)
+      .SetSkipReferences(false)
+      .SetDontShowChildren(false)
+      .SetDontShowValue(false)
+      .SetShowMembersOneLiner(false)
+      .SetHideItemNames(false);
+
+  SyntheticChildren::Flags synth_flags;
+  synth_flags.SetCascades(true).SetSkipPointers(false).SetSkipReferences(false);
+
+  // The names below are gnustep-base's concrete classes: the runtime reports
+  // them for a value and ObjCLanguage offers them as formatter candidates.
+  // Placeholder classes are omitted on purpose; they are what +alloc returns
+  // before -init has run and carry no contents.
+
+  // Strings (Source/GSString.m, Headers/Foundation/NSString.h).
+  static constexpr const char *g_string_classes[] = {
+      "GSTinyString",    "NSConstantString",   "GSString",
+      "GSCString",       "GSUnicodeString",    "GSCInlineString",
+      "GSUInlineString", "GSCBufferString",    "GSUnicodeBufferString",
+      "GSCSubString",    "GSUnicodeSubString", "GSMutableString",
+  };
+  for (const char *name : g_string_classes)
+    AddCXXSummary(objc_category_sp, GNUstepNSStringSummaryProvider,
+                  "GNUstep NSString summary provider", name, summary_flags);
+
+  // Numbers (Source/NSNumber.m).
+  static constexpr const char *g_number_classes[] = {
+      "NSSmallInt",
+      "NSSmallExtendedDouble",
+      "NSSmallRepeatingDouble",
+      "NSSmallFloat",
+      "NSIntNumber",
+      "NSBoolNumber",
+      "NSLongLongNumber",
+      "NSUnsignedLongLongNumber",
+      "NSFloatNumber",
+      "NSDoubleNumber",
+  };
+  for (const char *name : g_number_classes)
+    AddCXXSummary(objc_category_sp, GNUstepNSNumberSummaryProvider,
+                  "GNUstep NSNumber summary provider", name, summary_flags);
+
+  // Dates (Source/NSDate.m, Headers/Foundation/NSCalendarDate.h).
+  for (const char *name : {"GSSmallDate", "NSGDate", "NSCalendarDate"})
+    AddCXXSummary(objc_category_sp, GNUstepNSDateSummaryProvider,
+                  "GNUstep NSDate summary provider", name, summary_flags);
+
+  // Arrays (Source/GSArray.m).
+  for (const char *name : {"GSArray", "GSInlineArray", "GSMutableArray"}) {
+    AddCXXSummary(objc_category_sp, GNUstepNSArraySummaryProvider,
+                  "GNUstep NSArray summary provider", name, summary_flags);
+    AddCXXSynthetic(objc_category_sp, GNUstepNSArraySyntheticFrontEndCreator,
+                    "GNUstep NSArray synthetic children", name, synth_flags);
+  }
+
+  // Dictionaries (Source/GSDictionary.m).
+  for (const char *name :
+       {"GSDictionary", "GSMutableDictionary", "GSCachedDictionary"}) {
+    AddCXXSummary(objc_category_sp, GNUstepNSDictionarySummaryProvider,
+                  "GNUstep NSDictionary summary provider", name, summary_flags);
+    AddCXXSynthetic(
+        objc_category_sp, GNUstepNSDictionarySyntheticFrontEndCreator,
+        "GNUstep NSDictionary synthetic children", name, synth_flags);
+  }
+
+  // Sets (Source/GSSet.m, Source/GSCountedSet.m).
+  for (const char *name : {"GSSet", "GSMutableSet", "GSCountedSet"}) {
+    AddCXXSummary(objc_category_sp, GNUstepNSSetSummaryProvider,
+                  "GNUstep NSSet summary provider", name, summary_flags);
+    AddCXXSynthetic(objc_category_sp, GNUstepNSSetSyntheticFrontEndCreator,
+                    "GNUstep NSSet synthetic children", name, synth_flags);
+  }
+
+  // Data (Source/NSData.m).
+  for (const char *name : {"NSDataStatic", "NSDataEmpty", "NSDataMalloc",
+                           "NSDataWithDeallocatorBlock", "NSMutableDataMalloc",
+                           "NSMutableDataWithDeallocatorBlock"})
+    AddCXXSummary(objc_category_sp, GNUstepNSDataSummaryProvider,
+                  "GNUstep NSData summary provider", name, summary_flags);
+
+  AddCXXSummary(objc_category_sp, GNUstepNSNullSummaryProvider,
+                "GNUstep NSNull summary provider", "NSNull", summary_flags);
+
+  // Not covered: NSURL. gnustep-base declares its ivars behind
+  // GS_EXPOSE(NSURL), so they are in neither the debug info nor the
+  // __objc_ivar_offset symbols of a normal build; `po` still describes it.
+}
diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h
new file mode 100644
index 0000000000000..2ceda0aca08f7
--- /dev/null
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h
@@ -0,0 +1,104 @@
+//===-- GNUstepFormatters.h -------------------------------------*- C++ -*-===//
+//
+// 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
+//
+//===----------------------------------------------------------------------===//
+//
+// Data formatters for the concrete Foundation classes of gnustep-base, the
+// Foundation implementation used with the GNUstep libobjc2 runtime.
+//
+// Like the Apple formatters these never run code in the inferior. Unlike them
+// they do not hardcode ivar offsets: libobjc2 packs instance sizes and the
+// widths of `long`-typed ivars differ between LP64 and LLP64, so ivars are
+// looked up by name through the debug info attached to the value's dynamic
+// type. Small objects (libobjc2's tagged pointers) are decoded from the
+// pointer bits alone.
+//
+//===----------------------------------------------------------------------===//
+
+#ifndef LLDB_SOURCE_PLUGINS_LANGUAGE_OBJC_GNUSTEPFORMATTERS_H
+#define LLDB_SOURCE_PLUGINS_LANGUAGE_OBJC_GNUSTEPFORMATTERS_H
+
+#include "Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h"
+#include "lldb/DataFormatters/TypeCategory.h"
+#include "lldb/DataFormatters/TypeSummary.h"
+#include "lldb/DataFormatters/TypeSynthetic.h"
+#include "lldb/ValueObject/ValueObject.h"
+#include "lldb/lldb-forward.h"
+
+#include <optional>
+#include <string>
+
+namespace lldb_private {
+namespace formatters {
+
+/// True if the process debugging \p valobj uses the GNUstep libobjc2 runtime.
+bool IsGNUstepObjCRuntime(ValueObject &valobj);
+
+/// Registers all GNUstep formatters into the shared "objc" category, keyed
+/// by gnustep-base's concrete class names so they are picked up through the
+/// runtime-reported class name of a value.
+void LoadGNUstepFormatters(lldb::TypeCategoryImplSP objc_category_sp);
+
+/// Finds the ivar \p name of the object \p valobj points at, using the debug
+/// info of its dynamic type. Returns an empty pointer when the ivar is not
+/// visible, which the callers treat as "cannot format".
+lldb::ValueObjectSP GNUstepGetIvar(ValueObject &valobj, llvm::StringRef name);
+
+/// The value of a floating-point ValueObject as a double, or nullopt if it
+/// cannot be read as one.
+std::optional<double> GNUstepGetFloatValue(ValueObject &valobj);
+
+// Small-object (tagged pointer) decoding. The tag is the low three bits of
+// the pointer; the payload layouts come from libobjc2 and gnustep-base:
+//   1 NSSmallInt, 2 NSSmallExtendedDouble, 3 NSSmallRepeatingDouble,
+//   4 GSTinyString, 5 NSSmallFloat, 6 GSSmallDate.
+constexpr uint64_t g_gnustep_small_object_mask = 7;
+
+/// GSTinyString packs up to eight 7-bit characters and a 5-bit length into
+/// the pointer (gnustep-base Source/GSString.m, clang CGObjCGNU.cpp).
+std::optional<std::string> GNUstepDecodeTinyString(uint64_t ptr);
+/// NSSmallInt stores an arithmetically shifted integer (Source/NSNumber.m).
+int64_t GNUstepDecodeSmallInt(uint64_t ptr);
+/// NSSmallExtendedDouble / NSSmallRepeatingDouble / NSSmallFloat store a
+/// double whose low mantissa bits were displaced by the tag
+/// (unboxSmallExtendedDouble / unboxSmallRepeatingDouble in Source/NSNumber.m).
+double GNUstepDecodeSmallExtendedDouble(uint64_t ptr);
+double GNUstepDecodeSmallRepeatingDouble(uint64_t ptr);
+/// GSSmallDate stores a compressed NSTimeInterval since the 2001 reference
+/// date (decompressTimeInterval in Source/NSDate.m).
+double GNUstepDecodeSmallDate(uint64_t ptr);
+
+bool GNUstepNSStringSummaryProvider(ValueObject &valobj, Stream &stream,
+                                    const TypeSummaryOptions &options);
+bool GNUstepNSNumberSummaryProvider(ValueObject &valobj, Stream &stream,
+                                    const TypeSummaryOptions &options);
+bool GNUstepNSDateSummaryProvider(ValueObject &valobj, Stream &stream,
+                                  const TypeSummaryOptions &options);
+bool GNUstepNSArraySummaryProvider(ValueObject &valobj, Stream &stream,
+                                   const TypeSummaryOptions &options);
+bool GNUstepNSDictionarySummaryProvider(ValueObject &valobj, Stream &stream,
+                                        const TypeSummaryOptions &options);
+bool GNUstepNSSetSummaryProvider(ValueObject &valobj, Stream &stream,
+                                 const TypeSummaryOptions &options);
+bool GNUstepNSDataSummaryProvider(ValueObject &valobj, Stream &stream,
+                                  const TypeSummaryOptions &options);
+bool GNUstepNSNullSummaryProvider(ValueObject &valobj, Stream &stream,
+                                  const TypeSummaryOptions &options);
+
+SyntheticChildrenFrontEnd *
+GNUstepNSArraySyntheticFrontEndCreator(CXXSyntheticChildren *,
+                                       lldb::ValueObjectSP);
+SyntheticChildrenFrontEnd *
+GNUstepNSDictionarySyntheticFrontEndCreator(CXXSyntheticChildren *,
+                                            lldb::ValueObjectSP);
+SyntheticChildrenFrontEnd *
+GNUstepNSSetSyntheticFrontEndCreator(CXXSyntheticChildren *,
+                                     lldb::ValueObjectSP);
+
+} // namespace formatters
+} // namespace lldb_private
+
+#endif // LLDB_SOURCE_PLUGINS_LANGUAGE_OBJC_GNUSTEPFORMATTERS_H
diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepNSArray.cpp b/lldb/source/Plugins/Language/ObjC/GNUstepNSArray.cpp
new file mode 100644
index 0000000000000..9eba287eeecb6
--- /dev/null
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepNSArray.cpp
@@ -0,0 +1,130 @@
+//===-- GNUstepNSArray.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 "GNUstepFormatters.h"
+
+#include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
+#include "lldb/DataFormatters/FormattersHelpers.h"
+#include "lldb/DataFormatters/TypeSummary.h"
+#include "lldb/DataFormatters/TypeSynthetic.h"
+#include "lldb/Target/Language.h"
+#include "lldb/Target/Process.h"
+#include "lldb/Target/Target.h"
+#include "lldb/Utility/Stream.h"
+#include "lldb/ValueObject/ValueObject.h"
+
+using namespace lldb;
+using namespace lldb_private;
+using namespace lldb_private::formatters;
+
+namespace {
+
+/// GSArray, GSInlineArray and GSMutableArray all start with
+/// `id *_contents_array; unsigned _count;` (Source/GSPrivate.h). Where the
+/// element buffer lives (a separate allocation, or right after the instance
+/// for GSInlineArray) does not matter: _contents_array is always the absolute
+/// address of element 0.
+struct ArrayContents {
+  addr_t elements = LLDB_INVALID_ADDRESS;
+  uint64_t count = 0;
+};
+
+std::optional<ArrayContents> ReadArray(ValueObject &valobj) {
+  ValueObjectSP contents_sp = GNUstepGetIvar(valobj, "_contents_array");
+  ValueObjectSP count_sp = GNUstepGetIvar(valobj, "_count");
+  if (!contents_sp || !count_sp)
+    return std::nullopt;
+  ArrayContents contents;
+  contents.elements = contents_sp->GetValueAsUnsigned(LLDB_INVALID_ADDRESS);
+  contents.count = count_sp->GetValueAsUnsigned(0);
+  if (contents.count && contents.elements == LLDB_INVALID_ADDRESS)
+    return std::nullopt;
+  return contents;
+}
+
+class GNUstepNSArraySyntheticFrontEnd : public SyntheticChildrenFrontEnd {
+public:
+  GNUstepNSArraySyntheticFrontEnd(ValueObjectSP valobj_sp)
+      : SyntheticChildrenFrontEnd(*valobj_sp) {
+    if (valobj_sp) {
+      m_exe_ctx_ref = valobj_sp->GetExecutionContextRef();
+      if (ProcessSP process_sp = valobj_sp->GetProcessSP())
+        m_ptr_size = process_sp->GetAddressByteSize();
+      // Children are created as `id` so that each element resolves its own
+      // dynamic type and formatter, exactly like the Apple frontends do.
+      if (TargetSP target_sp = valobj_sp->GetTargetSP())
+        if (TypeSystemClangSP scratch_ts_sp =
+                ScratchTypeSystemClang::GetForTarget(*target_sp))
+          m_id_type = scratch_ts_sp->GetBasicType(eBasicTypeObjCID);
+    }
+  }
+
+  llvm::Expected<uint32_t> CalculateNumChildren() override {
+    return m_contents.count;
+  }
+
+  ValueObjectSP GetChildAtIndex(uint32_t idx) override {
+    if (idx >= m_contents.count || !m_id_type.IsValid())
+      return {};
+    StreamString name;
+    name.Printf("[%u]", idx);
+    return CreateChildValueObjectFromAddress(
+        name.GetString(), m_contents.elements + idx * m_ptr_size, m_exe_ctx_ref,
+        m_id_type);
+  }
+
+  lldb::ChildCacheState Update() override {
+    m_contents = ArrayContents();
+    if (std::optional<ArrayContents> contents = ReadArray(m_backend))
+      m_contents = *contents;
+    return lldb::ChildCacheState::eRefetch;
+  }
+
+  llvm::Expected<size_t> GetIndexOfChildWithName(ConstString name) override {
+    if (std::optional<size_t> idx = ExtractIndexFromString(name.GetCString()))
+      if (*idx < m_contents.count)
+        return *idx;
+    return llvm::createStringError("Type has no child named '%s'",
+                                   name.AsCString(""));
+  }
+
+private:
+  ExecutionContextRef m_exe_ctx_ref;
+  uint8_t m_ptr_size = 8;
+  CompilerType m_id_type;
+  ArrayContents m_contents;
+};
+
+} // namespace
+
+bool lldb_private::formatters::GNUstepNSArraySummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  std::optional<ArrayContents> contents = ReadArray(valobj);
+  if (!contents)
+    return false;
+
+  static constexpr llvm::StringLiteral g_TypeHint("NSArray");
+  llvm::StringRef prefix, suffix;
+  if (Language *language = Language::FindPlugin(options.GetLanguage()))
+    std::tie(prefix, suffix) = language->GetFormatterPrefixSuffix(g_TypeHint);
+  stream << prefix;
+  stream.Printf("%" PRIu64 " %s%s", contents->count, "element",
+                contents->count == 1 ? "" : "s");
+  stream << suffix;
+  return true;
+}
+
+SyntheticChildrenFrontEnd *
+lldb_private::formatters::GNUstepNSArraySyntheticFrontEndCreator(
+    CXXSyntheticChildren *, lldb::ValueObjectSP valobj_sp) {
+  if (!valobj_sp || !IsGNUstepObjCRuntime(*valobj_sp))
+    return nullptr;
+  return new GNUstepNSArraySyntheticFrontEnd(valobj_sp);
+}
diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepNSDictionary.cpp b/lldb/source/Plugins/Language/ObjC/GNUstepNSDictionary.cpp
new file mode 100644
index 0000000000000..2e87d854c8efd
--- /dev/null
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepNSDictionary.cpp
@@ -0,0 +1,264 @@
+//===-- GNUstepNSDictionary.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
+//
+//===----------------------------------------------------------------------===//
+//
+// gnustep-base's GSDictionary, GSSet and GSCountedSet all embed a
+// `GSIMapTable_t map` (Headers/GNUstepBase/GSIMap.h): `bucketCount` buckets,
+// each `{ nodeCount, firstNode }`, chaining nodes `{ nextInBucket, key
+// [, value] }` through nextInBucket. Sets instantiate the map without the
+// value member, so nodes are read through their debug-info types rather
+// than at fixed offsets.
+//
+//===----------------------------------------------------------------------===//
+
+#include "GNUstepFormatters.h"
+#include "NSDictionary.h"
+
+#include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
+#include "lldb/DataFormatters/FormattersHelpers.h"
+#include "lldb/DataFormatters/TypeSummary.h"
+#include "lldb/DataFormatters/TypeSynthetic.h"
+#include "lldb/Target/Language.h"
+#include "lldb/Target/Process.h"
+#include "lldb/Target/Target.h"
+#include "lldb/Utility/DataBufferHeap.h"
+#include "lldb/Utility/DataExtractor.h"
+#include "lldb/Utility/Status.h"
+#include "lldb/Utility/Stream.h"
+#include "lldb/ValueObject/ValueObject.h"
+
+#include <set>
+#include <vector>
+
+using namespace lldb;
+using namespace lldb_private;
+using namespace lldb_private::formatters;
+
+namespace {
+
+struct MapEntry {
+  addr_t key = 0;
+  addr_t value = 0;
+};
+
+/// The whole map's worth of entries, in bucket order (which is hash order,
+/// like -objectEnumerator).
+struct MapContents {
+  uint64_t node_count = 0;
+  std::vector<MapEntry> entries;
+};
+
+uint64_t ReadUnsignedMember(ValueObject &value, llvm::StringRef name,
+                            uint64_t fail = 0) {
+  if (ValueObjectSP member_sp = value.GetChildMemberWithName(name))
+    return member_sp->GetValueAsUnsigned(fail);
+  return fail;
+}
+
+/// A GSIMapKey/GSIMapVal is a union whose members are all pointer sized;
+/// its `obj` (or `nsu`) member is the entry.
+uint64_t ReadUnionWord(ValueObject &value, llvm::StringRef name) {
+  ValueObjectSP member_sp = value.GetChildMemberWithName(name);
+  if (!member_sp)
+    return 0;
+  if (ValueObjectSP first_sp = member_sp->GetChildAtIndex(0))
+    return first_sp->GetValueAsUnsigned(0);
+  return member_sp->GetValueAsUnsigned(0);
+}
+
+/// Reads just the element count.
+std::optional<uint64_t> ReadNodeCount(ValueObject &valobj) {
+  ValueObjectSP map_sp = GNUstepGetIvar(valobj, "map");
+  if (!map_sp)
+    return std::nullopt;
+  ValueObjectSP count_sp = map_sp->GetChildMemberWithName("nodeCount");
+  if (!count_sp)
+    return std::nullopt;
+  return count_sp->GetValueAsUnsigned(0);
+}
+
+/// Walks every bucket. \p want_value is false for sets, whose nodes have no
+/// value member (GSSet.m sets GSI_MAP_HAS_VALUE to 0). Bounded by
+/// nodeCount, by the bucket count, and by never revisiting a node.
+std::optional<MapContents> ReadMap(ValueObject &valobj, bool want_value) {
+  ValueObjectSP map_sp = GNUstepGetIvar(valobj, "map");
+  if (!map_sp)
+    return std::nullopt;
+  MapContents contents;
+  contents.node_count = ReadUnsignedMember(*map_sp, "nodeCount");
+  const uint64_t bucket_count = ReadUnsignedMember(*map_sp, "bucketCount");
+  ValueObjectSP buckets_sp = map_sp->GetChildMemberWithName("buckets");
+  if (!buckets_sp)
+    return std::nullopt;
+  if (contents.node_count == 0)
+    return contents;
+  // A table cannot sensibly have more buckets than a few times its nodes;
+  // anything else is a misread and would make the walk unbounded.
+  if (bucket_count == 0 || bucket_count > contents.node_count * 8 + 64)
+    return std::nullopt;
+
+  std::set<addr_t> seen;
+  for (uint64_t b = 0;
+       b < bucket_count && contents.entries.size() < contents.node_count; ++b) {
+    ValueObjectSP bucket_sp = buckets_sp->GetSyntheticArrayMember(b, true);
+    if (!bucket_sp)
+      break;
+    ValueObjectSP node_sp = bucket_sp->GetChildMemberWithName("firstNode");
+    while (node_sp && node_sp->GetValueAsUnsigned(0) != 0 &&
+           contents.entries.size() < contents.node_count) {
+      const addr_t node_addr = node_sp->GetValueAsUnsigned(0);
+      if (!seen.insert(node_addr).second)
+        return std::nullopt; // cycle: corrupt or racing table
+      Status error;
+      ValueObjectSP node_struct_sp = node_sp->Dereference(error);
+      if (!node_struct_sp || error.Fail())
+        break;
+      MapEntry entry;
+      entry.key = ReadUnionWord(*node_struct_sp, "key");
+      if (want_value)
+        entry.value = ReadUnionWord(*node_struct_sp, "value");
+      contents.entries.push_back(entry);
+      node_sp = node_struct_sp->GetChildMemberWithName("nextInBucket");
+    }
+  }
+  return contents;
+}
+
+/// Presents each entry as `[i] = { key, value }` (dictionaries) or as the
+/// key object itself (sets).
+class GNUstepMapSyntheticFrontEnd : public SyntheticChildrenFrontEnd {
+public:
+  GNUstepMapSyntheticFrontEnd(ValueObjectSP valobj_sp, bool is_dictionary)
+      : SyntheticChildrenFrontEnd(*valobj_sp), m_is_dictionary(is_dictionary) {
+    if (valobj_sp) {
+      m_exe_ctx_ref = valobj_sp->GetExecutionContextRef();
+      if (ProcessSP process_sp = valobj_sp->GetProcessSP()) {
+        m_ptr_size = process_sp->GetAddressByteSize();
+        m_order = process_sp->GetByteOrder();
+      }
+      if (TargetSP target_sp = valobj_sp->GetTargetSP()) {
+        if (TypeSystemClangSP scratch_ts_sp =
+                ScratchTypeSystemClang::GetForTarget(*target_sp))
+          m_id_type = scratch_ts_sp->GetBasicType(eBasicTypeObjCID);
+        if (m_is_dictionary)
+          m_pair_type = GetLLDBNSPairType(target_sp);
+      }
+    }
+  }
+
+  llvm::Expected<uint32_t> CalculateNumChildren() override {
+    return m_contents.entries.size();
+  }
+
+  ValueObjectSP GetChildAtIndex(uint32_t idx) override {
+    if (idx >= m_contents.entries.size())
+      return {};
+    if (m_children[idx])
+      return m_children[idx];
+    const MapEntry &entry = m_contents.entries[idx];
+    StreamString name;
+    name.Printf("[%u]", idx);
+    if (!m_is_dictionary) {
+      // A set element is the key object itself.
+      m_children[idx] =
+          CreateChildFromWords(name.GetString(), {entry.key}, m_id_type);
+      return m_children[idx];
+    }
+    if (!m_pair_type.IsValid())
+      return {};
+    m_children[idx] = CreateChildFromWords(
+        name.GetString(), {entry.key, entry.value}, m_pair_type);
+    return m_children[idx];
+  }
+
+  lldb::ChildCacheState Update() override {
+    m_contents = MapContents();
+    m_children.clear();
+    if (std::optional<MapContents> contents =
+            ReadMap(m_backend, m_is_dictionary))
+      m_contents = *contents;
+    m_children.resize(m_contents.entries.size());
+    return lldb::ChildCacheState::eRefetch;
+  }
+
+  llvm::Expected<size_t> GetIndexOfChildWithName(ConstString name) override {
+    if (std::optional<size_t> idx = ExtractIndexFromString(name.GetCString()))
+      if (*idx < m_contents.entries.size())
+        return *idx;
+    return llvm::createStringError("Type has no child named '%s'",
+                                   name.AsCString(""));
+  }
+
+private:
+  ValueObjectSP CreateChildFromWords(llvm::StringRef name,
+                                     std::initializer_list<addr_t> words,
+                                     CompilerType type) {
+    WritableDataBufferSP buffer_sp(
+        new DataBufferHeap(words.size() * m_ptr_size, 0));
+    uint8_t *bytes = buffer_sp->GetBytes();
+    for (addr_t word : words) {
+      if (m_ptr_size == 8)
+        memcpy(bytes, &word, 8);
+      else {
+        uint32_t narrow = static_cast<uint32_t>(word);
+        memcpy(bytes, &narrow, 4);
+      }
+      bytes += m_ptr_size;
+    }
+    DataExtractor data(buffer_sp, m_order, m_ptr_size);
+    return CreateChildValueObjectFromData(name, data, m_exe_ctx_ref, type);
+  }
+
+  bool m_is_dictionary;
+  ExecutionContextRef m_exe_ctx_ref;
+  uint8_t m_ptr_size = 8;
+  lldb::ByteOrder m_order = lldb::eByteOrderLittle;
+  CompilerType m_id_type;
+  CompilerType m_pair_type;
+  MapContents m_contents;
+  std::vector<ValueObjectSP> m_children;
+};
+
+} // namespace
+
+bool lldb_private::formatters::GNUstepNSDictionarySummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  std::optional<uint64_t> count = ReadNodeCount(valobj);
+  if (!count)
+    return false;
+  stream.Printf("%" PRIu64 " key/value pair%s", *count, *count == 1 ? "" : "s");
+  return true;
+}
+
+bool lldb_private::formatters::GNUstepNSSetSummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  std::optional<uint64_t> count = ReadNodeCount(valobj);
+  if (!count)
+    return false;
+  stream.Printf("%" PRIu64 " element%s", *count, *count == 1 ? "" : "s");
+  return true;
+}
+
+SyntheticChildrenFrontEnd *
+lldb_private::formatters::GNUstepNSDictionarySyntheticFrontEndCreator(
+    CXXSyntheticChildren *, lldb::ValueObjectSP valobj_sp) {
+  if (!valobj_sp || !IsGNUstepObjCRuntime(*valobj_sp))
+    return nullptr;
+  return new GNUstepMapSyntheticFrontEnd(valobj_sp, /*is_dictionary=*/true);
+}
+
+SyntheticChildrenFrontEnd *
+lldb_private::formatters::GNUstepNSSetSyntheticFrontEndCreator(
+    CXXSyntheticChildren *, lldb::ValueObjectSP valobj_sp) {
+  if (!valobj_sp || !IsGNUstepObjCRuntime(*valobj_sp))
+    return nullptr;
+  return new GNUstepMapSyntheticFrontEnd(valobj_sp, /*is_dictionary=*/false);
+}
diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepNSNumber.cpp b/lldb/source/Plugins/Language/ObjC/GNUstepNSNumber.cpp
new file mode 100644
index 0000000000000..7992b72558d41
--- /dev/null
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepNSNumber.cpp
@@ -0,0 +1,126 @@
+//===-- GNUstepNSNumber.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 "GNUstepFormatters.h"
+
+#include "Plugins/LanguageRuntime/ObjC/ObjCLanguageRuntime.h"
+#include "lldb/DataFormatters/TypeSummary.h"
+#include "lldb/Target/Language.h"
+#include "lldb/Target/Process.h"
+#include "lldb/Utility/Stream.h"
+
+#include <cstdarg>
+
+using namespace lldb;
+using namespace lldb_private;
+using namespace lldb_private::formatters;
+
+namespace {
+
+void PrintWithHint(Stream &stream, lldb::LanguageType lang,
+                   llvm::StringRef hint, const char *format, ...)
+    __attribute__((format(printf, 4, 5)));
+
+void PrintWithHint(Stream &stream, lldb::LanguageType lang,
+                   llvm::StringRef hint, const char *format, ...) {
+  llvm::StringRef prefix, suffix;
+  if (Language *language = Language::FindPlugin(lang))
+    std::tie(prefix, suffix) = language->GetFormatterPrefixSuffix(hint);
+  stream << prefix;
+  va_list args;
+  va_start(args, format);
+  stream.PrintfVarArg(format, args);
+  va_end(args);
+  stream << suffix;
+}
+
+} // namespace
+
+bool lldb_private::formatters::GNUstepNSNumberSummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  const uint64_t ptr = valobj.GetValueAsUnsigned(0);
+  if (ptr == 0)
+    return false;
+  const lldb::LanguageType lang = options.GetLanguage();
+
+  // Small objects: the tag selects the class and the payload is in the
+  // pointer (Source/NSNumber.m).
+  switch (ptr & g_gnustep_small_object_mask) {
+  case 1: // NSSmallInt
+    PrintWithHint(stream, lang, "NSNumber:long", "%" PRId64,
+                  GNUstepDecodeSmallInt(ptr));
+    return true;
+  case 2: // NSSmallExtendedDouble
+    PrintWithHint(stream, lang, "NSNumber:double", "%g",
+                  GNUstepDecodeSmallExtendedDouble(ptr));
+    return true;
+  case 3: // NSSmallRepeatingDouble
+    PrintWithHint(stream, lang, "NSNumber:double", "%g",
+                  GNUstepDecodeSmallRepeatingDouble(ptr));
+    return true;
+  case 5: // NSSmallFloat: same encoding, single precision when created
+    PrintWithHint(stream, lang, "NSNumber:float", "%f",
+                  static_cast<float>(GNUstepDecodeSmallRepeatingDouble(ptr)));
+    return true;
+  case 0:
+    break;
+  default:
+    return false;
+  }
+
+  ProcessSP process_sp = valobj.GetProcessSP();
+  ObjCLanguageRuntime *runtime =
+      process_sp ? ObjCLanguageRuntime::Get(*process_sp) : nullptr;
+  if (!runtime)
+    return false;
+  ObjCLanguageRuntime::ClassDescriptorSP descriptor =
+      runtime->GetClassDescriptor(valobj);
+  if (!descriptor || !descriptor->IsValid())
+    return false;
+  llvm::StringRef class_name = descriptor->GetClassName().GetStringRef();
+
+  // Every heap NSNumber subclass has exactly one ivar, `value`, whose C type
+  // is what the class name says (Source/NSNumber.m).
+  ValueObjectSP value_sp = GNUstepGetIvar(valobj, "value");
+  if (!value_sp)
+    return false;
+
+  if (class_name == "NSBoolNumber") {
+    stream.PutCString(value_sp->GetValueAsUnsigned(0) ? "YES" : "NO");
+    return true;
+  }
+  if (class_name == "NSIntNumber") {
+    PrintWithHint(stream, lang, "NSNumber:int", "%d",
+                  static_cast<int>(value_sp->GetValueAsSigned(0)));
+    return true;
+  }
+  if (class_name == "NSLongLongNumber") {
+    PrintWithHint(stream, lang, "NSNumber:long", "%" PRId64,
+                  value_sp->GetValueAsSigned(0));
+    return true;
+  }
+  if (class_name == "NSUnsignedLongLongNumber") {
+    PrintWithHint(stream, lang, "NSNumber:long", "%" PRIu64,
+                  value_sp->GetValueAsUnsigned(0));
+    return true;
+  }
+  if (class_name == "NSFloatNumber" || class_name == "NSDoubleNumber") {
+    std::optional<double> value = GNUstepGetFloatValue(*value_sp);
+    if (!value)
+      return false;
+    if (class_name == "NSFloatNumber")
+      PrintWithHint(stream, lang, "NSNumber:float", "%f",
+                    static_cast<float>(*value));
+    else
+      PrintWithHint(stream, lang, "NSNumber:double", "%g", *value);
+    return true;
+  }
+  return false;
+}
diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepNSString.cpp b/lldb/source/Plugins/Language/ObjC/GNUstepNSString.cpp
new file mode 100644
index 0000000000000..20f7ab889e9de
--- /dev/null
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepNSString.cpp
@@ -0,0 +1,239 @@
+//===-- GNUstepNSString.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 "GNUstepFormatters.h"
+
+#include "Plugins/LanguageRuntime/ObjC/ObjCLanguageRuntime.h"
+#include "lldb/DataFormatters/StringPrinter.h"
+#include "lldb/DataFormatters/TypeSummary.h"
+#include "lldb/Target/Language.h"
+#include "lldb/Target/Process.h"
+#include "lldb/Target/Target.h"
+#include "lldb/Utility/DataExtractor.h"
+#include "lldb/Utility/Status.h"
+#include "lldb/Utility/Stream.h"
+
+#include <vector>
+
+using namespace lldb;
+using namespace lldb_private;
+using namespace lldb_private::formatters;
+
+namespace {
+
+/// The character encodings a string may hold. gnustep-base's 8-bit strings
+/// use its "internal encoding", which defaults to ISO Latin-1
+/// (Source/GSString.m), so one byte is one code point.
+enum class Encoding { Latin1, UTF8, UTF16, UTF32 };
+
+/// Where the characters live and how many there are.
+struct StringContents {
+  addr_t address = LLDB_INVALID_ADDRESS;
+  /// Number of code units of the encoding, not bytes.
+  uint64_t count = 0;
+  Encoding encoding = Encoding::Latin1;
+};
+
+/// A ValueObject for the union `GSCharPtr _contents` (or a plain pointer)
+/// yields the buffer address either way.
+addr_t GetPointerValue(ValueObject &value) {
+  if (value.GetCompilerType().IsPointerType())
+    return value.GetValueAsUnsigned(LLDB_INVALID_ADDRESS);
+  // A union: any member is the same pointer.
+  if (ValueObjectSP first_sp = value.GetChildAtIndex(0))
+    return first_sp->GetValueAsUnsigned(LLDB_INVALID_ADDRESS);
+  return LLDB_INVALID_ADDRESS;
+}
+
+/// NSConstantString with the gnustep-2.x string ABI: `uint32_t flags`
+/// (low two bits: 0 ASCII, 1 UTF-8, 2 UTF-16, 3 UTF-32), `uint32_t nxcslen`
+/// (characters), `uint32_t size` (bytes), `uint32_t hash`, `const char
+/// *nxcsptr` (Headers/Foundation/NSString.h). The legacy ABI has only
+/// `nxcsptr` and a byte count `nxcslen`.
+std::optional<StringContents> ReadConstantString(ValueObject &valobj) {
+  ValueObjectSP ptr_sp = GNUstepGetIvar(valobj, "nxcsptr");
+  ValueObjectSP len_sp = GNUstepGetIvar(valobj, "nxcslen");
+  if (!ptr_sp || !len_sp)
+    return std::nullopt;
+  StringContents contents;
+  contents.address = ptr_sp->GetValueAsUnsigned(LLDB_INVALID_ADDRESS);
+  if (contents.address == LLDB_INVALID_ADDRESS)
+    return std::nullopt;
+
+  ValueObjectSP flags_sp = GNUstepGetIvar(valobj, "flags");
+  ValueObjectSP size_sp = GNUstepGetIvar(valobj, "size");
+  if (!flags_sp || !size_sp) {
+    // Legacy ABI: nxcslen is a byte count of UTF-8 data.
+    contents.encoding = Encoding::UTF8;
+    contents.count = len_sp->GetValueAsUnsigned(0);
+    return contents;
+  }
+  const uint64_t bytes = size_sp->GetValueAsUnsigned(0);
+  switch (flags_sp->GetValueAsUnsigned(0) & 3) {
+  case 0:
+  case 1:
+    contents.encoding = Encoding::UTF8;
+    contents.count = bytes;
+    break;
+  case 2:
+    contents.encoding = Encoding::UTF16;
+    contents.count = bytes / 2;
+    break;
+  default:
+    contents.encoding = Encoding::UTF32;
+    contents.count = bytes / 4;
+    break;
+  }
+  return contents;
+}
+
+/// GSString and everything derived from it, plus GSMutableString: the buffer
+/// pointer `_contents`, the character count `_count`, and `_flags` whose bit
+/// 0 (`wide`) selects 16-bit characters (Source/GSPrivate.h). The buffer is
+/// never NUL-terminated.
+std::optional<StringContents> ReadGSString(ValueObject &valobj) {
+  ValueObjectSP contents_sp = GNUstepGetIvar(valobj, "_contents");
+  ValueObjectSP count_sp = GNUstepGetIvar(valobj, "_count");
+  ValueObjectSP flags_sp = GNUstepGetIvar(valobj, "_flags");
+  if (!contents_sp || !count_sp || !flags_sp)
+    return std::nullopt;
+  StringContents contents;
+  contents.address = GetPointerValue(*contents_sp);
+  if (contents.address == LLDB_INVALID_ADDRESS)
+    return std::nullopt;
+  contents.count = count_sp->GetValueAsUnsigned(0);
+  bool wide = false;
+  if (ValueObjectSP wide_sp = flags_sp->GetChildMemberWithName("wide"))
+    wide = wide_sp->GetValueAsUnsigned(0) != 0;
+  contents.encoding = wide ? Encoding::UTF16 : Encoding::Latin1;
+  return contents;
+}
+
+bool DumpContents(ValueObject &valobj, Stream &stream,
+                  const TypeSummaryOptions &summary_options,
+                  const StringContents &contents) {
+  static constexpr llvm::StringLiteral g_TypeHint("NSString");
+  llvm::StringRef prefix, suffix;
+  if (Language *language = Language::FindPlugin(summary_options.GetLanguage()))
+    std::tie(prefix, suffix) = language->GetFormatterPrefixSuffix(g_TypeHint);
+
+  if (contents.count == 0) {
+    stream << prefix << "\"\"" << suffix;
+    return true;
+  }
+
+  StringPrinter::ReadStringAndDumpToStreamOptions options(valobj);
+  options.SetLocation(Address(contents.address));
+  options.SetTargetSP(valobj.GetTargetSP());
+  options.SetStream(&stream);
+  options.SetPrefixToken(prefix.str());
+  options.SetSuffixToken(suffix.str());
+  options.SetQuote('"');
+  options.SetSourceSize(contents.count);
+  options.SetHasSourceSize(true);
+  options.SetZeroTermination(StringPrinter::ZeroTermination::Ignore);
+  options.SetIgnoreMaxLength(summary_options.GetCapping() ==
+                             TypeSummaryCapping::eTypeSummaryUncapped);
+
+  switch (contents.encoding) {
+  case Encoding::Latin1: {
+    // Read exactly `count` bytes and transcode to UTF-8 ourselves: the ASCII
+    // path of the printer reads a C string (dropping the last byte for a
+    // terminator the buffer does not have) and cannot represent code points
+    // above 0x7f.
+    ProcessSP process_sp = valobj.GetProcessSP();
+    if (!process_sp)
+      return false;
+    const uint64_t max_size =
+        valobj.GetTargetSP()->GetMaximumSizeOfStringSummary();
+    uint64_t to_read = contents.count;
+    bool truncated = false;
+    if (!options.GetIgnoreMaxLength() && to_read > max_size) {
+      to_read = max_size;
+      truncated = true;
+    }
+    std::vector<uint8_t> latin1(to_read);
+    Status error;
+    if (to_read && process_sp->ReadMemory(contents.address, latin1.data(),
+                                          to_read, error) != to_read)
+      return false;
+    std::string utf8;
+    utf8.reserve(to_read * 2);
+    for (uint8_t byte : latin1) {
+      if (byte < 0x80) {
+        utf8.push_back(static_cast<char>(byte));
+      } else {
+        utf8.push_back(static_cast<char>(0xC0 | (byte >> 6)));
+        utf8.push_back(static_cast<char>(0x80 | (byte & 0x3F)));
+      }
+    }
+    StringPrinter::ReadBufferAndDumpToStreamOptions dump_options(options);
+    dump_options.SetData(DataExtractor(utf8.data(), utf8.size(),
+                                       process_sp->GetByteOrder(),
+                                       process_sp->GetAddressByteSize()));
+    dump_options.SetSourceSize(utf8.size());
+    dump_options.SetIsTruncated(truncated);
+    return StringPrinter::ReadBufferAndDumpToStream<
+        StringPrinter::StringElementType::UTF8>(dump_options);
+  }
+  case Encoding::UTF8:
+    return StringPrinter::ReadStringAndDumpToStream<
+        StringPrinter::StringElementType::UTF8>(options);
+  case Encoding::UTF16:
+    return StringPrinter::ReadStringAndDumpToStream<
+        StringPrinter::StringElementType::UTF16>(options);
+  case Encoding::UTF32:
+    return StringPrinter::ReadStringAndDumpToStream<
+        StringPrinter::StringElementType::UTF32>(options);
+  }
+  return false;
+}
+
+} // namespace
+
+bool lldb_private::formatters::GNUstepNSStringSummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  const uint64_t ptr = valobj.GetValueAsUnsigned(0);
+  if (ptr == 0)
+    return false;
+
+  // Up to eight ASCII characters live in the pointer itself; clang emits
+  // such literals directly (CGObjCGNU.cpp) and the runtime never allocates a
+  // GSTinyString object.
+  if (std::optional<std::string> tiny = GNUstepDecodeTinyString(ptr)) {
+    static constexpr llvm::StringLiteral g_TypeHint("NSString");
+    llvm::StringRef prefix, suffix;
+    if (Language *language = Language::FindPlugin(options.GetLanguage()))
+      std::tie(prefix, suffix) = language->GetFormatterPrefixSuffix(g_TypeHint);
+    stream << prefix << '"' << *tiny << '"' << suffix;
+    return true;
+  }
+  if (ptr & g_gnustep_small_object_mask)
+    return false;
+
+  ProcessSP process_sp = valobj.GetProcessSP();
+  ObjCLanguageRuntime *runtime =
+      process_sp ? ObjCLanguageRuntime::Get(*process_sp) : nullptr;
+  if (!runtime)
+    return false;
+  ObjCLanguageRuntime::ClassDescriptorSP descriptor =
+      runtime->GetClassDescriptor(valobj);
+  if (!descriptor || !descriptor->IsValid())
+    return false;
+
+  std::optional<StringContents> contents;
+  if (descriptor->GetClassName() == "NSConstantString")
+    contents = ReadConstantString(valobj);
+  else
+    contents = ReadGSString(valobj);
+  if (!contents)
+    return false;
+  return DumpContents(valobj, stream, options, *contents);
+}
diff --git a/lldb/source/Plugins/Language/ObjC/NSArray.cpp b/lldb/source/Plugins/Language/ObjC/NSArray.cpp
index 333aa1f683b5d..722e5e1e9e4b6 100644
--- a/lldb/source/Plugins/Language/ObjC/NSArray.cpp
+++ b/lldb/source/Plugins/Language/ObjC/NSArray.cpp
@@ -10,6 +10,7 @@
 #include "clang/Basic/TargetInfo.h"
 
 #include "Cocoa.h"
+#include "GNUstepFormatters.h"
 
 #include "Plugins/LanguageRuntime/ObjC/AppleObjCRuntime/AppleObjCRuntime.h"
 #include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
@@ -334,6 +335,10 @@ bool lldb_private::formatters::NSArraySummaryProvider(
 
   if (!runtime)
     return false;
+  // gnustep-base lays its classes out differently and names them
+  // differently; hand those over.
+  if (llvm::isa<GNUstepObjCRuntime>(runtime))
+    return GNUstepNSArraySummaryProvider(valobj, stream, options);
 
   ObjCLanguageRuntime::ClassDescriptorSP descriptor(
       runtime->GetClassDescriptor(valobj));
@@ -752,8 +757,11 @@ lldb_private::formatters::NSArraySyntheticFrontEndCreator(
   lldb::ProcessSP process_sp(valobj_sp->GetProcessSP());
   if (!process_sp)
     return nullptr;
-  AppleObjCRuntime *runtime = llvm::dyn_cast_or_null<AppleObjCRuntime>(
-      ObjCLanguageRuntime::Get(*process_sp));
+  ObjCLanguageRuntime *objc_runtime = ObjCLanguageRuntime::Get(*process_sp);
+  if (llvm::isa_and_nonnull<GNUstepObjCRuntime>(objc_runtime))
+    return GNUstepNSArraySyntheticFrontEndCreator(synth, valobj_sp);
+  AppleObjCRuntime *runtime =
+      llvm::dyn_cast_or_null<AppleObjCRuntime>(objc_runtime);
   if (!runtime)
     return nullptr;
 
diff --git a/lldb/source/Plugins/Language/ObjC/NSDictionary.cpp b/lldb/source/Plugins/Language/ObjC/NSDictionary.cpp
index e9a73b4013249..01f72aca1958d 100644
--- a/lldb/source/Plugins/Language/ObjC/NSDictionary.cpp
+++ b/lldb/source/Plugins/Language/ObjC/NSDictionary.cpp
@@ -11,6 +11,7 @@
 #include "clang/AST/DeclCXX.h"
 
 #include "CFBasicHash.h"
+#include "GNUstepFormatters.h"
 #include "NSDictionary.h"
 
 #include "Plugins/LanguageRuntime/ObjC/AppleObjCRuntime/AppleObjCRuntime.h"
@@ -65,7 +66,7 @@ NSDictionary_Additionals::GetAdditionalSynthetics() {
   return g_map;
 }
 
-static CompilerType GetLLDBNSPairType(TargetSP target_sp) {
+CompilerType lldb_private::formatters::GetLLDBNSPairType(TargetSP target_sp) {
   CompilerType compiler_type;
   TypeSystemClangSP scratch_ts_sp =
       ScratchTypeSystemClang::GetForTarget(*target_sp);
@@ -397,6 +398,10 @@ bool lldb_private::formatters::NSDictionarySummaryProvider(
 
   if (!runtime)
     return false;
+  // gnustep-base lays its classes out differently and names them
+  // differently; hand those over.
+  if (llvm::isa<GNUstepObjCRuntime>(runtime))
+    return GNUstepNSDictionarySummaryProvider(valobj, stream, options);
 
   ObjCLanguageRuntime::ClassDescriptorSP descriptor(
       runtime->GetNonKVOClassDescriptor(valobj));
@@ -506,8 +511,11 @@ lldb_private::formatters::NSDictionarySyntheticFrontEndCreator(
   lldb::ProcessSP process_sp(valobj_sp->GetProcessSP());
   if (!process_sp)
     return nullptr;
-  AppleObjCRuntime *runtime = llvm::dyn_cast_or_null<AppleObjCRuntime>(
-      ObjCLanguageRuntime::Get(*process_sp));
+  ObjCLanguageRuntime *objc_runtime = ObjCLanguageRuntime::Get(*process_sp);
+  if (llvm::isa_and_nonnull<GNUstepObjCRuntime>(objc_runtime))
+    return GNUstepNSDictionarySyntheticFrontEndCreator(synth, valobj_sp);
+  AppleObjCRuntime *runtime =
+      llvm::dyn_cast_or_null<AppleObjCRuntime>(objc_runtime);
   if (!runtime)
     return nullptr;
 
diff --git a/lldb/source/Plugins/Language/ObjC/NSDictionary.h b/lldb/source/Plugins/Language/ObjC/NSDictionary.h
index a65298de56b90..42f677c621190 100644
--- a/lldb/source/Plugins/Language/ObjC/NSDictionary.h
+++ b/lldb/source/Plugins/Language/ObjC/NSDictionary.h
@@ -11,6 +11,7 @@
 
 #include "lldb/DataFormatters/TypeSummary.h"
 #include "lldb/DataFormatters/TypeSynthetic.h"
+#include "lldb/Symbol/CompilerType.h"
 #include "lldb/Utility/ConstString.h"
 #include "lldb/Utility/Stream.h"
 #include "lldb/ValueObject/ValueObject.h"
@@ -36,6 +37,11 @@ SyntheticChildrenFrontEnd *
 NSDictionarySyntheticFrontEndCreator(CXXSyntheticChildren *,
                                      lldb::ValueObjectSP);
 
+/// The `struct { id key; id value; }` type used to present dictionary
+/// entries as children. Shared with the GNUstep dictionary frontend so both
+/// runtimes present entries identically.
+CompilerType GetLLDBNSPairType(lldb::TargetSP target_sp);
+
 class NSDictionary_Additionals {
 public:
   class AdditionalFormatterMatching {
diff --git a/lldb/source/Plugins/Language/ObjC/NSSet.cpp b/lldb/source/Plugins/Language/ObjC/NSSet.cpp
index 9dd177b52fb83..bf82528fdfd16 100644
--- a/lldb/source/Plugins/Language/ObjC/NSSet.cpp
+++ b/lldb/source/Plugins/Language/ObjC/NSSet.cpp
@@ -8,6 +8,7 @@
 
 #include "NSSet.h"
 #include "CFBasicHash.h"
+#include "GNUstepFormatters.h"
 
 #include "Plugins/LanguageRuntime/ObjC/AppleObjCRuntime/AppleObjCRuntime.h"
 #include "lldb/DataFormatters/FormattersHelpers.h"
@@ -229,6 +230,10 @@ bool lldb_private::formatters::NSSetSummaryProvider(
 
   if (!runtime)
     return false;
+  // gnustep-base lays its classes out differently and names them
+  // differently; hand those over.
+  if (llvm::isa<GNUstepObjCRuntime>(runtime))
+    return GNUstepNSSetSummaryProvider(valobj, stream, options);
 
   ObjCLanguageRuntime::ClassDescriptorSP descriptor(
       runtime->GetClassDescriptor(valobj));
@@ -312,6 +317,10 @@ lldb_private::formatters::NSSetSyntheticFrontEndCreator(
   ObjCLanguageRuntime *runtime = ObjCLanguageRuntime::Get(*process_sp);
   if (!runtime)
     return nullptr;
+  // gnustep-base lays its classes out differently and names them
+  // differently; hand those over.
+  if (llvm::isa<GNUstepObjCRuntime>(runtime))
+    return GNUstepNSSetSyntheticFrontEndCreator(synth, valobj_sp);
 
   CompilerType valobj_type(valobj_sp->GetCompilerType());
   Flags flags(valobj_type.GetTypeInfo());
diff --git a/lldb/source/Plugins/Language/ObjC/NSString.cpp b/lldb/source/Plugins/Language/ObjC/NSString.cpp
index 7a295119bd031..4bec85168603e 100644
--- a/lldb/source/Plugins/Language/ObjC/NSString.cpp
+++ b/lldb/source/Plugins/Language/ObjC/NSString.cpp
@@ -7,6 +7,7 @@
 //===----------------------------------------------------------------------===//
 
 #include "NSString.h"
+#include "GNUstepFormatters.h"
 
 #include "lldb/DataFormatters/FormattersHelpers.h"
 #include "lldb/DataFormatters/StringPrinter.h"
@@ -43,6 +44,10 @@ bool lldb_private::formatters::NSStringSummaryProvider(
 
   if (!runtime)
     return false;
+  // gnustep-base lays its classes out differently and names them
+  // differently; hand those over.
+  if (llvm::isa<GNUstepObjCRuntime>(runtime))
+    return GNUstepNSStringSummaryProvider(valobj, stream, summary_options);
 
   ObjCLanguageRuntime::ClassDescriptorSP descriptor(
       runtime->GetClassDescriptor(valobj));
diff --git a/lldb/source/Plugins/Language/ObjC/ObjCLanguage.cpp b/lldb/source/Plugins/Language/ObjC/ObjCLanguage.cpp
index 42c53e1b81cfb..b09eeaeac8513 100644
--- a/lldb/source/Plugins/Language/ObjC/ObjCLanguage.cpp
+++ b/lldb/source/Plugins/Language/ObjC/ObjCLanguage.cpp
@@ -30,6 +30,7 @@
 #include "CF.h"
 #include "Cocoa.h"
 #include "CoreMedia.h"
+#include "GNUstepFormatters.h"
 #include "NSDictionary.h"
 #include "NSSet.h"
 #include "NSString.h"
@@ -876,6 +877,7 @@ lldb::TypeCategoryImplSP ObjCLanguage::GetFormatters() {
     if (g_category) {
       LoadCoreMediaFormatters(g_category);
       LoadObjCFormatters(g_category);
+      lldb_private::formatters::LoadGNUstepFormatters(g_category);
     }
   });
   return g_category;

>From d24b68a6c0c9944358c280c48d1e54f869da987d Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Sun, 16 Aug 2026 01:38:57 +0100
Subject: [PATCH 21/38] [lldb][GNUstep] Find SmallObjectClasses through debug
 info as a fallback

The tagged-pointer vendor locates libobjc2's SmallObjectClasses table by
symbol name. The table has hidden visibility, so a linked image only has
a symbol for it if a PDB or an unstripped symbol table is present; a
libobjc2 built with DWARF into a PE - the layout the tools-windows-msvc
toolchain produces once -gdwarf is requested - has neither, and every
tagged pointer then went untyped. The debug info still describes the
table as a global variable, so fall back to that to find its address.

Assisted-by: Claude Fable 5
---
 .../GNUstepObjCClassDescriptor.cpp            | 42 ++++++++++++++++---
 1 file changed, 36 insertions(+), 6 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
index 1261aa347ab41..061320b590f86 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
@@ -12,6 +12,8 @@
 #include "lldb/Core/ModuleList.h"
 #include "lldb/Symbol/Symbol.h"
 #include "lldb/Symbol/SymbolContext.h"
+#include "lldb/Symbol/Variable.h"
+#include "lldb/Symbol/VariableList.h"
 #include "lldb/Target/Process.h"
 #include "lldb/Target/Target.h"
 #include "lldb/Utility/ArchSpec.h"
@@ -19,6 +21,16 @@
 #include "lldb/Utility/LLDBLog.h"
 #include "lldb/Utility/Log.h"
 #include "lldb/Utility/Status.h"
+#include "lldb/ValueObject/ValueObjectVariable.h"
+
+#include "llvm/ADT/DenseSet.h"
+#include "llvm/ADT/SmallPtrSet.h"
+#include "llvm/Support/MathExtras.h"
+
+#include <functional>
+#include <memory>
+#include <string>
+#include <vector>
 
 using namespace lldb;
 using namespace lldb_private;
@@ -183,11 +195,11 @@ bool GNUstepObjCTaggedPointerClassDescriptor::GetTaggedPointerInfoSigned(
   if (value_bits) {
     // Sign-extend from the target's pointer width before shifting, so that a
     // negative payload in a 32-bit pointer is not read as a large positive.
+    // Done through SignExtend64 rather than by hand: shifting a signed value
+    // into its own sign bit is undefined.
     const uint32_t pointer_bits = m_pointer_size * 8;
-    int64_t signed_value = static_cast<int64_t>(m_pointer_value)
-                           << (64 - pointer_bits);
-    signed_value >>= (64 - pointer_bits);
-    *value_bits = signed_value >> m_payload_shift;
+    *value_bits =
+        llvm::SignExtend64(m_pointer_value, pointer_bits) >> m_payload_shift;
   }
   if (payload)
     *payload = m_pointer_value;
@@ -232,10 +244,28 @@ GNUstepTaggedPointerVendor::GetClassDescriptor(lldb::addr_t ptr) {
         break;
       }
     }
+    // The table has hidden visibility, so a linked image carries no symbol
+    // for it unless a PDB or an unstripped symtab is around; the debug info
+    // still describes it as a global, which is enough to find its address.
+    if (*m_table_addr == LLDB_INVALID_ADDRESS) {
+      VariableList variables;
+      target.GetImages().FindGlobalVariables(ConstString("SmallObjectClasses"),
+                                             1, variables);
+      if (VariableSP variable_sp = variables.GetVariableAtIndex(0)) {
+        ValueObjectSP valobj_sp =
+            ValueObjectVariable::Create(&target, variable_sp);
+        if (valobj_sp) {
+          const addr_t table = valobj_sp->GetAddressOf(false).address;
+          if (table != 0 && table != LLDB_INVALID_ADDRESS)
+            m_table_addr = table;
+        }
+      }
+    }
     if (*m_table_addr == LLDB_INVALID_ADDRESS)
       LLDB_LOG(GetLog(LLDBLog::Language),
-               "GNUstepTaggedPointerVendor: SmallObjectClasses symbol not "
-               "found (stripped libobjc?); tagged pointer classes unknown");
+               "GNUstepTaggedPointerVendor: SmallObjectClasses not found in "
+               "any symbol table or debug info (stripped libobjc?); tagged "
+               "pointer classes unknown");
   }
   if (*m_table_addr == LLDB_INVALID_ADDRESS)
     return nullptr;

>From 6cf27ef922442f896810f59ee0f55d56484ad4a0 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Sun, 16 Aug 2026 01:38:58 +0100
Subject: [PATCH 22/38] [lldb][GNUstep] Add tests for the gnustep-base data
 formatters

Three layers:

- Unit tests for the small-object decoders, hermetic and hence run
  everywhere. The constants are what clang and gnustep-base actually
  produce (a live process was the oracle), including clang's own
  encoding of @"Hello" as a tagged GSTinyString.

- An API test, lang/objc-gnustep/data-formatters, whose inferior creates
  every concrete class the formatters know - strings in each storage
  form, tagged and boxed numbers, arrays, dictionaries, sets, data,
  dates, NSNull, and a custom object - and which checks the summaries,
  the synthetic children and the SB API. It needs gnustep-base, a
  larger dependency than the runtime, so it is gated behind a new
  objc-gnustep-base category driven by a new
  LLDB_TEST_OBJC_GNUSTEP_BASE_DIR CMake variable, plumbed through lit,
  dotest and Makefile.rules the same way LLDB_TEST_OBJC_GNUSTEP_DIR is.
  On Windows the runtime and Foundation DLLs, and their PDBs, are copied
  next to the test binary because the test harness scrubs the inferior's
  PATH.

- A Shell test, objc-gnustep-class-objects, for the class-object fix
  in the runtime, which needs no Foundation.

Assisted-by: Claude Fable 5
---
 .../Python/lldbsuite/test/builders/builder.py |   9 +-
 .../Python/lldbsuite/test/configuration.py    |   4 +
 lldb/packages/Python/lldbsuite/test/dotest.py |  23 ++-
 .../Python/lldbsuite/test/dotest_args.py      |   8 +
 .../Python/lldbsuite/test/make/Makefile.rules |  34 ++++
 .../Python/lldbsuite/test/test_categories.py  |   1 +
 .../objc-gnustep/data-formatters/Makefile     |   3 +
 .../TestGNUstepDataFormatters.py              | 169 ++++++++++++++++++
 .../objc-gnustep/data-formatters/categories   |   1 +
 .../lang/objc-gnustep/data-formatters/main.m  | 103 +++++++++++
 lldb/test/API/lit.cfg.py                      |   2 +
 lldb/test/API/lit.site.cfg.py.in              |   1 +
 lldb/test/CMakeLists.txt                      |  14 ++
 lldb/unittests/Language/ObjC/CMakeLists.txt   |   1 +
 .../Language/ObjC/GNUstepFormattersTest.cpp   |  95 ++++++++++
 15 files changed, 457 insertions(+), 11 deletions(-)
 create mode 100644 lldb/test/API/lang/objc-gnustep/data-formatters/Makefile
 create mode 100644 lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
 create mode 100644 lldb/test/API/lang/objc-gnustep/data-formatters/categories
 create mode 100644 lldb/test/API/lang/objc-gnustep/data-formatters/main.m
 create mode 100644 lldb/unittests/Language/ObjC/GNUstepFormattersTest.cpp

diff --git a/lldb/packages/Python/lldbsuite/test/builders/builder.py b/lldb/packages/Python/lldbsuite/test/builders/builder.py
index 3ed463ae76049..e88e3d824e981 100644
--- a/lldb/packages/Python/lldbsuite/test/builders/builder.py
+++ b/lldb/packages/Python/lldbsuite/test/builders/builder.py
@@ -244,9 +244,14 @@ def getLibCxxArgs(self):
         return []
 
     def getObjcGnustepArgs(self):
+        args = []
         if configuration.objc_gnustep_dir:
-            return ["OBJC_GNUSTEP_DIR={}".format(configuration.objc_gnustep_dir)]
-        return []
+            args.append("OBJC_GNUSTEP_DIR={}".format(configuration.objc_gnustep_dir))
+        if configuration.objc_gnustep_base_dir:
+            args.append(
+                "OBJC_GNUSTEP_BASE_DIR={}".format(configuration.objc_gnustep_base_dir)
+            )
+        return args
 
     def getLLDBObjRoot(self):
         if configuration.lldb_obj_root:
diff --git a/lldb/packages/Python/lldbsuite/test/configuration.py b/lldb/packages/Python/lldbsuite/test/configuration.py
index 2c7a4f5f12afe..6b3afeb38bfa2 100644
--- a/lldb/packages/Python/lldbsuite/test/configuration.py
+++ b/lldb/packages/Python/lldbsuite/test/configuration.py
@@ -152,6 +152,10 @@
 # non-Apple platforms.
 objc_gnustep_dir = None
 
+# GNUstep gnustep-base (Foundation) installation directory, for the tests
+# that need Foundation classes on top of the runtime.
+objc_gnustep_base_dir = None
+
 # A plugin whose tests will be enabled, like intel-pt.
 enabled_plugins = []
 
diff --git a/lldb/packages/Python/lldbsuite/test/dotest.py b/lldb/packages/Python/lldbsuite/test/dotest.py
index 3e4279dc9d2ac..c5531c683bff6 100644
--- a/lldb/packages/Python/lldbsuite/test/dotest.py
+++ b/lldb/packages/Python/lldbsuite/test/dotest.py
@@ -47,6 +47,7 @@
 from ..support import temp_file
 from ..support import xcode
 
+
 def is_exe(fpath):
     """Returns true if fpath is an executable."""
     if fpath is None:
@@ -74,8 +75,7 @@ def which(program):
 def usage(parser):
     parser.print_help()
     if configuration.verbose > 0:
-        print(
-            """
+        print("""
 Examples:
 
 This is an example of using the -f option to pinpoint to a specific test class
@@ -166,8 +166,7 @@ def usage(parser):
 
 $ ./dotest.py --log-success
 
-"""
-        )
+""")
     sys.exit(0)
 
 
@@ -294,6 +293,9 @@ def parseOptionsAndInitTestdirs():
     if args.objc_gnustep_dir:
         configuration.objc_gnustep_dir = args.objc_gnustep_dir
 
+    if args.objc_gnustep_base_dir:
+        configuration.objc_gnustep_base_dir = args.objc_gnustep_base_dir
+
     if args.libcxx_include_dir or args.libcxx_library_dir:
         if args.lldb_platform_name:
             logging.warning(
@@ -671,7 +673,7 @@ def setupSysPath():
         # Some of the code that uses this path assumes it hasn't resolved the Versions... link.
         # If the path we've constructed looks like that, then we'll strip out
         # the Versions/A part.
-        (before, frameWithVersion, after) = lldbPythonDir.rpartition(
+        before, frameWithVersion, after = lldbPythonDir.rpartition(
             "LLDB.framework/Versions/A"
         )
         if frameWithVersion != "":
@@ -947,14 +949,12 @@ def canRunMsvcStlTests():
             stderr=subprocess.PIPE,
             universal_newlines=True,
         )
-        _, stderr = p.communicate(
-            """
+        _, stderr = p.communicate("""
             #include <yvals_core.h>
             #ifndef _MSVC_STL_VERSION
             #error _MSVC_STL_VERSION not defined
             #endif
-            """
-        )
+            """)
         if not p.returncode:
             return True, "Compiling with MSVC STL"
         return (False, f"Not compiling with MSVC STL: {stderr}")
@@ -1029,6 +1029,11 @@ def checkObjcGnustepSupport():
             print("objc-gnustep tests will be skipped because no GNUstep")
             print("libobjc2 installation was specified")
         configuration.skip_categories.append("objc-gnustep")
+    if not configuration.objc_gnustep_base_dir:
+        if configuration.verbose:
+            print("objc-gnustep-base tests will be skipped because no")
+            print("gnustep-base installation was specified")
+        configuration.skip_categories.append("objc-gnustep-base")
 
 
 def checkExpressionSupport():
diff --git a/lldb/packages/Python/lldbsuite/test/dotest_args.py b/lldb/packages/Python/lldbsuite/test/dotest_args.py
index b3dbd1c378bb2..c2913637353c5 100644
--- a/lldb/packages/Python/lldbsuite/test/dotest_args.py
+++ b/lldb/packages/Python/lldbsuite/test/dotest_args.py
@@ -93,6 +93,14 @@ def create_parser():
             "Specify the path to a GNUstep libobjc2 installation to build Objective-C tests against on non-Apple platforms."
         ),
     )
+    group.add_argument(
+        "--objc-gnustep-base-dir",
+        metavar="dir",
+        dest="objc_gnustep_base_dir",
+        help=textwrap.dedent(
+            "Specify the path to a GNUstep gnustep-base (Foundation) installation; enables the objc-gnustep-base tests."
+        ),
+    )
     # FIXME? This won't work for different extra flags according to each triple.
     group.add_argument(
         "-E",
diff --git a/lldb/packages/Python/lldbsuite/test/make/Makefile.rules b/lldb/packages/Python/lldbsuite/test/make/Makefile.rules
index 33c2b0788f87d..700291012273d 100644
--- a/lldb/packages/Python/lldbsuite/test/make/Makefile.rules
+++ b/lldb/packages/Python/lldbsuite/test/make/Makefile.rules
@@ -565,6 +565,29 @@ ifneq "$(strip $(OBJC_GNUSTEP_DIR))" ""
 			LDFLAGS +=-Wl,/debug:dwarf
 			GNUSTEP_NEEDS_DLL_COPY := 1
 		endif
+		# gnustep-base (Foundation) on top of the runtime, for tests of the
+		# Foundation data formatters. Its headers need the gnustep-2.x string
+		# ABI (a newer -fobjc-runtime than the bare-runtime tests use), blocks
+		# and exceptions, and on Windows the dllimport declarations.
+		ifneq "$(strip $(OBJC_GNUSTEP_BASE_DIR))" ""
+			GNUSTEP_BASE_FLAGS := -fobjc-runtime=gnustep-2.2 -fblocks -fexceptions
+			GNUSTEP_BASE_FLAGS += -fobjc-exceptions -fconstant-string-class=NSConstantString
+			GNUSTEP_BASE_FLAGS += -DGNUSTEP -DGNUSTEP_BASE_LIBRARY=1 -DGNU_RUNTIME=1
+			GNUSTEP_BASE_FLAGS += -I$(OBJC_GNUSTEP_BASE_DIR)/include
+			ifeq "$(OS)" "Windows_NT"
+				GNUSTEP_BASE_FLAGS += -DGNUSTEP_WITH_DLL
+			endif
+			# .m files are compiled with CFLAGS in this file.
+			OBJCFLAGS += $(GNUSTEP_BASE_FLAGS)
+			CFLAGS += $(GNUSTEP_BASE_FLAGS)
+			LDFLAGS +=-L$(OBJC_GNUSTEP_BASE_DIR)/lib -lgnustep-base
+			ifeq "$(OS)" "Linux"
+				LDFLAGS +=-Wl,-rpath,$(OBJC_GNUSTEP_BASE_DIR)/lib
+			endif
+			ifeq "$(OS)" "Windows_NT"
+				GNUSTEP_NEEDS_BASE_DLL_COPY := 1
+			endif
+		endif
 	endif
 endif
 
@@ -763,6 +786,17 @@ objc.dll: $(OBJC_GNUSTEP_DIR)/lib/objc.dll
 	cp $< $@
 endif
 
+# Likewise for gnustep-base and the runtime DLLs it depends on, which the
+# tools-windows-msvc layout keeps under bin/. Their PDBs come along: the
+# runtime's small-object class table is a hidden symbol that LLDB can only
+# see through the PDB, and tagged pointers cannot be typed without it.
+ifeq "$(GNUSTEP_NEEDS_BASE_DLL_COPY)" "1"
+all: gnustep-base-dlls
+
+gnustep-base-dlls:
+	cp $(OBJC_GNUSTEP_BASE_DIR)/bin/*.dll $(OBJC_GNUSTEP_BASE_DIR)/bin/*.pdb .
+endif
+
 ### Local Variables: ###
 ### mode:makefile ###
 ### End: ###
diff --git a/lldb/packages/Python/lldbsuite/test/test_categories.py b/lldb/packages/Python/lldbsuite/test/test_categories.py
index efc55d4284e24..194abb55d54cb 100644
--- a/lldb/packages/Python/lldbsuite/test/test_categories.py
+++ b/lldb/packages/Python/lldbsuite/test/test_categories.py
@@ -44,6 +44,7 @@
     "pexpect": "Tests requiring the pexpect library to be available",
     "objc": "Tests related to the Objective-C programming language support",
     "objc-gnustep": "Tests requiring the GNUstep libobjc2 Objective-C runtime",
+    "objc-gnustep-base": "Tests requiring GNUstep's gnustep-base Foundation library",
     "pyapi": "Tests related to the Python API",
     "std-module": "Tests related to importing the std module",
     "stresstest": "Tests related to stressing lldb limits",
diff --git a/lldb/test/API/lang/objc-gnustep/data-formatters/Makefile b/lldb/test/API/lang/objc-gnustep/data-formatters/Makefile
new file mode 100644
index 0000000000000..845553d5e3f2f
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/Makefile
@@ -0,0 +1,3 @@
+OBJC_SOURCES := main.m
+
+include Makefile.rules
diff --git a/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py b/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
new file mode 100644
index 0000000000000..726b6889a5795
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
@@ -0,0 +1,169 @@
+"""
+Test the data formatters for gnustep-base's Foundation classes.
+"""
+
+import lldb
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+from lldbsuite.test import lldbutil
+
+
+class TestGNUstepDataFormatters(TestBase):
+    def stop_at_end(self):
+        self.build()
+        lldbutil.run_to_source_breakpoint(
+            self, "// break here", lldb.SBFileSpec("main.m")
+        )
+        # Every check below goes through the dynamic type, which is what a
+        # debugger front end asks for. The setting is per target, so it has
+        # to be applied to the one run_to_source_breakpoint created.
+        self.runCmd("settings set target.prefer-dynamic-value run-target")
+
+    def test_strings(self):
+        """Every concrete string class prints its characters as @"..."."""
+        self.stop_at_end()
+        self.expect("frame variable -d run-target tinyString", substrs=['@"Hi"'])
+        self.expect(
+            "frame variable -d run-target constantString",
+            substrs=['@"A constant string literal"'],
+        )
+        self.expect(
+            "frame variable -d run-target unicodeConstant", substrs=['@"Grüße, 世界"']
+        )
+        self.expect("frame variable -d run-target emptyString", substrs=['@""'])
+        self.expect("frame variable -d run-target builtString", substrs=['@"built 42"'])
+        self.expect(
+            "frame variable -d run-target unicodeBuilt", substrs=['@"ünïcödé 7"']
+        )
+        self.expect(
+            "frame variable -d run-target mutableString", substrs=['@"mutable string"']
+        )
+
+    def test_numbers(self):
+        """Tagged and boxed numbers print their value with a type prefix."""
+        self.stop_at_end()
+        self.expect("frame variable -d run-target boolYes", substrs=["YES"])
+        self.expect("frame variable -d run-target smallInt", substrs=["(int)5"])
+        self.expect("frame variable -d run-target taggedInt", substrs=["(long)123456"])
+        self.expect("frame variable -d run-target negativeInt", substrs=["(long)-99"])
+        self.expect(
+            "frame variable -d run-target longLong",
+            substrs=["(long)9223372036854775807"],
+        )
+        self.expect(
+            "frame variable -d run-target unsignedLongLong",
+            substrs=["(long)18446744073709551615"],
+        )
+        self.expect("frame variable -d run-target floatNumber", substrs=["(float)1.5"])
+        self.expect(
+            "frame variable -d run-target doubleNumber", substrs=["(double)3.14159"]
+        )
+        self.expect("frame variable -d run-target heapDouble", substrs=["(double)0.1"])
+
+    def test_collections(self):
+        """Collections summarize their count and expose elements as children."""
+        self.stop_at_end()
+        self.expect(
+            "frame variable -d run-target emptyArray", substrs=['@"0 elements"']
+        )
+        self.expect("frame variable -d run-target fruits", substrs=['@"3 elements"'])
+        self.expect(
+            "frame variable -d run-target mutableArray", substrs=['@"4 elements"']
+        )
+        self.expect("frame variable -d run-target nested", substrs=['@"2 elements"'])
+        self.expect(
+            "frame variable -d run-target emptyDict", substrs=["0 key/value pairs"]
+        )
+        self.expect(
+            "frame variable -d run-target person", substrs=["3 key/value pairs"]
+        )
+        self.expect(
+            "frame variable -d run-target mutableDict", substrs=["4 key/value pairs"]
+        )
+        self.expect("frame variable -d run-target colors", substrs=["3 elements"])
+        self.expect("frame variable -d run-target mutableSet", substrs=["4 elements"])
+        self.expect("frame variable -d run-target counted", substrs=["2 elements"])
+
+        # Children.
+        self.expect(
+            "frame variable -d run-target fruits[0] fruits[1] fruits[2]",
+            substrs=['@"apple"', '@"banana"', '@"cherry"'],
+        )
+        self.expect("frame variable -d run-target nested[0]", substrs=['@"3 elements"'])
+        # Dictionary entries are key/value pairs; order is hash order, so
+        # look at the whole set of entries.
+        self.expect(
+            "frame variable -d run-target person[0] person[1] person[2]",
+            # @30 lands in a tagged NSSmallInt (only -1..12 are boxed
+            # singletons), which prints as a long.
+            substrs=[
+                "key = ",
+                "value = ",
+                '@"name"',
+                '@"John Doe"',
+                '@"age"',
+                "(long)30",
+                '@"skills"',
+                '@"2 elements"',
+            ],
+            ordered=False,
+        )
+        self.expect(
+            "frame variable -d run-target colors[0] colors[1] colors[2]",
+            substrs=['@"red"', '@"green"', '@"blue"'],
+            ordered=False,
+        )
+
+    def test_others(self):
+        """NSData, NSDate, NSNull, nil and a custom object."""
+        self.stop_at_end()
+        self.expect("frame variable -d run-target data", substrs=["12 bytes"])
+        self.expect(
+            "frame variable -d run-target epoch", substrs=["2001-01-01 00:00:0"]
+        )
+        self.expect(
+            "frame variable -d run-target someDate", substrs=["2023-11-14 22:13:20 UTC"]
+        )
+        self.expect("frame variable -d run-target null", substrs=["<null>"])
+        self.expect("frame variable -d run-target nilObject", substrs=["nil"])
+        # A custom class: dynamic type plus formatted ivars, and its class
+        # object is not itself presented as an instance.
+        self.expect(
+            "frame variable -d run-target anonymous",
+            substrs=["(Account *) anonymous"],
+        )
+        self.expect(
+            "frame variable -d run-target *account",
+            substrs=[
+                "owner = ",
+                '@"Jane"',
+                "balance = ",
+                "(double)1234.5",
+                "tags = ",
+                '@"2 elements"',
+            ],
+        )
+        self.expect(
+            "frame variable -d run-target *account",
+            matching=False,
+            substrs=["(Account *) isa"],
+        )
+
+    def test_api(self):
+        """The same summaries come back through the SB API."""
+        self.stop_at_end()
+        frame = self.frame()
+        greeting = frame.FindVariable("tinyString").GetDynamicValue(
+            lldb.eDynamicCanRunTarget
+        )
+        self.assertEqual(greeting.GetSummary(), '@"Hi"')
+        fruits = frame.FindVariable("fruits").GetDynamicValue(lldb.eDynamicCanRunTarget)
+        self.assertEqual(fruits.GetSummary(), '@"3 elements"')
+        self.assertEqual(fruits.GetNumChildren(), 3)
+        first = fruits.GetChildAtIndex(0).GetDynamicValue(lldb.eDynamicCanRunTarget)
+        self.assertEqual(first.GetSummary(), '@"apple"')
+        person = frame.FindVariable("person").GetDynamicValue(lldb.eDynamicCanRunTarget)
+        self.assertEqual(person.GetNumChildren(), 3)
+        pair = person.GetChildAtIndex(0)
+        self.assertEqual(pair.GetChildMemberWithName("key").GetName(), "key")
+        self.assertEqual(pair.GetNumChildren(), 2)
diff --git a/lldb/test/API/lang/objc-gnustep/data-formatters/categories b/lldb/test/API/lang/objc-gnustep/data-formatters/categories
new file mode 100644
index 0000000000000..70b14bf34d6cc
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/categories
@@ -0,0 +1 @@
+objc-gnustep-base
diff --git a/lldb/test/API/lang/objc-gnustep/data-formatters/main.m b/lldb/test/API/lang/objc-gnustep/data-formatters/main.m
new file mode 100644
index 0000000000000..f709b62c75055
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/main.m
@@ -0,0 +1,103 @@
+// Every local below is a distinct concrete gnustep-base class, so the data
+// formatters for strings in all their storage forms, boxed and tagged
+// numbers, arrays, dictionaries, sets, data, dates and NSNull each get
+// exercised, plus a custom object.
+
+#import <Foundation/Foundation.h>
+
+ at interface Account : NSObject {
+  NSString *owner;
+  NSNumber *balance;
+  NSArray *tags;
+}
+- (instancetype)initWithOwner:(NSString *)o balance:(double)b;
+ at end
+
+ at implementation Account
+- (instancetype)initWithOwner:(NSString *)o balance:(double)b {
+  if ((self = [super init])) {
+    owner = o;
+    balance = @(b);
+    tags = @[ @"premium", @"verified" ];
+  }
+  return self;
+}
+- (NSString *)description {
+  return [NSString stringWithFormat:@"<Account %@: %@>", owner, balance];
+}
+ at end
+
+int main(int argc, const char *argv[]) {
+  @autoreleasepool {
+    // Strings: each literal/operation lands in a different concrete class.
+    NSString *tinyString = @"Hi";                            // GSTinyString
+    NSString *constantString = @"A constant string literal"; // NSConstantString
+    NSString *unicodeConstant = @"Grüße, 世界"; // NSConstantString, UTF-16
+    NSString *emptyString = @"";
+    NSString *builtString =
+        [NSString stringWithFormat:@"built %d", 42]; // GSCInlineString
+    NSString *unicodeBuilt =
+        [NSString stringWithFormat:@"ünïcödé %d", 7]; // GSUInlineString
+    NSMutableString *mutableString =
+        [NSMutableString stringWithString:@"mutable"]; // GSMutableString
+    [mutableString appendString:@" string"];
+
+    // Numbers: singletons, tagged small objects, and heap boxes.
+    NSNumber *boolYes = @YES;                    // NSBoolNumber
+    NSNumber *smallInt = @5;                     // NSIntNumber (singleton)
+    NSNumber *taggedInt = @123456;               // NSSmallInt
+    NSNumber *negativeInt = @-99;                // NSSmallInt
+    NSNumber *longLong = @9223372036854775807LL; // NSLongLongNumber
+    NSNumber *unsignedLongLong =
+        @18446744073709551615ULL;      // NSUnsignedLongLongNumber
+    NSNumber *floatNumber = @1.5f;     // NSSmallFloat
+    NSNumber *doubleNumber = @3.14159; // NSSmallRepeatingDouble
+    NSNumber *heapDouble = @0.1; // NSSmallExtendedDouble or NSDoubleNumber
+
+    // Collections.
+    NSArray *emptyArray = @[];
+    NSArray *fruits = @[ @"apple", @"banana", @"cherry" ]; // GSInlineArray
+    NSMutableArray *mutableArray = [NSMutableArray arrayWithArray:fruits];
+    [mutableArray addObject:@"date"]; // GSMutableArray
+    NSArray *nested = @[ fruits, @[ @1, @2 ] ];
+    NSDictionary *emptyDict = @{};
+    NSDictionary *person = @{
+      @"name" : @"John Doe",
+      @"age" : @30,
+      @"skills" : @[ @"Objective-C", @"Swift" ]
+    }; // GSDictionary
+    NSMutableDictionary *mutableDict =
+        [NSMutableDictionary dictionaryWithDictionary:person];
+    mutableDict[@"city"] = @"Berlin"; // GSMutableDictionary
+    NSSet *colors =
+        [NSSet setWithObjects:@"red", @"green", @"blue", nil]; // GSSet
+    NSMutableSet *mutableSet = [NSMutableSet setWithSet:colors];
+    [mutableSet addObject:@"yellow"]; // GSMutableSet
+    NSCountedSet *counted = [NSCountedSet setWithArray:@[ @"a", @"a", @"b" ]];
+
+    // Other value types.
+    NSData *data = [@"Hello, data!"
+        dataUsingEncoding:NSUTF8StringEncoding]; // NSDataMalloc
+    NSDate *epoch =
+        [NSDate dateWithTimeIntervalSinceReferenceDate:0]; // GSSmallDate
+    NSDate *someDate = [NSDate dateWithTimeIntervalSince1970:1700000000];
+    NSNull *null = [NSNull null];
+    NSURL *url = [NSURL URLWithString:@"https://www.gnustep.org/resources"];
+    id nilObject = nil;
+
+    // A custom class: gets dynamic type + ivars, po runs -description.
+    Account *account = [[Account alloc] initWithOwner:@"Jane" balance:1234.5];
+    id anonymous = account;
+
+    NSLog(@"%@ %@ %@ %@ %@ %@ %@", tinyString, constantString, unicodeConstant,
+          emptyString, builtString, unicodeBuilt, mutableString);
+    NSLog(@"%@ %@ %@ %@ %@ %@ %@ %@ %@", boolYes, smallInt, taggedInt,
+          negativeInt, longLong, unsignedLongLong, floatNumber, doubleNumber,
+          heapDouble);
+    NSLog(@"%@ %@ %@ %@ %@ %@ %@ %@ %@ %@", emptyArray, fruits, mutableArray,
+          nested, emptyDict, person, mutableDict, colors, mutableSet, counted);
+    NSLog(@"%@ %@ %@ %@ %@ %@ %@", data, epoch, someDate, null, url, account,
+          anonymous);
+    return nilObject != nil; // break here
+  }
+}
diff --git a/lldb/test/API/lit.cfg.py b/lldb/test/API/lit.cfg.py
index 3f32433a3beec..b7f873b3142fd 100644
--- a/lldb/test/API/lit.cfg.py
+++ b/lldb/test/API/lit.cfg.py
@@ -241,6 +241,8 @@ def delete_module_cache(path):
 # against it on non-Apple platforms.
 if is_configured("objc_gnustep_dir"):
     dotest_cmd += ["--objc-gnustep-dir", config.objc_gnustep_dir]
+if is_configured("objc_gnustep_base_dir"):
+    dotest_cmd += ["--objc-gnustep-base-dir", config.objc_gnustep_base_dir]
 
 # Forward ASan-specific environment variables to tests, as a test may load an
 # ASan-ified dylib.
diff --git a/lldb/test/API/lit.site.cfg.py.in b/lldb/test/API/lit.site.cfg.py.in
index 30bb3733121c3..7e7524ba229f8 100644
--- a/lldb/test/API/lit.site.cfg.py.in
+++ b/lldb/test/API/lit.site.cfg.py.in
@@ -43,6 +43,7 @@ config.libcxx_libs_dir = "@LIBCXX_LIBRARY_DIR@"
 config.libcxx_include_dir = "@LIBCXX_GENERATED_INCLUDE_DIR@"
 config.libcxx_include_target_dir = "@LIBCXX_GENERATED_INCLUDE_TARGET_DIR@"
 config.objc_gnustep_dir = "@LLDB_TEST_OBJC_GNUSTEP_DIR@"
+config.objc_gnustep_base_dir = "@LLDB_TEST_OBJC_GNUSTEP_BASE_DIR@"
 config.lldb_launcher = "@LLDB_LAUNCHER@"
 config.test_resource_dir = "@LLDB_TEST_RESOURCE_DIR@"
 config.lldb_enable_mte = @LLDB_ENABLE_MTE@
diff --git a/lldb/test/CMakeLists.txt b/lldb/test/CMakeLists.txt
index c79f05cf85841..b2ed4d5b9c36a 100644
--- a/lldb/test/CMakeLists.txt
+++ b/lldb/test/CMakeLists.txt
@@ -83,6 +83,20 @@ elseif (LLDB_TEST_OBJC_GNUSTEP_DIR)
   set(LLDB_TEST_OBJC_GNUSTEP_DIR "" CACHE PATH "Custom path to the GNUstep shared library" FORCE)
 endif()
 
+# gnustep-base (Foundation) is a separate, larger dependency than the runtime;
+# tests of the Foundation data formatters need it and are gated on this.
+set(LLDB_TEST_OBJC_GNUSTEP_BASE_DIR "" CACHE PATH
+  "Path to a GNUstep gnustep-base install (Foundation/Foundation.h under include/) used by data-formatter tests")
+if (LLDB_TEST_OBJC_GNUSTEP_BASE_DIR)
+  if (NOT LLDB_TEST_OBJC_GNUSTEP)
+    message(SEND_ERROR "LLDB_TEST_OBJC_GNUSTEP_BASE_DIR requires LLDB_TEST_OBJC_GNUSTEP=On.")
+  endif()
+  if (NOT EXISTS "${LLDB_TEST_OBJC_GNUSTEP_BASE_DIR}/include/Foundation/Foundation.h")
+    message(SEND_ERROR "Failed to find Foundation/Foundation.h under ${LLDB_TEST_OBJC_GNUSTEP_BASE_DIR}/include. "
+                       "Please check LLDB_TEST_OBJC_GNUSTEP_BASE_DIR.")
+  endif()
+endif()
+
 # LLVM_BUILD_MODE is used in lit.site.cfg
 if (CMAKE_CFG_INTDIR STREQUAL ".")
   set(LLVM_BUILD_MODE ".")
diff --git a/lldb/unittests/Language/ObjC/CMakeLists.txt b/lldb/unittests/Language/ObjC/CMakeLists.txt
index 82cc847e1045f..851a98975e4e2 100644
--- a/lldb/unittests/Language/ObjC/CMakeLists.txt
+++ b/lldb/unittests/Language/ObjC/CMakeLists.txt
@@ -1,4 +1,5 @@
 add_lldb_unittest(LanguageObjCTests
+  GNUstepFormattersTest.cpp
   ObjCLanguageTest.cpp
 
   LINK_LIBS
diff --git a/lldb/unittests/Language/ObjC/GNUstepFormattersTest.cpp b/lldb/unittests/Language/ObjC/GNUstepFormattersTest.cpp
new file mode 100644
index 0000000000000..b5b99b18c9e96
--- /dev/null
+++ b/lldb/unittests/Language/ObjC/GNUstepFormattersTest.cpp
@@ -0,0 +1,95 @@
+//===-- GNUstepFormattersTest.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 "Plugins/Language/ObjC/GNUstepFormatters.h"
+#include "gtest/gtest.h"
+
+#include <cmath>
+#include <cstring>
+
+using namespace lldb_private::formatters;
+
+// The small-object payloads below come from libobjc2's tag layout (three low
+// bits) and gnustep-base's encodings; the constants are what the compiler and
+// runtime actually produce, checked against a live process.
+
+TEST(GNUstepFormattersTest, TinyStringDecodesClangEmittedLiteral) {
+  // clang emits @"Hello" for the gnustep-2.x ABI as this constant
+  // (CGObjCGNU.cpp: 7-bit characters from the top, 5-bit length, tag 4).
+  EXPECT_EQ(GNUstepDecodeTinyString(0x919766cde000002cULL), "Hello");
+  EXPECT_EQ(GNUstepDecodeTinyString(0x91a4000000000014ULL), "Hi");
+  EXPECT_EQ(GNUstepDecodeTinyString(0xc3c386cca000002cULL), "apple");
+  EXPECT_EQ(GNUstepDecodeTinyString(0xc587761dd8400034ULL), "banana");
+}
+
+TEST(GNUstepFormattersTest, TinyStringEmptyAndLimits) {
+  // Zero characters: just the tag.
+  EXPECT_EQ(GNUstepDecodeTinyString(0x4), "");
+  // Eight characters fill every slot; nine means eight plus a terminator.
+  uint64_t eight = 4 | (8ULL << 3);
+  for (int i = 0; i < 8; ++i)
+    eight |= static_cast<uint64_t>('a' + i) << (57 - 7 * i);
+  EXPECT_EQ(GNUstepDecodeTinyString(eight), "abcdefgh");
+  uint64_t nine = (eight & ~(0x1fULL << 3)) | (9ULL << 3);
+  EXPECT_EQ(GNUstepDecodeTinyString(nine), "abcdefgh");
+  // Not a tiny string: wrong tag, or an impossible length.
+  EXPECT_FALSE(GNUstepDecodeTinyString(0x919766cde000002dULL).has_value());
+  EXPECT_FALSE(GNUstepDecodeTinyString(4 | (10ULL << 3)).has_value());
+}
+
+TEST(GNUstepFormattersTest, SmallIntIsArithmeticallyShifted) {
+  // NSSmallInt: value << 3 | 1 (Source/NSNumber.m).
+  EXPECT_EQ(GNUstepDecodeSmallInt((42ULL << 3) | 1), 42);
+  EXPECT_EQ(GNUstepDecodeSmallInt(0x00000000000f1201ULL), 123456);
+  // Negative values keep their sign through the shift.
+  EXPECT_EQ(GNUstepDecodeSmallInt(0xfffffffffffffce9ULL), -99);
+  EXPECT_EQ(GNUstepDecodeSmallInt(static_cast<uint64_t>(-1LL << 3) | 1), -1);
+}
+
+TEST(GNUstepFormattersTest, SmallDoublesRoundTrip) {
+  // Box a double the way boxDouble() does for the repeating (tag 3 / 5) and
+  // extended (tag 2) encodings, then check the decoders invert it.
+  auto bits_of = [](double d) {
+    uint64_t bits;
+    std::memcpy(&bits, &d, sizeof(bits));
+    return bits;
+  };
+  // Repeating: the low three mantissa bits are moved up into bits 3-5 and
+  // the tag takes their place. 1.5f as boxed by gnustep-base:
+  EXPECT_DOUBLE_EQ(GNUstepDecodeSmallRepeatingDouble(0x3ff8000000000005ULL),
+                   1.5);
+  {
+    // A double is boxable as "repeating" when its mantissa bits 3-5 equal
+    // its bits 0-2 (boxDouble in Source/NSNumber.m); the box then simply
+    // replaces bits 0-2 with the tag. Make 3.14159 satisfy that and check the
+    // decoder restores it exactly.
+    uint64_t b = bits_of(3.14159);
+    const uint64_t low = b & 7;
+    b = (b & ~0x38ULL) | (low << 3);
+    const uint64_t boxed = (b & ~7ULL) | 3;
+    EXPECT_EQ(bits_of(GNUstepDecodeSmallRepeatingDouble(boxed)), b);
+  }
+  {
+    // Extended: the low three mantissa bits are all equal to bit 3.
+    const uint64_t b = bits_of(0.1) & ~0xfULL; // clear low nibble
+    const uint64_t boxed_zero = b | 2;         // bit 3 = 0 -> low bits 000
+    EXPECT_EQ(bits_of(GNUstepDecodeSmallExtendedDouble(boxed_zero)), b);
+    const uint64_t boxed_one = b | 8 | 2; // bit 3 = 1 -> low bits 111
+    EXPECT_EQ(bits_of(GNUstepDecodeSmallExtendedDouble(boxed_one)), b | 0xf);
+  }
+}
+
+TEST(GNUstepFormattersTest, SmallDateDecodesReferenceDate) {
+  // [NSDate dateWithTimeIntervalSinceReferenceDate: 0] and 1700000000 seconds
+  // after 1970 (2023-11-14 22:13:20 UTC = 721692800 seconds after 2001), as
+  // observed in a live process. The compressed encoding drops low mantissa
+  // bits, so the reference date itself comes back a couple of seconds off -
+  // gnustep-base prints the same "00:00:02".
+  EXPECT_NEAR(GNUstepDecodeSmallDate(0x0880000000000006ULL), 0.0, 3.0);
+  EXPECT_DOUBLE_EQ(GNUstepDecodeSmallDate(0x16ac10a200000006ULL), 721692800.0);
+}

>From c3c6674ea6f011d0f354694e41b336f779fec756 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Mon, 17 Aug 2026 12:24:56 +0100
Subject: [PATCH 23/38] [lldb][GNUstep] Extend the data formatter and class
 object tests

Cover a few more Foundation types in the formatter test, and drop the
ivars from the class object test: what it checks is that a class object
is not presented as an instance of its own class, which its root class's
ivars already demonstrate.

Assisted-by: Claude Opus 5
---
 .../TestGNUstepDataFormatters.py              | 22 +++++++++++++++++++
 .../lang/objc-gnustep/data-formatters/main.m  |  9 ++++++--
 .../Shell/Expr/objc-gnustep-class-objects.m   | 13 +++++------
 3 files changed, 35 insertions(+), 9 deletions(-)

diff --git a/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py b/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
index 726b6889a5795..f1772b037e7f1 100644
--- a/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
@@ -149,6 +149,28 @@ def test_others(self):
             substrs=["(Account *) isa"],
         )
 
+    def test_step_through_dispatch(self):
+        """`step` at a message send lands in the method, not in objc_msgSend:
+        the runtime's step-through plan resolves the implementation. Covers
+        a method inside gnustep-base and one in the program."""
+        self.build()
+        target, process, thread, _ = lldbutil.run_to_source_breakpoint(
+            self, "// step here: Foundation", lldb.SBFileSpec("main.m")
+        )
+        thread.StepInto()
+        frame = thread.GetFrameAtIndex(0)
+        self.assertEqual(frame.GetFunctionName(), "-[GSArray count]")
+        self.assertEqual(frame.GetLineEntry().GetFileSpec().GetFilename(), "GSArray.m")
+        thread.StepOut()
+        # Now the send to the user class.
+        lldbutil.continue_to_source_breakpoint(
+            self, process, "// step here: user class", lldb.SBFileSpec("main.m")
+        )
+        thread.StepInto()
+        frame = thread.GetFrameAtIndex(0)
+        self.assertEqual(frame.GetFunctionName(), "-[Account description]")
+        self.assertEqual(frame.GetLineEntry().GetFileSpec().GetFilename(), "main.m")
+
     def test_api(self):
         """The same summaries come back through the SB API."""
         self.stop_at_end()
diff --git a/lldb/test/API/lang/objc-gnustep/data-formatters/main.m b/lldb/test/API/lang/objc-gnustep/data-formatters/main.m
index f709b62c75055..ff0456818ead8 100644
--- a/lldb/test/API/lang/objc-gnustep/data-formatters/main.m
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/main.m
@@ -89,6 +89,11 @@ int main(int argc, const char *argv[]) {
     Account *account = [[Account alloc] initWithOwner:@"Jane" balance:1234.5];
     id anonymous = account;
 
+    // Message sends to step into (through objc_msgSend): one into
+    // gnustep-base, one into this file.
+    NSUInteger fruitCount = [fruits count];        // step here: Foundation
+    NSString *accountText = [account description]; // step here: user class
+
     NSLog(@"%@ %@ %@ %@ %@ %@ %@", tinyString, constantString, unicodeConstant,
           emptyString, builtString, unicodeBuilt, mutableString);
     NSLog(@"%@ %@ %@ %@ %@ %@ %@ %@ %@", boolYes, smallInt, taggedInt,
@@ -96,8 +101,8 @@ int main(int argc, const char *argv[]) {
           heapDouble);
     NSLog(@"%@ %@ %@ %@ %@ %@ %@ %@ %@ %@", emptyArray, fruits, mutableArray,
           nested, emptyDict, person, mutableDict, colors, mutableSet, counted);
-    NSLog(@"%@ %@ %@ %@ %@ %@ %@", data, epoch, someDate, null, url, account,
-          anonymous);
+    NSLog(@"%@ %@ %@ %@ %@ %@ %@ %lu %@", data, epoch, someDate, null, url,
+          account, anonymous, (unsigned long)fruitCount, accountText);
     return nilObject != nil; // break here
   }
 }
diff --git a/lldb/test/Shell/Expr/objc-gnustep-class-objects.m b/lldb/test/Shell/Expr/objc-gnustep-class-objects.m
index 05c135b72bc4a..32d6152b3b04b 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-class-objects.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-class-objects.m
@@ -29,21 +29,20 @@ + (id)new {
 }
 @end
 
- at interface Base : NSObject {
-  int base_ivar;
-}
+// (No ivars beyond the root class's: clang trips an assertion compiling
+// some GNUstep classes with ivars in +assertions builds, see
+// objc-gnustep-print.m.)
+ at interface Base : NSObject
 @end
 @implementation Base
 @end
 
- at interface Derived : Base {
-  int derived_ivar;
-}
+ at interface Derived : Base
 @end
 @implementation Derived
 @end
 
-// RUN: %lldb -b -o "b objc-gnustep-class-objects.m:54" -o "run" \
+// RUN: %lldb -b -o "b objc-gnustep-class-objects.m:53" -o "run" \
 // RUN:          -o "frame variable -d run-target -T object" \
 // RUN:          -o "frame variable -d run-target -T *object" \
 // RUN:          -o "frame variable -d run-target -T object->isa" \

>From c969a26d776714ed79642c8b23b8067b49e8e4d4 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Mon, 17 Aug 2026 12:00:16 +0100
Subject: [PATCH 24/38] [lldb][GNUstep] Make the nil-receiver stepping check
 portable

Where a step at a message to nil ends up depends on whether the runtime
was built with source line information for its hand-written dispatch
assembly: with it, LLDB has source to step into and stops there; without
it, the step returns to the caller. Neither says anything about this
plugin, so assert what actually matters - that no method was entered -
rather than a specific line in the caller.

Assisted-by: Claude Opus 5
---
 lldb/test/Shell/Expr/objc-gnustep-stepping.m | 12 +++++++-----
 1 file changed, 7 insertions(+), 5 deletions(-)

diff --git a/lldb/test/Shell/Expr/objc-gnustep-stepping.m b/lldb/test/Shell/Expr/objc-gnustep-stepping.m
index d7f0278d46044..3696f96f4f9a9 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-stepping.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-stepping.m
@@ -35,13 +35,15 @@ - (int)twice:(int)value {
 // Stepping at a message send has to run through the runtime's dispatch
 // function and land in the method implementation.
 //
-// RUN: %lldb -b -o "b objc-gnustep-stepping.m:49" -o "run" -o "step" \
+// RUN: %lldb -b -o "b objc-gnustep-stepping.m:51" -o "run" -o "step" \
 // RUN:     -- %t | FileCheck %s --check-prefix=STEP_IN
 //
-// A message to nil dispatches nowhere, so the step must simply move on
-// instead of trying to run to an implementation.
+// A message to nil dispatches nowhere, so the step must not try to run to an
+// implementation. Where it does land depends on whether the runtime build
+// carries source line information for its hand-written dispatch assembly, so
+// the check below only asserts that no method was entered.
 //
-// RUN: %lldb -b -o "b objc-gnustep-stepping.m:51" -o "run" -o "step" \
+// RUN: %lldb -b -o "b objc-gnustep-stepping.m:53" -o "run" -o "step" \
 // RUN:     -- %t | FileCheck %s --check-prefix=STEP_OVER_NIL
 //
 int main() {
@@ -58,4 +60,4 @@ int main() {
 //
 // STEP_OVER_NIL: (lldb) step
 // STEP_OVER_NIL: stop reason = step in
-// STEP_OVER_NIL: main at objc-gnustep-stepping.m:52
+// STEP_OVER_NIL-NOT: -[Doubler twice:]

>From a9d20f37df576ab2c0f1dfcb755cc0dbad700d3d Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Mon, 17 Aug 2026 14:35:15 +0100
Subject: [PATCH 25/38] [lldb] Add a test for Objective-C type completion on
 MSVC targets

Compile an Objective-C interface hierarchy for x86_64-pc-windows-msvc,
link it into a PE image and dump the Clang AST LLDB builds from its
DWARF. Without the guard in CompleteRecordType() this crashes in
CXXRecordDecl::calculateInheritanceModel() on a null pointer.

Requires lld so the object can be linked into an image; lldb-test has
no symbol vendor for a bare COFF object file.

Assisted-by: Claude Opus 5
---
 ...clang-ast-from-dwarf-objc-interface-msvc.m | 38 +++++++++++++++++++
 1 file changed, 38 insertions(+)
 create mode 100644 lldb/test/Shell/SymbolFile/DWARF/clang-ast-from-dwarf-objc-interface-msvc.m

diff --git a/lldb/test/Shell/SymbolFile/DWARF/clang-ast-from-dwarf-objc-interface-msvc.m b/lldb/test/Shell/SymbolFile/DWARF/clang-ast-from-dwarf-objc-interface-msvc.m
new file mode 100644
index 0000000000000..42d6f5bf7d6f9
--- /dev/null
+++ b/lldb/test/Shell/SymbolFile/DWARF/clang-ast-from-dwarf-objc-interface-msvc.m
@@ -0,0 +1,38 @@
+// Completing an Objective-C interface from DWARF must not go through the
+// Microsoft C++ ABI inheritance model code, which only applies to
+// CXXRecordDecls. Before this was guarded, every Objective-C type completion
+// for a target using the Microsoft C++ ABI crashed LLDB.
+// REQUIRES: lld, x86
+
+// RUN: %clang --target=x86_64-pc-windows-msvc -gdwarf -c -o %t.obj -- %s
+// RUN: lld-link -debug:dwarf -nodefaultlib -force:unresolved -entry:main \
+// RUN:     -out:%t.exe -- %t.obj
+// RUN: lldb-test symbols -dump-clang-ast %t.exe | FileCheck %s
+
+// CHECK: ObjCInterfaceDecl {{.*}} Base
+// CHECK-NEXT: ObjCIvarDecl {{.*}} base_ivar 'int'
+// CHECK: ObjCInterfaceDecl {{.*}} Derived
+// CHECK-NEXT: super ObjCInterface {{.*}} 'Base'
+// CHECK-NEXT: ObjCIvarDecl {{.*}} derived_ivar 'int'
+
+__attribute__((objc_root_class))
+ at interface Base {
+  int base_ivar;
+}
+ at end
+
+ at implementation Base
+ at end
+
+ at interface Derived : Base {
+  int derived_ivar;
+}
+ at end
+
+ at implementation Derived
+ at end
+
+int main(void) {
+  Derived *d = 0;
+  return (int)(__SIZE_TYPE__)d;
+}

>From b060aacbb95526796fb8900811978b0c3e7984a2 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 08:58:49 +0100
Subject: [PATCH 26/38] [lldb][GNUstep] Harden the ISA-to-descriptor map

Three latent problems in the class map, none reachable today but all
reachable as soon as anything resolves a class from inside the sweep:

- libobjc2 gives a metaclass the same `name` as its class (class.h), so
  indexing a metaclass by name let a by-name lookup return it instead of
  the class - an interface whose class methods look like instance methods
  and which has no ivars. Metaclasses are still cached by ISA, just not
  indexed by name.

- UpdateISAToDescriptorMapIfNeeded iterated m_pending_modules by reference
  and cleared it at the end, publishing the "up to date" flags only at the
  end too. A reentrant call therefore restarted the sweep and cleared the
  vector the outer loop was walking. Publish the flags up front, take
  ownership of the pending list, and guard against reentry.

- The unit test's ClassSize() counted the three fields after instance_size
  as `long`; they are pointers. Harmless while classes are written 0x100
  apart, wrong as soon as a test lays out an ivar or method list. Replace
  it with the real sizeof(struct objc_class), add the ivars/methods/dtable
  offsets, and pin all of them with a test against the values libobjc2
  hard-codes itself: DTABLE_OFFSET of 64/56/32 (asmconstants.h, enforced
  by a _Static_assert in dtable.c) and sizeof 136/128/68.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 32 +++++++++++---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  3 ++
 .../GNUstepObjCClassDescriptorTest.cpp        | 43 ++++++++++++++++++-
 3 files changed, 71 insertions(+), 7 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index fe70bd20f2916..ae8e4c5751702 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -22,6 +22,7 @@
 #include "lldb/Expression/UtilityFunction.h"
 #include "lldb/Symbol/Symbol.h"
 #include "lldb/Symbol/SymbolContext.h"
+#include "lldb/Symbol/Symtab.h"
 #include "lldb/Symbol/Type.h"
 #include "lldb/Target/ABI.h"
 #include "lldb/Target/ExecutionContext.h"
@@ -37,11 +38,13 @@
 #include "lldb/Utility/RegularExpression.h"
 #include "lldb/ValueObject/ValueObject.h"
 
+#include "llvm/ADT/STLExtras.h"
 #include "llvm/IR/IRBuilder.h"
 #include "llvm/IR/LegacyPassManager.h"
 #include "llvm/IR/Module.h"
 #include "llvm/Pass.h"
 #include "llvm/Support/Regex.h"
+#include "llvm/Support/SaveAndRestore.h"
 
 using namespace lldb;
 using namespace lldb_private;
@@ -772,11 +775,23 @@ void GNUstepObjCRuntime::UpdateISAToDescriptorMapIfNeeded() {
     return;
   }
 
+  // Anything reached from the sweep that resolves a class by ISA or by name
+  // lands back here, so publish the "up to date" state and take ownership of
+  // the pending list up front: a reentrant call must not restart the sweep
+  // and clear the vector the outer loop is walking.
+  if (m_updating_isa_map)
+    return;
+  llvm::SaveAndRestore<bool> updating(m_updating_isa_map, true);
+  m_isa_map_dirty = false;
+  m_isa_to_descriptor_stop_id = stop_id;
+  std::vector<ModuleSP> pending;
+  pending.swap(m_pending_modules);
+
   // The first update has to look at everything already loaded; afterwards only
   // the modules that arrived since need scanning, so a dlopen does not re-walk
   // every symbol table in the process.
   if (m_swept_all_modules) {
-    for (const ModuleSP &module_sp : m_pending_modules)
+    for (const ModuleSP &module_sp : pending)
       AddClassesFromModule(module_sp);
   } else {
     const ModuleList &images = GetTargetRef().GetImages();
@@ -786,10 +801,6 @@ void GNUstepObjCRuntime::UpdateISAToDescriptorMapIfNeeded() {
       AddClassesFromModule(images.GetModuleAtIndex(i));
     m_swept_all_modules = true;
   }
-
-  m_pending_modules.clear();
-  m_isa_map_dirty = false;
-  m_isa_to_descriptor_stop_id = stop_id;
 }
 
 bool GNUstepObjCRuntime::IsRuntimeInternalAddress(lldb::addr_t addr) {
@@ -875,7 +886,16 @@ GNUstepObjCRuntime::GetClassDescriptorFromISA(ObjCISA isa) {
       m_process->shared_from_this(), isa);
   if (!descriptor_sp->IsValid())
     return ClassDescriptorSP();
-  AddClass(isa, descriptor_sp, descriptor_sp->GetClassName().GetCString());
+  // libobjc2 gives a metaclass the same `name` as its class, so indexing one
+  // by name would let a by-name lookup (GetISA, and through it anything that
+  // resolves a class by name) hand back the metaclass: an interface whose
+  // class methods look like instance methods and which has no ivars. Cache
+  // metaclasses by ISA all the same, so they are not re-parsed on every
+  // lookup.
+  if (descriptor_sp->IsMetaclass())
+    AddClass(isa, descriptor_sp);
+  else
+    AddClass(isa, descriptor_sp, descriptor_sp->GetClassName().GetCString());
   return descriptor_sp;
 }
 
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 042ed2e25a48c..aeefba8cccc8c 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -200,6 +200,9 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   /// Set when new modules arrive; cleared once the ISA-to-descriptor map has
   /// been refreshed, so the symbol sweep only reruns after module changes.
   bool m_isa_map_dirty = true;
+
+  /// Guards against a sweep that resolves a class re-entering the sweep.
+  bool m_updating_isa_map = false;
 };
 
 } // namespace lldb_private
diff --git a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
index bc3ace1cc96f7..c19b783a57632 100644
--- a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
+++ b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
@@ -129,7 +129,28 @@ class GNUstepClassDescriptorTest : public ::testing::TestWithParam<DataModel> {
   addr_t InstanceSizeOffset() const {
     return 3 * PointerSize() + 2 * LongSize();
   }
-  addr_t ClassSize() const { return 3 * PointerSize() + 6 * LongSize(); }
+
+  static addr_t AlignUp(addr_t value, uint32_t alignment) {
+    return (value + alignment - 1) & ~static_cast<addr_t>(alignment - 1);
+  }
+
+  /// `ivars` is the first field after the three `long`s, so it picks up
+  /// whatever tail padding the target's alignment requires.
+  addr_t IvarsOffset() const {
+    return AlignUp(3 * PointerSize() + 3 * LongSize(), PointerSize());
+  }
+  addr_t MethodsOffset() const { return IvarsOffset() + PointerSize(); }
+
+  /// sizeof(struct objc_class): the three leading pointers, three `long`s,
+  /// then nine pointers through `extra_data`, `abi_version` (a `long`), and
+  /// `properties`. Cross-check: `dtable` lands at libobjc2's DTABLE_OFFSET
+  /// of 64 / 56 / 32 (asmconstants.h), which DTableOffset() asserts.
+  addr_t DTableOffset() const { return IvarsOffset() + 2 * PointerSize(); }
+  addr_t ClassSize() const {
+    return AlignUp(IvarsOffset() + 9 * PointerSize() + LongSize(),
+                   PointerSize()) +
+           PointerSize();
+  }
 
   /// Lays out a class structure, returning its address.
   addr_t WriteClass(addr_t addr, addr_t metaclass, addr_t superclass,
@@ -168,6 +189,26 @@ constexpr addr_t g_super_name_addr = FakeProcess::g_base_addr + 0x480;
 /// A well-formed class parses on every data model. This is what proves the
 /// field offsets track the target's `long` size rather than its pointer size:
 /// on Windows the instance size sits four bytes earlier than on Linux.
+// libobjc2 hard-codes the offset of `dtable` per data model in
+// asmconstants.h and pins it with a _Static_assert in dtable.c, so it is the
+// one field whose position is guaranteed by the runtime itself. Checking the
+// layout helpers against it catches a wrong `long` width, which would
+// otherwise silently shift every field after the class name.
+TEST_P(GNUstepClassDescriptorTest, LayoutMatchesLibobjc2) {
+  const bool is_lp64 = PointerSize() == 8 && LongSize() == 8;
+  const bool is_llp64 = PointerSize() == 8 && LongSize() == 4;
+  const addr_t expected_dtable = is_lp64 ? 64 : (is_llp64 ? 56 : 32);
+  const addr_t expected_size = is_lp64 ? 136 : (is_llp64 ? 128 : 68);
+
+  EXPECT_EQ(DTableOffset(), expected_dtable);
+  EXPECT_EQ(ClassSize(), expected_size);
+  // The fields this descriptor actually reads, for the same three models.
+  EXPECT_EQ(InfoOffset(), is_lp64 ? 32u : (is_llp64 ? 28u : 16u));
+  EXPECT_EQ(InstanceSizeOffset(), is_lp64 ? 40u : (is_llp64 ? 32u : 20u));
+  EXPECT_EQ(IvarsOffset(), is_lp64 ? 48u : (is_llp64 ? 40u : 24u));
+  EXPECT_EQ(MethodsOffset(), is_lp64 ? 56u : (is_llp64 ? 48u : 28u));
+}
+
 TEST_P(GNUstepClassDescriptorTest, ParsesWellFormedClass) {
   FakeProcess &process = GetProcess();
   process.WriteCString(g_name_addr, "Derived");

>From e57d8b77e8a82d9ae190bafbd7517d4811fd5ac2 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 08:59:45 +0100
Subject: [PATCH 27/38] [lldb][GNUstep] Implement `po` without depending on
 gnustep-base

`po` resolved gnustep-base's `_NSPrintForDebugger` by symbol and called it.
gnustep-base defines that hook (Source/NSDebug.m) but does not dllexport it
from the MSVC DLL, so on Windows there was nothing to call and the test
suite carried a shim.m defining it in the test executable.

The hook's entire body is:

  if (object && [object respondsToSelector: @selector(description)])
    return [[object description] UTF8String];
  return NULL;

and class_respondsToSelector, objc_msg_lookup, sel_registerName and
object_getClass are all OBJC_PUBLIC in libobjc2. So build that as a utility
function instead of resolving the symbol. This is not an approximation of
_NSPrintForDebugger - it is the same function.

Doing it in the runtime rather than through the symbol also means `po` no
longer differs by platform: the symbol is exported from libgnustep-base.so
but not from the Windows DLL, so keeping it as a "fast path" would have left
Linux and Windows on different code paths computing the same thing.

Two things fall out. `po` now works against a bare libobjc2 with no
Foundation at all, for any object implementing -description; and the
resolved-address cache that was never invalidated on module load - so `po`
stayed broken after a dlopen of gnustep-base - is gone rather than fixed.

object_getClass is used rather than a raw isa read so tagged pointers
dispatch correctly, which the added tests cover.

shim.m is deleted. The Shell test's hermetic _NSPrintForDebugger stand-in is
replaced by a real -description/-UTF8String pair, so it now exercises the
path that ships.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 157 +++++++++++++-----
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  22 ++-
 .../TestGNUstepDataFormatters.py              |  31 ++++
 lldb/test/Shell/Expr/objc-gnustep-print.m     |  39 ++++-
 4 files changed, 192 insertions(+), 57 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index ae8e4c5751702..434707923ecd2 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -247,20 +247,102 @@ GNUstepObjCRuntime::GNUstepObjCRuntime(Process *process)
   ReadObjCLibraryIfNeeded(process->GetTarget().GetImages());
 }
 
-Address *GNUstepObjCRuntime::GetPrintForDebuggerAddr() {
-  if (!m_print_for_debugger_addr_up) {
-    SymbolContextList sc_list;
-    GetTargetRef().GetImages().FindSymbolsWithNameAndType(
-        ConstString("_NSPrintForDebugger"), eSymbolTypeCode, sc_list);
-    for (const SymbolContext &sc : sc_list) {
-      if (!sc.symbol)
-        continue;
-      m_print_for_debugger_addr_up =
-          std::make_unique<Address>(sc.symbol->GetAddress());
-      break;
-    }
+FunctionCaller *
+GNUstepObjCRuntime::GetObjectDescriptionCaller(ExecutionContext &exe_ctx) {
+  // gnustep-base's _NSPrintForDebugger (Source/NSDebug.m) is exactly:
+  //
+  //   if (object && [object respondsToSelector: @selector(description)])
+  //     return [[object description] UTF8String];
+  //   return NULL;
+  //
+  // Every runtime function it needs is OBJC_PUBLIC in libobjc2, so rather
+  // than depending on Foundation exporting that hook - it does from
+  // libgnustep-base.so but not from the MSVC gnustep-base DLL, which is why
+  // this used to need a shim on Windows - reproduce it here. As a bonus this
+  // makes `po` work against a bare libobjc2 with no Foundation at all, for
+  // any object implementing -description.
+  //
+  // object_getClass() rather than a raw isa read, because it also resolves
+  // tagged pointers (classForObject) and skips libobjc2's hidden classes.
+  static const char *g_description_name = "$__lldb_gnustep_object_description";
+  static const char *g_description_code =
+      "void *object_getClass(void *object);\n"
+      "void *sel_registerName(const char *name);\n"
+      "signed char class_respondsToSelector(void *cls, void *sel);\n"
+      "void *objc_msg_lookup(void *receiver, void *selector);\n"
+      "\n"
+      "const char *$__lldb_gnustep_object_description(void *object) {\n"
+      "  if (!object)\n"
+      "    return 0;\n"
+      "  void *description_sel = sel_registerName(\"description\");\n"
+      "  if (!class_respondsToSelector(object_getClass(object), "
+      "description_sel))\n"
+      "    return 0;\n"
+      "  void *(*description_imp)(void *, void *) =\n"
+      "      (void *(*)(void *, void *))objc_msg_lookup(object, "
+      "description_sel);\n"
+      "  if (!description_imp)\n"
+      "    return 0;\n"
+      "  void *description = description_imp(object, description_sel);\n"
+      "  if (!description)\n"
+      "    return 0;\n"
+      "  void *utf8_sel = sel_registerName(\"UTF8String\");\n"
+      "  if (!class_respondsToSelector(object_getClass(description), "
+      "utf8_sel))\n"
+      "    return 0;\n"
+      "  const char *(*utf8_imp)(void *, void *) =\n"
+      "      (const char *(*)(void *, void *))objc_msg_lookup(description, "
+      "utf8_sel);\n"
+      "  if (!utf8_imp)\n"
+      "    return 0;\n"
+      "  return utf8_imp(description, utf8_sel);\n"
+      "}\n";
+
+  std::lock_guard<std::mutex> guard(m_description_mutex);
+  if (m_description_caller)
+    return m_description_caller;
+  // Don't pay for compiling the wrapper again once it is known not to work
+  // in this process.
+  if (m_description_failed)
+    return nullptr;
+
+  Log *log = GetLog(LLDBLog::Expressions);
+
+  auto utility_fn_or_error = exe_ctx.GetTargetRef().CreateUtilityFunction(
+      g_description_code, g_description_name, eLanguageTypeC, exe_ctx);
+  if (!utility_fn_or_error) {
+    LLDB_LOG_ERROR(log, utility_fn_or_error.takeError(),
+                   "failed to build object description utility: {0}");
+    m_description_failed = true;
+    return nullptr;
+  }
+  m_description_utility_up = std::move(*utility_fn_or_error);
+
+  TypeSystemClangSP scratch_ts_sp =
+      ScratchTypeSystemClang::GetForTarget(GetTargetRef());
+  if (!scratch_ts_sp) {
+    m_description_failed = true;
+    return nullptr;
   }
-  return m_print_for_debugger_addr_up.get();
+
+  Value void_ptr_value;
+  void_ptr_value.SetValueType(Value::ValueType::Scalar);
+  void_ptr_value.SetCompilerType(
+      scratch_ts_sp->GetBasicType(eBasicTypeVoid).GetPointerType());
+  ValueList args;
+  args.PushValue(void_ptr_value);
+
+  Status error;
+  m_description_caller = m_description_utility_up->MakeFunctionCaller(
+      scratch_ts_sp->GetCStringType(true), args, exe_ctx.GetThreadSP(), error);
+  if (error.Fail()) {
+    LLDB_LOG(log, "failed to make object description caller: {0}",
+             error.AsCString());
+    m_description_caller = nullptr;
+    m_description_failed = true;
+    return nullptr;
+  }
+  return m_description_caller;
 }
 
 llvm::Error GNUstepObjCRuntime::GetObjectDescription(Stream &str,
@@ -292,13 +374,6 @@ llvm::Error GNUstepObjCRuntime::GetObjectDescription(Stream &str,
 llvm::Error
 GNUstepObjCRuntime::GetObjectDescription(Stream &strm, Value &value,
                                          ExecutionContextScope *exe_scope) {
-  // The libobjc2 runtime alone cannot describe objects; the hook lives in
-  // gnustep-base (Foundation), just like on Darwin.
-  Address *function_address = GetPrintForDebuggerAddr();
-  if (!function_address)
-    return llvm::createStringError(
-        "gnustep-base is not loaded: _NSPrintForDebugger not found");
-
   ExecutionContext exe_ctx;
   exe_scope->CalculateExecutionContext(exe_ctx);
   Process *process = exe_ctx.GetProcessPtr();
@@ -338,25 +413,18 @@ GNUstepObjCRuntime::GetObjectDescription(Stream &strm, Value &value,
   DiagnosticManager diagnostics;
   lldb::addr_t wrapper_struct_addr = LLDB_INVALID_ADDRESS;
 
-  if (!m_print_object_caller_up) {
-    Status error;
-    m_print_object_caller_up.reset(
-        exe_scope->CalculateTarget()->GetFunctionCallerForLanguage(
-            eLanguageTypeC, return_compiler_type, *function_address,
-            arg_value_list, "gnustep-object-description", error));
-    if (error.Fail()) {
-      m_print_object_caller_up.reset();
-      return llvm::createStringError(
-          llvm::Twine("could not get function runner to call "
-                      "_NSPrintForDebugger: ") +
-          error.AsCString());
-    }
-    m_print_object_caller_up->InsertFunction(exe_ctx, wrapper_struct_addr,
-                                             diagnostics);
-  } else {
-    m_print_object_caller_up->WriteFunctionArguments(
-        exe_ctx, wrapper_struct_addr, arg_value_list, diagnostics);
-  }
+  // Building the caller needs a thread, so this has to follow the frame
+  // selection above. MakeFunctionCaller already compiled the wrapper into
+  // the inferior, so only a fresh argument struct is needed per call.
+  FunctionCaller *caller = GetObjectDescriptionCaller(exe_ctx);
+  if (!caller)
+    return llvm::createStringError(
+        "could not build the object description function");
+
+  if (!caller->WriteFunctionArguments(exe_ctx, wrapper_struct_addr,
+                                      arg_value_list, diagnostics))
+    return llvm::createStringError(
+        "could not write the object description arguments");
 
   EvaluateExpressionOptions options;
   options.SetUnwindOnError(true);
@@ -366,21 +434,26 @@ GNUstepObjCRuntime::GetObjectDescription(Stream &strm, Value &value,
   options.SetTimeout(process->GetUtilityExpressionTimeout());
   options.SetIsForUtilityExpr(true);
 
-  ExpressionResults results = m_print_object_caller_up->ExecuteFunction(
+  ExpressionResults results = caller->ExecuteFunction(
       exe_ctx, &wrapper_struct_addr, options, diagnostics, ret);
   if (results != eExpressionCompleted)
     return llvm::createStringError(
-        "could not evaluate _NSPrintForDebugger in the inferior");
+        "could not evaluate the object description in the inferior");
 
   addr_t result_ptr = ret.GetScalar().ULongLong(LLDB_INVALID_ADDRESS);
   if (result_ptr == 0 || result_ptr == LLDB_INVALID_ADDRESS)
     return llvm::createStringError("object returned no description");
 
+  // -description returns inferior memory, so a string that never terminates
+  // would otherwise be read until the host runs out of it. No real
+  // description approaches this.
+  static constexpr size_t g_max_description_length = 1024 * 1024;
+
   char buf[512];
   size_t cstr_len = 0;
   size_t full_buffer_len = sizeof(buf) - 1;
   size_t curr_len = full_buffer_len;
-  while (curr_len == full_buffer_len) {
+  while (curr_len == full_buffer_len && cstr_len < g_max_description_length) {
     Status error;
     curr_len = process->ReadCStringFromMemory(result_ptr + cstr_len, buf,
                                               sizeof(buf), error);
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index aeefba8cccc8c..7ee270991db80 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -165,16 +165,24 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   /// symbol is not named after the class).
   lldb::TypeSP LookupClassTypeInDebugInfo(ConstString class_name);
 
-  /// Address of gnustep-base's `const char *_NSPrintForDebugger(id)`, the
-  /// same debugger hook AppleObjCRuntime uses. Resolved lazily; nullptr when
-  /// gnustep-base is not loaded in the inferior.
-  Address *GetPrintForDebuggerAddr();
+  /// Lazily-built FunctionCaller for a utility function that reproduces
+  /// gnustep-base's `_NSPrintForDebugger` (NSDebug.m) using nothing but
+  /// libobjc2's exported API, so `po` works whether or not Foundation is
+  /// loaded and regardless of whether gnustep-base exports that hook - it
+  /// does on ELF but not from the MSVC DLL. Returns nullptr on failure; the
+  /// caller is owned by the utility function and stays valid for the
+  /// runtime's life.
+  FunctionCaller *GetObjectDescriptionCaller(ExecutionContext &exe_ctx);
 
   lldb::ModuleSP m_objc_module_sp;
 
-  std::unique_ptr<Address> m_print_for_debugger_addr_up;
-
-  std::unique_ptr<FunctionCaller> m_print_object_caller_up;
+  /// Utility function wrapping the -description/-UTF8String pair; owns
+  /// m_description_caller. Guarded by m_description_mutex, which also
+  /// latches a failed build so it is not retried on every `po`.
+  std::mutex m_description_mutex;
+  std::unique_ptr<UtilityFunction> m_description_utility_up;
+  FunctionCaller *m_description_caller = nullptr;
+  bool m_description_failed = false;
 
   /// Utility function wrapping objc_msg_lookup; owns m_msg_lookup_caller.
   /// Guarded by m_msg_lookup_mutex, which also latches a failed build so it
diff --git a/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py b/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
index f1772b037e7f1..6c71fa24ed360 100644
--- a/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
@@ -189,3 +189,34 @@ def test_api(self):
         pair = person.GetChildAtIndex(0)
         self.assertEqual(pair.GetChildMemberWithName("key").GetName(), "key")
         self.assertEqual(pair.GetNumChildren(), 2)
+
+    def test_po(self):
+        """`po` describes objects without gnustep-base exporting a hook.
+
+        LLDB reproduces _NSPrintForDebugger (Source/NSDebug.m) from libobjc2's
+        exported runtime API rather than resolving that symbol, which
+        gnustep-base defines but does not dllexport on Windows. Nothing in
+        this directory supplies it, so these assertions fail if that
+        dependency ever comes back.
+        """
+        self.stop_at_end()
+        # A user class reached through its own -description.
+        self.expect("po account", substrs=["<Account Jane: 1234.5>"])
+        # Tagged pointers: the receiver's class comes from the pointer bits,
+        # not from a first-word read, so these only work if the description
+        # call dispatches through the runtime rather than dereferencing.
+        self.expect("po tinyString", substrs=["Hi"])
+        self.expect("po taggedInt", substrs=["123456"])
+        # A container, whose description recurses through its elements.
+        self.expect("po fruits", substrs=["apple", "banana", "cherry"])
+        # nil must not run anything in the inferior.
+        self.expect("po nilObject", substrs=["nil"])
+        # None of the above may have fallen back to `p`.
+        for expr in ("account", "tinyString", "taggedInt", "fruits"):
+            self.expect("po " + expr, matching=False, substrs=["was unsuccessful"])
+
+    def test_po_is_repeatable(self):
+        """Repeated `po` on the same object keeps working."""
+        self.stop_at_end()
+        for _ in range(3):
+            self.expect("po account", substrs=["<Account Jane: 1234.5>"])
diff --git a/lldb/test/Shell/Expr/objc-gnustep-print.m b/lldb/test/Shell/Expr/objc-gnustep-print.m
index 873ac3092df1f..04b9f693f8d0e 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-print.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-print.m
@@ -105,20 +105,43 @@ int main() {
   return 0;
 }
 
-// LLDB resolves `_NSPrintForDebugger` by symbol in any loaded module and
-// calls it to implement `po`. In a full GNUstep environment gnustep-base
-// provides it; this hermetic stand-in exercises the same machinery.
-const char *_NSPrintForDebugger(id object) {
-  if (!object)
-    return 0;
-  return object_getClassName(object);
+// `po` sends -description and then -UTF8String to the result, which is what
+// gnustep-base's _NSPrintForDebugger does (Source/NSDebug.m), reached
+// through libobjc2's exported runtime API rather than through that symbol.
+// Nothing below needs Foundation, so this also covers `po` against a bare
+// runtime - a configuration where gnustep-base's hook does not exist at all.
+ at interface Str : NSObject {
+  const char *_bytes;
 }
++ (id)withBytes:(const char *)bytes;
+- (const char *)UTF8String;
+ at end
+ at implementation Str
++ (id)withBytes:(const char *)bytes {
+  Str *str = [Str new];
+  str->_bytes = bytes;
+  return str;
+}
+- (const char *)UTF8String {
+  return _bytes;
+}
+ at end
+
+ at interface TestObj (Description)
+- (id)description;
+ at end
+ at implementation TestObj (Description)
+- (id)description {
+  return [Str withBytes:"<TestObj: described>"];
+}
+ at end
 
 // RUN: %lldb -b -o "b objc-gnustep-print.m:105" -o "run" -o "po t" \
 // RUN:     -- %t | FileCheck %s --check-prefix=PO
 //
 // PO: (lldb) po t
-// PO: TestObj
+// PO: <TestObj: described>
+// PO-NOT: warning: `po` was unsuccessful
 
 // Stepping at a message send goes through the objc_msgSend trampoline into
 // the method implementation.

>From 672629e7f5dbd9db974b67d0db529c4e1b4a63c9 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 09:02:04 +0100
Subject: [PATCH 28/38] [lldb][GNUstep] Step over a message to nil instead of
 into the runtime

A message to nil dispatches nowhere, so GetStepThroughTrampolinePlan had
nothing to run to and declined to provide a plan at all. That leaves the
thread stopped on the dispatch function's first instruction. When the
runtime carries no line information for its hand-written assembly this is
invisible - LLDB steps back out on its own because there is no source - but
a libobjc2 built from source does have it, and the user lands in
objc_msgSend.x86-64.S with no way to tell why.

Return a step-out plan instead, which is where a nil send returns to and
what the user asked to step over.

Only the nil case is affected. A non-nil send already matched the dispatch
entry point on its first instruction and ran to the implementation, verified
against a libobjc2 built with debug info covering objc_msgSend.S; widening
that match was considered and rejected, since the receiver and selector are
read from argument registers and are only valid at the entry.

The existing test could only assert that no method was entered, because
where a nil send landed depended on how the runtime was built. It now
asserts the frame's source location, which is what distinguishes "back in
the caller" from "stranded in the runtime" - the function name alone looks
plausible either way.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 22 ++++++++++++++++---
 lldb/test/Shell/Expr/objc-gnustep-stepping.m  | 20 ++++++++++-------
 2 files changed, 31 insertions(+), 11 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 434707923ecd2..339b1df0202d9 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -31,6 +31,7 @@
 #include "lldb/Target/Target.h"
 #include "lldb/Target/Thread.h"
 #include "lldb/Target/ThreadPlanRunToAddress.h"
+#include "lldb/Target/ThreadPlanStepOut.h"
 #include "lldb/Utility/ArchSpec.h"
 #include "lldb/Utility/ConstString.h"
 #include "lldb/Utility/LLDBLog.h"
@@ -689,9 +690,24 @@ GNUstepObjCRuntime::GetStepThroughTrampolinePlan(Thread &thread,
       return {};
   }
 
-  // A message to nil does not dispatch anywhere.
-  if (receiver == 0 || receiver == LLDB_INVALID_ADDRESS)
-    return {};
+  // A message to nil does not dispatch anywhere, so there is no
+  // implementation to run to. Declining to provide a plan at all would leave
+  // the thread stopped on the dispatch function's first instruction: harmless
+  // when the runtime carries no line information for its hand-written
+  // assembly, but with a runtime built from source it drops the user into
+  // objc_msgSend.S. Step back out to the sender instead, which is where a nil
+  // send returns to and what the user asked to step over.
+  if (receiver == 0 || receiver == LLDB_INVALID_ADDRESS) {
+    const bool continue_to_next_branch = true;
+    const bool gather_return_value = false;
+    auto step_out_sp = std::make_shared<ThreadPlanStepOut>(
+        thread, stop_others, eVoteNo, eVoteNoOpinion, /*frame_idx=*/0,
+        continue_to_next_branch, gather_return_value);
+    // Nothing further should be stepped through on the way out; the enclosing
+    // step-in plan decides where to stop once we are back in the sender.
+    step_out_sp->ClearShouldStopHereCallbacks();
+    return step_out_sp;
+  }
 
   // Consult the method cache before running anything in the inferior.
   // Tagged pointers skip the cache: their ISA is not the object's first word.
diff --git a/lldb/test/Shell/Expr/objc-gnustep-stepping.m b/lldb/test/Shell/Expr/objc-gnustep-stepping.m
index 3696f96f4f9a9..17cfde7b05bb3 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-stepping.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-stepping.m
@@ -35,22 +35,24 @@ - (int)twice:(int)value {
 // Stepping at a message send has to run through the runtime's dispatch
 // function and land in the method implementation.
 //
-// RUN: %lldb -b -o "b objc-gnustep-stepping.m:51" -o "run" -o "step" \
+// RUN: %lldb -b -o "b objc-gnustep-stepping.m:53" -o "run" -o "step" \
 // RUN:     -- %t | FileCheck %s --check-prefix=STEP_IN
 //
 // A message to nil dispatches nowhere, so the step must not try to run to an
-// implementation. Where it does land depends on whether the runtime build
-// carries source line information for its hand-written dispatch assembly, so
-// the check below only asserts that no method was entered.
+// implementation - and must not strand the user in the runtime's dispatch
+// assembly either. A runtime built from source has line information for that
+// assembly, so without stepping back out the debugger would stop in
+// objc_msgSend.S; asserting on the frame's source location is what catches
+// that, since the function name alone looks plausible either way.
 //
-// RUN: %lldb -b -o "b objc-gnustep-stepping.m:53" -o "run" -o "step" \
-// RUN:     -- %t | FileCheck %s --check-prefix=STEP_OVER_NIL
+// RUN: %lldb -b -o "b objc-gnustep-stepping.m:55" -o "run" -o "step" \
+// RUN:     -o "frame info" -- %t | FileCheck %s --check-prefix=STEP_OVER_NIL
 //
 int main() {
   Doubler *doubler = [Doubler new];
-  int value = [doubler twice:21];
+  int value = [doubler twice:21]; // line 53: STEP_IN breaks here
   Doubler *nothing = (Doubler *)0;
-  int none = [nothing twice:1];
+  int none = [nothing twice:1]; // line 55: STEP_OVER_NIL breaks here
   return value + none;
 }
 //
@@ -61,3 +63,5 @@ int main() {
 // STEP_OVER_NIL: (lldb) step
 // STEP_OVER_NIL: stop reason = step in
 // STEP_OVER_NIL-NOT: -[Doubler twice:]
+// STEP_OVER_NIL: (lldb) frame info
+// STEP_OVER_NIL: frame #0: {{.*}}main at objc-gnustep-stepping.m:{{[0-9]+}}

>From 73a288ae1888c1bc054d840ce25979c2fd258690 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 09:09:38 +0100
Subject: [PATCH 29/38] [lldb][GNUstep] Add Objective-C exception breakpoints
 and exception objects

The runtime had a throw-site resolver but nothing wired to it, so
`breakpoint set -E objc` produced a breakpoint that stopped with no
explanation, `thread exception` printed nothing, and lldb-dap's exception
view was empty.

Add the pieces around it:

- A frame recognizer on objc_exception_throw. `void
  objc_exception_throw(id)` is the entry point on all three of libobjc2's
  exception back-ends, so argument 0 is the thrown object regardless of
  whether the platform unwinds via Itanium, __cxa_* over SEH, or native MSVC
  exceptions. This is what produces both "stop reason = hit Objective-C
  exception" and the synthesized `exception` argument, and it is what
  Thread::GetCurrentException consults first.

- SetExceptionBreakpoints/Clear/AreSet/ExplainStop over that resolver.

- GetBreakpointExceptionPrecondition, which was simply not passed to
  RegisterPlugin, so `breakpoint set -E objc -O <class>` silently did
  nothing.

- Catch breakpoints, which Apple's runtime still has a FIXME for. libobjc2
  exports objc_begin_catch wherever exceptions unwind through the Itanium
  ABI; the MSVC build raises a native SEH exception instead and catch is a
  __CxxFrameHandler3 funclet with no symbol to break on. The resolver asks
  the loaded runtime module rather than the target triple, so a MinGW build
  on Windows still gets catch breakpoints.

- GetExceptionObjectForThread. At the throw itself the recognizer already
  has the object, which works everywhere; a stop further into the unwind
  falls back to the C++ runtime and confirms the thrown object really is an
  NSException before claiming it, since an ObjC exception raised through the
  Itanium ABI is otherwise indistinguishable from a C++ one.

The recognizer is registered without a module filter because the runtime
library's file name differs by platform (libobjc.so.4.6, objc.dll); an empty
module ConstString matches any module.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 195 +++++++++++++++++-
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  18 ++
 .../test/Shell/Expr/objc-gnustep-exceptions.m |  98 +++++++++
 3 files changed, 303 insertions(+), 8 deletions(-)
 create mode 100644 lldb/test/Shell/Expr/objc-gnustep-exceptions.m

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 339b1df0202d9..57f613c4a6435 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -28,6 +28,8 @@
 #include "lldb/Target/ExecutionContext.h"
 #include "lldb/Target/Process.h"
 #include "lldb/Target/RegisterContext.h"
+#include "lldb/Target/StackFrameRecognizer.h"
+#include "lldb/Target/StopInfo.h"
 #include "lldb/Target/Target.h"
 #include "lldb/Target/Thread.h"
 #include "lldb/Target/ThreadPlanRunToAddress.h"
@@ -38,6 +40,8 @@
 #include "lldb/Utility/Log.h"
 #include "lldb/Utility/RegularExpression.h"
 #include "lldb/ValueObject/ValueObject.h"
+#include "lldb/ValueObject/ValueObjectConstResult.h"
+#include "lldb/ValueObject/ValueObjectList.h"
 
 #include "llvm/ADT/STLExtras.h"
 #include "llvm/IR/IRBuilder.h"
@@ -156,6 +160,81 @@ class GNUstepObjCSelectorRegistrationPass : public llvm::ModulePass {
 };
 
 char GNUstepObjCSelectorRegistrationPass::ID = 0;
+
+/// Presents the object being thrown as an `exception` argument on a frame
+/// stopped at libobjc2's throw entry point, which is what `thread exception`
+/// and lldb-dap's exception view read.
+class GNUstepObjCExceptionRecognizedStackFrame : public RecognizedStackFrame {
+public:
+  explicit GNUstepObjCExceptionRecognizedStackFrame(StackFrameSP frame_sp) {
+    ThreadSP thread_sp = frame_sp->GetThread();
+    if (!thread_sp)
+      return;
+    ProcessSP process_sp = thread_sp->GetProcess();
+    if (!process_sp)
+      return;
+
+    const ABISP &abi = process_sp->GetABI();
+    if (!abi)
+      return;
+
+    TypeSystemClangSP scratch_ts_sp =
+        ScratchTypeSystemClang::GetForTarget(process_sp->GetTarget());
+    if (!scratch_ts_sp)
+      return;
+    CompilerType void_ptr_type =
+        scratch_ts_sp->GetBasicType(eBasicTypeVoid).GetPointerType();
+
+    // `void objc_exception_throw(id object)` on every libobjc2 exception
+    // back-end, so the thrown object is argument 0 regardless of how the
+    // unwinder is implemented on this platform.
+    ValueList args;
+    Value input_value;
+    input_value.SetCompilerType(void_ptr_type);
+    args.PushValue(input_value);
+    if (!abi->GetArgumentValues(*thread_sp, args))
+      return;
+
+    Value value(args.GetValueAtIndex(0)->GetScalar().ULongLong());
+    value.SetCompilerType(void_ptr_type);
+    m_exception_sp = ValueObjectConstResult::Create(frame_sp.get(), value,
+                                                    ConstString("exception"));
+    m_exception_sp = ValueObjectRecognizerSynthesizedValue::Create(
+        *m_exception_sp, eValueTypeVariableArgument);
+    m_exception_sp = m_exception_sp->GetDynamicValue(eDynamicDontRunTarget);
+
+    m_arguments = std::make_shared<ValueObjectList>();
+    m_arguments->Append(m_exception_sp);
+    m_stop_desc = "hit Objective-C exception";
+  }
+
+  ValueObjectSP GetExceptionObject() override { return m_exception_sp; }
+
+private:
+  ValueObjectSP m_exception_sp;
+};
+
+class GNUstepObjCExceptionThrowFrameRecognizer : public StackFrameRecognizer {
+  RecognizedStackFrameSP RecognizeFrame(StackFrameSP frame) override {
+    return std::make_shared<GNUstepObjCExceptionRecognizedStackFrame>(frame);
+  }
+  std::string GetName() override {
+    return "GNUstep ObjC Exception Throw StackFrame Recognizer";
+  }
+};
+
+/// The runtime library's file name differs by platform (libobjc.so.4.6,
+/// objc.dll, ...), so the recognizer is registered without a module filter
+/// and matched on the symbol alone; an empty module ConstString matches any
+/// module (StackFrameRecognizer.cpp).
+void RegisterGNUstepObjCExceptionRecognizer(Process *process) {
+  static const std::vector<ConstString> g_symbols = {
+      ConstString("objc_exception_throw")};
+  process->GetTarget().GetFrameRecognizerManager().AddRecognizer(
+      std::make_shared<GNUstepObjCExceptionThrowFrameRecognizer>(),
+      ConstString(), g_symbols, Mangled::NamePreference::ePreferDemangled,
+      /*first_instruction_only=*/true);
+}
 } // namespace
 
 char GNUstepObjCRuntime::ID = 0;
@@ -163,7 +242,8 @@ char GNUstepObjCRuntime::ID = 0;
 void GNUstepObjCRuntime::Initialize() {
   PluginManager::RegisterPlugin(
       GetPluginNameStatic(), "GNUstep Objective-C Language Runtime - libobjc2",
-      CreateInstance);
+      CreateInstance, /*command_callback=*/nullptr,
+      GetBreakpointExceptionPrecondition);
 }
 
 void GNUstepObjCRuntime::Terminate() {
@@ -236,6 +316,7 @@ LanguageRuntime *GNUstepObjCRuntime::CreateInstance(Process *process,
   if (!FindGNUstepObjCRuntimeModule(target.GetImages()))
     return nullptr;
 
+  RegisterGNUstepObjCExceptionRecognizer(process);
   return new GNUstepObjCRuntime(process);
 }
 
@@ -599,15 +680,113 @@ GNUstepObjCRuntime::FixUpDynamicType(const TypeAndOrName &type_and_or_name,
 BreakpointResolverSP
 GNUstepObjCRuntime::CreateExceptionResolver(const BreakpointSP &bkpt,
                                             bool catch_bp, bool throw_bp) {
-  BreakpointResolverSP resolver_sp;
-
+  std::vector<std::string> names;
   if (throw_bp)
-    resolver_sp = std::make_shared<BreakpointResolverName>(
-        bkpt, "objc_exception_throw", eFunctionNameTypeBase,
-        eLanguageTypeUnknown, Breakpoint::Exact, 0,
-        /*offset_is_insn_count = */ false, eLazyBoolNo);
+    names.emplace_back("objc_exception_throw");
+
+  // Unlike Apple's runtime, libobjc2 has an entry point for entering a
+  // handler - but only where exceptions unwind through the Itanium ABI. The
+  // MSVC build raises a native SEH exception instead (eh_win32_msvc.cc) and
+  // catch is a __CxxFrameHandler3 funclet with no symbol to break on, so ask
+  // the runtime module rather than the target triple.
+  if (catch_bp && ModuleDefinesFunction(m_objc_module_sp, "objc_begin_catch"))
+    names.emplace_back("objc_begin_catch");
+
+  if (names.empty())
+    return {};
+
+  return std::make_shared<BreakpointResolverName>(
+      bkpt, names, eFunctionNameTypeBase, eLanguageTypeUnknown,
+      /*offset=*/0, /*skip_prologue=*/eLazyBoolNo);
+}
+
+void GNUstepObjCRuntime::SetExceptionBreakpoints() {
+  if (!m_process)
+    return;
+
+  const bool catch_bp = false;
+  const bool throw_bp = true;
+  const bool is_internal = true;
+
+  if (!m_objc_exception_bp_sp) {
+    m_objc_exception_bp_sp = LanguageRuntime::CreateExceptionBreakpoint(
+        m_process->GetTarget(), GetLanguageType(), catch_bp, throw_bp,
+        is_internal);
+    if (m_objc_exception_bp_sp)
+      m_objc_exception_bp_sp->SetBreakpointKind("ObjC exception");
+  } else {
+    m_objc_exception_bp_sp->SetEnabled(true);
+  }
+}
 
-  return resolver_sp;
+void GNUstepObjCRuntime::ClearExceptionBreakpoints() {
+  if (!m_process)
+    return;
+
+  if (m_objc_exception_bp_sp)
+    m_objc_exception_bp_sp->SetEnabled(false);
+}
+
+bool GNUstepObjCRuntime::ExceptionBreakpointsAreSet() {
+  return m_objc_exception_bp_sp && m_objc_exception_bp_sp->IsEnabled();
+}
+
+bool GNUstepObjCRuntime::ExceptionBreakpointsExplainStop(
+    StopInfoSP stop_reason) {
+  if (!m_process || !m_objc_exception_bp_sp)
+    return false;
+
+  if (!stop_reason || stop_reason->GetStopReason() != eStopReasonBreakpoint)
+    return false;
+
+  const uint64_t break_site_id = stop_reason->GetValue();
+  return m_process->GetBreakpointSiteList().StopPointSiteContainsBreakpoint(
+      break_site_id, m_objc_exception_bp_sp->GetID());
+}
+
+ValueObjectSP
+GNUstepObjCRuntime::GetExceptionObjectForThread(ThreadSP thread_sp) {
+  if (!thread_sp || !thread_sp->SafeToCallFunctions())
+    return {};
+
+  // libobjc2 picks one of three exception back-ends at build time
+  // (CMakeLists.txt): Itanium unwinding on ELF and Mach-O, __cxa_* over SEH
+  // on MinGW, and native MSVC exceptions on Windows. Only the first two are
+  // reachable through the C++ runtime, so recovering the object mid-unwind
+  // is inherently platform-specific.
+  //
+  // Stopped at the throw itself, though, the object is simply argument 0 -
+  // which holds on every back-end, and is where the frame recognizer already
+  // presents it. Prefer that, and fall back to the C++ runtime for a stop
+  // further into the unwind.
+  if (StackFrameSP frame_sp = thread_sp->GetStackFrameAtIndex(0)) {
+    if (RecognizedStackFrameSP recognized_sp = frame_sp->GetRecognizedFrame()) {
+      if (ValueObjectSP exception_sp = recognized_sp->GetExceptionObject())
+        return exception_sp;
+    }
+  }
+
+  auto *cpp_runtime = m_process->GetLanguageRuntime(eLanguageTypeC_plus_plus);
+  if (!cpp_runtime)
+    return {};
+  ValueObjectSP cpp_exception_sp =
+      cpp_runtime->GetExceptionObjectForThread(thread_sp);
+  if (!cpp_exception_sp)
+    return {};
+
+  // An ObjC exception raised through the Itanium ABI is indistinguishable
+  // from a C++ one at this level, so confirm the thrown object really is an
+  // NSException (or a subclass) before claiming it.
+  ClassDescriptorSP descriptor_sp = GetClassDescriptor(*cpp_exception_sp);
+  if (!descriptor_sp || !descriptor_sp->IsValid())
+    return {};
+
+  static const ConstString g_NSException("NSException");
+  for (; descriptor_sp; descriptor_sp = descriptor_sp->GetSuperclass()) {
+    if (descriptor_sp->GetClassName() == g_NSException)
+      return cpp_exception_sp;
+  }
+  return {};
 }
 
 llvm::Expected<std::unique_ptr<UtilityFunction>>
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 7ee270991db80..cd1da89b5c634 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -84,6 +84,19 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   CreateExceptionResolver(const lldb::BreakpointSP &bkpt, bool catch_bp,
                           bool throw_bp) override;
 
+  void SetExceptionBreakpoints() override;
+
+  void ClearExceptionBreakpoints() override;
+
+  bool ExceptionBreakpointsAreSet() override;
+
+  bool ExceptionBreakpointsExplainStop(lldb::StopInfoSP stop_reason) override;
+
+  /// The object of the Objective-C exception being thrown on \p thread, or an
+  /// empty ValueObjectSP if the thread is not throwing one.
+  lldb::ValueObjectSP
+  GetExceptionObjectForThread(lldb::ThreadSP thread_sp) override;
+
   lldb::ThreadPlanSP GetStepThroughTrampolinePlan(Thread &thread,
                                                   bool stop_others) override;
 
@@ -211,6 +224,11 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
 
   /// Guards against a sweep that resolves a class re-entering the sweep.
   bool m_updating_isa_map = false;
+
+  /// The internal breakpoint on the runtime's throw entry point, used by
+  /// `process handle`/`thread exception` to stop where an exception is
+  /// raised rather than where it is caught.
+  lldb::BreakpointSP m_objc_exception_bp_sp;
 };
 
 } // namespace lldb_private
diff --git a/lldb/test/Shell/Expr/objc-gnustep-exceptions.m b/lldb/test/Shell/Expr/objc-gnustep-exceptions.m
new file mode 100644
index 0000000000000..de44edee8914c
--- /dev/null
+++ b/lldb/test/Shell/Expr/objc-gnustep-exceptions.m
@@ -0,0 +1,98 @@
+// REQUIRES: objc-gnustep
+//
+// RUN: %build %s --compiler=clang --objc-gnustep --output=%t
+
+#import "objc/runtime.h"
+
+ at protocol NSCoding
+ at end
+
+#ifdef __has_attribute
+#if __has_attribute(objc_root_class)
+__attribute__((objc_root_class))
+#endif
+#endif
+ at interface NSObject <NSCoding> {
+  id isa;
+}
++ (id)new;
+ at end
+ at implementation NSObject
++ (id)new {
+  return class_createInstance(self, 0);
+}
+ at end
+
+// `po` on the exception object goes through -description/-UTF8String, so a
+// minimal pair of those is all this needs; nothing here uses Foundation.
+ at interface Str : NSObject {
+  const char *_bytes;
+}
++ (id)withBytes:(const char *)bytes;
+- (const char *)UTF8String;
+ at end
+ at implementation Str
++ (id)withBytes:(const char *)bytes {
+  Str *str = [Str new];
+  str->_bytes = bytes;
+  return str;
+}
+- (const char *)UTF8String {
+  return _bytes;
+}
+ at end
+
+ at interface Boom : NSObject
+- (id)description;
+ at end
+ at implementation Boom
+- (id)description {
+  return [Str withBytes:"<Boom: thrown>"];
+}
+ at end
+
+void thrower(void) { @throw [Boom new]; }
+
+const char *g_caught_name = 0;
+
+int main() {
+  @try {
+    thrower();
+  } @catch (id caught) {
+    // Something with real code, so a breakpoint can land inside the handler.
+    g_caught_name = object_getClassName(caught);
+  }
+  return g_caught_name == 0;
+}
+
+// An Objective-C exception breakpoint stops where the exception is raised,
+// and the frame recognizer presents the thrown object as `exception`.
+//
+// RUN: %lldb -b -o "breakpoint set -E objc" -o "run" -o "frame variable" \
+// RUN:     -o "thread exception" \
+// RUN:     -- %t | FileCheck %s --check-prefix=THROW
+//
+// THROW: (lldb) breakpoint set -E objc
+// THROW: Breakpoint {{[0-9]+}}:
+//
+// THROW: (lldb) run
+// THROW: stop reason = hit Objective-C exception
+//
+// The recognizer synthesizes the argument, so the thrown object shows up in
+// `frame variable` even though objc_exception_throw has no debug info - and
+// it carries the dynamic type, not the `id` the runtime declares.
+// THROW: (lldb) frame variable
+// THROW: (Boom *) exception = 0x
+//
+// THROW: (lldb) thread exception
+// THROW: (Boom *) exception = 0x
+
+// The object is reachable as a real local inside the handler, where `po`
+// works on it normally.
+//
+// RUN: %lldb -b -o "b objc-gnustep-exceptions.m:63" -o "run" -o "po caught" \
+// RUN:     -- %t | FileCheck %s --check-prefix=CAUGHT
+//
+// CAUGHT: (lldb) po caught
+// CAUGHT: <Boom: thrown>
+// CAUGHT-NOT: warning: `po` was unsuccessful

>From 2ecf2d088c044d8eaf82cbc566cd1bd3ec903d9a Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 09:12:54 +0100
Subject: [PATCH 30/38] [lldb][GNUstep] Test catch breakpoints where the
 runtime supports them

libobjc2 exports objc_begin_catch wherever exceptions unwind through the
Itanium ABI, so a catch breakpoint stops on entry to the handler with the
@catch line as its caller. Gate the check on !windows-msvc rather than on
the OS: the MSVC build raises a native SEH exception and has no such entry
point, but MinGW (windows-gnu) uses __cxa_* over SEH and does.

Assisted-by: Claude Opus 5
---
 lldb/test/Shell/Expr/objc-gnustep-exceptions.m | 13 +++++++++++++
 1 file changed, 13 insertions(+)

diff --git a/lldb/test/Shell/Expr/objc-gnustep-exceptions.m b/lldb/test/Shell/Expr/objc-gnustep-exceptions.m
index de44edee8914c..b24924aed78f5 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-exceptions.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-exceptions.m
@@ -96,3 +96,16 @@ int main() {
 // CAUGHT: (lldb) po caught
 // CAUGHT: <Boom: thrown>
 // CAUGHT-NOT: warning: `po` was unsuccessful
+
+// Unlike Apple's runtime, libobjc2 has an entry point for entering a handler,
+// so catch breakpoints are real. It only exists where exceptions unwind
+// through the Itanium ABI: the MSVC build raises a native SEH exception and
+// catch is a __CxxFrameHandler3 funclet with nothing to break on. MinGW
+// (windows-gnu) uses __cxa_* over SEH and does have it, so gate on the ABI
+// rather than on the OS.
+//
+// RUN: %if !windows-msvc %{ %lldb -b -o "breakpoint set -E objc --on-catch true --on-throw false" -o "run" -o "bt" -- %t | FileCheck %s --check-prefix=CATCH %}
+//
+// CATCH: stop reason = breakpoint
+// CATCH: frame #0: {{.*}}objc_begin_catch
+// CATCH: frame #1: {{.*}}main at objc-gnustep-exceptions.m:

>From a020f4f8935d916effbcf555ae612d371be3a0c4 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 09:24:55 +0100
Subject: [PATCH 31/38] [lldb][GNUstep] Add a type encoding parser for libobjc2
 metadata

libobjc2 stores an Objective-C type encoding alongside every ivar, method
and property. Nothing could turn those into types, so GetEncodingToType()
returned nullptr and all of that metadata was inert.

The grammar is the standard one, so this is close to
AppleObjCTypeEncodingParser, and deliberately differs in four places:

- Leading argument qualifiers ("nNoORVA", libobjc2's encoding2.c) are
  skipped. clang emits these for declarations such as
  `- (oneway void)release`, and an unhandled one fails the whole encoding,
  which would drop the method. `r` is deliberately not in that set: it is
  _C_CONST, a real qualifier that changes the type.

- `@"Name"` is always a class name. Apple has to disambiguate it from a
  field named "Name" because its runtime emits quoted field names inside
  struct encodings. clang's GNUstep output never does - verified against the
  shipped library, whose ivar encodings expand struct members positionally
  (`{_NSRange=QQ}`, not `{_NSRange="location"Q"length"Q}`) - so applying
  that disambiguation here would mis-parse `{Foo=@"NSString"i}`, reading the
  trailing `i` as evidence that "NSString" had been a field name.

- A class name the runtime cannot resolve yields `id`. Apple asserts, which
  would abort a debug LLDB; an encoding routinely outlives the class it
  names.

- Records are cached, keyed on the AST and on the exact encoding text. Both
  halves are needed: RealizeType is called with different ASTContexts and a
  QualType is only valid in the one that made it, and keying on the tag
  alone would let an opaque `{Foo=}` shadow a later `{Foo=ii}`.

`D` (long double) is also handled, since it is a real type on x86-64 ELF and
Apple's parser drops any method mentioning one.

The encoding comes from inferior memory, so recursion is depth-bounded. The
parser takes a triple rather than a runtime, so it needs no target state and
is tested directly: 51 cases across LP64, LLP64 and ILP32, including every
truncation of a nested encoding and a 4096-deep pointer chain.

Assisted-by: Claude Opus 5
---
 .../ObjC/GNUstepObjCRuntime/CMakeLists.txt    |   1 +
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp |   9 +
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |   7 +
 .../GNUstepObjCTypeEncodingParser.cpp         | 396 ++++++++++++++++++
 .../GNUstepObjCTypeEncodingParser.h           | 103 +++++
 .../ObjC/GNUstepObjCRuntime/CMakeLists.txt    |   3 +
 .../GNUstepObjCTypeEncodingParserTest.cpp     | 263 ++++++++++++
 7 files changed, 782 insertions(+)
 create mode 100644 lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParser.cpp
 create mode 100644 lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParser.h
 create mode 100644 lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParserTest.cpp

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
index 14364c12dfcde..46cf4ef300797 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
@@ -1,5 +1,6 @@
 add_lldb_library(lldbPluginGNUstepObjCRuntime PLUGIN
   GNUstepObjCClassDescriptor.cpp
+  GNUstepObjCTypeEncodingParser.cpp
   GNUstepObjCRuntime.cpp
   GNUstepThreadPlanStepThroughObjCTrampoline.cpp
 
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 57f613c4a6435..d79a38fbf828a 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -8,6 +8,7 @@
 
 #include "GNUstepObjCRuntime.h"
 #include "GNUstepObjCClassDescriptor.h"
+#include "GNUstepObjCTypeEncodingParser.h"
 #include "GNUstepThreadPlanStepThroughObjCTrampoline.h"
 
 #include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
@@ -1139,6 +1140,14 @@ GNUstepObjCRuntime::GetClassDescriptor(ValueObject &in_value) {
   return ObjCLanguageRuntime::GetClassDescriptor(in_value);
 }
 
+ObjCLanguageRuntime::EncodingToTypeSP
+GNUstepObjCRuntime::GetEncodingToType() {
+  if (!m_encoding_to_type_sp)
+    m_encoding_to_type_sp = std::make_shared<GNUstepObjCTypeEncodingParser>(
+        GetTargetRef().GetArchitecture().GetTriple(), this);
+  return m_encoding_to_type_sp;
+}
+
 ObjCLanguageRuntime::ClassDescriptorSP
 GNUstepObjCRuntime::GetClassDescriptorFromISA(ObjCISA isa) {
   if (ClassDescriptorSP descriptor_sp =
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index cd1da89b5c634..6d22d5fd0ede3 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -133,6 +133,11 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
 
   ClassDescriptorSP GetClassDescriptorFromISA(ObjCISA isa) override;
 
+  /// Realizes libobjc2's type encodings, which is what makes the ivar and
+  /// method metadata usable as types. Built on first use and kept, because
+  /// the types it returns are only valid in the AST it created them in.
+  EncodingToTypeSP GetEncodingToType() override;
+
   /// Lazily-built FunctionCaller for a utility function that resolves a
   /// method implementation via libobjc2's
   /// `IMP objc_msg_lookup(id receiver, SEL selector)`, used by the
@@ -229,6 +234,8 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   /// `process handle`/`thread exception` to stop where an exception is
   /// raised rather than where it is caught.
   lldb::BreakpointSP m_objc_exception_bp_sp;
+
+  EncodingToTypeSP m_encoding_to_type_sp;
 };
 
 } // namespace lldb_private
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParser.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParser.cpp
new file mode 100644
index 0000000000000..1b6b7e28b4209
--- /dev/null
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParser.cpp
@@ -0,0 +1,396 @@
+//===-- GNUstepObjCTypeEncodingParser.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 "GNUstepObjCTypeEncodingParser.h"
+
+#include "Plugins/ExpressionParser/Clang/ClangUtil.h"
+#include "Plugins/Language/ObjC/ObjCConstants.h"
+#include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
+
+#include "lldb/Symbol/CompilerType.h"
+#include "lldb/Symbol/DeclVendor.h"
+#include "lldb/Utility/ConstString.h"
+#include "lldb/Utility/LLDBLog.h"
+#include "lldb/Utility/Log.h"
+
+#include "llvm/ADT/ScopeExit.h"
+#include "llvm/ADT/StringExtras.h"
+
+#include <limits>
+#include <optional>
+#include <vector>
+
+using namespace lldb_private;
+
+// Encodings ObjCConstants.h does not name. _C_COMPLEX is a libobjc2
+// extension (objc/runtime.h); long double has no macro there at all, and is
+// a real type on x86-64 ELF, so a method mentioning one would otherwise be
+// dropped wholesale.
+static constexpr char _C_COMPLEX = 'j';
+static constexpr char _C_LNG_DBL = 'D';
+
+// Method-argument qualifiers, from libobjc2's objc_skip_type_qualifiers
+// (encoding2.c). `r` is _C_CONST and is deliberately absent: it is a real
+// qualifier that changes the type, and BuildType handles it.
+static constexpr llvm::StringLiteral g_argument_qualifiers = "nNoORVA";
+
+// An encoding nested more deeply than this is not something a compiler
+// emitted; refusing it keeps a malformed string from recursing without bound.
+static constexpr unsigned g_max_depth = 64;
+
+static char popChar(llvm::StringRef &str) {
+  const char c = str.front();
+  str = str.drop_front();
+  return c;
+}
+
+/// Reads a decimal count, consuming the digits. Returns nullopt when the value
+/// does not fit in 32 bits: the encoding comes from inferior memory, and a
+/// wrapped extent would size an array wrongly rather than fail.
+static std::optional<uint32_t> ReadNumber(llvm::StringRef &type) {
+  uint64_t total = 0;
+  while (!type.empty() && llvm::isDigit(type.front())) {
+    total = 10 * total + (popChar(type) - '0');
+    if (total > std::numeric_limits<uint32_t>::max())
+      return std::nullopt;
+  }
+  return static_cast<uint32_t>(total);
+}
+
+/// Reads up to (not including) the closing quote, which stays in \p type.
+static std::optional<std::string> ReadQuotedString(llvm::StringRef &type) {
+  std::string buffer;
+  while (!type.empty() && type.front() != '"')
+    buffer.push_back(popChar(type));
+  if (type.empty())
+    return std::nullopt;
+  return buffer;
+}
+
+/// Reads a struct or union tag, which runs up to the '='.
+static std::string ReadAggregateName(llvm::StringRef &type) {
+  std::string buffer;
+  while (!type.empty() && type.front() != _C_STRUCT_E &&
+         type.front() != _C_UNION_E && type.front() != '=')
+    buffer.push_back(popChar(type));
+  return buffer;
+}
+
+GNUstepObjCTypeEncodingParser::GNUstepObjCTypeEncodingParser(
+    const llvm::Triple &triple, ObjCLanguageRuntime *runtime)
+    : ObjCLanguageRuntime::EncodingToType(), m_runtime(runtime) {
+  m_scratch_ast_ctx_sp = std::make_shared<TypeSystemClang>(
+      "GNUstepObjCTypeEncodingParser ASTContext", triple);
+}
+
+GNUstepObjCTypeEncodingParser::StructElement
+GNUstepObjCTypeEncodingParser::ReadStructElement(TypeSystemClang &ast_ctx,
+                                                 llvm::StringRef &type,
+                                                 bool for_expression) {
+  StructElement retval;
+  // Quoted field names are an Apple extension that clang's GNUstep output
+  // does not produce, but consuming one costs nothing and keeps this usable
+  // against an encoding that came from somewhere else.
+  if (type.consume_front("\"")) {
+    if (auto maybe_name = ReadQuotedString(type)) {
+      retval.name = *maybe_name;
+      type = type.drop_front(); // the closing quote
+    } else {
+      return retval;
+    }
+  }
+  uint32_t bitfield_size = 0;
+  retval.type = BuildType(ast_ctx, type, for_expression, &bitfield_size);
+  retval.bitfield = bitfield_size;
+  return retval;
+}
+
+clang::QualType GNUstepObjCTypeEncodingParser::BuildAggregate(
+    TypeSystemClang &ast_ctx, llvm::StringRef &type, bool for_expression,
+    char opener, char closer, uint32_t kind) {
+  llvm::StringRef start = type;
+  if (!type.consume_front(opener))
+    return clang::QualType();
+
+  const std::string name = ReadAggregateName(type);
+
+  // Templated names are parsed for their side effect on `type` and then
+  // discarded; there is no sensible clang record to build for one.
+  const bool is_templated = name.find('<') != std::string::npos;
+
+  // An opaque aggregate - `{Foo}` with no '=' - carries no members. libobjc2
+  // spells this `{Foo=}`, but accept both rather than failing the whole
+  // encoding.
+  bool has_members = type.consume_front("=");
+
+  std::vector<StructElement> elements;
+  bool closed = false;
+  while (!type.empty()) {
+    if (type.consume_front(closer)) {
+      closed = true;
+      break;
+    }
+    if (!has_members)
+      return clang::QualType();
+    StructElement element = ReadStructElement(ast_ctx, type, for_expression);
+    if (element.type.isNull())
+      return clang::QualType();
+    elements.push_back(std::move(element));
+  }
+  if (!closed || is_templated)
+    return clang::QualType();
+
+  // Key the cache on the exact text this record was built from, so that two
+  // structurally different types sharing a tag - `{Foo=}` and `{Foo=ii}` -
+  // do not collide, and so a repeated struct is laid out once.
+  const llvm::StringRef encoding = start.take_front(start.size() - type.size());
+  llvm::StringMap<clang::QualType> &cache = m_record_cache[&ast_ctx];
+  auto cached = cache.find(encoding);
+  if (cached != cache.end())
+    return cached->second;
+
+  CompilerType record_type(ast_ctx.CreateRecordType(
+      nullptr, OptionalClangModuleID(), name, kind, lldb::eLanguageTypeC));
+  if (!record_type)
+    return clang::QualType();
+
+  TypeSystemClang::StartTagDeclarationDefinition(record_type);
+  unsigned count = 0;
+  for (StructElement &element : elements) {
+    if (element.name.empty())
+      element.name = ("__unnamed_" + llvm::Twine(count)).str();
+    TypeSystemClang::AddFieldToRecordType(record_type, element.name.c_str(),
+                                          ast_ctx.GetType(element.type),
+                                          element.bitfield);
+    ++count;
+  }
+  TypeSystemClang::CompleteTagDeclarationDefinition(record_type);
+
+  clang::QualType qual_type = ClangUtil::GetQualType(record_type);
+  cache[encoding] = qual_type;
+  return qual_type;
+}
+
+clang::QualType GNUstepObjCTypeEncodingParser::BuildArray(
+    TypeSystemClang &ast_ctx, llvm::StringRef &type, bool for_expression) {
+  if (!type.consume_front(_C_ARY_B))
+    return clang::QualType();
+
+  const std::optional<uint32_t> size = ReadNumber(type);
+  if (!size)
+    return clang::QualType();
+  clang::QualType element_type(BuildType(ast_ctx, type, for_expression));
+  if (element_type.isNull())
+    return clang::QualType();
+  if (!type.consume_front(_C_ARY_E))
+    return clang::QualType();
+
+  CompilerType array_type(ast_ctx.CreateArrayType(
+      CompilerType(ast_ctx.weak_from_this(), element_type.getAsOpaquePtr()),
+      *size, /*is_vector=*/false));
+  return ClangUtil::GetQualType(array_type);
+}
+
+clang::QualType GNUstepObjCTypeEncodingParser::BuildObjCObjectPointerType(
+    TypeSystemClang &clang_ast_ctx, llvm::StringRef &type,
+    bool for_expression) {
+  if (!type.consume_front(_C_ID))
+    return clang::QualType();
+
+  clang::ASTContext &ast_ctx = clang_ast_ctx.getASTContext();
+
+  // `@?` is a block. Its full signature may follow in an extended encoding,
+  // which nothing here needs, so treat it as an opaque object pointer.
+  if (type.consume_front(_C_UNDEF))
+    return ast_ctx.getObjCIdType();
+
+  std::string name;
+  if (type.consume_front("\"")) {
+    // Unlike Apple's runtime, clang's GNUstep output never emits quoted field
+    // names inside a struct, so a quoted string after '@' is unambiguously a
+    // class name. Apple peeks at the next character to tell the two apart;
+    // doing that here would mis-parse `{Foo=@"NSString"i}`, whose `i` is the
+    // next field rather than a hint that "NSString" was a field name.
+    if (auto maybe_name = ReadQuotedString(type)) {
+      name = *maybe_name;
+      type = type.drop_front(); // the closing quote
+    } else {
+      return clang::QualType();
+    }
+  }
+
+  if (!for_expression || name.empty())
+    return ast_ctx.getObjCIdType();
+
+  // Protocol qualifiers carry no type information here: `<Proto>` alone is
+  // just `id`, and `NSFoo<Proto>` is an NSFoo.
+  const size_t less_than_pos = name.find('<');
+  if (less_than_pos == 0)
+    return ast_ctx.getObjCIdType();
+  if (less_than_pos != std::string::npos)
+    name.erase(less_than_pos);
+
+  DeclVendor *decl_vendor = m_runtime ? m_runtime->GetDeclVendor() : nullptr;
+  if (!decl_vendor)
+    return ast_ctx.getObjCIdType();
+
+  auto types = decl_vendor->FindTypes(ConstString(name), /*max_matches=*/1);
+  if (types.empty()) {
+    // Naming a class the runtime has not realized is expected, not a bug: an
+    // encoding outlives the class it mentions.
+    LLDB_LOG(GetLog(LLDBLog::Types),
+             "GNUstep type encoding names an unknown class: {0}", name);
+    return ast_ctx.getObjCIdType();
+  }
+  return ClangUtil::GetQualType(types.front().GetPointerType());
+}
+
+clang::QualType GNUstepObjCTypeEncodingParser::BuildType(
+    TypeSystemClang &clang_ast_ctx, llvm::StringRef &type, bool for_expression,
+    uint32_t *bitfield_bit_size) {
+  if (type.empty())
+    return clang::QualType();
+
+  // Every nesting construct - pointers, arrays, aggregates, const - recurses
+  // here, and the encoding is inferior data. Bound the depth so a malformed
+  // one cannot exhaust the stack.
+  if (m_depth >= g_max_depth)
+    return clang::QualType();
+  ++m_depth;
+  llvm::scope_exit depth_guard([this] { --m_depth; });
+
+  // Skip any argument qualifiers. clang emits these for declarations such as
+  // `- (oneway void)release`, and leaving them in place would drop the whole
+  // method.
+  while (!type.empty() && g_argument_qualifiers.contains(type.front()))
+    type = type.drop_front();
+  if (type.empty())
+    return clang::QualType();
+
+  clang::ASTContext &ast_ctx = clang_ast_ctx.getASTContext();
+
+  switch (type.front()) {
+  default:
+    break;
+  case _C_STRUCT_B:
+    return BuildAggregate(clang_ast_ctx, type, for_expression, _C_STRUCT_B,
+                          _C_STRUCT_E,
+                          llvm::to_underlying(clang::TagTypeKind::Struct));
+  case _C_UNION_B:
+    return BuildAggregate(clang_ast_ctx, type, for_expression, _C_UNION_B,
+                          _C_UNION_E,
+                          llvm::to_underlying(clang::TagTypeKind::Union));
+  case _C_ARY_B:
+    return BuildArray(clang_ast_ctx, type, for_expression);
+  case _C_ID:
+    return BuildObjCObjectPointerType(clang_ast_ctx, type, for_expression);
+  }
+
+  // Save a copy so an unrecognized encoding can be left untouched for the
+  // caller to notice.
+  llvm::StringRef backup = type;
+
+  switch (popChar(type)) {
+  default:
+    type = backup;
+    return clang::QualType();
+  case _C_CHR:
+    return ast_ctx.CharTy;
+  case _C_INT:
+    return ast_ctx.IntTy;
+  case _C_SHT:
+    return ast_ctx.ShortTy;
+  case _C_LNG:
+    // clang only emits 'l' where `long` is 32 bits and 'q' otherwise
+    // (ASTContext::getObjCEncodingForPrimitiveType), so this is right on
+    // LP64, LLP64 and ILP32 alike.
+    return ast_ctx.getIntTypeForBitwidth(32, /*Signed=*/true);
+  case _C_LNG_LNG:
+    return ast_ctx.LongLongTy;
+  case _C_UCHR:
+    return ast_ctx.UnsignedCharTy;
+  case _C_UINT:
+    return ast_ctx.UnsignedIntTy;
+  case _C_USHT:
+    return ast_ctx.UnsignedShortTy;
+  case _C_ULNG:
+    return ast_ctx.getIntTypeForBitwidth(32, /*Signed=*/false);
+  case _C_ULNG_LNG:
+    return ast_ctx.UnsignedLongLongTy;
+  case _C_FLT:
+    return ast_ctx.FloatTy;
+  case _C_DBL:
+    return ast_ctx.DoubleTy;
+  case _C_LNG_DBL:
+    return ast_ctx.LongDoubleTy;
+  case _C_BOOL:
+    return ast_ctx.BoolTy;
+  case _C_VOID:
+    return ast_ctx.VoidTy;
+  case _C_CHARPTR:
+  case _C_ATOM:
+    // _C_ATOM is a char* whose contents are interned.
+    return ast_ctx.getPointerType(ast_ctx.CharTy);
+  case _C_CLASS:
+    return ast_ctx.getObjCClassType();
+  case _C_SEL:
+    return ast_ctx.getObjCSelType();
+  case _C_COMPLEX: {
+    clang::QualType element_type =
+        BuildType(clang_ast_ctx, type, for_expression);
+    if (element_type.isNull())
+      return clang::QualType();
+    return ast_ctx.getComplexType(element_type);
+  }
+  case _C_VECTOR:
+    // The published grammar gives no way to recover a vector's width, so
+    // there is nothing to build. Consume it and report failure rather than
+    // silently producing the element type.
+    return clang::QualType();
+  case _C_BFLD: {
+    const std::optional<uint32_t> size = ReadNumber(type);
+    if (!size || !bitfield_bit_size)
+      return clang::QualType();
+    *bitfield_bit_size = *size;
+    return ast_ctx.UnsignedIntTy;
+  }
+  case _C_CONST: {
+    clang::QualType target_type =
+        BuildType(clang_ast_ctx, type, for_expression);
+    if (target_type.isNull())
+      return clang::QualType();
+    if (target_type == ast_ctx.UnknownAnyTy)
+      return ast_ctx.UnknownAnyTy;
+    return ast_ctx.getConstType(target_type);
+  }
+  case _C_PTR: {
+    if (!for_expression && type.consume_front(_C_UNDEF)) {
+      // A pointer to something unrepresentable is more useful as void* than
+      // as nothing at all, when the expression parser is not involved.
+      return ast_ctx.VoidPtrTy;
+    }
+    clang::QualType target_type =
+        BuildType(clang_ast_ctx, type, for_expression);
+    if (target_type.isNull())
+      return clang::QualType();
+    if (target_type == ast_ctx.UnknownAnyTy)
+      return ast_ctx.UnknownAnyTy;
+    return ast_ctx.getPointerType(target_type);
+  }
+  case _C_UNDEF:
+    return for_expression ? ast_ctx.UnknownAnyTy : clang::QualType();
+  }
+}
+
+CompilerType GNUstepObjCTypeEncodingParser::RealizeType(
+    TypeSystemClang &ast_ctx, const char *name, bool for_expression) {
+  if (!name || !name[0])
+    return CompilerType();
+  llvm::StringRef lexer(name);
+  return ast_ctx.GetType(BuildType(ast_ctx, lexer, for_expression));
+}
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParser.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParser.h
new file mode 100644
index 0000000000000..2d3fa4ab7d200
--- /dev/null
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParser.h
@@ -0,0 +1,103 @@
+//===-- GNUstepObjCTypeEncodingParser.h -------------------------*- C++ -*-===//
+//
+// 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
+//
+//===----------------------------------------------------------------------===//
+
+#ifndef LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPOBJCTYPEENCODINGPARSER_H
+#define LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPOBJCTYPEENCODINGPARSER_H
+
+#include "Plugins/LanguageRuntime/ObjC/ObjCLanguageRuntime.h"
+#include "lldb/lldb-private.h"
+
+#include "llvm/ADT/DenseMap.h"
+#include "llvm/ADT/StringMap.h"
+#include "llvm/ADT/StringRef.h"
+#include "llvm/TargetParser/Triple.h"
+
+#include "clang/AST/ASTContext.h"
+
+#include <string>
+
+namespace lldb_private {
+
+/// Turns an Objective-C type encoding into a CompilerType, for the encodings
+/// libobjc2 stores alongside its ivars, methods and properties.
+///
+/// The grammar is the standard one (documented normatively in libobjc2's
+/// ABIDoc/abi.tex), so this is close to AppleObjCTypeEncodingParser. It
+/// differs in four ways that matter for libobjc2 input:
+///
+///   - Leading method qualifiers ("nNoORVA", libobjc2's encoding2.c) are
+///     skipped. `A` for _Atomic is a libobjc2 addition. `r` is deliberately
+///     not in that set: it is _C_CONST, and is handled as a real qualifier.
+///   - `@"Name"` is always a class name. Apple has to disambiguate it from a
+///     field named "Name", because its runtime emits quoted field names
+///     inside struct encodings; clang's GNUstep output never does, so the
+///     disambiguation would actively mis-parse `{Foo=@"NSString"i}`.
+///   - A class name the runtime cannot resolve yields `id` rather than
+///     tripping an assertion. libobjc2 encodings routinely name classes that
+///     are not realized.
+///   - Record types are cached, so a struct appearing in many encodings does
+///     not mint a fresh (and separately laid out) decl each time.
+///
+/// The parser holds no target state, so it can be constructed from a triple
+/// alone; a runtime is only needed to resolve `@"Name"` for expressions.
+class GNUstepObjCTypeEncodingParser
+    : public ObjCLanguageRuntime::EncodingToType {
+public:
+  /// \param triple selects the data model, which decides how wide `long`
+  ///        and friends are.
+  /// \param runtime resolves `@"Name"` when realizing types for expressions;
+  ///        without one such a name degrades to `id`.
+  explicit GNUstepObjCTypeEncodingParser(
+      const llvm::Triple &triple, ObjCLanguageRuntime *runtime = nullptr);
+
+  ~GNUstepObjCTypeEncodingParser() override = default;
+
+  CompilerType RealizeType(TypeSystemClang &ast_ctx, const char *name,
+                           bool for_expression) override;
+
+private:
+  struct StructElement {
+    std::string name;
+    clang::QualType type;
+    uint32_t bitfield = 0;
+  };
+
+  clang::QualType BuildType(TypeSystemClang &ast_ctx, llvm::StringRef &type,
+                            bool for_expression,
+                            uint32_t *bitfield_bit_size = nullptr);
+
+  clang::QualType BuildAggregate(TypeSystemClang &ast_ctx,
+                                 llvm::StringRef &type, bool for_expression,
+                                 char opener, char closer, uint32_t kind);
+
+  clang::QualType BuildArray(TypeSystemClang &ast_ctx, llvm::StringRef &type,
+                             bool for_expression);
+
+  clang::QualType BuildObjCObjectPointerType(TypeSystemClang &ast_ctx,
+                                             llvm::StringRef &type,
+                                             bool for_expression);
+
+  StructElement ReadStructElement(TypeSystemClang &ast_ctx,
+                                  llvm::StringRef &type, bool for_expression);
+
+  ObjCLanguageRuntime *m_runtime;
+
+  /// Recursion depth of the current BuildType call chain.
+  unsigned m_depth = 0;
+
+  /// Records already built, keyed by the AST they belong to and then by the
+  /// exact encoding text they were built from. Both halves are needed:
+  /// RealizeType is called with different ASTContexts, and a QualType is only
+  /// valid in the one that created it.
+  llvm::DenseMap<TypeSystemClang *, llvm::StringMap<clang::QualType>>
+      m_record_cache;
+};
+
+} // namespace lldb_private
+
+#endif // LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPOBJCTYPEENCODINGPARSER_H
diff --git a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
index 39b22ebd2816e..db7a4ca17b0b4 100644
--- a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
+++ b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
@@ -1,5 +1,6 @@
 add_lldb_unittest(LanguageRuntimeObjCGNUstepTests
   GNUstepObjCClassDescriptorTest.cpp
+  GNUstepObjCTypeEncodingParserTest.cpp
 
   LINK_COMPONENTS
     Support
@@ -10,6 +11,8 @@ add_lldb_unittest(LanguageRuntimeObjCGNUstepTests
     lldbTarget
     lldbUtility
     lldbPluginGNUstepObjCRuntime
+    lldbPluginExpressionParserClang
+    lldbPluginTypeSystemClang
     lldbPluginPlatformLinux
     lldbPluginPlatformWindows
   )
diff --git a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParserTest.cpp b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParserTest.cpp
new file mode 100644
index 0000000000000..43e7639d3f42f
--- /dev/null
+++ b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParserTest.cpp
@@ -0,0 +1,263 @@
+//===-- GNUstepObjCTypeEncodingParserTest.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 "Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCTypeEncodingParser.h"
+
+#include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
+#include "TestingSupport/SubsystemRAII.h"
+#include "lldb/Host/FileSystem.h"
+#include "lldb/Host/HostInfo.h"
+#include "lldb/Symbol/CompilerType.h"
+
+#include "gtest/gtest.h"
+
+#include <memory>
+#include <string>
+
+using namespace lldb_private;
+
+namespace {
+
+/// The parser needs no target state, so each data model is just a triple.
+struct DataModel {
+  const char *triple;
+  /// Width of `long` in bits, which is what differs between LP64 and LLP64.
+  unsigned long_bits;
+  /// Width of a pointer in bits, which is what drives aggregate layout.
+  unsigned pointer_bits;
+};
+
+class GNUstepTypeEncodingParserTest
+    : public ::testing::TestWithParam<DataModel> {
+public:
+  void SetUp() override {
+    m_triple = llvm::Triple(GetParam().triple);
+    m_ast_sp = std::make_shared<TypeSystemClang>("test ASTContext", m_triple);
+    // No runtime: `@"Name"` has no DeclVendor to resolve against and must
+    // degrade to `id` rather than crash.
+    m_parser = std::make_unique<GNUstepObjCTypeEncodingParser>(m_triple);
+  }
+
+  void TearDown() override {
+    m_parser.reset();
+    m_ast_sp.reset();
+  }
+
+  /// Realizes \p encoding and returns its type name, or "" if it failed.
+  std::string Realize(llvm::StringRef encoding, bool for_expression = false) {
+    CompilerType type = m_parser->RealizeType(*m_ast_sp, encoding.str().c_str(),
+                                              for_expression);
+    if (!type)
+      return "";
+    return type.GetTypeName().GetString();
+  }
+
+  std::optional<uint64_t> SizeOf(llvm::StringRef encoding) {
+    CompilerType type =
+        m_parser->RealizeType(*m_ast_sp, encoding.str().c_str(), false);
+    if (!type)
+      return std::nullopt;
+    llvm::Expected<uint64_t> size = type.GetByteSize(nullptr);
+    if (!size) {
+      llvm::consumeError(size.takeError());
+      return std::nullopt;
+    }
+    return *size;
+  }
+
+  SubsystemRAII<FileSystem, HostInfo> subsystems;
+  llvm::Triple m_triple;
+  std::shared_ptr<TypeSystemClang> m_ast_sp;
+  std::unique_ptr<GNUstepObjCTypeEncodingParser> m_parser;
+};
+
+TEST_P(GNUstepTypeEncodingParserTest, Scalars) {
+  EXPECT_EQ(Realize("c"), "char");
+  EXPECT_EQ(Realize("i"), "int");
+  EXPECT_EQ(Realize("s"), "short");
+  EXPECT_EQ(Realize("q"), "long long");
+  EXPECT_EQ(Realize("C"), "unsigned char");
+  EXPECT_EQ(Realize("I"), "unsigned int");
+  EXPECT_EQ(Realize("S"), "unsigned short");
+  EXPECT_EQ(Realize("Q"), "unsigned long long");
+  EXPECT_EQ(Realize("f"), "float");
+  EXPECT_EQ(Realize("d"), "double");
+  EXPECT_EQ(Realize("B"), "bool");
+  EXPECT_EQ(Realize("v"), "void");
+  EXPECT_EQ(Realize("*"), "char *");
+}
+
+// clang emits 'l' only where `long` is 32 bits and 'q' otherwise
+// (ASTContext::getObjCEncodingForPrimitiveType), so 'l' always means 32 bits
+// no matter which data model the target uses.
+TEST_P(GNUstepTypeEncodingParserTest, LongIsAlways32Bits) {
+  EXPECT_EQ(SizeOf("l"), std::optional<uint64_t>(4u));
+  EXPECT_EQ(SizeOf("L"), std::optional<uint64_t>(4u));
+  EXPECT_EQ(SizeOf("q"), std::optional<uint64_t>(8u));
+  EXPECT_EQ(SizeOf("Q"), std::optional<uint64_t>(8u));
+}
+
+TEST_P(GNUstepTypeEncodingParserTest, LongDouble) {
+  // Unhandled by Apple's parser; an encoding mentioning one would otherwise
+  // fail and take the whole method with it.
+  EXPECT_EQ(Realize("D"), "long double");
+}
+
+TEST_P(GNUstepTypeEncodingParserTest, ObjCBuiltins) {
+  EXPECT_EQ(Realize("@"), "id");
+  EXPECT_EQ(Realize("#"), "Class");
+  EXPECT_EQ(Realize(":"), "SEL");
+  // A block. The extended form carries a signature nothing here needs.
+  EXPECT_EQ(Realize("@?"), "id");
+}
+
+// Without a DeclVendor the class name cannot be resolved. That must degrade
+// to `id`, not assert - libobjc2 encodings routinely name classes that are
+// not realized.
+TEST_P(GNUstepTypeEncodingParserTest, UnknownClassNameDegradesToId) {
+  EXPECT_EQ(Realize("@\"NSString\""), "id");
+  EXPECT_EQ(Realize("@\"NSString\"", /*for_expression=*/true), "id");
+  EXPECT_EQ(Realize("@\"<NSCopying>\"", /*for_expression=*/true), "id");
+  EXPECT_EQ(Realize("@\"NSFoo<NSCopying>\"", /*for_expression=*/true), "id");
+}
+
+TEST_P(GNUstepTypeEncodingParserTest, Pointers) {
+  EXPECT_EQ(Realize("^i"), "int *");
+  EXPECT_EQ(Realize("^^v"), "void **");
+  EXPECT_EQ(Realize("^?"), "void *");
+  EXPECT_EQ(Realize("ri"), "const int");
+  EXPECT_EQ(Realize("^ri"), "const int *");
+}
+
+TEST_P(GNUstepTypeEncodingParserTest, Arrays) {
+  EXPECT_EQ(Realize("[10i]"), "int[10]");
+  EXPECT_EQ(SizeOf("[10i]"), std::optional<uint64_t>(40u));
+  EXPECT_EQ(SizeOf("[3[4f]]"), std::optional<uint64_t>(48u));
+}
+
+TEST_P(GNUstepTypeEncodingParserTest, ArrayExtentDoesNotWrap) {
+  // The encoding is inferior data, so an extent that does not fit must fail
+  // rather than wrap: 10000000000 truncated to 32 bits is 1410065408, and an
+  // array of that many ints would be accepted as if it were the real size.
+  EXPECT_EQ(Realize("[10000000000i]"), "");
+  EXPECT_EQ(Realize("[4294967296i]"), "");
+  // One below the limit still parses, so the bound is not off by one.
+  EXPECT_EQ(Realize("[4294967295i]"), "int[4294967295]");
+}
+
+TEST_P(GNUstepTypeEncodingParserTest, Structs) {
+  EXPECT_EQ(Realize("{CGPoint=dd}"), "CGPoint");
+  EXPECT_EQ(SizeOf("{CGPoint=dd}"), std::optional<uint64_t>(16u));
+  // An opaque struct, which is how libobjc2 spells a forward declaration.
+  EXPECT_EQ(Realize("{CGImage=}"), "CGImage");
+  EXPECT_EQ(Realize("^{CGImage=}"), "CGImage *");
+  EXPECT_EQ(Realize("(U=ic)"), "U");
+}
+
+// This is the encoding shape libobjc2 actually emits for a struct ivar,
+// verified against the shipped library: members are expanded positionally
+// with no field names.
+TEST_P(GNUstepTypeEncodingParserTest, StructWithoutFieldNames) {
+  EXPECT_EQ(Realize("{_NSRange=QQ}"), "_NSRange");
+  EXPECT_EQ(SizeOf("{_NSRange=QQ}"), std::optional<uint64_t>(16u));
+}
+
+// A quoted string after '@' is a class name, never a field name: unlike
+// Apple's runtime, clang's GNUstep output does not emit quoted field names,
+// so peeking to disambiguate would mis-parse the `i` here as evidence that
+// "NSString" was a field name.
+TEST_P(GNUstepTypeEncodingParserTest, ClassNameInStructIsNotAFieldName) {
+  // An object pointer followed by an int: the size follows the pointer
+  // width, so LLP64 and LP64 agree here and only ILP32 differs.
+  EXPECT_EQ(SizeOf("{Foo=@\"NSString\"i}"),
+            std::optional<uint64_t>(GetParam().pointer_bits == 32 ? 8u : 16u));
+}
+
+TEST_P(GNUstepTypeEncodingParserTest, Bitfields) {
+  // A bitfield outside a struct has nowhere to report its width.
+  EXPECT_EQ(Realize("b3"), "");
+  // Bitfields are given an unsigned int base type, so eight bits of them
+  // still occupy one int rather than one byte.
+  EXPECT_EQ(SizeOf("{Flags=b1b1b6}"), std::optional<uint64_t>(4u));
+}
+
+// clang emits these for declarations such as `- (oneway void)release`.
+// Leaving them unhandled would drop the whole method.
+TEST_P(GNUstepTypeEncodingParserTest, SkipsArgumentQualifiers) {
+  EXPECT_EQ(Realize("Vv"), "void");
+  EXPECT_EQ(Realize("ni"), "int");
+  EXPECT_EQ(Realize("Ni"), "int");
+  EXPECT_EQ(Realize("oi"), "int");
+  EXPECT_EQ(Realize("Oi"), "int");
+  EXPECT_EQ(Realize("Ri"), "int");
+  // libobjc2's addition, for _Atomic.
+  EXPECT_EQ(Realize("Ai"), "int");
+  // 'r' is const, not an argument qualifier, and must keep its meaning.
+  EXPECT_NE(Realize("ri"), "int");
+}
+
+// The same struct appearing twice must yield the same type rather than two
+// separately laid out decls sharing a name.
+TEST_P(GNUstepTypeEncodingParserTest, RecordsAreCached) {
+  CompilerType first = m_parser->RealizeType(*m_ast_sp, "{CGPoint=dd}", false);
+  CompilerType second = m_parser->RealizeType(*m_ast_sp, "{CGPoint=dd}", false);
+  ASSERT_TRUE(first);
+  EXPECT_EQ(first.GetOpaqueQualType(), second.GetOpaqueQualType());
+}
+
+// Two different types can legitimately share a tag - an opaque declaration
+// and its definition - so the cache must not collapse them.
+TEST_P(GNUstepTypeEncodingParserTest, SameTagDifferentBodyIsNotCached) {
+  CompilerType opaque = m_parser->RealizeType(*m_ast_sp, "{Foo=}", false);
+  CompilerType defined = m_parser->RealizeType(*m_ast_sp, "{Foo=ii}", false);
+  ASSERT_TRUE(opaque);
+  ASSERT_TRUE(defined);
+  EXPECT_NE(opaque.GetOpaqueQualType(), defined.GetOpaqueQualType());
+}
+
+// Nothing here may crash, hang, or recurse without bound. The encoding comes
+// from inferior memory, so it cannot be assumed well-formed.
+TEST_P(GNUstepTypeEncodingParserTest, MalformedInputIsRejected) {
+  for (llvm::StringRef bad :
+       {"", "{", "{Foo=", "{Foo=ii", "[10", "[10i", "^", "b", "@\"", "(U=ic",
+        "{Foo=@\"NSString", "]", ")", "}", "="}) {
+    // No assertion on the result beyond "it returned"; the point is that it
+    // terminates and does not trip an assertion inside clang.
+    Realize(bad);
+  }
+}
+
+TEST_P(GNUstepTypeEncodingParserTest, DeeplyNestedInputTerminates) {
+  EXPECT_EQ(Realize(std::string(4096, '^') + "v"), "");
+  EXPECT_EQ(Realize(std::string(1000, '{')), "");
+}
+
+// Truncating a valid encoding at every length is the cheapest way to reach
+// the partial states a hand-written parser gets wrong.
+TEST_P(GNUstepTypeEncodingParserTest, EveryTruncationTerminates) {
+  const std::string full =
+      "{Outer=@\"NSString\"^{Inner=ii}[4f]b3q{_NSRange=QQ}}";
+  for (size_t n = 1; n <= full.size(); ++n)
+    Realize(llvm::StringRef(full).take_front(n));
+}
+
+INSTANTIATE_TEST_SUITE_P(
+    DataModels, GNUstepTypeEncodingParserTest,
+    ::testing::Values(DataModel{"x86_64-pc-linux", 64, 64},
+                      // Windows is LLP64: pointers are 64 bits, long is 32.
+                      DataModel{"x86_64-pc-windows-msvc", 32, 64},
+                      DataModel{"i386-pc-linux", 32, 32}),
+    [](const ::testing::TestParamInfo<DataModel> &info) {
+      std::string name = info.param.triple;
+      for (char &c : name)
+        if (!std::isalnum(static_cast<unsigned char>(c)))
+          c = '_';
+      return name;
+    });
+
+} // namespace

>From 668332e89ae244570dc363551e09740d596ff64e Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 09:33:05 +0100
Subject: [PATCH 32/38] [lldb][GNUstep] Read ivars from libobjc2 metadata

Implements GetNumIVars/GetIVarAtIndex by walking libobjc2's objc_ivar_list
(ivar.h), which is what makes a class's ivars reachable without debug info.
Types come from the encoding parser; name, offset and size are useful on
their own, so an encoding that does not realize leaves the ivar in place
with an empty type rather than dropping it.

The metadata is inferior data and cannot be assumed well-formed, so the
whole list is rejected rather than partially trusted when anything about it
does not add up. Two of the guards are not obvious:

- objc_resolve_class sets objc_class_flag_resolved *before* it calls
  objc_compute_ivar_offsets (class_table.c), so the flag alone does not mean
  the offsets are absolute. Stop anywhere in that window - including a +load
  breakpoint while a sibling class is resolving - and the stored offsets are
  still relative to the start of this class's own ivars.
  objc_compute_ivar_offsets only runs while instance_size is non-positive
  and leaves it positive (ivar.c), so requiring a positive instance_size is
  what closes that window.

- libobjc2 stores a *pointer* to each offset so it can rewrite it in place,
  but between class_addIvar and objc_registerClassPair the field holds the
  offset inline, as a small integer masquerading as a pointer (runtime.c).

The element stride is read from the list rather than assumed, because that
is how the runtime walks the array.

GetTypeBitSize is overridden at the same time, and deliberately in the same
commit: the inherited implementation derives a class's size from the ivar
list as the end of the last ivar, which becomes reachable the moment ivars
are readable. That is wrong for libobjc2 twice over - it ignores trailing
padding, and objc_class::ivars holds only the class's own ivars, so a class
declaring none would report no size at all while its sibling reported one.
instance_size is the runtime's own answer, so use it and decline when it is
not yet available. Note that with debug info present nothing currently
consults this path; the override is what keeps the inherited computation
from answering once it becomes reachable.

IsKVO is also implemented, over objc_class_flag_hidden_class. That is
libobjc2's analogue of an Apple KVO subclass - a class object_getClass()
walks past (runtime.c) - so the inherited GetNonKVOClassDescriptor does the
rest without new machinery.

33 tests across the three data models, covering the accepted shape and each
rejection: a declared stride that is honoured and one that is too small, the
resolved-but-not-laid-out window, an implausible count, an ivar that does
not fit in the object, and an inline offset.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCClassDescriptor.cpp            | 186 ++++++++++++++
 .../GNUstepObjCClassDescriptor.h              |  54 +++++
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp |  31 ++-
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  12 +
 .../GNUstepObjCClassDescriptorTest.cpp        | 229 ++++++++++++++++++
 5 files changed, 507 insertions(+), 5 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
index 061320b590f86..a0fbe22e65e3d 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
@@ -38,6 +38,18 @@ using namespace lldb_private;
 // Flags from libobjc2's `enum objc_class_flags` (class.h).
 static constexpr uint64_t g_class_flag_meta = 1ULL << 0;
 static constexpr uint64_t g_class_flag_resolved = 1ULL << 9;
+static constexpr uint64_t g_class_flag_hidden = 1ULL << 12;
+
+// Bounds on what a well-formed ivar list can say about itself. These are not
+// tuning knobs: the values come from inferior memory, so a corrupted or
+// misidentified structure must not be able to drive an unbounded read.
+static constexpr uint32_t g_max_ivars = 4096;
+static constexpr size_t g_max_type_encoding_length = 1024;
+
+// Element strides are read from the metadata rather than assumed, because
+// that is how the runtime walks these arrays - but a stride this large is
+// not something a compiler emitted.
+static constexpr uint64_t g_max_element_stride = 1024;
 
 // An upper bound for plausible class names. A string that does not terminate
 // within this many bytes is not a class name, and stopping there keeps a
@@ -55,6 +67,8 @@ struct ClassLayout {
   uint64_t name_offset;
   uint64_t info_offset;
   uint64_t instance_size_offset;
+  uint64_t ivars_offset;
+  uint64_t methods_offset;
 };
 
 ClassLayout GetClassLayout(Process &process) {
@@ -70,6 +84,13 @@ ClassLayout GetClassLayout(Process &process) {
   layout.name_offset = 2 * layout.pointer_size;
   layout.info_offset = 3 * layout.pointer_size + layout.long_size;
   layout.instance_size_offset = 3 * layout.pointer_size + 2 * layout.long_size;
+  // `ivars` is the first field after the three `long`s, so it picks up
+  // whatever tail padding the target's alignment requires. Cross-check: two
+  // pointers further on is `dtable`, which libobjc2 pins per data model in
+  // asmconstants.h and asserts in dtable.c.
+  layout.ivars_offset = llvm::alignTo(
+      3 * layout.pointer_size + 3 * layout.long_size, layout.pointer_size);
+  layout.methods_offset = layout.ivars_offset + layout.pointer_size;
   return layout;
 }
 } // namespace
@@ -123,6 +144,7 @@ void GNUstepObjCClassDescriptor::Read() {
   // both directions is what keeps an arbitrary readable address from being
   // accepted as a class.
   const bool is_meta = (info & g_class_flag_meta) != 0;
+  m_is_hidden = (info & g_class_flag_hidden) != 0;
   if (!is_meta) {
     const uint64_t metaclass_info = process_sp->ReadUnsignedIntegerFromMemory(
         metaclass + layout.info_offset, layout.long_size, 0, error);
@@ -133,6 +155,7 @@ void GNUstepObjCClassDescriptor::Read() {
   // Only a resolved class has a real superclass pointer and instance size;
   // see the class documentation.
   const bool resolved = (info & g_class_flag_resolved) != 0;
+  m_resolved = resolved;
   if (resolved) {
     const int64_t instance_size = process_sp->ReadSignedIntegerFromMemory(
         m_isa + layout.instance_size_offset, layout.long_size, 0, error);
@@ -149,6 +172,169 @@ void GNUstepObjCClassDescriptor::Read() {
   m_valid = true;
 }
 
+std::vector<GNUstepObjCClassDescriptor::RawIvar>
+GNUstepObjCClassDescriptor::ReadIvarList() const {
+  std::vector<RawIvar> ivars;
+  ProcessSP process_sp = m_process_wp.lock();
+  if (!process_sp || !m_valid || m_is_meta)
+    return ivars;
+
+  // See the header: the resolved flag is set before the offsets are computed,
+  // and a positive instance_size is what says the computation finished.
+  if (!m_resolved || m_instance_size == 0)
+    return ivars;
+
+  const ClassLayout layout = GetClassLayout(*process_sp);
+  const uint32_t ptr_size = layout.pointer_size;
+
+  Status error;
+  const addr_t list_addr =
+      process_sp->ReadPointerFromMemory(m_isa + layout.ivars_offset, error);
+  if (error.Fail() || list_addr == 0 || list_addr == LLDB_INVALID_ADDRESS ||
+      list_addr % ptr_size != 0)
+    return ivars;
+
+  // struct objc_ivar_list { int count; size_t size; struct objc_ivar[]; }
+  // (ivar.h). `size` is the element stride and is read rather than assumed,
+  // because the runtime uses it to walk the array.
+  const uint64_t count_offset = 0;
+  const uint64_t size_offset = llvm::alignTo(sizeof(uint32_t), ptr_size);
+  const uint64_t entries_offset = size_offset + ptr_size;
+
+  const int64_t count = process_sp->ReadSignedIntegerFromMemory(
+      list_addr + count_offset, sizeof(uint32_t), 0, error);
+  if (error.Fail() || count <= 0 || count > g_max_ivars)
+    return ivars;
+
+  const uint64_t stride = process_sp->ReadUnsignedIntegerFromMemory(
+      list_addr + size_offset, ptr_size, 0, error);
+  // An element smaller than the struct this code knows how to read would make
+  // every field after the first come from the wrong place.
+  const uint64_t min_stride = 3 * ptr_size + 2 * sizeof(uint32_t);
+  if (error.Fail() || stride < min_stride || stride > g_max_element_stride)
+    return ivars;
+
+  Log *log = GetLog(LLDBLog::Types);
+  ivars.reserve(count);
+  for (int64_t i = 0; i < count; ++i) {
+    // struct objc_ivar { const char *name; const char *type; int *offset;
+    //                    uint32_t size; uint32_t flags; }
+    const addr_t entry = list_addr + entries_offset + i * stride;
+
+    const addr_t name_ptr = process_sp->ReadPointerFromMemory(entry, error);
+    if (error.Fail() || name_ptr == 0 || name_ptr == LLDB_INVALID_ADDRESS)
+      return {};
+    const addr_t type_ptr =
+        process_sp->ReadPointerFromMemory(entry + ptr_size, error);
+    if (error.Fail() || type_ptr == 0 || type_ptr == LLDB_INVALID_ADDRESS)
+      return {};
+    const addr_t offset_ptr =
+        process_sp->ReadPointerFromMemory(entry + 2 * ptr_size, error);
+    // libobjc2 stores a pointer to the offset, not the offset itself, so that
+    // the runtime can rewrite it in place. Between class_addIvar and
+    // objc_registerClassPair (runtime.c) the field briefly holds a small
+    // integer masquerading as a pointer, which this alignment check rejects.
+    if (error.Fail() || offset_ptr == 0 || offset_ptr == LLDB_INVALID_ADDRESS ||
+        offset_ptr % sizeof(int32_t) != 0)
+      return {};
+    const uint32_t ivar_size = process_sp->ReadUnsignedIntegerFromMemory(
+        entry + 3 * ptr_size, sizeof(uint32_t), 0, error);
+    if (error.Fail())
+      return {};
+
+    // The offset is a 32-bit signed int wherever libobjc2 runs.
+    const int64_t offset = process_sp->ReadSignedIntegerFromMemory(
+        offset_ptr, sizeof(int32_t), 0, error);
+    if (error.Fail())
+      return {};
+
+    char name_buffer[g_max_class_name_length];
+    const size_t name_length = process_sp->ReadCStringFromMemory(
+        name_ptr, name_buffer, sizeof(name_buffer), error);
+    if (error.Fail() || name_length == 0 ||
+        name_length >= sizeof(name_buffer) - 1)
+      return {};
+
+    char type_buffer[g_max_type_encoding_length];
+    const size_t type_length = process_sp->ReadCStringFromMemory(
+        type_ptr, type_buffer, sizeof(type_buffer), error);
+    if (error.Fail() || type_length >= sizeof(type_buffer) - 1)
+      return {};
+
+    // An ivar that does not fit inside the object is evidence that this is
+    // not really an ivar list, so discard the whole thing rather than
+    // reporting a plausible-looking subset.
+    if (offset < 0 ||
+        static_cast<uint64_t>(offset) + ivar_size > m_instance_size) {
+      LLDB_LOG(log,
+               "GNUstep ivar {0} of {1} lies outside the object "
+               "(offset {2}, size {3}, instance size {4}); ignoring the list",
+               name_buffer, m_name, offset, ivar_size, m_instance_size);
+      return {};
+    }
+
+    RawIvar ivar;
+    ivar.name = ConstString(name_buffer);
+    ivar.type_encoding.assign(type_buffer, type_length);
+    ivar.offset = static_cast<int32_t>(offset);
+    ivar.size = ivar_size;
+    ivars.push_back(std::move(ivar));
+  }
+  return ivars;
+}
+
+void GNUstepObjCClassDescriptor::GetIVarInformation() {
+  if (m_ivars_filled)
+    return;
+
+  std::vector<RawIvar> raw = ReadIvarList();
+
+  // A descriptor is kept in the runtime's ISA map for the life of the
+  // process, so an answer latched here is permanent. Latch only a positive
+  // one: every failure inside ReadIvarList - a class caught mid-resolution,
+  // a list briefly dangling while another thread grows it - also yields an
+  // empty vector, and caching that would report "this class has no ivars"
+  // for the rest of the session. A class that genuinely declares none is
+  // cheap to re-read.
+  if (raw.empty())
+    return;
+  m_ivars_filled = true;
+
+  ProcessSP process_sp = m_process_wp.lock();
+  ObjCLanguageRuntime::EncodingToTypeSP encoding_to_type_sp;
+  if (process_sp) {
+    if (ObjCLanguageRuntime *runtime = ObjCLanguageRuntime::Get(*process_sp))
+      encoding_to_type_sp = runtime->GetEncodingToType();
+  }
+
+  m_ivars.reserve(raw.size());
+  for (const RawIvar &ivar : raw) {
+    iVarDescriptor descriptor;
+    descriptor.m_name = ivar.name;
+    descriptor.m_size = ivar.size;
+    descriptor.m_offset = ivar.offset;
+    // The name, offset and size are useful on their own, so an encoding that
+    // does not realize leaves the ivar in place with an empty type rather
+    // than dropping it.
+    if (encoding_to_type_sp)
+      descriptor.m_type = encoding_to_type_sp->RealizeType(
+          ivar.type_encoding.c_str(), /*for_expression=*/false);
+    m_ivars.push_back(std::move(descriptor));
+  }
+}
+
+size_t GNUstepObjCClassDescriptor::GetNumIVars() {
+  GetIVarInformation();
+  return m_ivars.size();
+}
+
+ObjCLanguageRuntime::ClassDescriptor::iVarDescriptor
+GNUstepObjCClassDescriptor::GetIVarAtIndex(size_t idx) {
+  if (idx >= GetNumIVars())
+    return iVarDescriptor();
+  return m_ivars[idx];
+}
+
 ObjCLanguageRuntime::ClassDescriptorSP
 GNUstepObjCClassDescriptor::GetSuperclass() {
   if (!m_valid || m_superclass_isa == 0)
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
index 25ef25c22400b..26c2975f4b3cd 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
@@ -15,6 +15,8 @@
 #include "lldb/lldb-types.h"
 
 #include <optional>
+#include <string>
+#include <vector>
 
 namespace lldb_private {
 
@@ -80,7 +82,54 @@ class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
   /// that resolves to one is a Class, not an object.
   bool IsMetaclass() const { return m_is_meta; }
 
+  /// True once libobjc2 has resolved the class: before that its superclass
+  /// pointer may still hold a name, its instance size is the negated size of
+  /// its own ivars, and its ivar offsets are not yet absolute. A descriptor
+  /// built in that state is a snapshot of it, so callers that cache
+  /// descriptors must not cache an unresolved one.
+  bool IsResolved() const { return m_resolved; }
+
+  /// libobjc2's analogue of an Apple KVO subclass: a class the runtime hides
+  /// from object_getClass(), which walks past it to the first visible
+  /// superclass (runtime.c). Associated-object classes are the in-tree
+  /// producer (associate.m).
+  bool IsKVO() override { return m_is_hidden; }
+
+  size_t GetNumIVars() override;
+
+  iVarDescriptor GetIVarAtIndex(size_t idx) override;
+
 protected:
+  /// One entry of libobjc2's `objc_ivar_list`, as read from memory. The type
+  /// is kept as its encoding here; turning it into a CompilerType needs the
+  /// runtime, which this class deliberately does not depend on.
+  struct RawIvar {
+    ConstString name;
+    std::string type_encoding;
+    int32_t offset = 0;
+    uint32_t size = 0;
+  };
+
+  /// Reads this class's own ivars, or an empty list if the class has none or
+  /// its metadata is not yet trustworthy. Pure memory reads.
+  ///
+  /// The offsets libobjc2 stores are only meaningful once the runtime has
+  /// computed them, and `objc_class_flag_resolved` alone does not say that:
+  /// objc_resolve_class sets the flag *before* calling
+  /// objc_compute_ivar_offsets (class_table.c), so a stop anywhere in that
+  /// window - including a +load breakpoint while a sibling class is being
+  /// resolved - would otherwise yield offsets relative to the start of this
+  /// class's own ivars rather than to the object. objc_compute_ivar_offsets
+  /// only runs while instance_size is non-positive and leaves it positive
+  /// (ivar.c), so requiring a positive instance_size closes that window.
+  std::vector<RawIvar> ReadIvarList() const;
+
+  /// Realized ivars, filled on first use. Not cached while the class is
+  /// unresolved: a descriptor lives in the runtime's ISA map for the life of
+  /// the process, so caching an unresolved class's ivars would pin
+  /// class-relative offsets forever.
+  void GetIVarInformation();
+
   /// Parse `struct objc_class` at m_isa. Called from the constructor; sets
   /// m_valid only if the structure passes the consistency checks that keep a
   /// stray pointer into readable memory from being reported as a class.
@@ -93,7 +142,12 @@ class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
   ObjCLanguageRuntime::ObjCISA m_metaclass_isa = 0;
   uint64_t m_instance_size = 0;
   bool m_is_meta = false;
+  bool m_is_hidden = false;
+  bool m_resolved = false;
   bool m_valid = false;
+
+  std::vector<iVarDescriptor> m_ivars;
+  bool m_ivars_filled = false;
 };
 
 /// Class descriptor for libobjc2 "small objects" (tagged pointers). The
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index d79a38fbf828a..c392c7a0b42e3 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -1114,7 +1114,13 @@ void GNUstepObjCRuntime::AddClassesFromModule(const ModuleSP &module_sp) {
     name.consume_front(prefix);
     auto descriptor_sp = std::make_shared<GNUstepObjCClassDescriptor>(
         m_process->shared_from_this(), isa);
-    if (descriptor_sp->IsValid())
+    // A descriptor is a snapshot taken when it was built, and the map holds
+    // it for the life of the process. Before the runtime resolves a class its
+    // superclass pointer and instance size are not yet meaningful, and this
+    // sweep runs at the loader's rendezvous stop - which for a dlopen'd image
+    // is *before* its __objc_load. Caching one then would pin an empty class
+    // permanently, so leave it to be rebuilt on demand.
+    if (descriptor_sp->IsValid() && descriptor_sp->IsResolved())
       AddClass(isa, descriptor_sp, name.str().c_str());
   }
 }
@@ -1140,6 +1146,18 @@ GNUstepObjCRuntime::GetClassDescriptor(ValueObject &in_value) {
   return ObjCLanguageRuntime::GetClassDescriptor(in_value);
 }
 
+std::optional<uint64_t>
+GNUstepObjCRuntime::GetTypeBitSize(const CompilerType &compiler_type) {
+  ClassDescriptorSP descriptor_sp =
+      GetClassDescriptorFromClassName(compiler_type.GetTypeName());
+  if (!descriptor_sp || !descriptor_sp->IsValid())
+    return std::nullopt;
+  const uint64_t instance_size = descriptor_sp->GetInstanceSize();
+  if (instance_size == 0)
+    return std::nullopt;
+  return instance_size * 8;
+}
+
 ObjCLanguageRuntime::EncodingToTypeSP
 GNUstepObjCRuntime::GetEncodingToType() {
   if (!m_encoding_to_type_sp)
@@ -1169,10 +1187,13 @@ GNUstepObjCRuntime::GetClassDescriptorFromISA(ObjCISA isa) {
   // class methods look like instance methods and which has no ivars. Cache
   // metaclasses by ISA all the same, so they are not re-parsed on every
   // lookup.
-  if (descriptor_sp->IsMetaclass())
-    AddClass(isa, descriptor_sp);
-  else
-    AddClass(isa, descriptor_sp, descriptor_sp->GetClassName().GetCString());
+  // Only a resolved class is safe to cache; see AddClassesFromModule.
+  if (descriptor_sp->IsResolved()) {
+    if (descriptor_sp->IsMetaclass())
+      AddClass(isa, descriptor_sp);
+    else
+      AddClass(isa, descriptor_sp, descriptor_sp->GetClassName().GetCString());
+  }
   return descriptor_sp;
 }
 
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 6d22d5fd0ede3..51154687c2eaa 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -138,6 +138,18 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   /// the types it returns are only valid in the AST it created them in.
   EncodingToTypeSP GetEncodingToType() override;
 
+  /// Size of an Objective-C class, in bits.
+  ///
+  /// The inherited implementation derives this from the ivar list, as the end
+  /// of the last ivar. That is wrong for libobjc2 twice over: it ignores
+  /// trailing padding, and `objc_class::ivars` holds only the class's *own*
+  /// ivars, so a class that declares none would report nothing at all while
+  /// its sibling reported a size. The runtime already knows the answer -
+  /// `instance_size` is the true body size once the class is resolved - so
+  /// use that, and decline rather than guess when it is not yet available.
+  std::optional<uint64_t>
+  GetTypeBitSize(const CompilerType &compiler_type) override;
+
   /// Lazily-built FunctionCaller for a utility function that resolves a
   /// method implementation via libobjc2's
   /// `IMP objc_msg_lookup(id receiver, SEL selector)`, used by the
diff --git a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
index c19b783a57632..7e567b4207dd4 100644
--- a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
+++ b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
@@ -395,4 +395,233 @@ INSTANTIATE_TEST_SUITE_P(
       return name;
     });
 
+// --- Ivar list tests -------------------------------------------------------
+//
+// The addresses below sit in the same fake block as the class structures.
+constexpr addr_t g_ivar_list_addr = FakeProcess::g_base_addr + 0x600;
+constexpr addr_t g_ivar_names_addr = FakeProcess::g_base_addr + 0x700;
+constexpr addr_t g_ivar_offsets_addr = FakeProcess::g_base_addr + 0x800;
+
+/// One ivar to lay out. `offset` is the value the offset *variable* holds,
+/// which is what libobjc2 points at rather than storing inline.
+struct IvarSpec {
+  const char *name;
+  const char *type_encoding;
+  int32_t offset;
+  uint32_t size;
+};
+
+class GNUstepIvarTest : public GNUstepClassDescriptorTest {
+public:
+  /// sizeof(struct objc_ivar): three pointers then two uint32_t.
+  uint64_t IvarStride() const {
+    return 3 * PointerSize() + 2 * sizeof(uint32_t);
+  }
+
+  /// Offset of the first element of an objc_ivar_list: {int count; size_t
+  /// size;} with the size_t at its natural alignment.
+  uint64_t IvarListHeaderSize() const {
+    return AlignUp(sizeof(uint32_t), PointerSize()) + PointerSize();
+  }
+
+  /// Writes an objc_ivar_list plus the strings and offset variables it
+  /// points at, and returns its address.
+  addr_t WriteIvarList(llvm::ArrayRef<IvarSpec> ivars, uint32_t count_override,
+                       uint64_t stride_override,
+                       uint64_t inline_offset_value = 0) {
+    FakeProcess &process = GetProcess();
+    const uint64_t stride = stride_override ? stride_override : IvarStride();
+    process.WriteInteger(g_ivar_list_addr,
+                         count_override ? count_override : ivars.size(),
+                         sizeof(uint32_t));
+    process.WriteInteger(g_ivar_list_addr +
+                             AlignUp(sizeof(uint32_t), PointerSize()),
+                         stride, PointerSize());
+
+    addr_t name_addr = g_ivar_names_addr;
+    addr_t offset_var = g_ivar_offsets_addr;
+    for (size_t i = 0; i < ivars.size(); ++i) {
+      const addr_t entry = g_ivar_list_addr + IvarListHeaderSize() + i * stride;
+      process.WriteCString(name_addr, ivars[i].name);
+      const addr_t type_addr = name_addr + 0x20;
+      process.WriteCString(type_addr, ivars[i].type_encoding);
+      process.WriteInteger(offset_var, static_cast<uint64_t>(ivars[i].offset),
+                           sizeof(uint32_t));
+
+      process.WriteInteger(entry, name_addr, PointerSize());
+      process.WriteInteger(entry + PointerSize(), type_addr, PointerSize());
+      process.WriteInteger(entry + 2 * PointerSize(),
+                           inline_offset_value ? inline_offset_value
+                                               : offset_var,
+                           PointerSize());
+      process.WriteInteger(entry + 3 * PointerSize(), ivars[i].size,
+                           sizeof(uint32_t));
+      process.WriteInteger(entry + 3 * PointerSize() + sizeof(uint32_t), 0,
+                           sizeof(uint32_t));
+
+      name_addr += 0x40;
+      offset_var += sizeof(uint32_t);
+    }
+    return g_ivar_list_addr;
+  }
+
+  /// Lays out a resolved class with the given ivars and returns a descriptor.
+  GNUstepObjCClassDescriptor
+  MakeClassWithIvars(llvm::ArrayRef<IvarSpec> ivars, uint64_t instance_size,
+                     uint64_t info = g_flag_resolved,
+                     uint32_t count_override = 0, uint64_t stride_override = 0,
+                     uint64_t inline_offset_value = 0) {
+    FakeProcess &process = GetProcess();
+    process.WriteCString(g_name_addr, "Widget");
+    WriteClass(g_metaclass_addr, g_metaclass_addr, 0, g_name_addr,
+               g_flag_meta | g_flag_resolved, 0);
+    WriteClass(g_class_addr, g_metaclass_addr, 0, g_name_addr, info,
+               instance_size);
+    const addr_t list = WriteIvarList(ivars, count_override, stride_override,
+                                      inline_offset_value);
+    process.WriteInteger(g_class_addr + IvarsOffset(), list, PointerSize());
+    return GNUstepObjCClassDescriptor(m_process_sp, g_class_addr);
+  }
+};
+
+TEST_P(GNUstepIvarTest, ReadsIvars) {
+  // Deliberately not pointer-aligned sizes, so a stride computed from the
+  // wrong data model lands on the wrong entry.
+  const IvarSpec ivars[] = {
+      {"_count", "i", 0, 4},
+      {"_name", "@", static_cast<int32_t>(PointerSize()), PointerSize()},
+      {"_flag", "c", static_cast<int32_t>(2 * PointerSize()), 1},
+  };
+  GNUstepObjCClassDescriptor descriptor =
+      MakeClassWithIvars(ivars, 3 * PointerSize());
+
+  ASSERT_EQ(descriptor.GetNumIVars(), 3u);
+  EXPECT_EQ(descriptor.GetIVarAtIndex(0).m_name, ConstString("_count"));
+  EXPECT_EQ(descriptor.GetIVarAtIndex(0).m_offset, 0);
+  EXPECT_EQ(descriptor.GetIVarAtIndex(0).m_size, 4u);
+  EXPECT_EQ(descriptor.GetIVarAtIndex(1).m_name, ConstString("_name"));
+  EXPECT_EQ(descriptor.GetIVarAtIndex(1).m_offset,
+            static_cast<int32_t>(PointerSize()));
+  EXPECT_EQ(descriptor.GetIVarAtIndex(2).m_name, ConstString("_flag"));
+  EXPECT_EQ(descriptor.GetIVarAtIndex(2).m_offset,
+            static_cast<int32_t>(2 * PointerSize()));
+}
+
+// An out-of-range index must be inert rather than reading past the vector.
+TEST_P(GNUstepIvarTest, RejectsOutOfRangeIndex) {
+  const IvarSpec ivars[] = {{"_count", "i", 0, 4}};
+  GNUstepObjCClassDescriptor descriptor = MakeClassWithIvars(ivars, 8);
+  ASSERT_EQ(descriptor.GetNumIVars(), 1u);
+  EXPECT_FALSE(descriptor.GetIVarAtIndex(1).m_name);
+  EXPECT_FALSE(descriptor.GetIVarAtIndex(1000).m_name);
+}
+
+// The runtime walks the array by the stride the list declares, so this code
+// must too rather than assuming sizeof(objc_ivar).
+TEST_P(GNUstepIvarTest, HonorsDeclaredStride) {
+  const IvarSpec ivars[] = {{"_a", "i", 0, 4}, {"_b", "i", 4, 4}};
+  GNUstepObjCClassDescriptor descriptor =
+      MakeClassWithIvars(ivars, 16, g_flag_resolved, 0, IvarStride() + 8);
+  ASSERT_EQ(descriptor.GetNumIVars(), 2u);
+  EXPECT_EQ(descriptor.GetIVarAtIndex(1).m_name, ConstString("_b"));
+  EXPECT_EQ(descriptor.GetIVarAtIndex(1).m_offset, 4);
+}
+
+// A stride smaller than the struct would make every field after the first
+// come from the wrong place.
+TEST_P(GNUstepIvarTest, RejectsUndersizedStride) {
+  const IvarSpec ivars[] = {{"_a", "i", 0, 4}};
+  GNUstepObjCClassDescriptor descriptor =
+      MakeClassWithIvars(ivars, 16, g_flag_resolved, 0, PointerSize());
+  EXPECT_EQ(descriptor.GetNumIVars(), 0u);
+}
+
+// objc_resolve_class sets objc_class_flag_resolved *before* it calls
+// objc_compute_ivar_offsets, so the flag alone does not mean the offsets are
+// absolute yet. A non-positive instance_size is what marks that window, and
+// reporting ivars inside it would give offsets relative to the start of this
+// class's own ivars.
+TEST_P(GNUstepIvarTest, RejectsClassResolvedButNotYetLaidOut) {
+  const IvarSpec ivars[] = {{"_a", "i", 0, 4}};
+  GNUstepObjCClassDescriptor descriptor =
+      MakeClassWithIvars(ivars, 0, g_flag_resolved);
+  EXPECT_EQ(descriptor.GetNumIVars(), 0u);
+}
+
+TEST_P(GNUstepIvarTest, RejectsUnresolvedClass) {
+  const IvarSpec ivars[] = {{"_a", "i", 0, 4}};
+  GNUstepObjCClassDescriptor descriptor = MakeClassWithIvars(ivars, 16, 0);
+  EXPECT_EQ(descriptor.GetNumIVars(), 0u);
+}
+
+// A count the structure could not possibly hold is evidence this is not an
+// ivar list at all.
+TEST_P(GNUstepIvarTest, RejectsImplausibleCount) {
+  const IvarSpec ivars[] = {{"_a", "i", 0, 4}};
+  GNUstepObjCClassDescriptor huge =
+      MakeClassWithIvars(ivars, 16, g_flag_resolved, 0x10000);
+  EXPECT_EQ(huge.GetNumIVars(), 0u);
+
+  GNUstepObjCClassDescriptor negative =
+      MakeClassWithIvars(ivars, 16, g_flag_resolved, static_cast<uint32_t>(-1));
+  EXPECT_EQ(negative.GetNumIVars(), 0u);
+}
+
+// An ivar that does not fit inside the object means the list was
+// misidentified, so none of it can be trusted - not just that entry.
+TEST_P(GNUstepIvarTest, RejectsIvarOutsideTheObject) {
+  const IvarSpec ivars[] = {{"_a", "i", 0, 4}, {"_b", "i", 4096, 4}};
+  GNUstepObjCClassDescriptor descriptor = MakeClassWithIvars(ivars, 16);
+  EXPECT_EQ(descriptor.GetNumIVars(), 0u);
+}
+
+// Between class_addIvar and objc_registerClassPair, libobjc2 stores the
+// offset inline in the pointer field (runtime.c), so it is a small integer
+// masquerading as a pointer rather than something safe to dereference.
+TEST_P(GNUstepIvarTest, RejectsInlineOffsetMasqueradingAsPointer) {
+  // Only one class is laid out per test: Process caches the memory it reads,
+  // so writing a second variant over the same addresses would be masked by
+  // the first descriptor's reads. ReadsIvars covers the accepted shape.
+  const IvarSpec ivars[] = {{"_a", "i", 0, 4}};
+  GNUstepObjCClassDescriptor broken =
+      MakeClassWithIvars(ivars, 16, g_flag_resolved, 0, 0,
+                         /*inline_offset_value=*/3);
+  EXPECT_EQ(broken.GetNumIVars(), 0u);
+}
+
+TEST_P(GNUstepIvarTest, ClassWithNoIvarListHasNone) {
+  FakeProcess &process = GetProcess();
+  process.WriteCString(g_name_addr, "Widget");
+  WriteClass(g_metaclass_addr, g_metaclass_addr, 0, g_name_addr,
+             g_flag_meta | g_flag_resolved, 0);
+  WriteClass(g_class_addr, g_metaclass_addr, 0, g_name_addr, g_flag_resolved,
+             16);
+  process.WriteInteger(g_class_addr + IvarsOffset(), 0, PointerSize());
+  GNUstepObjCClassDescriptor descriptor(m_process_sp, g_class_addr);
+  EXPECT_EQ(descriptor.GetNumIVars(), 0u);
+}
+
+// A metaclass describes the class object, which has no ivars of its own.
+TEST_P(GNUstepIvarTest, MetaclassHasNoIvars) {
+  const IvarSpec ivars[] = {{"_a", "i", 0, 4}};
+  MakeClassWithIvars(ivars, 16);
+  GetProcess().WriteInteger(g_metaclass_addr + IvarsOffset(), g_ivar_list_addr,
+                            PointerSize());
+  GNUstepObjCClassDescriptor metaclass(m_process_sp, g_metaclass_addr);
+  EXPECT_EQ(metaclass.GetNumIVars(), 0u);
+}
+
+INSTANTIATE_TEST_SUITE_P(DataModels, GNUstepIvarTest,
+                         ::testing::Values(DataModel{"x86_64-pc-linux", 8, 8},
+                                           DataModel{"x86_64-pc-windows-msvc",
+                                                     8, 4},
+                                           DataModel{"i386-pc-linux", 4, 4}),
+                         [](const ::testing::TestParamInfo<DataModel> &info) {
+                           std::string name = info.param.triple;
+                           for (char &c : name)
+                             if (!std::isalnum(static_cast<unsigned char>(c)))
+                               c = '_';
+                           return name;
+                         });
+
 } // namespace

>From cf49fd717f83fc1a9c619b7ed14bc77535658fe7 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 09:39:07 +0100
Subject: [PATCH 33/38] [lldb][GNUstep] Fall back to runtime metadata for
 ivars, and format NSURL

Formatters reached ivars only through debug info. gnustep-base hides several
classes' ivars behind GS_EXPOSE, so a build against the installed headers
sees an @interface with no ivars at all, and a stripped build has none
either - which is why NSURL was previously left uncovered.

GNUstepGetIvar now falls back to libobjc2's own ivar metadata when debug
info has nothing. Debug info stays the preferred source, because it is the
only one that describes members *inside* a struct-typed ivar: libobjc2's
encodings expand struct members positionally with no field names
(`{_NSRange=QQ}`), so `map.nodeCount` and friends remain reachable only
through DWARF. Flat ivars - which is what the string, number, array, data
and date formatters read - are now reachable either way.

With that in place, NSURL formats. Reaching _urlString and _baseURL by name
also avoids the fixed offsets Apple's provider computes, which is what makes
this correct across data models without a per-platform table.

Assisted-by: Claude Opus 5
---
 .../Language/ObjC/GNUstepFormatters.cpp       | 110 +++++++++++++++++-
 .../Plugins/Language/ObjC/GNUstepFormatters.h |  29 ++++-
 .../TestGNUstepDataFormatters.py              |   6 +
 3 files changed, 135 insertions(+), 10 deletions(-)

diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp
index 93995df8da654..6fa432e966796 100644
--- a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp
@@ -52,7 +52,56 @@ ValueObjectSP lldb_private::formatters::GNUstepGetIvar(ValueObject &valobj,
   if (ValueObjectSP dynamic_sp =
           object_sp->GetDynamicValue(lldb::eDynamicDontRunTarget))
     object_sp = dynamic_sp;
-  return object_sp->GetChildMemberWithName(name);
+  if (ValueObjectSP child_sp = object_sp->GetChildMemberWithName(name))
+    return child_sp;
+
+  // Debug info is the better source - it knows about members inside a
+  // struct-typed ivar, which the runtime's encodings cannot express - but it
+  // is not always there. gnustep-base hides several classes' ivars behind
+  // GS_EXPOSE, so a consumer of the installed headers sees an @interface
+  // with no ivars at all, and a stripped build has none either. The runtime
+  // metadata still describes them, so fall back to it.
+  return GNUstepGetIvarFromRuntime(*object_sp, name);
+}
+
+ValueObjectSP
+lldb_private::formatters::GNUstepGetIvarFromRuntime(ValueObject &valobj,
+                                                    llvm::StringRef name) {
+  ProcessSP process_sp = valobj.GetProcessSP();
+  if (!process_sp)
+    return {};
+  auto *runtime = llvm::dyn_cast_or_null<GNUstepObjCRuntime>(
+      ObjCLanguageRuntime::Get(*process_sp));
+  if (!runtime)
+    return {};
+
+  const addr_t object_addr = valobj.GetValueAsUnsigned(LLDB_INVALID_ADDRESS);
+  if (object_addr == 0 || object_addr == LLDB_INVALID_ADDRESS)
+    return {};
+
+  // An ivar declared by a superclass is just as much a part of the object, so
+  // walk up until it is found. Offsets are absolute, so no adjustment is
+  // needed at each step.
+  ObjCLanguageRuntime::ClassDescriptorSP descriptor_sp =
+      runtime->GetClassDescriptor(valobj);
+  // The chain comes from inferior memory, so bound the walk rather than
+  // trusting it to terminate.
+  static constexpr uint32_t g_max_superclass_depth = 256;
+  const ConstString ivar_name(name);
+  for (uint32_t depth = 0; descriptor_sp && depth < g_max_superclass_depth;
+       ++depth, descriptor_sp = descriptor_sp->GetSuperclass()) {
+    const size_t num_ivars = descriptor_sp->GetNumIVars();
+    for (size_t i = 0; i < num_ivars; ++i) {
+      const auto &ivar = descriptor_sp->GetIVarAtIndex(i);
+      if (ivar.m_name != ivar_name)
+        continue;
+      if (!ivar.m_type)
+        return {};
+      return valobj.GetSyntheticChildAtOffset(ivar.m_offset, ivar.m_type,
+                                              /*can_create=*/true, ivar_name);
+    }
+  }
+  return {};
 }
 
 std::optional<double>
@@ -127,8 +176,57 @@ double lldb_private::formatters::GNUstepDecodeSmallDate(uint64_t ptr) {
   return value;
 }
 
+// A URL relative to a base that is itself relative is real, but a chain
+// longer than this is corrupt or circular.
+static constexpr uint32_t g_max_url_base_depth = 16;
+
 // --- Small providers -------------------------------------------------------
 
+// Recursion is kept out of the registered provider, which must match the
+// summary-callback signature exactly.
+static bool GNUstepNSURLSummary(ValueObject &valobj, Stream &stream,
+                                const TypeSummaryOptions &options,
+                                uint32_t depth) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  // gnustep-base keeps NSURL's ivars behind GS_EXPOSE(NSURL), so a build
+  // against the installed headers has no debug info for them. Reaching them
+  // by name through the runtime's own metadata avoids the fixed offsets
+  // Apple's provider has to use.
+  ValueObjectSP url_sp = GNUstepGetIvar(valobj, "_urlString");
+  if (!url_sp || url_sp->GetValueAsUnsigned(0) == 0)
+    return false;
+
+  // A relative URL keeps the base it was resolved against, which is how
+  // -absoluteString presents it too. The chain is inferior data and a URL
+  // whose base is itself would otherwise recurse until the stack runs out,
+  // and this runs whenever the value is merely displayed.
+  StreamString base_summary;
+  if (depth < g_max_url_base_depth) {
+    ValueObjectSP base_sp = GNUstepGetIvar(valobj, "_baseURL");
+    if (base_sp && base_sp->GetValueAsUnsigned(0) != 0)
+      if (!GNUstepNSURLSummary(*base_sp, base_summary, options, depth + 1))
+        base_summary.Clear();
+  }
+
+  if (base_summary.Empty())
+    return GNUstepNSStringSummaryProvider(*url_sp, stream, options);
+
+  StreamString url_summary;
+  if (!GNUstepNSStringSummaryProvider(*url_sp, url_summary, options))
+    return false;
+  llvm::StringRef url_text = url_summary.GetString();
+  llvm::StringRef base_text = base_summary.GetString();
+  // Both arrive quoted as @"...", and the pair reads better as one string.
+  url_text.consume_front("@\"");
+  url_text.consume_back("\"");
+  base_text.consume_front("@\"");
+  base_text.consume_back("\"");
+  stream.Printf("@\"%s -- %s\"", url_text.str().c_str(),
+                base_text.str().c_str());
+  return true;
+}
+
 bool lldb_private::formatters::GNUstepNSNullSummaryProvider(
     ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
   if (!IsGNUstepObjCRuntime(valobj))
@@ -279,7 +377,11 @@ void lldb_private::formatters::LoadGNUstepFormatters(
   AddCXXSummary(objc_category_sp, GNUstepNSNullSummaryProvider,
                 "GNUstep NSNull summary provider", "NSNull", summary_flags);
 
-  // Not covered: NSURL. gnustep-base declares its ivars behind
-  // GS_EXPOSE(NSURL), so they are in neither the debug info nor the
-  // __objc_ivar_offset symbols of a normal build; `po` still describes it.
+  AddCXXSummary(objc_category_sp, GNUstepNSURLSummaryProvider,
+                "GNUstep NSURL summary provider", "NSURL", summary_flags);
+}
+
+bool lldb_private::formatters::GNUstepNSURLSummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  return GNUstepNSURLSummary(valobj, stream, options, /*depth=*/0);
 }
diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h
index 2ceda0aca08f7..a7f4c8b6490da 100644
--- a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h
@@ -12,9 +12,10 @@
 // Like the Apple formatters these never run code in the inferior. Unlike them
 // they do not hardcode ivar offsets: libobjc2 packs instance sizes and the
 // widths of `long`-typed ivars differ between LP64 and LLP64, so ivars are
-// looked up by name through the debug info attached to the value's dynamic
-// type. Small objects (libobjc2's tagged pointers) are decoded from the
-// pointer bits alone.
+// looked up by name - through the debug info attached to the value's dynamic
+// type where there is any, and through libobjc2's own metadata otherwise.
+// Small objects (libobjc2's tagged pointers) are decoded from the pointer
+// bits alone.
 //
 //===----------------------------------------------------------------------===//
 
@@ -42,11 +43,24 @@ bool IsGNUstepObjCRuntime(ValueObject &valobj);
 /// runtime-reported class name of a value.
 void LoadGNUstepFormatters(lldb::TypeCategoryImplSP objc_category_sp);
 
-/// Finds the ivar \p name of the object \p valobj points at, using the debug
-/// info of its dynamic type. Returns an empty pointer when the ivar is not
-/// visible, which the callers treat as "cannot format".
+/// Returns the ivar named \p name on \p valobj, or an empty pointer if it
+/// cannot be reached, which the callers treat as "cannot format".
+///
+/// Debug info is preferred, because it is the only source that describes
+/// members *inside* a struct-typed ivar. Where it has nothing - gnustep-base
+/// hides several classes' ivars behind GS_EXPOSE, and a stripped build has
+/// none at all - libobjc2's own metadata is used instead.
 lldb::ValueObjectSP GNUstepGetIvar(ValueObject &valobj, llvm::StringRef name);
 
+/// Finds an ivar using only libobjc2's runtime metadata, for classes whose
+/// ivars debug info does not describe - gnustep-base hides several behind
+/// GS_EXPOSE, and a stripped build has none. Only reaches an ivar the class
+/// or one of its superclasses declares directly: the runtime's encodings
+/// expand struct members positionally, with no field names, so a member
+/// *inside* a struct-typed ivar is not addressable this way.
+lldb::ValueObjectSP GNUstepGetIvarFromRuntime(ValueObject &valobj,
+                                              llvm::StringRef name);
+
 /// The value of a floating-point ValueObject as a double, or nullopt if it
 /// cannot be read as one.
 std::optional<double> GNUstepGetFloatValue(ValueObject &valobj);
@@ -85,6 +99,9 @@ bool GNUstepNSSetSummaryProvider(ValueObject &valobj, Stream &stream,
                                  const TypeSummaryOptions &options);
 bool GNUstepNSDataSummaryProvider(ValueObject &valobj, Stream &stream,
                                   const TypeSummaryOptions &options);
+bool GNUstepNSURLSummaryProvider(ValueObject &valobj, Stream &stream,
+                                 const TypeSummaryOptions &options);
+
 bool GNUstepNSNullSummaryProvider(ValueObject &valobj, Stream &stream,
                                   const TypeSummaryOptions &options);
 
diff --git a/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py b/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
index 6c71fa24ed360..791ae3d877ad9 100644
--- a/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/TestGNUstepDataFormatters.py
@@ -125,6 +125,12 @@ def test_others(self):
             "frame variable -d run-target someDate", substrs=["2023-11-14 22:13:20 UTC"]
         )
         self.expect("frame variable -d run-target null", substrs=["<null>"])
+        # NSURL keeps its ivars behind GS_EXPOSE, so nothing here has debug
+        # info for them; this only works by reading libobjc2's own metadata.
+        self.expect(
+            "frame variable -d run-target url",
+            substrs=['@"https://www.gnustep.org/resources"'],
+        )
         self.expect("frame variable -d run-target nilObject", substrs=["nil"])
         # A custom class: dynamic type plus formatted ivars, and its class
         # object is not itself presented as an instance.

>From b9cdf46286626fbab92b814a60ffcf7b14fab96f Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 09:48:06 +0100
Subject: [PATCH 34/38] [lldb] Fix a crash listing a frame recognizer with no
 module

A recognizer meant to match in any module is registered with an empty module
name. ConstString::GetCString() returns null for an empty string, and
StackFrameRecognizerManager::ForEach passes it straight into a std::string
parameter, so `frame recognizer list` crashed in strlen.

Nothing hit this before because every in-tree recognizer names a module, and
`frame recognizer add` rejects a missing -s, so the case is only reachable
through the API. It comes up for a language runtime whose library is named
differently on each platform, which is why the existing NullModuleRegex test
covers the regex path but not this one.

Assisted-by: Claude Opus 5
---
 lldb/source/Target/StackFrameRecognizer.cpp   |  7 +++--
 .../Target/StackFrameRecognizerTest.cpp       | 29 +++++++++++++++++++
 2 files changed, 34 insertions(+), 2 deletions(-)

diff --git a/lldb/source/Target/StackFrameRecognizer.cpp b/lldb/source/Target/StackFrameRecognizer.cpp
index 99ef837ec580b..65b3e525522a1 100644
--- a/lldb/source/Target/StackFrameRecognizer.cpp
+++ b/lldb/source/Target/StackFrameRecognizer.cpp
@@ -139,9 +139,12 @@ void StackFrameRecognizerManager::ForEach(
                module_name, llvm::ArrayRef(ConstString(symbol_name)),
                entry.symbol_mangling, true);
     } else {
+      // A recognizer that matches in any module has no module name, and
+      // ConstString::GetCString() is null for an empty string, which the
+      // std::string parameter cannot be constructed from.
       callback(entry.recognizer_id, entry.enabled, entry.recognizer->GetName(),
-               entry.module.GetCString(), entry.symbols, entry.symbol_mangling,
-               false);
+               entry.module.GetStringRef().str(), entry.symbols,
+               entry.symbol_mangling, false);
     }
   }
 }
diff --git a/lldb/unittests/Target/StackFrameRecognizerTest.cpp b/lldb/unittests/Target/StackFrameRecognizerTest.cpp
index b356cd87d54ac..9b5b269bc9e40 100644
--- a/lldb/unittests/Target/StackFrameRecognizerTest.cpp
+++ b/lldb/unittests/Target/StackFrameRecognizerTest.cpp
@@ -76,3 +76,32 @@ TEST_F(StackFrameRecognizerTest, NullModuleRegex) {
 
   EXPECT_TRUE(any_printed);
 }
+
+// A recognizer that should match in any module is registered with an empty
+// module name. ConstString::GetCString() is null for an empty string, and
+// ForEach hands that to a std::string parameter, so this used to crash. The
+// command layer requires a module, so only a recognizer registered through
+// the API - as the GNUstep ObjC runtime's does, because the runtime library
+// is named differently on each platform - can reach it.
+TEST_F(StackFrameRecognizerTest, NullModuleName) {
+  DebuggerSP debugger_sp = Debugger::CreateInstance();
+  ASSERT_TRUE(debugger_sp);
+
+  StackFrameRecognizerManager manager;
+  std::vector<ConstString> symbols = {ConstString("boom")};
+  manager.AddRecognizer(std::make_shared<DummyStackFrameRecognizer>(),
+                        ConstString(), symbols,
+                        Mangled::NamePreference::ePreferDemangled, false);
+
+  bool any_printed = false;
+  std::string reported_module = "unset";
+  manager.ForEach([&](uint32_t recognizer_id, bool enabled, std::string name,
+                      std::string module, llvm::ArrayRef<ConstString> symbols,
+                      Mangled::NamePreference symbol_mangling, bool regexp) {
+    any_printed = true;
+    reported_module = module;
+  });
+
+  EXPECT_TRUE(any_printed);
+  EXPECT_EQ(reported_module, "");
+}

>From 0a96a8d63485aa630e972228014e8bf104e4d033 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 09:48:08 +0100
Subject: [PATCH 35/38] [lldb][GNUstep] Format NSException, and match the throw
 frame by mangled name

Adds an NSException summary provider. gnustep-base's NSException has three
ivars - _e_name, _e_reason and _reserved, not the four Apple's does - and
they sit behind GS_EXPOSE(NSException), so this works only through the
runtime-metadata fallback. userInfo lives inside _reserved and is not
reachable by name, so it is not reported.

Also fixes the exception frame recognizer, which did not fire against any
libobjc2 carrying debug info. It was registered to match on the demangled
name, following AppleObjCRuntimeV2. libobjc2's Windows exception back-end is
C++ (eh_win32_msvc.cc), so with debug info present the demangled name of
this extern "C" function is `::objc_exception_throw(id)` rather than the
bare symbol, and the comparison failed. Preferring the mangled name gives
`objc_exception_throw` there, and falls back to the same string through the
symbol when there is no debug info.

The Shell test did not catch this: on Windows it stopped on the executable's
import thunk, which has no debug info, so it exercised the fallback rather
than the path a real runtime takes. The new API test runs against
gnustep-base, where the throw happens inside the runtime library itself.

Assisted-by: Claude Opus 5
---
 .../Language/ObjC/GNUstepFormatters.cpp       | 33 ++++++--
 .../Plugins/Language/ObjC/GNUstepFormatters.h |  3 +
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 63 ++++++++++++---
 .../API/lang/objc-gnustep/exceptions/Makefile |  3 +
 .../exceptions/TestGNUstepExceptions.py       | 79 +++++++++++++++++++
 .../lang/objc-gnustep/exceptions/categories   |  1 +
 .../API/lang/objc-gnustep/exceptions/main.m   | 20 +++++
 7 files changed, 185 insertions(+), 17 deletions(-)
 create mode 100644 lldb/test/API/lang/objc-gnustep/exceptions/Makefile
 create mode 100644 lldb/test/API/lang/objc-gnustep/exceptions/TestGNUstepExceptions.py
 create mode 100644 lldb/test/API/lang/objc-gnustep/exceptions/categories
 create mode 100644 lldb/test/API/lang/objc-gnustep/exceptions/main.m

diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp
index 6fa432e966796..c7ca2dae23fea 100644
--- a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.cpp
@@ -227,6 +227,30 @@ static bool GNUstepNSURLSummary(ValueObject &valobj, Stream &stream,
   return true;
 }
 
+bool lldb_private::formatters::GNUstepNSURLSummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  return GNUstepNSURLSummary(valobj, stream, options, /*depth=*/0);
+}
+
+bool lldb_private::formatters::GNUstepNSExceptionSummaryProvider(
+    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
+  if (!IsGNUstepObjCRuntime(valobj))
+    return false;
+  // gnustep-base's NSException has three ivars - _e_name, _e_reason and
+  // _reserved - not the four Apple's does, and they sit behind
+  // GS_EXPOSE(NSException), so this depends on the runtime-metadata
+  // fallback. userInfo lives inside _reserved and is not reachable by name.
+  ValueObjectSP reason_sp = GNUstepGetIvar(valobj, "_e_reason");
+  if (!reason_sp || reason_sp->GetValueAsUnsigned(0) == 0) {
+    // A raised exception always has a name even when it carries no reason.
+    ValueObjectSP name_sp = GNUstepGetIvar(valobj, "_e_name");
+    if (!name_sp || name_sp->GetValueAsUnsigned(0) == 0)
+      return false;
+    return GNUstepNSStringSummaryProvider(*name_sp, stream, options);
+  }
+  return GNUstepNSStringSummaryProvider(*reason_sp, stream, options);
+}
+
 bool lldb_private::formatters::GNUstepNSNullSummaryProvider(
     ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
   if (!IsGNUstepObjCRuntime(valobj))
@@ -377,11 +401,10 @@ void lldb_private::formatters::LoadGNUstepFormatters(
   AddCXXSummary(objc_category_sp, GNUstepNSNullSummaryProvider,
                 "GNUstep NSNull summary provider", "NSNull", summary_flags);
 
+  AddCXXSummary(objc_category_sp, GNUstepNSExceptionSummaryProvider,
+                "GNUstep NSException summary provider", "NSException",
+                summary_flags);
+
   AddCXXSummary(objc_category_sp, GNUstepNSURLSummaryProvider,
                 "GNUstep NSURL summary provider", "NSURL", summary_flags);
 }
-
-bool lldb_private::formatters::GNUstepNSURLSummaryProvider(
-    ValueObject &valobj, Stream &stream, const TypeSummaryOptions &options) {
-  return GNUstepNSURLSummary(valobj, stream, options, /*depth=*/0);
-}
diff --git a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h
index a7f4c8b6490da..2ef396da1f64a 100644
--- a/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h
+++ b/lldb/source/Plugins/Language/ObjC/GNUstepFormatters.h
@@ -102,6 +102,9 @@ bool GNUstepNSDataSummaryProvider(ValueObject &valobj, Stream &stream,
 bool GNUstepNSURLSummaryProvider(ValueObject &valobj, Stream &stream,
                                  const TypeSummaryOptions &options);
 
+bool GNUstepNSExceptionSummaryProvider(ValueObject &valobj, Stream &stream,
+                                       const TypeSummaryOptions &options);
+
 bool GNUstepNSNullSummaryProvider(ValueObject &valobj, Stream &stream,
                                   const TypeSummaryOptions &options);
 
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index c392c7a0b42e3..42994c35922fa 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -57,6 +57,12 @@ using namespace lldb_private;
 
 LLDB_PLUGIN_DEFINE(GNUstepObjCRuntime)
 
+// A type encoding longer than this, or an ISA chain deeper than this, is not
+// something a compiler produced. Both come from inferior memory, so they are
+// bounded rather than trusted.
+static constexpr size_t g_max_type_encoding_length = 1024;
+static constexpr uint32_t g_max_superclass_depth = 256;
+
 namespace {
 /// Registers the Objective-C selectors of a JIT'd expression module with the
 /// libobjc2 runtime.
@@ -216,6 +222,7 @@ class GNUstepObjCExceptionRecognizedStackFrame : public RecognizedStackFrame {
 };
 
 class GNUstepObjCExceptionThrowFrameRecognizer : public StackFrameRecognizer {
+public:
   RecognizedStackFrameSP RecognizeFrame(StackFrameSP frame) override {
     return std::make_shared<GNUstepObjCExceptionRecognizedStackFrame>(frame);
   }
@@ -228,12 +235,37 @@ class GNUstepObjCExceptionThrowFrameRecognizer : public StackFrameRecognizer {
 /// objc.dll, ...), so the recognizer is registered without a module filter
 /// and matched on the symbol alone; an empty module ConstString matches any
 /// module (StackFrameRecognizer.cpp).
+///
+/// The mangled name is what has to match. libobjc2's Windows exception
+/// back-end is C++ (eh_win32_msvc.cc), so where the runtime carries debug
+/// info the demangled name of this extern "C" function is
+/// `::objc_exception_throw(id)` rather than the bare symbol. Preferring the
+/// mangled name gives `objc_exception_throw` there, and falls back to the
+/// same string through the symbol when there is no debug info at all.
 void RegisterGNUstepObjCExceptionRecognizer(Process *process) {
   static const std::vector<ConstString> g_symbols = {
       ConstString("objc_exception_throw")};
-  process->GetTarget().GetFrameRecognizerManager().AddRecognizer(
+  static const std::string g_name =
+      GNUstepObjCExceptionThrowFrameRecognizer().GetName();
+
+  // A runtime is created per Process but recognizers live on the Target, so
+  // re-running the same target would otherwise stack up a copy per run, each
+  // consulted for every frame.
+  StackFrameRecognizerManager &manager =
+      process->GetTarget().GetFrameRecognizerManager();
+  bool already_registered = false;
+  manager.ForEach([&](uint32_t, bool, std::string name, std::string,
+                      llvm::ArrayRef<ConstString>, Mangled::NamePreference,
+                      bool) {
+    if (name == g_name)
+      already_registered = true;
+  });
+  if (already_registered)
+    return;
+
+  manager.AddRecognizer(
       std::make_shared<GNUstepObjCExceptionThrowFrameRecognizer>(),
-      ConstString(), g_symbols, Mangled::NamePreference::ePreferDemangled,
+      ConstString(), g_symbols, Mangled::NamePreference::ePreferMangled,
       /*first_instruction_only=*/true);
 }
 } // namespace
@@ -750,16 +782,20 @@ GNUstepObjCRuntime::GetExceptionObjectForThread(ThreadSP thread_sp) {
   if (!thread_sp || !thread_sp->SafeToCallFunctions())
     return {};
 
-  // libobjc2 picks one of three exception back-ends at build time
-  // (CMakeLists.txt): Itanium unwinding on ELF and Mach-O, __cxa_* over SEH
-  // on MinGW, and native MSVC exceptions on Windows. Only the first two are
-  // reachable through the C++ runtime, so recovering the object mid-unwind
-  // is inherently platform-specific.
+  // Stopped at the throw itself the object is simply argument 0, which holds
+  // on every one of libobjc2's exception back-ends and is where the frame
+  // recognizer already presents it. That is the path that works, so prefer
+  // it.
   //
-  // Stopped at the throw itself, though, the object is simply argument 0 -
-  // which holds on every back-end, and is where the frame recognizer already
-  // presents it. Prefer that, and fall back to the C++ runtime for a stop
-  // further into the unwind.
+  // Further into the unwind there is nothing runtime-independent to read, so
+  // ask the C++ runtime. Note this does not currently recover anything on
+  // ELF: libobjc2 raises with its own exception class rather than through
+  // __cxa_throw, so libstdc++ never records the exception and
+  // __cxa_current_exception_type() returns null - measured both at
+  // objc_begin_catch and inside the handler body. It is kept for the
+  // back-ends that do unwind through __cxa_* (MinGW), where it has not been
+  // verified either. `thread exception` is therefore reliable at the throw
+  // site and empty elsewhere.
   if (StackFrameSP frame_sp = thread_sp->GetStackFrameAtIndex(0)) {
     if (RecognizedStackFrameSP recognized_sp = frame_sp->GetRecognizedFrame()) {
       if (ValueObjectSP exception_sp = recognized_sp->GetExceptionObject())
@@ -782,8 +818,11 @@ GNUstepObjCRuntime::GetExceptionObjectForThread(ThreadSP thread_sp) {
   if (!descriptor_sp || !descriptor_sp->IsValid())
     return {};
 
+  // The chain comes from inferior memory, so bound the walk rather than
+  // trusting it to terminate.
   static const ConstString g_NSException("NSException");
-  for (; descriptor_sp; descriptor_sp = descriptor_sp->GetSuperclass()) {
+  for (uint32_t depth = 0; descriptor_sp && depth < g_max_superclass_depth;
+       ++depth, descriptor_sp = descriptor_sp->GetSuperclass()) {
     if (descriptor_sp->GetClassName() == g_NSException)
       return cpp_exception_sp;
   }
diff --git a/lldb/test/API/lang/objc-gnustep/exceptions/Makefile b/lldb/test/API/lang/objc-gnustep/exceptions/Makefile
new file mode 100644
index 0000000000000..845553d5e3f2f
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/exceptions/Makefile
@@ -0,0 +1,3 @@
+OBJC_SOURCES := main.m
+
+include Makefile.rules
diff --git a/lldb/test/API/lang/objc-gnustep/exceptions/TestGNUstepExceptions.py b/lldb/test/API/lang/objc-gnustep/exceptions/TestGNUstepExceptions.py
new file mode 100644
index 0000000000000..01e36bae73ca8
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/exceptions/TestGNUstepExceptions.py
@@ -0,0 +1,79 @@
+"""
+Test Objective-C exception support against gnustep-base's NSException.
+"""
+
+import lldb
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+from lldbsuite.test import lldbutil
+
+
+class TestGNUstepExceptions(TestBase):
+    def test_formatter_in_handler(self):
+        """An NSException formats by its reason, from runtime metadata.
+
+        gnustep-base declares _e_name and _e_reason behind
+        GS_EXPOSE(NSException), so nothing here has debug info for them.
+        """
+        self.build()
+        lldbutil.run_to_source_breakpoint(
+            self, "// break in handler", lldb.SBFileSpec("main.m")
+        )
+        self.runCmd("settings set target.prefer-dynamic-value run-target")
+        self.expect(
+            "frame variable -d run-target caught",
+            substrs=['@"the bad thing happened"'],
+        )
+        self.expect("po caught", substrs=["the bad thing happened"])
+
+    def test_stops_at_throw(self):
+        """`breakpoint set -E objc` stops where the exception is raised, and
+        the frame recognizer presents the thrown object."""
+        self.build()
+        target = self.dbg.CreateTarget(self.getBuildArtifact("a.out"))
+        self.assertTrue(target, VALID_TARGET)
+        self.runCmd("breakpoint set -E objc")
+
+        process = target.LaunchSimple(None, None, self.get_process_working_directory())
+        self.assertTrue(process, PROCESS_IS_VALID)
+        self.assertEqual(process.GetState(), lldb.eStateStopped)
+
+        thread = lldbutil.get_stopped_thread(process, lldb.eStopReasonBreakpoint)
+        self.assertIsNotNone(thread, "stopped on the exception breakpoint")
+        # The recognizer's description surfaces through the command layer
+        # rather than through SBThread::GetStopDescription.
+        self.expect(
+            "thread list",
+            substrs=["stopped", "stop reason = hit Objective-C exception"],
+        )
+
+        # The recognizer synthesizes `exception` as an argument, so it is
+        # visible even though objc_exception_throw has no debug info.
+        exception = thread.GetCurrentException()
+        self.assertTrue(exception.IsValid(), "thread has a current exception")
+        self.assertIn("NSException", exception.GetTypeName())
+
+    def test_exception_is_recognized_argument(self):
+        """The thrown object is exposed as a recognized argument, which is
+        what lldb-dap's exception view reads."""
+        self.build()
+        target = self.dbg.CreateTarget(self.getBuildArtifact("a.out"))
+        self.assertTrue(target, VALID_TARGET)
+        self.runCmd("breakpoint set -E objc")
+        process = target.LaunchSimple(None, None, self.get_process_working_directory())
+        self.assertTrue(process, PROCESS_IS_VALID)
+        thread = lldbutil.get_stopped_thread(process, lldb.eStopReasonBreakpoint)
+        self.assertIsNotNone(thread)
+
+        options = lldb.SBVariablesOptions()
+        options.SetIncludeArguments(False)
+        options.SetIncludeRecognizedArguments(True)
+        options.SetIncludeLocals(False)
+        options.SetIncludeStatics(False)
+        variables = thread.GetFrameAtIndex(0).GetVariables(options)
+        self.assertEqual(variables.GetSize(), 1)
+        self.assertEqual(variables.GetValueAtIndex(0).GetName(), "exception")
+        self.assertEqual(
+            variables.GetValueAtIndex(0).GetValueType(),
+            lldb.eValueTypeVariableArgument,
+        )
diff --git a/lldb/test/API/lang/objc-gnustep/exceptions/categories b/lldb/test/API/lang/objc-gnustep/exceptions/categories
new file mode 100644
index 0000000000000..70b14bf34d6cc
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/exceptions/categories
@@ -0,0 +1 @@
+objc-gnustep-base
diff --git a/lldb/test/API/lang/objc-gnustep/exceptions/main.m b/lldb/test/API/lang/objc-gnustep/exceptions/main.m
new file mode 100644
index 0000000000000..4c0099df1c6bc
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/exceptions/main.m
@@ -0,0 +1,20 @@
+#import <Foundation/Foundation.h>
+
+static void raiser(void) {
+  [[NSException exceptionWithName:@"BadThingException"
+                           reason:@"the bad thing happened"
+                         userInfo:nil] raise];
+}
+
+int main(int argc, const char **argv) {
+  @autoreleasepool {
+    NSException *caught = nil;
+    @try {
+      raiser();
+    } @catch (NSException *e) {
+      caught = e;
+      NSLog(@"%@", [caught reason]); // break in handler
+    }
+    return caught == nil;
+  }
+}

>From 821e220808da813976af987497fc3421094bbec3 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 10:07:28 +0100
Subject: [PATCH 36/38] [lldb][test] Add --no-debug-info to the Shell build
 helper

`-g` was appended unconditionally, so a Shell test had no way to produce a
translation unit without debug info. That is needed to test anything that
has to work from a source other than debug info - a language runtime's own
metadata, for instance - and stripping afterwards is a poorer substitute,
because -g0 never emits the sections at all.

Assisted-by: Claude Opus 5
---
 .../Shell/BuildScript/toolchain-clang.test     |  6 ++++++
 lldb/test/Shell/helper/build.py                | 18 +++++++++++++++---
 2 files changed, 21 insertions(+), 3 deletions(-)

diff --git a/lldb/test/Shell/BuildScript/toolchain-clang.test b/lldb/test/Shell/BuildScript/toolchain-clang.test
index 04a4551506783..175958661757e 100644
--- a/lldb/test/Shell/BuildScript/toolchain-clang.test
+++ b/lldb/test/Shell/BuildScript/toolchain-clang.test
@@ -12,3 +12,9 @@ CHECK-64: {{.*}}clang{{(\.EXE)?}} -m64 -g -O0 -c {{.*}}-o {{.*}}foo.exe-foobar.o
 CHECK: linking foo.exe-foobar.o -> foo.exe
 CHECK-32: {{.*}}clang++{{(\.EXE)?}} -m32 {{(-L.* )?(-Wl,-rpath,.* )?}}-o {{.*}}foo.exe {{.*}}foo.exe-foobar.o
 CHECK-64: {{.*}}clang++{{(\.EXE)?}} -m64 {{(-L.* )?(-Wl,-rpath,.* )?}}-o {{.*}}foo.exe {{.*}}foo.exe-foobar.o
+
+RUN: %build -n --verbose --arch=64 --compiler=clang --no-debug-info --mode=compile-and-link -o %t/foo.exe foobar.c \
+RUN:    | FileCheck --check-prefix=NODEBUG %s
+
+NODEBUG: compiling foobar.c -> foo.exe-foobar.o
+NODEBUG: {{.*}}clang{{(\.EXE)?}} -m64 -g0 -O0 -c {{.*}}-o {{.*}}foo.exe-foobar.o {{.*}}foobar.c
diff --git a/lldb/test/Shell/helper/build.py b/lldb/test/Shell/helper/build.py
index d09b28c9bc17b..b21bf6e3bf45c 100755
--- a/lldb/test/Shell/helper/build.py
+++ b/lldb/test/Shell/helper/build.py
@@ -113,6 +113,14 @@
     help="When specified, the resulting image should not link against system libraries or include system headers.  Useful when writing cross-targeting tests.",
 )
 
+parser.add_argument(
+    "--no-debug-info",
+    dest="no_debug_info",
+    action="store_true",
+    default=False,
+    help="Compile without debug information.  Useful for testing behaviour that has to work from something other than debug info, such as a language runtime's own metadata.",
+)
+
 parser.add_argument(
     "--opt",
     dest="opt",
@@ -307,6 +315,7 @@ def __init__(self, toolchain_type, args, obj_ext):
         self.output = args.output
         self.mode = args.mode
         self.nodefaultlib = args.nodefaultlib
+        self.no_debug_info = args.no_debug_info
         self.verbose = args.verbose
         self.obj_ext = obj_ext
         self.lib_paths = args.libs_dir
@@ -694,7 +703,8 @@ def _get_compilation_command(self, source, obj):
         if self.nodefaultlib:
             args.append("/GS-")
             args.append("/GR-")
-        args.append("/Z7")
+        if not self.no_debug_info:
+            args.append("/Z7")
         if self.toolchain_type == "clang-cl":
             args.append("-Xclang")
             args.append("-fkeep-static-consts")
@@ -764,7 +774,7 @@ def _get_compilation_command(self, source, obj):
         args = [self.compiler_for_file(source)]
         args = self._add_m_option_if_needed(args)
 
-        args.append("-g")
+        args.append("-g0" if self.no_debug_info else "-g")
         if self.opt == "none":
             args.append("-O0")
         elif self.opt == "basic":
@@ -785,7 +795,9 @@ def _get_compilation_command(self, source, obj):
                     # CodeView cannot represent Objective-C types, so force
                     # DWARF even though the target is MSVC. The debugger needs
                     # it to recognize classes and resolve dynamic types.
-                    args.extend(["-gdwarf", "-Xclang", "--dependent-lib=msvcrtd"])
+                    if not self.no_debug_info:
+                        args.append("-gdwarf")
+                    args.extend(["-Xclang", "--dependent-lib=msvcrtd"])
         elif self.sysroot:
             args.extend(["--sysroot", self.sysroot])
 

>From 7dcd1d672b761b0588260a560ada6f4c8d4f2d4c Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 10:07:30 +0100
Subject: [PATCH 37/38] [lldb][GNUstep] Synthesize interfaces from runtime
 metadata

Adds a DeclVendor, so a class the debug info does not describe is still
usable: `type lookup` prints it, `frame variable` expands it, and an
expression can name its type and reach its ivars. Until now
GetDeclVendor() returned nullptr, so ClangASTSource::FindDeclInObjCRuntime
returned silently and the class was a dead end.

Interfaces are built lazily - FindDecls returns a forward declaration and
the ivars are filled in only when clang asks for the definition - which
keeps a lookup for one class from dragging in its whole superclass chain.
FinishDecl starts the definition and clears the external-storage bits
*before* running the callbacks, because the superclass leg is eager and a
class reaching itself through its isa chain would otherwise recurse without
bound.

Methods are not vended yet. After __objc_load a selector's name field holds
a numeric dispatch index rather than a string (selector_table.cc), so
recovering names needs either symbols for the method functions or a call
into the runtime; ivars are what make a class inspectable, and an interface
completed from them alone is well formed.

GetRuntimeType is overridden at the same time, and necessarily so. The
inherited implementation consults debug info through
LookupInCompleteClassCache, which keys on an eSymbolTypeObjCClass symbol
that only Mach-O produces - so for gnustep-2.x it always misses and the
synthesized interface would win by default. That is a downgrade wherever
debug info exists, because the runtime's encodings cannot describe members
inside a struct-typed ivar: without this, the NSString and NSDictionary
formatters lose `_flags.wide` and the GSIMapTable walk, which the
data-formatters test caught.

Assisted-by: Claude Opus 5
---
 lldb/include/lldb/Symbol/DeclVendor.h         |   1 +
 .../ObjC/GNUstepObjCRuntime/CMakeLists.txt    |   1 +
 .../GNUstepObjCClassDescriptor.cpp            |  28 ++
 .../GNUstepObjCClassDescriptor.h              |  20 ++
 .../GNUstepObjCDeclVendor.cpp                 | 255 ++++++++++++++++++
 .../GNUstepObjCDeclVendor.h                   |  77 ++++++
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp |  43 ++-
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  22 ++
 .../Expr/Inputs/objc-gnustep-hidden-class.m   |  44 +++
 .../Shell/Expr/objc-gnustep-decl-vendor.m     |  57 ++++
 10 files changed, 547 insertions(+), 1 deletion(-)
 create mode 100644 lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.cpp
 create mode 100644 lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.h
 create mode 100644 lldb/test/Shell/Expr/Inputs/objc-gnustep-hidden-class.m
 create mode 100644 lldb/test/Shell/Expr/objc-gnustep-decl-vendor.m

diff --git a/lldb/include/lldb/Symbol/DeclVendor.h b/lldb/include/lldb/Symbol/DeclVendor.h
index 5b0cbf9a15357..375308781288c 100644
--- a/lldb/include/lldb/Symbol/DeclVendor.h
+++ b/lldb/include/lldb/Symbol/DeclVendor.h
@@ -22,6 +22,7 @@ class DeclVendor {
   enum DeclVendorKind {
     eClangModuleDeclVendor,
     eAppleObjCDeclVendor,
+    eGNUstepObjCDeclVendor,
     eLastClangDeclVendor,
   };
   // Constructors and Destructors
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
index 46cf4ef300797..1a906f5fa8ab0 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/CMakeLists.txt
@@ -1,5 +1,6 @@
 add_lldb_library(lldbPluginGNUstepObjCRuntime PLUGIN
   GNUstepObjCClassDescriptor.cpp
+  GNUstepObjCDeclVendor.cpp
   GNUstepObjCTypeEncodingParser.cpp
   GNUstepObjCRuntime.cpp
   GNUstepThreadPlanStepThroughObjCTrampoline.cpp
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
index a0fbe22e65e3d..7c42ec76eda6c 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
@@ -172,6 +172,34 @@ void GNUstepObjCClassDescriptor::Read() {
   m_valid = true;
 }
 
+bool GNUstepObjCClassDescriptor::Describe(
+    std::function<void(ObjCLanguageRuntime::ObjCISA)> const &superclass_func,
+    std::function<bool(const char *, const char *)> const &instance_method_func,
+    std::function<bool(const char *, const char *)> const &class_method_func,
+    std::function<bool(const char *, const char *, lldb::addr_t, uint64_t)> const
+        &ivar_func) const {
+  if (!m_valid)
+    return false;
+
+  // A root class has no superclass to report; libobjc2 leaves the field null
+  // once the class is resolved.
+  if (superclass_func && m_superclass_isa != 0)
+    superclass_func(m_superclass_isa);
+
+  if (ivar_func) {
+    for (const RawIvar &ivar : ReadIvarList()) {
+      // libobjc2 stores a pointer to the offset so it can rewrite it in
+      // place, and that pointer is what Apple's runtime reports here, so
+      // pass the already-resolved offset as the address. Consumers that only
+      // want the value - which is all of them in tree - are unaffected.
+      if (ivar_func(ivar.name.GetCString(), ivar.type_encoding.c_str(),
+                    static_cast<lldb::addr_t>(ivar.offset), ivar.size))
+        break;
+    }
+  }
+  return true;
+}
+
 std::vector<GNUstepObjCClassDescriptor::RawIvar>
 GNUstepObjCClassDescriptor::ReadIvarList() const {
   std::vector<RawIvar> ivars;
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
index 26c2975f4b3cd..cfe7ce9c451f2 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
@@ -14,6 +14,7 @@
 #include "lldb/lldb-forward.h"
 #include "lldb/lldb-types.h"
 
+#include <functional>
 #include <optional>
 #include <string>
 #include <vector>
@@ -95,6 +96,25 @@ class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
   /// producer (associate.m).
   bool IsKVO() override { return m_is_hidden; }
 
+  /// Reports this class's superclass and ivars to \p superclass_func and
+  /// \p ivar_func. Any callback may be null.
+  ///
+  /// Methods are not reported yet: after __objc_load, a selector's name field
+  /// holds a numeric dispatch index rather than a string (selector_table.cc),
+  /// so recovering names needs either symbols for the method functions or a
+  /// call into the runtime, and neither belongs in a descriptor that promises
+  /// not to execute code. Returning true with no methods is well defined -
+  /// the callbacks are optional - and lets the interface be completed from
+  /// its ivars alone.
+  bool Describe(std::function<void(ObjCLanguageRuntime::ObjCISA)> const
+                    &superclass_func,
+                std::function<bool(const char *, const char *)> const
+                    &instance_method_func,
+                std::function<bool(const char *, const char *)> const
+                    &class_method_func,
+                std::function<bool(const char *, const char *, lldb::addr_t,
+                                   uint64_t)> const &ivar_func) const override;
+
   size_t GetNumIVars() override;
 
   iVarDescriptor GetIVarAtIndex(size_t idx) override;
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.cpp
new file mode 100644
index 0000000000000..329fb9ea7c5bf
--- /dev/null
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.cpp
@@ -0,0 +1,255 @@
+//===-- GNUstepObjCDeclVendor.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 "GNUstepObjCDeclVendor.h"
+
+#include "Plugins/ExpressionParser/Clang/ClangUtil.h"
+#include "Plugins/TypeSystem/Clang/TypeSystemClang.h"
+
+#include "lldb/Core/Module.h"
+#include "lldb/Target/Process.h"
+#include "lldb/Target/Target.h"
+#include "lldb/Utility/LLDBLog.h"
+#include "lldb/Utility/Log.h"
+
+#include "clang/AST/ASTContext.h"
+#include "clang/AST/DeclObjC.h"
+#include "clang/AST/ExternalASTSource.h"
+
+using namespace lldb;
+using namespace lldb_private;
+
+namespace lldb_private {
+
+/// Completes an interface the moment clang needs its members, rather than
+/// when the class was first named. Mirrors AppleObjCExternalASTSource.
+class GNUstepObjCExternalASTSource : public clang::ExternalASTSource {
+public:
+  explicit GNUstepObjCExternalASTSource(GNUstepObjCDeclVendor &decl_vendor)
+      : m_decl_vendor(decl_vendor) {}
+
+  bool FindExternalVisibleDeclsByName(
+      const clang::DeclContext *decl_ctx, clang::DeclarationName name,
+      const clang::DeclContext *original_dc) override {
+    auto *interface_decl = llvm::dyn_cast<clang::ObjCInterfaceDecl>(decl_ctx);
+    if (!interface_decl) {
+      SetNoExternalVisibleDeclsForName(decl_ctx, name);
+      return false;
+    }
+    if (!m_decl_vendor.FinishDecl(
+            const_cast<clang::ObjCInterfaceDecl *>(interface_decl)))
+      return false;
+    return !interface_decl->lookup(name).empty();
+  }
+
+  void CompleteType(clang::TagDecl *tag_decl) override {}
+
+  void CompleteType(clang::ObjCInterfaceDecl *interface_decl) override {
+    m_decl_vendor.FinishDecl(interface_decl);
+  }
+
+  bool layoutRecordType(
+      const clang::RecordDecl *Record, uint64_t &Size, uint64_t &Alignment,
+      llvm::DenseMap<const clang::FieldDecl *, uint64_t> &FieldOffsets,
+      llvm::DenseMap<const clang::CXXRecordDecl *, clang::CharUnits>
+          &BaseOffsets,
+      llvm::DenseMap<const clang::CXXRecordDecl *, clang::CharUnits>
+          &VirtualBaseOffsets) override {
+    return false;
+  }
+
+  void StartTranslationUnit(clang::ASTConsumer *Consumer) override {
+    clang::TranslationUnitDecl *tu_decl =
+        m_decl_vendor.m_ast_ctx_sp->getASTContext().getTranslationUnitDecl();
+    tu_decl->setHasExternalVisibleStorage();
+    tu_decl->setHasExternalLexicalStorage();
+  }
+
+private:
+  GNUstepObjCDeclVendor &m_decl_vendor;
+};
+
+} // namespace lldb_private
+
+GNUstepObjCDeclVendor::GNUstepObjCDeclVendor(ObjCLanguageRuntime &runtime)
+    : DeclVendor(eGNUstepObjCDeclVendor), m_runtime(runtime) {
+  m_ast_ctx_sp = std::make_shared<TypeSystemClang>(
+      "GNUstepObjCDeclVendor AST",
+      runtime.GetProcess()->GetTarget().GetArchitecture().GetTriple());
+  auto external_source_owning_ptr =
+      llvm::makeIntrusiveRefCnt<GNUstepObjCExternalASTSource>(*this);
+  m_external_source = external_source_owning_ptr.get();
+  m_ast_ctx_sp->getASTContext().setExternalSource(external_source_owning_ptr);
+}
+
+clang::ObjCInterfaceDecl *
+GNUstepObjCDeclVendor::GetDeclForISA(ObjCLanguageRuntime::ObjCISA isa) {
+  auto iter = m_isa_to_interface.find(isa);
+  if (iter != m_isa_to_interface.end())
+    return iter->second;
+
+  ObjCLanguageRuntime::ClassDescriptorSP descriptor =
+      m_runtime.GetClassDescriptorFromISA(isa);
+  if (!descriptor)
+    return nullptr;
+  ConstString name(descriptor->GetClassName());
+  if (!name)
+    return nullptr;
+
+  clang::ASTContext &ast_ctx = m_ast_ctx_sp->getASTContext();
+  clang::IdentifierInfo &identifier_info =
+      ast_ctx.Idents.get(name.GetStringRef());
+
+  clang::ObjCInterfaceDecl *new_iface_decl = clang::ObjCInterfaceDecl::Create(
+      ast_ctx, ast_ctx.getTranslationUnitDecl(), clang::SourceLocation(),
+      &identifier_info, /*typeParamList=*/nullptr, /*PrevDecl=*/nullptr);
+
+  // The ISA is how FinishDecl gets back to the runtime from a bare decl.
+  ClangASTMetadata meta_data;
+  meta_data.SetISAPtr(isa);
+  m_ast_ctx_sp->SetMetadata(new_iface_decl, meta_data);
+
+  new_iface_decl->setHasExternalVisibleStorage();
+  new_iface_decl->setHasExternalLexicalStorage();
+  ast_ctx.getTranslationUnitDecl()->addDecl(new_iface_decl);
+
+  m_isa_to_interface[isa] = new_iface_decl;
+  return new_iface_decl;
+}
+
+bool GNUstepObjCDeclVendor::FinishDecl(
+    clang::ObjCInterfaceDecl *interface_decl) {
+  if (!interface_decl)
+    return false;
+
+  std::optional<ClangASTMetadata> meta_data =
+      m_ast_ctx_sp->GetMetadata(interface_decl);
+  if (!meta_data)
+    return false;
+  const ObjCLanguageRuntime::ObjCISA isa = meta_data->GetISAPtr();
+  if (isa == 0)
+    return false;
+
+  // Already done. This also terminates the superclass recursion below: a
+  // cycle in the isa graph re-enters here and stops.
+  if (!interface_decl->hasExternalVisibleStorage())
+    return true;
+
+  // The definition has to be started, and the external-storage bits cleared,
+  // *before* the callbacks run - otherwise a class that reaches itself
+  // through its superclass chain recurses without bound.
+  interface_decl->startDefinition();
+  interface_decl->setHasExternalVisibleStorage(false);
+  interface_decl->setHasExternalLexicalStorage(false);
+
+  ObjCLanguageRuntime::ClassDescriptorSP descriptor =
+      m_runtime.GetClassDescriptorFromISA(isa);
+  if (!descriptor)
+    return false;
+
+  ObjCLanguageRuntime::EncodingToTypeSP encoding_to_type_sp =
+      m_runtime.GetEncodingToType();
+  Log *log = GetLog(LLDBLog::Types);
+
+  auto superclass_func = [this,
+                          interface_decl](ObjCLanguageRuntime::ObjCISA super) {
+    clang::ObjCInterfaceDecl *superclass_decl = GetDeclForISA(super);
+    if (!superclass_decl)
+      return;
+    // A superclass has to be complete before it can be attached, so this is
+    // eager where everything else here is lazy.
+    FinishDecl(superclass_decl);
+    clang::ASTContext &ast_ctx = m_ast_ctx_sp->getASTContext();
+    interface_decl->setSuperClass(ast_ctx.getTrivialTypeSourceInfo(
+        ast_ctx.getObjCInterfaceType(superclass_decl)));
+  };
+
+  auto ivar_func = [this, interface_decl, &encoding_to_type_sp,
+                    log](const char *name, const char *type,
+                         lldb::addr_t offset, uint64_t size) -> bool {
+    if (!name || !type || !encoding_to_type_sp)
+      return false;
+    // Deliberately not for_expression: an ivar's precise Objective-C type
+    // would send the parser back through this vendor while it is mid-
+    // FinishDecl, and dynamic typing resolves it anyway.
+    CompilerType ivar_type = encoding_to_type_sp->RealizeType(
+        *m_ast_ctx_sp, type, /*for_expression=*/false);
+    if (!ivar_type) {
+      // The ivar still occupies space even when its encoding cannot be
+      // realized. Dropping it would leave clang to lay the interface out from
+      // the ivars that remain, silently shifting the offset of every ivar
+      // after it - so the debugger would report wrong values rather than
+      // decline to answer. Stand in an opaque block of the right size.
+      LLDB_LOG(log,
+               "GNUstep ivar {0} has an unrealizable type {1}; substituting "
+               "an opaque {2}-byte placeholder",
+               name, type, size);
+      if (size == 0)
+        return false;
+      ivar_type = m_ast_ctx_sp->CreateArrayType(
+          m_ast_ctx_sp->GetBasicType(lldb::eBasicTypeChar), size,
+          /*is_vector=*/false);
+      if (!ivar_type)
+        return false;
+    }
+    clang::ASTContext &ast_ctx = m_ast_ctx_sp->getASTContext();
+    clang::ObjCIvarDecl *ivar_decl = clang::ObjCIvarDecl::Create(
+        ast_ctx, interface_decl, clang::SourceLocation(),
+        clang::SourceLocation(), &ast_ctx.Idents.get(name),
+        ClangUtil::GetQualType(ivar_type), /*TInfo=*/nullptr,
+        clang::ObjCIvarDecl::Public, /*BW=*/nullptr, /*synthesized=*/false);
+    if (ivar_decl)
+      interface_decl->addDecl(ivar_decl);
+    return false;
+  };
+
+  if (!descriptor->Describe(superclass_func, /*instance_method_func=*/nullptr,
+                            /*class_method_func=*/nullptr, ivar_func)) {
+    LLDB_LOG(log, "GNUstep runtime could not describe class {0}",
+             descriptor->GetClassName());
+    return false;
+  }
+  return true;
+}
+
+uint32_t GNUstepObjCDeclVendor::FindDecls(ConstString name, bool append,
+                                          uint32_t max_matches,
+                                          std::vector<CompilerDecl> &decls) {
+  if (!append)
+    decls.clear();
+  if (!name || max_matches == 0)
+    return 0;
+
+  clang::ASTContext &ast_ctx = m_ast_ctx_sp->getASTContext();
+  clang::IdentifierInfo &identifier_info =
+      ast_ctx.Idents.get(name.GetStringRef());
+  clang::DeclarationName decl_name =
+      ast_ctx.DeclarationNames.getIdentifier(&identifier_info);
+
+  // Anything already vended stays vended, so repeated lookups of the same
+  // name return the same decl rather than a fresh one.
+  for (clang::NamedDecl *candidate :
+       ast_ctx.getTranslationUnitDecl()->lookup(decl_name)) {
+    if (auto *result_iface_decl =
+            llvm::dyn_cast<clang::ObjCInterfaceDecl>(candidate)) {
+      decls.push_back(m_ast_ctx_sp->GetCompilerDecl(result_iface_decl));
+      return 1;
+    }
+  }
+
+  const ObjCLanguageRuntime::ObjCISA isa = m_runtime.GetISA(name);
+  if (isa == 0)
+    return 0;
+
+  clang::ObjCInterfaceDecl *iface_decl = GetDeclForISA(isa);
+  if (!iface_decl)
+    return 0;
+
+  decls.push_back(m_ast_ctx_sp->GetCompilerDecl(iface_decl));
+  return 1;
+}
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.h
new file mode 100644
index 0000000000000..13ca60e03e788
--- /dev/null
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.h
@@ -0,0 +1,77 @@
+//===-- GNUstepObjCDeclVendor.h ---------------------------------*- C++ -*-===//
+//
+// 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
+//
+//===----------------------------------------------------------------------===//
+
+#ifndef LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPOBJCDECLVENDOR_H
+#define LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPOBJCDECLVENDOR_H
+
+#include "Plugins/LanguageRuntime/ObjC/ObjCLanguageRuntime.h"
+#include "lldb/Symbol/DeclVendor.h"
+#include "lldb/lldb-private.h"
+
+#include "llvm/ADT/DenseMap.h"
+
+#include "clang/AST/DeclObjC.h"
+
+namespace lldb_private {
+
+class GNUstepObjCExternalASTSource;
+
+/// Builds Objective-C interface declarations out of libobjc2's runtime
+/// metadata, so that a class the debug info does not describe is still
+/// usable: `frame variable` can show its ivars, `expr` can name its type,
+/// and `type lookup` can print it.
+///
+/// Declarations are created lazily. FindDecls returns a forward declaration;
+/// the ivars are filled in only when clang asks for the definition, through
+/// an ExternalASTSource. That laziness is what keeps a lookup for one class
+/// from dragging in its whole superclass chain and every type they mention.
+class GNUstepObjCDeclVendor : public DeclVendor {
+public:
+  explicit GNUstepObjCDeclVendor(ObjCLanguageRuntime &runtime);
+
+  ~GNUstepObjCDeclVendor() override = default;
+
+  static bool classof(const DeclVendor *vendor) {
+    return vendor->GetKind() == eGNUstepObjCDeclVendor;
+  }
+
+  uint32_t FindDecls(ConstString name, bool append, uint32_t max_matches,
+                     std::vector<CompilerDecl> &decls) override;
+
+  /// Fills in \p interface_decl's superclass and ivars. Called by the AST
+  /// source when clang first needs the definition, and directly for a
+  /// superclass, which has to be complete before it can be attached.
+  ///
+  /// Returns false if the runtime could not describe the class, in which
+  /// case the interface is left as a definition with no members.
+  bool FinishDecl(clang::ObjCInterfaceDecl *interface_decl);
+
+private:
+  /// Returns a forward declaration for \p isa, creating it if needed.
+  clang::ObjCInterfaceDecl *GetDeclForISA(ObjCLanguageRuntime::ObjCISA isa);
+
+  ObjCLanguageRuntime &m_runtime;
+
+  /// The vendor owns its own AST. Decls are copied into the expression
+  /// parser's AST by ClangASTImporter when they are used, so they must not
+  /// be created in a context that could outlive or conflict with it.
+  std::shared_ptr<TypeSystemClang> m_ast_ctx_sp;
+
+  /// Not owned; the AST context holds the reference that keeps it alive.
+  GNUstepObjCExternalASTSource *m_external_source = nullptr;
+
+  llvm::DenseMap<ObjCLanguageRuntime::ObjCISA, clang::ObjCInterfaceDecl *>
+      m_isa_to_interface;
+
+  // The AST source completes decls on demand and needs the vendor's AST.
+  friend class GNUstepObjCExternalASTSource;
+};
+
+} // namespace lldb_private
+
+#endif // LLDB_SOURCE_PLUGINS_LANGUAGERUNTIME_OBJC_GNUSTEPOBJCRUNTIME_GNUSTEPOBJCDECLVENDOR_H
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index 42994c35922fa..dc17fbff5bf8f 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -8,6 +8,7 @@
 
 #include "GNUstepObjCRuntime.h"
 #include "GNUstepObjCClassDescriptor.h"
+#include "GNUstepObjCDeclVendor.h"
 #include "GNUstepObjCTypeEncodingParser.h"
 #include "GNUstepThreadPlanStepThroughObjCTrampoline.h"
 
@@ -353,7 +354,15 @@ LanguageRuntime *GNUstepObjCRuntime::CreateInstance(Process *process,
   return new GNUstepObjCRuntime(process);
 }
 
-GNUstepObjCRuntime::~GNUstepObjCRuntime() = default;
+GNUstepObjCRuntime::~GNUstepObjCRuntime() {
+  // The encoding parser caches clang::QualTypes keyed by the TypeSystemClang
+  // that minted them, and one of those ASTs belongs to the decl vendor. A
+  // QualType is a tagged pointer and dropping the cache never dereferences
+  // one, so this is ordering hygiene rather than a live hazard - but state it
+  // here instead of leaving it to depend on member declaration order.
+  m_encoding_to_type_sp.reset();
+  m_decl_vendor_up.reset();
+}
 
 GNUstepObjCRuntime::GNUstepObjCRuntime(Process *process)
     : ObjCLanguageRuntime(process), m_objc_module_sp(nullptr),
@@ -1197,6 +1206,38 @@ GNUstepObjCRuntime::GetTypeBitSize(const CompilerType &compiler_type) {
   return instance_size * 8;
 }
 
+std::optional<CompilerType>
+GNUstepObjCRuntime::GetRuntimeType(CompilerType base_type) {
+  CompilerType class_type;
+  bool is_pointer_type = false;
+  if (TypeSystemClang::IsObjCObjectPointerType(base_type, &class_type))
+    is_pointer_type = true;
+  else if (TypeSystemClang::IsObjCObjectOrInterfaceType(base_type))
+    class_type = base_type;
+  else
+    return std::nullopt;
+  if (!class_type)
+    return std::nullopt;
+
+  ConstString class_name(class_type.GetTypeName());
+  if (!class_name)
+    return std::nullopt;
+
+  if (TypeSP type_sp = LookupClassTypeInDebugInfo(class_name)) {
+    if (CompilerType complete_type = type_sp->GetFullCompilerType();
+        complete_type.GetCompleteType())
+      return is_pointer_type ? complete_type.GetPointerType() : complete_type;
+  }
+
+  return ObjCLanguageRuntime::GetRuntimeType(base_type);
+}
+
+DeclVendor *GNUstepObjCRuntime::GetDeclVendor() {
+  if (!m_decl_vendor_up)
+    m_decl_vendor_up = std::make_unique<GNUstepObjCDeclVendor>(*this);
+  return m_decl_vendor_up.get();
+}
+
 ObjCLanguageRuntime::EncodingToTypeSP
 GNUstepObjCRuntime::GetEncodingToType() {
   if (!m_encoding_to_type_sp)
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 51154687c2eaa..2bc81aa7f3081 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -27,6 +27,7 @@
 namespace lldb_private {
 
 class GNUstepTaggedPointerVendor;
+class GNUstepObjCDeclVendor;
 
 class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
 public:
@@ -138,6 +139,23 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   /// the types it returns are only valid in the AST it created them in.
   EncodingToTypeSP GetEncodingToType() override;
 
+  /// Synthesizes Objective-C interface declarations from runtime metadata,
+  /// so a class the debug info does not describe is still usable. Built on
+  /// first use; the decls it owns are copied into the expression parser's
+  /// AST as they are needed.
+  DeclVendor *GetDeclVendor() override;
+
+  /// Resolves \p base_type to the most complete description available.
+  ///
+  /// The inherited implementation consults debug info through
+  /// LookupInCompleteClassCache, which keys on an eSymbolTypeObjCClass
+  /// symbol that only Mach-O produces - so for gnustep-2.x it always misses
+  /// and the runtime-synthesized interface wins by default. That is a
+  /// downgrade wherever debug info exists, because the runtime's metadata
+  /// cannot describe members inside a struct-typed ivar. Prefer debug info
+  /// explicitly, and fall back to the inherited behaviour otherwise.
+  std::optional<CompilerType> GetRuntimeType(CompilerType base_type) override;
+
   /// Size of an Objective-C class, in bits.
   ///
   /// The inherited implementation derives this from the ivar list, as the end
@@ -247,7 +265,11 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   /// raised rather than where it is caught.
   lldb::BreakpointSP m_objc_exception_bp_sp;
 
+  /// Torn down in an explicit order by the destructor, which see: the parser
+  /// caches types belonging to the vendor's AST.
   EncodingToTypeSP m_encoding_to_type_sp;
+
+  std::unique_ptr<GNUstepObjCDeclVendor> m_decl_vendor_up;
 };
 
 } // namespace lldb_private
diff --git a/lldb/test/Shell/Expr/Inputs/objc-gnustep-hidden-class.m b/lldb/test/Shell/Expr/Inputs/objc-gnustep-hidden-class.m
new file mode 100644
index 0000000000000..3ef3731afc5da
--- /dev/null
+++ b/lldb/test/Shell/Expr/Inputs/objc-gnustep-hidden-class.m
@@ -0,0 +1,44 @@
+// Compiled without debug information, so the only description of Hidden
+// that reaches the debugger is libobjc2's own runtime metadata. The header
+// this file's users see declares nothing but the class name.
+
+#import "objc/runtime.h"
+
+ at protocol NSCoding
+ at end
+
+#ifdef __has_attribute
+#if __has_attribute(objc_root_class)
+__attribute__((objc_root_class))
+#endif
+#endif
+ at interface NSObject <NSCoding> {
+  id isa;
+}
++ (id)new;
+ at end
+ at implementation NSObject
++ (id)new {
+  return class_createInstance(self, 0);
+}
+ at end
+
+ at interface Hidden : NSObject {
+ at public
+  int _int;
+  float _float;
+  char _char;
+  void *_ptr;
+}
+ at end
+ at implementation Hidden
+ at end
+
+id MakeHidden(void) {
+  Hidden *hidden = [Hidden new];
+  hidden->_int = 1;
+  hidden->_float = 2.0f;
+  hidden->_char = '\3';
+  hidden->_ptr = (void *)4;
+  return hidden;
+}
diff --git a/lldb/test/Shell/Expr/objc-gnustep-decl-vendor.m b/lldb/test/Shell/Expr/objc-gnustep-decl-vendor.m
new file mode 100644
index 0000000000000..d436dc8df40c8
--- /dev/null
+++ b/lldb/test/Shell/Expr/objc-gnustep-decl-vendor.m
@@ -0,0 +1,57 @@
+// REQUIRES: objc-gnustep
+//
+// A class whose only description is libobjc2's runtime metadata: the other
+// half of this program is compiled with -g0, and nothing here declares
+// anything about Hidden beyond its name.
+//
+// RUN: %build %S/Inputs/objc-gnustep-hidden-class.m --compiler=clang --objc-gnustep \
+// RUN:     --no-debug-info --mode=compile --output=%t-hidden.o
+// RUN: %build %s --compiler=clang --objc-gnustep --mode=compile --output=%t-main.o
+// RUN: %build %t-hidden.o %t-main.o --compiler=clang --objc-gnustep --mode=link --output=%t
+
+ at class Hidden;
+id MakeHidden(void);
+
+int main() {
+  Hidden *hidden = (Hidden *)MakeHidden();
+  return hidden != 0; // break here
+}
+
+// The premise: the debug info really does not describe this class, so
+// `image lookup -t` finds nothing and exits non-zero. If this ever starts
+// matching, the rest of the test proves nothing.
+//
+// RUN: not %lldb -b -o "image lookup -t Hidden" -- %t \
+// RUN:     | FileCheck %s --check-prefix=NODWARF
+//
+// NODWARF-NOT: name = "Hidden"
+
+// The interface is synthesized from the runtime instead, so the type can be
+// named and its ivars read.
+//
+// RUN: %lldb -b -o "b objc-gnustep-decl-vendor.m:17" -o "run" \
+// RUN:     -o "type lookup Hidden" \
+// RUN:     -o "expr -- ((Hidden *)hidden)->_int" \
+// RUN:     -o "expr -- ((Hidden *)hidden)->_float" \
+// RUN:     -o "frame variable -d run-target *hidden" \
+// RUN:     -- %t | FileCheck %s --check-prefix=VENDOR
+//
+// VENDOR: (lldb) type lookup Hidden
+// VENDOR: @interface Hidden
+// VENDOR-DAG: int _int;
+// VENDOR-DAG: float _float;
+// VENDOR-DAG: char _char;
+//
+// VENDOR: (lldb) expr -- ((Hidden *)hidden)->_int
+// VENDOR: (int) $0 = 1
+//
+// VENDOR: (lldb) expr -- ((Hidden *)hidden)->_float
+// VENDOR: (float) $1 = 2
+//
+// And the object expands in full, which is what a debugger UI shows.
+//
+// VENDOR: (lldb) frame variable -d run-target *hidden
+// VENDOR-DAG: _int = 1
+// VENDOR-DAG: _float = 2
+// VENDOR-DAG: _char = '{{.*}}3'
+// VENDOR-DAG: _ptr = 0x{{0*}}4

>From 707b4468606cff42f8b1770b715e928f4b621f46 Mon Sep 17 00:00:00 2001
From: Rob <robk at robk.dev>
Date: Tue, 18 Aug 2026 10:15:52 +0100
Subject: [PATCH 38/38] [lldb][GNUstep] Vend methods from runtime metadata

Completes the DeclVendor: a message send to a class with no debug info now
type-checks and runs, where before only its ivars were reachable.

Recovering a method's name is the awkward part. After __objc_load a
selector's name field holds a numeric dispatch index rather than a string
(selector_table.cc), so it cannot be read back from memory. It is recovered
from the symbol clang emits for the selector instead,
".objc_selector_<name>_<mangled types>" (CGObjCGNU.cpp). A selector name can
contain both '_' and ':', so the boundary is not findable by scanning; the
suffix is subtracted instead, using the selector's own `types` string, which
survives registration untouched. No code is run in the inferior to do this.

Two details worth naming. The first selector in a section shares an address
with the section's start sentinel, so every symbol at the address is
considered rather than whichever an exact lookup returns. And a class's
class methods are its metaclass's instance methods, as they are in Apple's
runtime, so Describe recurses into the metaclass for them.

The same selector legitimately appears more than once - categories prepend
their lists and may override a method - so methods are deduplicated by
selector before being turned into decls.

Assisted-by: Claude Opus 5
---
 .../GNUstepObjCClassDescriptor.cpp            | 120 +++++++++++-
 .../GNUstepObjCClassDescriptor.h              |  48 +++--
 .../GNUstepObjCDeclVendor.cpp                 | 180 +++++++++++++++++-
 .../GNUstepObjCDeclVendor.h                   |   7 +
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp |  75 +++++++-
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |   9 +
 .../Expr/Inputs/objc-gnustep-hidden-class.m   |  12 ++
 .../Shell/Expr/objc-gnustep-decl-vendor.m     |  19 ++
 8 files changed, 441 insertions(+), 29 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
index 7c42ec76eda6c..70e421b5a5a50 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
@@ -7,6 +7,7 @@
 //===----------------------------------------------------------------------===//
 
 #include "GNUstepObjCClassDescriptor.h"
+#include "GNUstepObjCRuntime.h"
 
 #include "lldb/Core/Module.h"
 #include "lldb/Core/ModuleList.h"
@@ -50,6 +51,8 @@ static constexpr size_t g_max_type_encoding_length = 1024;
 // that is how the runtime walks these arrays - but a stride this large is
 // not something a compiler emitted.
 static constexpr uint64_t g_max_element_stride = 1024;
+static constexpr uint32_t g_max_methods = 8192;
+static constexpr uint32_t g_max_method_lists = 512;
 
 // An upper bound for plausible class names. A string that does not terminate
 // within this many bytes is not a class name, and stopping there keeps a
@@ -176,8 +179,8 @@ bool GNUstepObjCClassDescriptor::Describe(
     std::function<void(ObjCLanguageRuntime::ObjCISA)> const &superclass_func,
     std::function<bool(const char *, const char *)> const &instance_method_func,
     std::function<bool(const char *, const char *)> const &class_method_func,
-    std::function<bool(const char *, const char *, lldb::addr_t, uint64_t)> const
-        &ivar_func) const {
+    std::function<bool(const char *, const char *, lldb::addr_t,
+                       uint64_t)> const &ivar_func) const {
   if (!m_valid)
     return false;
 
@@ -186,6 +189,40 @@ bool GNUstepObjCClassDescriptor::Describe(
   if (superclass_func && m_superclass_isa != 0)
     superclass_func(m_superclass_isa);
 
+  if (instance_method_func || class_method_func) {
+    ProcessSP process_sp = m_process_wp.lock();
+    auto *runtime = process_sp ? llvm::dyn_cast_or_null<GNUstepObjCRuntime>(
+                                     ObjCLanguageRuntime::Get(*process_sp))
+                               : nullptr;
+    // A method list read off a class object is always that object's own
+    // instance methods, whether or not the object happens to be a metaclass.
+    // Which of the caller's two callbacks that corresponds to is the caller's
+    // business - see the metaclass hop below.
+    const auto &method_func = instance_method_func;
+    if (runtime && method_func) {
+      // The same selector legitimately appears more than once: categories
+      // prepend their lists, and a category may override a method.
+      llvm::DenseSet<ConstString> seen;
+      for (const RawMethod &method : ReadMethodList()) {
+        ConstString name = runtime->GetSelectorName(method.selector);
+        if (!name || !seen.insert(name).second)
+          continue;
+        if (method_func(name.GetCString(), method.types.c_str()))
+          break;
+      }
+    }
+
+    // libobjc2 stores a class's *class* methods on its metaclass, as that
+    // metaclass's instance methods, so they are collected by asking the
+    // metaclass for its instance methods and reporting them as ours.
+    if (!m_is_meta && class_method_func) {
+      if (std::unique_ptr<ObjCLanguageRuntime::ClassDescriptor> metaclass =
+              GetMetaclass())
+        metaclass->Describe(nullptr, /*instance_method_func=*/class_method_func,
+                            nullptr, nullptr);
+    }
+  }
+
   if (ivar_func) {
     for (const RawIvar &ivar : ReadIvarList()) {
       // libobjc2 stores a pointer to the offset so it can rewrite it in
@@ -200,6 +237,85 @@ bool GNUstepObjCClassDescriptor::Describe(
   return true;
 }
 
+std::vector<GNUstepObjCClassDescriptor::RawMethod>
+GNUstepObjCClassDescriptor::ReadMethodList() const {
+  std::vector<RawMethod> methods;
+  ProcessSP process_sp = m_process_wp.lock();
+  if (!process_sp || !m_valid)
+    return methods;
+
+  const ClassLayout layout = GetClassLayout(*process_sp);
+  const uint32_t ptr_size = layout.pointer_size;
+
+  Status error;
+  addr_t list_addr =
+      process_sp->ReadPointerFromMemory(m_isa + layout.methods_offset, error);
+  if (error.Fail())
+    return methods;
+
+  // struct objc_method_list { objc_method_list *next; int count; size_t size;
+  //                           objc_method methods[]; }  (method.h)
+  const uint64_t count_offset = ptr_size;
+  const uint64_t size_offset =
+      llvm::alignTo(ptr_size + sizeof(uint32_t), ptr_size);
+  const uint64_t entries_offset = size_offset + ptr_size;
+  const uint64_t min_stride = 3 * ptr_size;
+
+  // A class's own methods are followed by one list per category. The chain
+  // and its contents both come from inferior memory, so bound the number of
+  // lists, the total number of methods, and revisiting: a `next` pointing
+  // back into the chain would otherwise multiply out to millions of entries
+  // even with each list individually bounded.
+  llvm::SmallPtrSet<const void *, 16> visited;
+  for (uint32_t list = 0; list < g_max_method_lists; ++list) {
+    if (list_addr == 0 || list_addr == LLDB_INVALID_ADDRESS ||
+        list_addr % ptr_size != 0)
+      break;
+    if (!visited.insert(reinterpret_cast<const void *>(list_addr)).second)
+      break;
+    if (methods.size() >= g_max_methods)
+      break;
+
+    const int64_t count = process_sp->ReadSignedIntegerFromMemory(
+        list_addr + count_offset, sizeof(uint32_t), 0, error);
+    if (error.Fail() || count < 0 || count > g_max_methods)
+      break;
+    const uint64_t stride = process_sp->ReadUnsignedIntegerFromMemory(
+        list_addr + size_offset, ptr_size, 0, error);
+    if (error.Fail() || stride < min_stride || stride > g_max_element_stride)
+      break;
+
+    for (int64_t i = 0; i < count && methods.size() < g_max_methods; ++i) {
+      // struct objc_method { IMP imp; SEL selector; const char *types; }
+      const addr_t entry = list_addr + entries_offset + i * stride;
+      const addr_t selector =
+          process_sp->ReadPointerFromMemory(entry + ptr_size, error);
+      if (error.Fail() || selector == 0 || selector == LLDB_INVALID_ADDRESS)
+        continue;
+      const addr_t types_ptr =
+          process_sp->ReadPointerFromMemory(entry + 2 * ptr_size, error);
+      if (error.Fail())
+        continue;
+
+      RawMethod method;
+      method.selector = selector;
+      if (types_ptr != 0 && types_ptr != LLDB_INVALID_ADDRESS) {
+        char buffer[g_max_type_encoding_length];
+        const size_t length = process_sp->ReadCStringFromMemory(
+            types_ptr, buffer, sizeof(buffer), error);
+        if (error.Success() && length < sizeof(buffer) - 1)
+          method.types.assign(buffer, length);
+      }
+      methods.push_back(std::move(method));
+    }
+
+    list_addr = process_sp->ReadPointerFromMemory(list_addr, error);
+    if (error.Fail())
+      break;
+  }
+  return methods;
+}
+
 std::vector<GNUstepObjCClassDescriptor::RawIvar>
 GNUstepObjCClassDescriptor::ReadIvarList() const {
   std::vector<RawIvar> ivars;
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
index cfe7ce9c451f2..10c3a39d81dfb 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.h
@@ -96,24 +96,26 @@ class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
   /// producer (associate.m).
   bool IsKVO() override { return m_is_hidden; }
 
-  /// Reports this class's superclass and ivars to \p superclass_func and
-  /// \p ivar_func. Any callback may be null.
+  /// Reports this class's superclass, methods and ivars to the matching
+  /// callbacks. Any callback may be null, and a method or ivar callback
+  /// returning true stops the iteration.
   ///
-  /// Methods are not reported yet: after __objc_load, a selector's name field
-  /// holds a numeric dispatch index rather than a string (selector_table.cc),
-  /// so recovering names needs either symbols for the method functions or a
-  /// call into the runtime, and neither belongs in a descriptor that promises
-  /// not to execute code. Returning true with no methods is well defined -
-  /// the callbacks are optional - and lets the interface be completed from
-  /// its ivars alone.
-  bool Describe(std::function<void(ObjCLanguageRuntime::ObjCISA)> const
-                    &superclass_func,
-                std::function<bool(const char *, const char *)> const
-                    &instance_method_func,
-                std::function<bool(const char *, const char *)> const
-                    &class_method_func,
-                std::function<bool(const char *, const char *, lldb::addr_t,
-                                   uint64_t)> const &ivar_func) const override;
+  /// Class methods are collected from the metaclass, which is where libobjc2
+  /// keeps them, as its instance methods.
+  ///
+  /// A method whose name cannot be recovered is skipped rather than reported
+  /// under a placeholder: the name lives only in the symbol clang emitted for
+  /// the selector, because __objc_load overwrites the name field in memory
+  /// with a numeric dispatch index (selector_table.cc).
+  ///
+  /// Returns true if the class could be described at all.
+  bool Describe(
+      std::function<void(ObjCLanguageRuntime::ObjCISA)> const &superclass_func,
+      std::function<bool(const char *, const char *)> const
+          &instance_method_func,
+      std::function<bool(const char *, const char *)> const &class_method_func,
+      std::function<bool(const char *, const char *, lldb::addr_t,
+                         uint64_t)> const &ivar_func) const override;
 
   size_t GetNumIVars() override;
 
@@ -130,6 +132,18 @@ class GNUstepObjCClassDescriptor : public ObjCLanguageRuntime::ClassDescriptor {
     uint32_t size = 0;
   };
 
+  /// One entry of libobjc2's `objc_method_list`. The selector is kept as its
+  /// address: after __objc_load its name field holds a dispatch index rather
+  /// than a string, so the name has to come from the symbol emitted for it.
+  struct RawMethod {
+    lldb::addr_t selector = 0;
+    std::string types;
+  };
+
+  /// Reads the methods this class implements, walking the whole list chain
+  /// (categories are prepended to it). Pure memory reads.
+  std::vector<RawMethod> ReadMethodList() const;
+
   /// Reads this class's own ivars, or an empty list if the class has none or
   /// its metadata is not yet trustworthy. Pure memory reads.
   ///
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.cpp
index 329fb9ea7c5bf..68c0f7739703a 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.cpp
@@ -21,6 +21,8 @@
 #include "clang/AST/DeclObjC.h"
 #include "clang/AST/ExternalASTSource.h"
 
+#include "llvm/ADT/StringExtras.h"
+
 using namespace lldb;
 using namespace lldb_private;
 
@@ -122,6 +124,131 @@ GNUstepObjCDeclVendor::GetDeclForISA(ObjCLanguageRuntime::ObjCISA isa) {
   return new_iface_decl;
 }
 
+namespace {
+/// Splits a runtime method type encoding into its component types.
+///
+/// The encoding interleaves types with the byte offsets of the arguments in
+/// the (long obsolete) argument frame - "v16 at 0:8" is a void return, then
+/// `self` at 0 and `_cmd` at 8. Only the types are wanted, so the digits are
+/// dropped; digits *inside* an aggregate are part of it and are kept, which
+/// is what the depth counter is for.
+///
+/// The resulting order is fixed: [0] return, [1] self, [2] _cmd, [3...]
+/// the declared parameters.
+class MethodTypeSplitter {
+public:
+  explicit MethodTypeSplitter(llvm::StringRef types) { Parse(types); }
+
+  bool IsValid() const { return m_valid && m_types.size() >= 3; }
+  llvm::StringRef GetReturnType() const { return m_types[0]; }
+  size_t GetNumArguments() const { return m_types.size() - 3; }
+  llvm::StringRef GetArgumentType(size_t idx) const { return m_types[idx + 3]; }
+
+private:
+  void Parse(llvm::StringRef types) {
+    std::string current;
+    unsigned depth = 0;
+    for (char c : types) {
+      const bool is_digit = llvm::isDigit(c);
+      if (depth == 0 && is_digit) {
+        // An offset: it ends the type that preceded it.
+        if (!current.empty()) {
+          m_types.push_back(current);
+          current.clear();
+        }
+        continue;
+      }
+      if (c == '{' || c == '(' || c == '[')
+        ++depth;
+      else if (c == '}' || c == ')' || c == ']')
+        if (depth > 0)
+          --depth;
+      current.push_back(c);
+      // A complete aggregate ends the type too, since no offset follows it
+      // until the next argument.
+      if (depth == 0 && (c == '}' || c == ')' || c == ']')) {
+        m_types.push_back(current);
+        current.clear();
+      }
+    }
+    if (!current.empty())
+      m_types.push_back(current);
+    m_valid = !m_types.empty();
+  }
+
+  std::vector<std::string> m_types;
+  bool m_valid = false;
+};
+} // namespace
+
+clang::ObjCMethodDecl *GNUstepObjCDeclVendor::BuildMethodDecl(
+    clang::ObjCInterfaceDecl *interface_decl, llvm::StringRef name,
+    llvm::StringRef types, bool is_instance_method) {
+  MethodTypeSplitter splitter(types);
+  if (!splitter.IsValid())
+    return nullptr;
+
+  ObjCLanguageRuntime::EncodingToTypeSP encoding_to_type_sp =
+      m_runtime.GetEncodingToType();
+  if (!encoding_to_type_sp)
+    return nullptr;
+
+  clang::ASTContext &ast_ctx = m_ast_ctx_sp->getASTContext();
+
+  // Method types are realized for expressions: unlike an ivar, a return type
+  // of @"NSString" is worth resolving, and recursing back into this vendor
+  // for it is safe because the method is not part of any interface yet.
+  CompilerType return_type = encoding_to_type_sp->RealizeType(
+      *m_ast_ctx_sp, splitter.GetReturnType().str().c_str(),
+      /*for_expression=*/true);
+  if (!return_type)
+    return nullptr;
+
+  // "initWithFoo:bar:" has as many pieces as colons; a zero-argument
+  // selector is a single identifier with none.
+  llvm::SmallVector<llvm::StringRef, 4> pieces;
+  name.split(pieces, ':');
+  const bool has_colons = name.contains(':');
+  if (has_colons && !pieces.empty() && pieces.back().empty())
+    pieces.pop_back();
+  if (pieces.empty())
+    return nullptr;
+
+  llvm::SmallVector<const clang::IdentifierInfo *, 4> selector_pieces;
+  for (llvm::StringRef piece : pieces)
+    selector_pieces.push_back(&ast_ctx.Idents.get(piece));
+
+  const unsigned num_selector_args = has_colons ? selector_pieces.size() : 0;
+  clang::Selector selector =
+      ast_ctx.Selectors.getSelector(num_selector_args, selector_pieces.data());
+
+  clang::ObjCMethodDecl *method_decl = clang::ObjCMethodDecl::Create(
+      ast_ctx, clang::SourceLocation(), clang::SourceLocation(), selector,
+      ClangUtil::GetQualType(return_type), /*ReturnTInfo=*/nullptr,
+      interface_decl, is_instance_method, /*isVariadic=*/false,
+      /*isPropertyAccessor=*/false, /*isSynthesizedAccessorStub=*/false,
+      /*isImplicitlyDeclared=*/true, /*isDefined=*/false,
+      clang::ObjCImplementationControl::None,
+      /*HasRelatedResultType=*/false);
+  if (!method_decl)
+    return nullptr;
+
+  llvm::SmallVector<clang::ParmVarDecl *, 4> params;
+  for (size_t i = 0; i < splitter.GetNumArguments(); ++i) {
+    CompilerType arg_type = encoding_to_type_sp->RealizeType(
+        *m_ast_ctx_sp, splitter.GetArgumentType(i).str().c_str(),
+        /*for_expression=*/true);
+    if (!arg_type)
+      return nullptr;
+    params.push_back(clang::ParmVarDecl::Create(
+        ast_ctx, method_decl, clang::SourceLocation(), clang::SourceLocation(),
+        /*Id=*/nullptr, ClangUtil::GetQualType(arg_type), /*TInfo=*/nullptr,
+        clang::SC_None, /*DefArg=*/nullptr));
+  }
+  method_decl->setMethodParams(ast_ctx, params, {});
+  return method_decl;
+}
+
 bool GNUstepObjCDeclVendor::FinishDecl(
     clang::ObjCInterfaceDecl *interface_decl) {
   if (!interface_decl)
@@ -140,6 +267,15 @@ bool GNUstepObjCDeclVendor::FinishDecl(
   if (!interface_decl->hasExternalVisibleStorage())
     return true;
 
+  // Ask the runtime before publishing anything. Starting the definition and
+  // then failing would leave a members-less interface that the guard above
+  // makes permanent, so a class that was momentarily unreadable would stay
+  // empty for the rest of the session.
+  ObjCLanguageRuntime::ClassDescriptorSP descriptor =
+      m_runtime.GetClassDescriptorFromISA(isa);
+  if (!descriptor)
+    return false;
+
   // The definition has to be started, and the external-storage bits cleared,
   // *before* the callbacks run - otherwise a class that reaches itself
   // through its superclass chain recurses without bound.
@@ -147,11 +283,6 @@ bool GNUstepObjCDeclVendor::FinishDecl(
   interface_decl->setHasExternalVisibleStorage(false);
   interface_decl->setHasExternalLexicalStorage(false);
 
-  ObjCLanguageRuntime::ClassDescriptorSP descriptor =
-      m_runtime.GetClassDescriptorFromISA(isa);
-  if (!descriptor)
-    return false;
-
   ObjCLanguageRuntime::EncodingToTypeSP encoding_to_type_sp =
       m_runtime.GetEncodingToType();
   Log *log = GetLog(LLDBLog::Types);
@@ -159,11 +290,28 @@ bool GNUstepObjCDeclVendor::FinishDecl(
   auto superclass_func = [this,
                           interface_decl](ObjCLanguageRuntime::ObjCISA super) {
     clang::ObjCInterfaceDecl *superclass_decl = GetDeclForISA(super);
-    if (!superclass_decl)
+    if (!superclass_decl || superclass_decl == interface_decl)
       return;
     // A superclass has to be complete before it can be attached, so this is
     // eager where everything else here is lazy.
     FinishDecl(superclass_decl);
+
+    // The recursion latch above stops FinishDecl re-entering, but it does not
+    // stop the *edge* being written: with A's superclass B and B's superclass
+    // A, both setSuperClass calls would still run and leave a cycle in the
+    // AST that clang then walks without bound. Reject any edge that is
+    // already reachable upwards from the proposed superclass.
+    for (const clang::ObjCInterfaceDecl *ancestor = superclass_decl; ancestor;
+         ancestor = ancestor->getSuperClass()) {
+      if (ancestor == interface_decl) {
+        LLDB_LOG(GetLog(LLDBLog::Types),
+                 "GNUstep class {0} would close a superclass cycle; leaving it "
+                 "without a superclass",
+                 interface_decl->getName());
+        return;
+      }
+    }
+
     clang::ASTContext &ast_ctx = m_ast_ctx_sp->getASTContext();
     interface_decl->setSuperClass(ast_ctx.getTrivialTypeSourceInfo(
         ast_ctx.getObjCInterfaceType(superclass_decl)));
@@ -208,8 +356,24 @@ bool GNUstepObjCDeclVendor::FinishDecl(
     return false;
   };
 
-  if (!descriptor->Describe(superclass_func, /*instance_method_func=*/nullptr,
-                            /*class_method_func=*/nullptr, ivar_func)) {
+  auto make_method_func = [this, interface_decl, log](bool is_instance_method) {
+    return [this, interface_decl, log,
+            is_instance_method](const char *name, const char *types) -> bool {
+      if (!name || !types)
+        return false;
+      if (clang::ObjCMethodDecl *method_decl =
+              BuildMethodDecl(interface_decl, name, types, is_instance_method))
+        interface_decl->addDecl(method_decl);
+      else
+        LLDB_LOG(log, "GNUstep method {0} has an unrealizable signature {1}",
+                 name, types);
+      return false;
+    };
+  };
+
+  if (!descriptor->Describe(
+          superclass_func, make_method_func(/*is_instance_method=*/true),
+          make_method_func(/*is_instance_method=*/false), ivar_func)) {
     LLDB_LOG(log, "GNUstep runtime could not describe class {0}",
              descriptor->GetClassName());
     return false;
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.h
index 13ca60e03e788..71c9976a66029 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCDeclVendor.h
@@ -52,6 +52,13 @@ class GNUstepObjCDeclVendor : public DeclVendor {
   bool FinishDecl(clang::ObjCInterfaceDecl *interface_decl);
 
 private:
+  /// Builds an ObjCMethodDecl from a runtime method name and type
+  /// encoding, or nullptr if the encoding cannot be realized.
+  clang::ObjCMethodDecl *
+  BuildMethodDecl(clang::ObjCInterfaceDecl *interface_decl,
+                  llvm::StringRef name, llvm::StringRef types,
+                  bool is_instance_method);
+
   /// Returns a forward declaration for \p isa, creating it if needed.
   clang::ObjCInterfaceDecl *GetDeclForISA(ObjCLanguageRuntime::ObjCISA isa);
 
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
index dc17fbff5bf8f..ea7ca8ec3959e 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.cpp
@@ -1020,6 +1020,78 @@ GNUstepObjCRuntime::FindDispatchEntryPoint(lldb::addr_t pc) {
   return std::nullopt;
 }
 
+ConstString GNUstepObjCRuntime::GetSelectorName(lldb::addr_t sel_addr) {
+  if (!m_process || sel_addr == 0 || sel_addr == LLDB_INVALID_ADDRESS)
+    return ConstString();
+
+  // clang emits every selector as a global named
+  // ".objc_selector_<name>_<mangled types>" (CGObjCGNU.cpp). That symbol is
+  // the only place the name survives: __objc_load overwrites the selector's
+  // name field with a numeric dispatch index (selector_table.cc), so it
+  // cannot be read back from memory.
+  Address resolved;
+  if (!GetTargetRef().ResolveLoadAddress(sel_addr, resolved))
+    return ConstString();
+  ModuleSP module_sp = resolved.GetModule();
+  if (!module_sp)
+    return ConstString();
+  Symtab *symtab = module_sp->GetSymtab();
+  if (!symtab)
+    return ConstString();
+
+  static constexpr llvm::StringLiteral g_selector_prefix = ".objc_selector_";
+
+  // The first selector in a section shares its address with the section's
+  // start sentinel, so take every symbol at this address rather than
+  // whichever one an exact lookup happens to return.
+  llvm::StringRef symbol_name;
+  symtab->ForEachSymbolContainingFileAddress(
+      resolved.GetFileAddress(), [&](Symbol *symbol) -> bool {
+        llvm::StringRef name = symbol->GetName().GetStringRef();
+        if (!name.starts_with(g_selector_prefix))
+          return true; // keep looking
+        symbol_name = name;
+        return false;
+      });
+  if (symbol_name.empty())
+    return ConstString();
+
+  llvm::StringRef name = symbol_name.drop_front(g_selector_prefix.size());
+
+  // A selector name may contain both '_' and ':', so the boundary cannot be
+  // found by scanning. Subtract the suffix instead: the types half of the
+  // symbol is exactly the selector's own `types` string, mangled. That
+  // string is still readable - only the name field was overwritten.
+  const uint32_t ptr_size = m_process->GetAddressByteSize();
+  Status error;
+  const addr_t types_addr =
+      m_process->ReadPointerFromMemory(sel_addr + ptr_size, error);
+  std::string suffix("_");
+  if (error.Success() && types_addr != 0 &&
+      types_addr != LLDB_INVALID_ADDRESS) {
+    char buffer[g_max_type_encoding_length];
+    const size_t length = m_process->ReadCStringFromMemory(
+        types_addr, buffer, sizeof(buffer), error);
+    if (error.Success() && length < sizeof(buffer) - 1) {
+      // GetSymbolNameForTypeEncoding: '@' is replaced on ELF because it is
+      // reserved for symbol versioning, '=' on Windows because lld rejects
+      // it in an exported name. MinGW is isOSWindows() and not ELF, so the
+      // predicates are asked separately rather than derived from each other.
+      const llvm::Triple &triple = GetTargetRef().GetArchitecture().GetTriple();
+      std::string mangled(buffer, length);
+      if (triple.isOSBinFormatELF())
+        llvm::replace(mangled, '@', '\1');
+      if (triple.isOSWindows())
+        llvm::replace(mangled, '=', '\2');
+      suffix += mangled;
+    }
+  }
+
+  if (!name.consume_back(suffix))
+    return ConstString();
+  return ConstString(name);
+}
+
 FunctionCaller *GNUstepObjCRuntime::GetMsgLookupFunctionCaller(Thread &thread) {
   // Build (once) a utility function that resolves a method implementation by
   // calling libobjc2's objc_msg_lookup, and a FunctionCaller to invoke it.
@@ -1238,8 +1310,7 @@ DeclVendor *GNUstepObjCRuntime::GetDeclVendor() {
   return m_decl_vendor_up.get();
 }
 
-ObjCLanguageRuntime::EncodingToTypeSP
-GNUstepObjCRuntime::GetEncodingToType() {
+ObjCLanguageRuntime::EncodingToTypeSP GNUstepObjCRuntime::GetEncodingToType() {
   if (!m_encoding_to_type_sp)
     m_encoding_to_type_sp = std::make_shared<GNUstepObjCTypeEncodingParser>(
         GetTargetRef().GetArchitecture().GetTriple(), this);
diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
index 2bc81aa7f3081..c177266dce544 100644
--- a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCRuntime.h
@@ -168,6 +168,15 @@ class GNUstepObjCRuntime : public lldb_private::ObjCLanguageRuntime {
   std::optional<uint64_t>
   GetTypeBitSize(const CompilerType &compiler_type) override;
 
+  /// The name of the selector at \p sel_addr, recovered from the symbol
+  /// clang emits for it. Empty when there is no such symbol.
+  ///
+  /// This cannot be read from memory: __objc_load overwrites a selector's
+  /// name field with a numeric dispatch index (selector_table.cc), so the
+  /// string is gone by the time a debugger sees it. No code is run in the
+  /// inferior to find it.
+  ConstString GetSelectorName(lldb::addr_t sel_addr);
+
   /// Lazily-built FunctionCaller for a utility function that resolves a
   /// method implementation via libobjc2's
   /// `IMP objc_msg_lookup(id receiver, SEL selector)`, used by the
diff --git a/lldb/test/Shell/Expr/Inputs/objc-gnustep-hidden-class.m b/lldb/test/Shell/Expr/Inputs/objc-gnustep-hidden-class.m
index 3ef3731afc5da..fc822fbda9354 100644
--- a/lldb/test/Shell/Expr/Inputs/objc-gnustep-hidden-class.m
+++ b/lldb/test/Shell/Expr/Inputs/objc-gnustep-hidden-class.m
@@ -30,8 +30,20 @@ @interface Hidden : NSObject {
   char _char;
   void *_ptr;
 }
+- (int)doubled:(int)value;
+- (int)plainInt;
++ (int)classAnswer;
 @end
 @implementation Hidden
+- (int)doubled:(int)value {
+  return value * 2;
+}
+- (int)plainInt {
+  return _int;
+}
++ (int)classAnswer {
+  return 7;
+}
 @end
 
 id MakeHidden(void) {
diff --git a/lldb/test/Shell/Expr/objc-gnustep-decl-vendor.m b/lldb/test/Shell/Expr/objc-gnustep-decl-vendor.m
index d436dc8df40c8..993156e747da0 100644
--- a/lldb/test/Shell/Expr/objc-gnustep-decl-vendor.m
+++ b/lldb/test/Shell/Expr/objc-gnustep-decl-vendor.m
@@ -34,6 +34,9 @@ int main() {
 // RUN:     -o "expr -- ((Hidden *)hidden)->_int" \
 // RUN:     -o "expr -- ((Hidden *)hidden)->_float" \
 // RUN:     -o "frame variable -d run-target *hidden" \
+// RUN:     -o "expr -- [(Hidden *)hidden plainInt]" \
+// RUN:     -o "expr -- [(Hidden *)hidden doubled:21]" \
+// RUN:     -o "expr -- [Hidden classAnswer]" \
 // RUN:     -- %t | FileCheck %s --check-prefix=VENDOR
 //
 // VENDOR: (lldb) type lookup Hidden
@@ -55,3 +58,19 @@ int main() {
 // VENDOR-DAG: _float = 2
 // VENDOR-DAG: _char = '{{.*}}3'
 // VENDOR-DAG: _ptr = 0x{{0*}}4
+//
+// Methods are synthesized too, so a message send type-checks and runs. The
+// selector's name comes from the symbol clang emits for it: after
+// __objc_load the name field in memory holds a dispatch index instead.
+//
+// VENDOR: (lldb) expr -- [(Hidden *)hidden plainInt]
+// VENDOR: (int) $2 = 1
+//
+// VENDOR: (lldb) expr -- [(Hidden *)hidden doubled:21]
+// VENDOR: (int) $3 = 42
+//
+// Class methods come from the metaclass, where libobjc2 keeps them as its
+// instance methods.
+//
+// VENDOR: (lldb) expr -- [Hidden classAnswer]
+// VENDOR: (int) $4 = 7



More information about the llvm-commits mailing list