[Lldb-commits] [lldb] [lldb][Windows] Report module paths the way the loader spelled them (PR #226492)

Charles Zablit via lldb-commits lldb-commits at lists.llvm.org
Fri Sep 25 06:35:42 PDT 2026


https://github.com/charles-zablit created https://github.com/llvm/llvm-project/pull/226492

None

>From 19daade10dbc53fa5fa29e8f36e72f7016317358 Mon Sep 17 00:00:00 2001
From: Charles Zablit <c_zablit at apple.com>
Date: Fri, 25 Sep 2026 14:19:45 +0100
Subject: [PATCH] [lldb][Windows] Report module paths the way the loader
 spelled them

---
 .../Windows-DYLD/DynamicLoaderWindowsDYLD.cpp |   9 +-
 .../ObjectFile/PECOFF/ObjectFilePECOFF.cpp    |  11 +-
 .../Process/Windows/Common/DebuggerThread.cpp | 106 ++++++++++++++++--
 .../Process/Windows/Common/DebuggerThread.h   |   6 +
 .../Windows/Common/NativeProcessWindows.cpp   |  32 +++++-
 .../module_aliased_path/Makefile              |   5 +
 .../TestModuleAliasedPath.py                  |  77 +++++++++++++
 .../functionalities/module_aliased_path/foo.c |   3 +
 .../module_aliased_path/main.c                |   6 +
 9 files changed, 237 insertions(+), 18 deletions(-)
 create mode 100644 lldb/test/API/functionalities/module_aliased_path/Makefile
 create mode 100644 lldb/test/API/functionalities/module_aliased_path/TestModuleAliasedPath.py
 create mode 100644 lldb/test/API/functionalities/module_aliased_path/foo.c
 create mode 100644 lldb/test/API/functionalities/module_aliased_path/main.c

diff --git a/lldb/source/Plugins/DynamicLoader/Windows-DYLD/DynamicLoaderWindowsDYLD.cpp b/lldb/source/Plugins/DynamicLoader/Windows-DYLD/DynamicLoaderWindowsDYLD.cpp
index 2d6e97b067c300..fb0619364dcb99 100644
--- a/lldb/source/Plugins/DynamicLoader/Windows-DYLD/DynamicLoaderWindowsDYLD.cpp
+++ b/lldb/source/Plugins/DynamicLoader/Windows-DYLD/DynamicLoaderWindowsDYLD.cpp
@@ -148,8 +148,15 @@ void DynamicLoaderWindowsDYLD::DidAttach() {
   // Try to fetch the load address of the file from the process, since there
   // could be randomization of the load address.
   lldb::addr_t load_addr = GetLoadAddress(executable);
-  if (load_addr == LLDB_INVALID_ADDRESS)
+  if (load_addr == LLDB_INVALID_ADDRESS) {
+    // The process does not know the executable under the target's path: it
+    // was started through another path to the same file, or runs a different
+    // one. Take the modules it reports as loaded, which replaces the target's
+    // executable with the process's.
+    auto error = m_process->LoadModules();
+    LLDB_LOG_ERROR(log, std::move(error), "failed to load modules: {0}");
     return;
+  }
 
   // Request the process base address.
   lldb::addr_t image_base = m_process->GetImageInfoAddress();
diff --git a/lldb/source/Plugins/ObjectFile/PECOFF/ObjectFilePECOFF.cpp b/lldb/source/Plugins/ObjectFile/PECOFF/ObjectFilePECOFF.cpp
index 985dd6b516bae9..e8167f102a9682 100644
--- a/lldb/source/Plugins/ObjectFile/PECOFF/ObjectFilePECOFF.cpp
+++ b/lldb/source/Plugins/ObjectFile/PECOFF/ObjectFilePECOFF.cpp
@@ -1161,12 +1161,17 @@ uint32_t ObjectFilePECOFF::ParseDependentModules() {
     // At this moment we only have the base name of the DLL. The full path can
     // only be seen after the dynamic loading.  Our best guess is Try to get it
     // with the help of the object file's directory.
-    llvm::SmallString<128> dll_fullpath;
+    //
+    // Keep the directory as it is spelled in m_file rather than resolving it
+    // (e.g. with real_path): that is how the Windows loader names a DLL it
+    // finds next to the executable, so resolving a subst drive, junction or
+    // symbolic link here would give the module a different path from the one
+    // the process reports for it once it is loaded.
     FileSpec dll_specs(dll_name);
     dll_specs.SetDirectory(m_file.GetDirectory());
 
-    if (!llvm::sys::fs::real_path(dll_specs.GetPath(), dll_fullpath))
-      m_deps_filespec->EmplaceBack(dll_fullpath);
+    if (FileSystem::Instance().Exists(dll_specs))
+      m_deps_filespec->Append(dll_specs);
     else {
       // Known DLLs or DLL not found in the object file directory.
       m_deps_filespec->EmplaceBack(dll_name);
diff --git a/lldb/source/Plugins/Process/Windows/Common/DebuggerThread.cpp b/lldb/source/Plugins/Process/Windows/Common/DebuggerThread.cpp
index 260ac0fe0faf66..c058e54fd4f832 100644
--- a/lldb/source/Plugins/Process/Windows/Common/DebuggerThread.cpp
+++ b/lldb/source/Plugins/Process/Windows/Common/DebuggerThread.cpp
@@ -29,12 +29,14 @@
 #include "lldb/Utility/LLDBLog.h"
 #include "llvm/ADT/STLExtras.h"
 #include "llvm/Support/ConvertUTF.h"
+#include "llvm/Support/Path.h"
 #include "llvm/Support/Threading.h"
 #include "llvm/Support/raw_ostream.h"
 
 #include <optional>
 #include <pathcch.h>
 #include <psapi.h>
+#include <winternl.h>
 
 #ifndef STATUS_WX86_BREAKPOINT
 #define STATUS_WX86_BREAKPOINT 0x4000001FL // For WOW64
@@ -453,6 +455,58 @@ DebuggerThread::HandleCreateThreadEvent(const CREATE_THREAD_DEBUG_INFO &info,
   return DBG_CONTINUE;
 }
 
+/// Read the path the process was created with from its process parameters
+/// (PEB::ProcessParameters->ImagePathName).
+///
+/// This keeps the subst drive, junction or symbolic link the process was
+/// launched through. The other ways of getting the executable's path at the
+/// create-process event resolve it: CREATE_PROCESS_DEBUG_INFO::lpImageName is
+/// null, QueryFullProcessImageNameW reports the resolved file name, and
+/// GetModuleFileNameExW falls back to the same because the loader has not
+/// built its module list yet. The process parameters are written by
+/// CreateProcess before the first thread runs, so they are already there.
+static std::optional<std::string> GetImagePathFromPEB(HANDLE process) {
+  using NtQueryInformationProcessFn =
+      NTSTATUS(NTAPI *)(HANDLE, PROCESSINFOCLASS, PVOID, ULONG, PULONG);
+  static auto query = reinterpret_cast<NtQueryInformationProcessFn>(
+      ::GetProcAddress(::GetModuleHandleW(L"ntdll.dll"),
+                       "NtQueryInformationProcess"));
+  if (!query)
+    return std::nullopt;
+
+  PROCESS_BASIC_INFORMATION pbi = {};
+  if (query(process, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr) != 0 ||
+      !pbi.PebBaseAddress)
+    return std::nullopt;
+
+  PEB peb = {};
+  if (!::ReadProcessMemory(process, pbi.PebBaseAddress, &peb, sizeof(peb),
+                           nullptr) ||
+      !peb.ProcessParameters)
+    return std::nullopt;
+
+  RTL_USER_PROCESS_PARAMETERS params = {};
+  if (!::ReadProcessMemory(process, peb.ProcessParameters, &params,
+                           sizeof(params), nullptr) ||
+      !params.ImagePathName.Buffer || params.ImagePathName.Length == 0)
+    return std::nullopt;
+
+  std::wstring wpath(params.ImagePathName.Length / sizeof(wchar_t), L'\0');
+  if (!::ReadProcessMemory(process, params.ImagePathName.Buffer, wpath.data(),
+                           params.ImagePathName.Length, nullptr))
+    return std::nullopt;
+
+  std::string path;
+  if (!llvm::convertWideToUTF8(wpath, path))
+    return std::nullopt;
+  // A process launched through an extended-length path has the "\\?\" prefix.
+  llvm::StringRef path_ref = path;
+  if (path_ref.consume_front("\\\\?\\UNC\\"))
+    return "\\\\" + path_ref.str();
+  path_ref.consume_front("\\\\?\\");
+  return path_ref.str();
+}
+
 DWORD
 DebuggerThread::HandleCreateProcessEvent(const CREATE_PROCESS_DEBUG_INFO &info,
                                          DWORD thread_id) {
@@ -476,6 +530,7 @@ DebuggerThread::HandleCreateProcessEvent(const CREATE_PROCESS_DEBUG_INFO &info,
   m_image_file = info.hFile;
 
   lldb::addr_t load_addr = reinterpret_cast<lldb::addr_t>(info.lpBaseOfImage);
+  m_image_path = GetImagePathFromPEB(info.hProcess).value_or("");
   m_debug_delegate->OnDebuggerConnected(load_addr);
 
   return DBG_CONTINUE;
@@ -617,15 +672,17 @@ static std::optional<std::string> GetFileNameFromHandleFallback(HANDLE hFile) {
   return GetMappedFileDosPath(::GetCurrentProcess(), pMem.get());
 }
 
-static std::optional<std::string> GetFileNameByLoadAddress(HANDLE process,
-                                                           LPVOID base_addr) {
+/// The path the loader recorded for the module at \p base_addr, or nullopt if
+/// the loader's module list has no entry for it yet.
+static std::optional<std::string> GetLoaderModuleName(HANDLE process,
+                                                      LPVOID base_addr) {
   std::vector<wchar_t> module_filename(MAX_PATH + 1);
   while (module_filename.size() <= PATHCCH_MAX_CCH) {
     DWORD len =
         ::GetModuleFileNameExW(process, reinterpret_cast<HMODULE>(base_addr),
                                module_filename.data(), module_filename.size());
     if (len == 0)
-      break; // Not loaded as a module; fall back to the mapped-file query.
+      return std::nullopt;
     if (len < module_filename.size()) {
       std::string path_utf8;
       llvm::convertWideToUTF8(std::wstring_view(module_filename.data(), len),
@@ -634,9 +691,7 @@ static std::optional<std::string> GetFileNameByLoadAddress(HANDLE process,
     }
     module_filename.resize(module_filename.size() * 2);
   }
-
-  // Fallback: ask the kernel for the file backing the mapping at this address.
-  return GetMappedFileDosPath(process, base_addr);
+  return std::nullopt;
 }
 
 // Determine how many bytes can be read at `addr` in `process` before crossing
@@ -747,7 +802,30 @@ DebuggerThread::HandleLoadDllEvent(const LOAD_DLL_DEBUG_INFO &info,
     action = m_debug_delegate->OnLoadDll(module_spec, load_addr, thread_id);
   };
 
-  std::optional<std::string> resolved_path;
+  HANDLE process = m_process.GetNativeProcess().GetSystemHandle();
+
+  // Prefer the path the loader used for the DLL, which keeps the subst drive,
+  // junction or symbolic link it was reached through, so that the DLLs next to
+  // the executable are reported under the same directory as the executable
+  // (see GetImagePathFromPEB). lpImageName holds it for DLLs the loader maps
+  // while we watch; it is only a base name for ntdll.dll, and is often null
+  // for the DLLs reported on attach, which the loader's module list already
+  // has instead.
+  std::optional<std::string> loader_path =
+      GetFileNameFromImageNameField(process, info);
+  if (loader_path && !llvm::sys::path::is_absolute(
+                         *loader_path, llvm::sys::path::Style::windows))
+    loader_path.reset();
+  if (!loader_path)
+    loader_path = GetLoaderModuleName(process, info.lpBaseOfDll);
+  if (loader_path) {
+    llvm::StringRef path_ref = *loader_path;
+    path_ref.consume_front("\\\\?\\");
+    loader_path = path_ref.str();
+  }
+
+  // The file handle gives the resolved path, with the on-disk case.
+  std::optional<std::string> file_path;
   if (info.hFile != nullptr) {
     std::vector<wchar_t> buffer(1);
     DWORD required_size =
@@ -760,17 +838,23 @@ DebuggerThread::HandleLoadDllEvent(const LOAD_DLL_DEBUG_INFO &info,
       llvm::convertWideToUTF8(buffer.data(), path_str_utf8);
       llvm::StringRef path_str = path_str_utf8;
       path_str.consume_front("\\\\?\\");
-      resolved_path = path_str.str();
+      file_path = path_str.str();
     } else {
-      resolved_path = GetFileNameFromHandleFallback(info.hFile);
+      file_path = GetFileNameFromHandleFallback(info.hFile);
     }
   }
 
-  HANDLE process = m_process.GetNativeProcess().GetSystemHandle();
+  // The loader's path often differs from the on-disk one only in case (e.g.
+  // C:\windows\System32\KERNEL32.DLL); keep the on-disk spelling then.
+  std::optional<std::string> resolved_path = loader_path;
+  if (!resolved_path ||
+      (file_path &&
+       llvm::StringRef(*file_path).equals_insensitive(*resolved_path)))
+    resolved_path = file_path;
   if (!resolved_path)
     resolved_path = GetFileNameFromImageNameField(process, info);
   if (!resolved_path)
-    resolved_path = GetFileNameByLoadAddress(process, info.lpBaseOfDll);
+    resolved_path = GetMappedFileDosPath(process, info.lpBaseOfDll);
 
   if (resolved_path)
     on_load_dll(*resolved_path);
diff --git a/lldb/source/Plugins/Process/Windows/Common/DebuggerThread.h b/lldb/source/Plugins/Process/Windows/Common/DebuggerThread.h
index 03202264b01ee8..a9fa3ec0c7599d 100644
--- a/lldb/source/Plugins/Process/Windows/Common/DebuggerThread.h
+++ b/lldb/source/Plugins/Process/Windows/Common/DebuggerThread.h
@@ -36,6 +36,11 @@ class DebuggerThread : public std::enable_shared_from_this<DebuggerThread> {
   HostProcess GetProcess() const { return m_process; }
   HostThread GetMainThread() const { return m_main_thread; }
 
+  /// The path the process was created with, as recorded in its process
+  /// parameters, or an empty string if it could not be read. Set by the
+  /// create-process event.
+  const std::string &GetImagePath() const { return m_image_path; }
+
   /// Returns the exception the debug loop is currently reporting, or null if
   /// there is none. Safe to call from any thread.
   ExceptionRecordSP GetActiveException();
@@ -75,6 +80,7 @@ class DebuggerThread : public std::enable_shared_from_this<DebuggerThread> {
 
   // The image file of the process being debugged.
   HANDLE m_image_file = nullptr;
+  std::string m_image_path;
 
   // The current exception waiting to be handled.
   ExceptionRecordSP m_active_exception;
diff --git a/lldb/source/Plugins/Process/Windows/Common/NativeProcessWindows.cpp b/lldb/source/Plugins/Process/Windows/Common/NativeProcessWindows.cpp
index 849931eaec3727..2c738b505d7054 100644
--- a/lldb/source/Plugins/Process/Windows/Common/NativeProcessWindows.cpp
+++ b/lldb/source/Plugins/Process/Windows/Common/NativeProcessWindows.cpp
@@ -360,8 +360,16 @@ Status NativeProcessWindows::RemoveBreakpoint(lldb::addr_t addr,
   return RemoveSoftwareBreakpoint(addr);
 }
 
-// Resolve the fully qualified, normalized on disk path of a module loaded in
-// the target process.
+// Get the path of a module loaded in the target process, as the loader recorded
+// it.
+//
+// Keep the loader's spelling, which is the path the process reached the module
+// through, rather than resolving it: a process started from a subst drive,
+// junction or symbolic link reports its executable and the DLLs next to it
+// under that path, and the client matches modules by path, so a resolved path
+// makes it add a second module for a file it already has. This is also what
+// lldb-server reports on Linux, where the link_map names are the paths the
+// dynamic loader opened. The resolved path is only used for its on-disk case.
 static bool GetLoadedModulePath(HANDLE process, HMODULE module,
                                 std::string &path) {
   std::vector<wchar_t> name(MAX_PATH);
@@ -408,7 +416,17 @@ static bool GetLoadedModulePath(HANDLE process, HMODULE module,
         canonical.replace(0, wcslen(kUNCPrefix), L"\\\\");
       else if (canonical.rfind(kDOSPrefix, 0) == 0)
         canonical.erase(0, wcslen(kDOSPrefix));
-      wpath = std::move(canonical);
+      // A process launched through an extended-length path records its
+      // modules with the prefix too.
+      std::wstring loader_path = wpath;
+      if (loader_path.rfind(kUNCPrefix, 0) == 0)
+        loader_path.replace(0, wcslen(kUNCPrefix), L"\\\\");
+      else if (loader_path.rfind(kDOSPrefix, 0) == 0)
+        loader_path.erase(0, wcslen(kDOSPrefix));
+      if (::_wcsicmp(canonical.c_str(), loader_path.c_str()) == 0)
+        wpath = std::move(canonical);
+      else
+        wpath = std::move(loader_path);
       break;
     }
     full.resize(needed);
@@ -550,7 +568,15 @@ void NativeProcessWindows::OnDebuggerConnected(lldb::addr_t image_base) {
   }
 
   if (got_info) {
+    // Host::GetProcessInfo reports the resolved path of the executable; record
+    // the one the process was launched with, like the DLLs next to it (see
+    // DebuggerThread::HandleLoadDllEvent).
     FileSpec exe = info.GetExecutableFile();
+    if (const std::string &image_path =
+            m_session_data->m_debugger->GetImagePath();
+        !image_path.empty() &&
+        !llvm::StringRef(image_path).equals_insensitive(exe.GetPath()))
+      exe = FileSpec(image_path);
     if (exe) {
       FileSystem::Instance().Resolve(exe);
       m_loaded_modules.Add(exe, image_base);
diff --git a/lldb/test/API/functionalities/module_aliased_path/Makefile b/lldb/test/API/functionalities/module_aliased_path/Makefile
new file mode 100644
index 00000000000000..e2607a0b08a7c3
--- /dev/null
+++ b/lldb/test/API/functionalities/module_aliased_path/Makefile
@@ -0,0 +1,5 @@
+C_SOURCES := main.c
+DYLIB_C_SOURCES := foo.c
+DYLIB_NAME := foo
+
+include Makefile.rules
diff --git a/lldb/test/API/functionalities/module_aliased_path/TestModuleAliasedPath.py b/lldb/test/API/functionalities/module_aliased_path/TestModuleAliasedPath.py
new file mode 100644
index 00000000000000..d804c51e284359
--- /dev/null
+++ b/lldb/test/API/functionalities/module_aliased_path/TestModuleAliasedPath.py
@@ -0,0 +1,77 @@
+"""
+Test that a process launched through an aliased directory reports its modules
+under that directory, so LLDB does not duplicate the modules it already has.
+
+The target is created from a junction to the build directory. lldb-server must
+report the executable and the DLL next to it with the path the Windows loader
+recorded, which keeps the junction, rather than with the resolved path, which
+does not. Otherwise LLDB adds a second module for each file and the breakpoints
+get a location in both.
+"""
+
+import os
+import subprocess
+
+import lldb
+from lldbsuite.test.decorators import *
+from lldbsuite.test.lldbtest import *
+from lldbsuite.test import lldbutil
+
+
+ at skipUnlessWindows
+ at skipIfWindowsAndNoLLDBServer
+ at skipIfRemote
+class TestModuleAliasedPath(TestBase):
+    NO_DEBUG_INFO_TESTCASE = True
+
+    def test_launch_through_aliased_path(self):
+        self.build()
+
+        build_dir = self.getBuildDir()
+        alias_dir = build_dir + ".alias"
+        if os.path.lexists(alias_dir):
+            os.rmdir(alias_dir)
+        subprocess.check_call(
+            'mklink /J "%s" "%s"' % (alias_dir, build_dir),
+            shell=True,
+            stdout=subprocess.DEVNULL,
+        )
+        self.addTearDownHook(lambda: os.rmdir(alias_dir))
+
+        target = self.dbg.CreateTarget(os.path.join(alias_dir, "a.out"))
+        self.assertTrue(target, VALID_TARGET)
+        main_bkpt = target.BreakpointCreateBySourceRegex(
+            "break main", lldb.SBFileSpec("main.c")
+        )
+        foo_bkpt = target.BreakpointCreateBySourceRegex(
+            "break here", lldb.SBFileSpec("foo.c")
+        )
+        self.assertEqual(main_bkpt.GetNumLocations(), 1)
+
+        process = target.LaunchSimple(None, None, self.get_process_working_directory())
+        self.assertState(process.GetState(), lldb.eStateStopped)
+        self.assertIsNotNone(
+            lldbutil.get_one_thread_stopped_at_breakpoint(process, main_bkpt)
+        )
+
+        lib_name = self.platformContext.getFullLibName("foo")
+        for name in ["a.out", lib_name]:
+            paths = [
+                m.GetFileSpec().fullpath
+                for m in target.module_iter()
+                if m.GetFileSpec().GetFilename() == name
+            ]
+            self.assertEqual(len(paths), 1, "one module for %s: %s" % (name, paths))
+            self.assertTrue(
+                paths[0].lower().startswith(alias_dir.lower()),
+                "%s is reported under the junction" % paths[0],
+            )
+
+        for bkpt in [main_bkpt, foo_bkpt]:
+            self.assertEqual(bkpt.GetNumLocations(), 1)
+            self.assertEqual(bkpt.GetNumResolvedLocations(), 1)
+
+        process.Continue()
+        self.assertIsNotNone(
+            lldbutil.get_one_thread_stopped_at_breakpoint(process, foo_bkpt)
+        )
diff --git a/lldb/test/API/functionalities/module_aliased_path/foo.c b/lldb/test/API/functionalities/module_aliased_path/foo.c
new file mode 100644
index 00000000000000..375a5207d26548
--- /dev/null
+++ b/lldb/test/API/functionalities/module_aliased_path/foo.c
@@ -0,0 +1,3 @@
+int LLDB_DYLIB_EXPORT foo(void) {
+  return 42; // break here
+}
diff --git a/lldb/test/API/functionalities/module_aliased_path/main.c b/lldb/test/API/functionalities/module_aliased_path/main.c
new file mode 100644
index 00000000000000..3bd932f5e291e3
--- /dev/null
+++ b/lldb/test/API/functionalities/module_aliased_path/main.c
@@ -0,0 +1,6 @@
+extern int foo(void);
+
+int main(void) {
+  int result = foo(); // break main
+  return result == 42 ? 0 : 1;
+}



More information about the lldb-commits mailing list