This is an automated email from the ASF dual-hosted git repository.

bneradt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/trafficserver.git


The following commit(s) were added to refs/heads/master by this push:
     new 11e99cb679 Fix DbgCtl use-after-free shutdown crash via leaky 
singleton (#12777)
11e99cb679 is described below

commit 11e99cb6796bd5eae3fd6be0822e7f0ec9f86bd0
Author: Brian Neradt <[email protected]>
AuthorDate: Tue Jan 6 14:16:12 2026 -0600

    Fix DbgCtl use-after-free shutdown crash via leaky singleton (#12777)
    
    The DbgCtl registry was experiencing use-after-free crashes during program
    shutdown, particularly visible in CI regression tests. The root cause
    was the undefined destruction order of static objects in C++.
    
    Problem:
    - DbgCtl objects can be static or thread-local with lifetimes spanning 
program
      execution
    - When one compilation unit's DbgCtl destructs and triggers registry 
cleanup,
      other compilation units may still have DbgCtl objects with pointers into
      that registry
    - Thread exit order is also unpredictable, causing similar issues when
      thread-local DbgCtl objects destruct
    
    This implements the "leaky singleton" pattern where the registry is:
    - Created on first use
    - Never destroyed (destructor is now = default)
    - Memory (~20KB) reclaimed by OS at process exit
    
    Fixes: #12776
---
 ci/asan_leak_suppression/regression.txt        |  3 ++
 include/tsutil/DbgCtl.h                        | 12 ++---
 plugins/esi/test/CMakeLists.txt                |  5 +++
 plugins/esi/test/esi_test_leak_suppression.txt |  5 +++
 plugins/esi/test/print_funcs.cc                | 16 +------
 src/tsutil/DbgCtl.cc                           | 62 +++-----------------------
 6 files changed, 24 insertions(+), 79 deletions(-)

diff --git a/ci/asan_leak_suppression/regression.txt 
b/ci/asan_leak_suppression/regression.txt
index 4e98783f67..b3dfcf8363 100644
--- a/ci/asan_leak_suppression/regression.txt
+++ b/ci/asan_leak_suppression/regression.txt
@@ -19,5 +19,8 @@ leak:RegressionTest_HostDBProcessor
 leak:RegressionTest_DNS
 leak:RegressionTest_UDPNet_echo
 leak:HttpConfig::startup
+# DbgCtl leaky singleton pattern - intentional, see issue #12776.
+leak:DbgCtl::_new_reference
+leak:_new_reference
 # libswoc
 leak:MemArena::make_block
diff --git a/include/tsutil/DbgCtl.h b/include/tsutil/DbgCtl.h
index dee8d1ebfb..76cde235f1 100644
--- a/include/tsutil/DbgCtl.h
+++ b/include/tsutil/DbgCtl.h
@@ -67,12 +67,10 @@ public:
   //
   DbgCtl() : _ptr{&_No_tag_dummy()} {}
 
-  ~DbgCtl()
-  {
-    if (_ptr != &_No_tag_dummy()) {
-      _rm_reference();
-    }
-  }
+  // Default destructor: the registry uses a "leaky singleton" pattern to avoid
+  // use-after-free crashes during shutdown due to undefined static destruction
+  // order. See issue #12776.
+  ~DbgCtl() = default;
 
   // No copying. Only moving from a tagged to a tagless instance allowed.
   //
@@ -153,8 +151,6 @@ private:
 
   static const _TagData *_new_reference(char const *tag);
 
-  static void _rm_reference();
-
   class _RegistryAccessor;
 
   static std::atomic<int> _config_mode;
diff --git a/plugins/esi/test/CMakeLists.txt b/plugins/esi/test/CMakeLists.txt
index 1aff571bcb..c9bc8c2d5f 100644
--- a/plugins/esi/test/CMakeLists.txt
+++ b/plugins/esi/test/CMakeLists.txt
@@ -22,6 +22,11 @@ macro(ADD_ESI_TEST NAME)
   add_executable(${NAME} print_funcs.cc ${ARGN})
   target_link_libraries(${NAME} PRIVATE esitest)
   add_catch2_test(NAME ${NAME}_esi COMMAND $<TARGET_FILE:${NAME}>)
+  # Use ASan leak suppression for intentional DbgCtl leaks (issue #12776)
+  set_tests_properties(
+    ${NAME}_esi PROPERTIES ENVIRONMENT
+                           
"LSAN_OPTIONS=suppressions=${CMAKE_CURRENT_SOURCE_DIR}/esi_test_leak_suppression.txt"
+  )
 endmacro()
 
 add_esi_test(test_docnode docnode_test.cc)
diff --git a/plugins/esi/test/esi_test_leak_suppression.txt 
b/plugins/esi/test/esi_test_leak_suppression.txt
new file mode 100644
index 0000000000..8a857cbeab
--- /dev/null
+++ b/plugins/esi/test/esi_test_leak_suppression.txt
@@ -0,0 +1,5 @@
+# ESI unit test leak suppressions
+# DbgCtl leaky singleton pattern - intentional, see issue #12776
+leak:DbgCtl::_new_reference
+leak:_new_reference
+
diff --git a/plugins/esi/test/print_funcs.cc b/plugins/esi/test/print_funcs.cc
index abc9894761..2596a46205 100644
--- a/plugins/esi/test/print_funcs.cc
+++ b/plugins/esi/test/print_funcs.cc
@@ -63,14 +63,12 @@ class DbgCtl::_RegistryAccessor
 {
 public:
   // No mutex protection, assuming unit test is single threaded.
-  //
   static std::map<char const *, bool> &
   registry()
   {
     static std::map<char const *, bool> r;
     return r;
   }
-  static inline int ref_count{0};
 };
 
 std::atomic<int> DbgCtl::_config_mode{1};
@@ -78,11 +76,9 @@ std::atomic<int> DbgCtl::_config_mode{1};
 DbgCtl::_TagData const *
 DbgCtl::_new_reference(char const *tag)
 {
-  ++_RegistryAccessor::ref_count;
-
   auto it{_RegistryAccessor::registry().find(tag)};
   if (it == _RegistryAccessor::registry().end()) {
-    char *s = new char[std::strlen(tag) + 1];
+    char *s = new char[std::strlen(tag) + 1]; // Never deleted - leaky 
singleton pattern.
     std::strcpy(s, tag);
     auto r{_RegistryAccessor::registry().emplace(s, true)}; // Tag is always 
enabled.
     it = r.first;
@@ -90,16 +86,6 @@ DbgCtl::_new_reference(char const *tag)
   return &(*it);
 }
 
-void
-DbgCtl::_rm_reference()
-{
-  if (!--_RegistryAccessor::ref_count) {
-    for (auto &elem : _RegistryAccessor::registry()) {
-      delete[] elem.first;
-    }
-  }
-}
-
 bool
 DbgCtl::_override_global_on()
 {
diff --git a/src/tsutil/DbgCtl.cc b/src/tsutil/DbgCtl.cc
index 0263c31139..cc78dea8b3 100644
--- a/src/tsutil/DbgCtl.cc
+++ b/src/tsutil/DbgCtl.cc
@@ -54,9 +54,6 @@ DbgCtl::operator=(DbgCtl &&src)
   return *this;
 }
 
-// The resistry of fast debug controllers has a ugly implementation to handle 
the whole-program initialization
-// and destruction order problem with C++.
-//
 class DbgCtl::_RegistryAccessor
 {
 private:
@@ -79,15 +76,8 @@ public:
   private:
     Registry() = default;
 
-    // Mutex must be locked before this is called.
-    //
-    ~Registry()
-    {
-      for (auto &elem : map) {
-        delete[] elem.first;
-      }
-      _mtx.unlock();
-    }
+    // Destructor never called - this is a leaky singleton. See issue #12776.
+    ~Registry() = default;
 
     std::mutex _mtx;
 
@@ -115,8 +105,8 @@ public:
     }
   }
 
-  // This is not static so it can't be called with the registry mutex is 
unlocked.  It should not be called
-  // after registry is deleted.
+  // This is not static so it can't be called with the registry mutex unlocked.
+  // The registry is never deleted (leaky singleton pattern), so it's always 
safe to call after creation.
   //
   Registry &
   data()
@@ -124,20 +114,6 @@ public:
     return *_registry_instance;
   }
 
-  void
-  delete_registry()
-  {
-    auto r = _registry_instance.load();
-    debug_assert(r != nullptr);
-    _registry_instance = nullptr;
-    delete r;
-    _mtx_is_locked = false;
-  }
-
-  // Reference count of references to Registry.
-  //
-  inline static std::atomic<unsigned> registry_reference_count{0};
-
 private:
   bool _mtx_is_locked{false};
 
@@ -152,13 +128,6 @@ DbgCtl::_new_reference(char const *tag)
 
   _TagData *new_tag_data{nullptr};
 
-  // DbgCtl instances may be declared as static objects in the destructors of 
objects not destroyed till program exit.
-  // So, we must handle the case where the construction of such instances of 
DbgCtl overlaps with the destruction of
-  // other instances of DbgCtl.  That is why it is important to make sure the 
reference count is non-zero before
-  // constructing _RegistryAccessor.  The _RegistryAccessor constructor is 
thereby able to assume that, if it creates
-  // the Registry, the new Registry will not be destroyed before the mutex in 
the new Registry is locked.
-
-  ++_RegistryAccessor::registry_reference_count;
   {
     _RegistryAccessor ra;
 
@@ -172,7 +141,7 @@ DbgCtl::_new_reference(char const *tag)
 
     debug_assert(sz > 0);
 
-    char *t = new char[sz + 1]; // Deleted by ~Registry().
+    char *t = new char[sz + 1]; // Never deleted (leaky singleton) - reclaimed 
by OS at process exit.
     std::memcpy(t, tag, sz + 1);
     _TagData new_elem{t, false};
 
@@ -201,30 +170,11 @@ DbgCtl::_new_reference(char const *tag)
   return new_tag_data;
 }
 
-void
-DbgCtl::_rm_reference()
-{
-  _RegistryAccessor ra;
-
-  debug_assert(ra.registry_reference_count != 0);
-
-  --ra.registry_reference_count;
-
-  if (0 == ra.registry_reference_count) {
-    ra.delete_registry();
-  }
-}
-
 void
 DbgCtl::update(const std::function<bool(const char *)> &f)
 {
   _RegistryAccessor ra;
-
-  if (!ra.registry_reference_count) {
-    return;
-  }
-
-  auto &d{ra.data()};
+  auto             &d{ra.data()};
 
   for (auto &i : d.map) {
     i.second = f(i.first);

Reply via email to