[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
Mon Aug 17 06:11:17 PDT 2026


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

>From 5b44923354c0678147dcafffc05bb93275efb979 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/24] [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 | 4 ++++
 1 file changed, 4 insertions(+)

diff --git a/lldb/source/Initialization/SystemInitializerCommon.cpp b/lldb/source/Initialization/SystemInitializerCommon.cpp
index b5d1c25e15008..ba1299f7956d2 100644
--- a/lldb/source/Initialization/SystemInitializerCommon.cpp
+++ b/lldb/source/Initialization/SystemInitializerCommon.cpp
@@ -53,12 +53,16 @@ llvm::Error SystemInitializerCommon::Initialize() {
     ::SetErrorMode(GetErrorMode() | SEM_FAILCRITICALERRORS |
                    SEM_NOGPFAULTERRORBOX);
 
+#ifdef _MSC_VER
+    // The CRT debug reporting functions are only available with the
+    // Microsoft C runtime, not when building with MinGW.
     _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 3664ca26aa4e4190ad1e129f01c0451e54d5b320 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/24] [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            | 191 ++++++++++++++++++
 .../GNUstepObjCClassDescriptor.h              | 144 +++++++++++++
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.cpp | 121 ++++++++++-
 .../GNUstepObjCRuntime/GNUstepObjCRuntime.h   |  15 ++
 .../Shell/Expr/objc-gnustep-dynamic-types.m   |  55 +++++
 6 files changed, 524 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..a19d5ea53266b
--- /dev/null
+++ b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
@@ -0,0 +1,191 @@
+//===-- 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();
+  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 3dc6065bc7bab9941e8276d535e5f0182b8031d4 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/24] [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 528bb75ca3af6865f6d25a2e889dcaf3a42b980a 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/24] [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 b47e1cd04005085fe4e87bec8b5757570602fe14 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/24] [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 8cc05f061358386264d239dd33903b01c5c3242d 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/24] [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 f58b029d717027c91171a534cf2afe6b3435efba 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/24] [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 81438edb439fe735afa336f2d36db3297ffa7d16 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/24] [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 7621b6762105f7e32a75e9dd1938efdcc79f0a71 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/24] [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 0a5b7f7bd6412f57663d371a91dd6fe9b9e03e19 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/24] [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 a19d5ea53266b..50ad32f1d3715 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;
 }
 
@@ -124,9 +173,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;
@@ -139,7 +194,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;
@@ -147,8 +203,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;
@@ -178,13 +235,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 4ab85a90c498846794fda829bc5562aac7fa9cd5 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/24] [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        | 334 ++++++++++++++++++
 5 files changed, 352 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..df7fb4e951943
--- /dev/null
+++ b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
@@ -0,0 +1,334 @@
+//===-- 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(addr_t vm_addr, void *buf, size_t size,
+                      Status &error) override {
+    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 27a784f0ac8b59faae11cfa38100a0ee7a406890 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/24] [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 50ad32f1d3715..dabc137f17188 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 2ee9284d7954d9e3a49b6ccc56ec35b5ee6e7bfe 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/24] [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 f19ddb706c4ecaa57bc199ab9048fd18b08a9d08 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/24] [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 1991b6a9035d35063bc918a61d271f81e837153d 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/24] [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 37c934c265a5ea0566dd577e3c4c32d198ee606e 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/24] [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 708e3db3c86d629f0afd48c995ac0a88d288768d 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/24] [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 5f005efea25e6a902d16837cf7c0ce6863061164 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/24] [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 f152c976d99e1cdef48dacfdb64ab33869fa7b97 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/24] [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 dabc137f17188..6d34f2c6a3c17 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 df7fb4e951943..76b1f2856c17b 100644
--- a/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
+++ b/lldb/unittests/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptorTest.cpp
@@ -182,6 +182,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 bd094e67a4a8392e5ac12fd48172058e67582347 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/24] [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 413db426a5fdbbe05f241af1f0990312008ccadf 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/24] [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            | 25 +++++++++++++++++--
 1 file changed, 23 insertions(+), 2 deletions(-)

diff --git a/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp b/lldb/source/Plugins/LanguageRuntime/ObjC/GNUstepObjCRuntime/GNUstepObjCClassDescriptor.cpp
index 6d34f2c6a3c17..a8fdad4f09381 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,7 @@
 #include "lldb/Utility/LLDBLog.h"
 #include "lldb/Utility/Log.h"
 #include "lldb/Utility/Status.h"
+#include "lldb/ValueObject/ValueObjectVariable.h"
 
 using namespace lldb;
 using namespace lldb_private;
@@ -226,10 +229,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 2916d5211a6e2d9cfbd084662777767e879ad7ce 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/24] [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 +++++++++++
 .../lang/objc-gnustep/data-formatters/shim.m  |  12 ++
 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 ++++++++++
 16 files changed, 469 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/test/API/lang/objc-gnustep/data-formatters/shim.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..12865f8c0e2da
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/Makefile
@@ -0,0 +1,3 @@
+OBJC_SOURCES := main.m shim.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/lang/objc-gnustep/data-formatters/shim.m b/lldb/test/API/lang/objc-gnustep/data-formatters/shim.m
new file mode 100644
index 0000000000000..abeabcf760402
--- /dev/null
+++ b/lldb/test/API/lang/objc-gnustep/data-formatters/shim.m
@@ -0,0 +1,12 @@
+// gnustep-base defines _NSPrintForDebugger (Source/NSDebug.m) but does not
+// dllexport it on Windows MSVC, so the debugger cannot resolve it in the DLL
+// and `po` has nothing to call. Defining it in the app makes it visible in
+// the executable's symbol table; the body mirrors NSDebug.m.
+#import <Foundation/Foundation.h>
+
+const char *_NSPrintForDebugger(id object) {
+  if (object && [object respondsToSelector:@selector(description)])
+    return [[object description] UTF8String];
+
+  return NULL;
+}
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 fb36f824ca1af8c18c660c5df55b4e2c971336c8 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/24] [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 70b24bdc982028abc4bee710e8b9e0b6512e657d 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/24] [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:]



More information about the llvm-commits mailing list