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

via lldb-commits lldb-commits at lists.llvm.org
Fri Sep 25 07:52:55 PDT 2026


llvmorg-github-actions[bot] wrote:


<!--LLVM PR SUMMARY COMMENT-->

@llvm/pr-subscribers-lldb

Author: Charles Zablit (charles-zablit)

<details>
<summary>Changes</summary>

On Windows, when creating a target, the path gets canonicalized: 
Consider a subst mapping from `C:\S` to `S:\`. A target created with `S:\a.exe` becomes `C:\S\a.exe`. 

The process reports `C:\S\a.exe`, lldb adds a second module for a file it already has, and breakpoints get a stale unresolved location next to the real one. This is why swift-ci's Windows tests, which run from a `T:` subst drive, fail with `2 != 1`.

This patch matches what lldb-server does on Linux: report the paths the loader used instead.

- DLL load events use `lpImageName`, or the loader's module list on attach.
- The executable's path comes from `PEB::ProcessParameters->ImagePathName`.
- `ObjectFilePECOFF::ParseDependentModules` no longer resolves dependent DLL paths with `real_path`.
- `DynamicLoaderWindowsDYLD::DidAttach` loads the process's modules even when it does not know the target's executable path.

When the paths differ only in case, the on-disk spelling is kept.

Tested on Windows x86_64 from a subst drive. The patch fixes 37 API tests failures.

The patch adds a regression test `functionalities/module_aliased_path` that fails without the fix.

---
Full diff: https://github.com/llvm/llvm-project/pull/226492.diff


9 Files Affected:

- (modified) lldb/source/Plugins/DynamicLoader/Windows-DYLD/DynamicLoaderWindowsDYLD.cpp (+8-1) 
- (modified) lldb/source/Plugins/ObjectFile/PECOFF/ObjectFilePECOFF.cpp (+2-3) 
- (modified) lldb/source/Plugins/Process/Windows/Common/DebuggerThread.cpp (+86-11) 
- (modified) lldb/source/Plugins/Process/Windows/Common/DebuggerThread.h (+6) 
- (modified) lldb/source/Plugins/Process/Windows/Common/NativeProcessWindows.cpp (+18-3) 
- (added) lldb/test/API/functionalities/module_aliased_path/Makefile (+5) 
- (added) lldb/test/API/functionalities/module_aliased_path/TestModuleAliasedPath.py (+90) 
- (added) lldb/test/API/functionalities/module_aliased_path/foo.c (+3) 
- (added) lldb/test/API/functionalities/module_aliased_path/main.c (+6) 


``````````diff
diff --git a/lldb/source/Plugins/DynamicLoader/Windows-DYLD/DynamicLoaderWindowsDYLD.cpp b/lldb/source/Plugins/DynamicLoader/Windows-DYLD/DynamicLoaderWindowsDYLD.cpp
index 2d6e97b067c30..fb0619364dcb9 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 985dd6b516bae..bc769e0394eda 100644
--- a/lldb/source/Plugins/ObjectFile/PECOFF/ObjectFilePECOFF.cpp
+++ b/lldb/source/Plugins/ObjectFile/PECOFF/ObjectFilePECOFF.cpp
@@ -1161,12 +1161,11 @@ 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;
     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 260ac0fe0faf6..67db11b8f4641 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,53 @@ 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.
+static std::optional<std::string> GetImagePathFromPEB(HANDLE process) {
+  // winternl.h declares NtQueryInformationProcess, but there is no import
+  // library for it.
+  static LazyImport<decltype(&::NtQueryInformationProcess)>
+      s_query_information_process{L"ntdll.dll", "NtQueryInformationProcess"};
+  if (!s_query_information_process)
+    return std::nullopt;
+
+  PROCESS_BASIC_INFORMATION pbi = {};
+  if ((*s_query_information_process)(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 +525,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 +667,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 +686,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 +797,25 @@ 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.
+  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 +828,24 @@ 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). If that's the case, keep the on disk
+  // spelling.
+  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 03202264b01ee..a9fa3ec0c7599 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 849931eaec372..0045696bd416b 100644
--- a/lldb/source/Plugins/Process/Windows/Common/NativeProcessWindows.cpp
+++ b/lldb/source/Plugins/Process/Windows/Common/NativeProcessWindows.cpp
@@ -360,8 +360,10 @@ 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.
 static bool GetLoadedModulePath(HANDLE process, HMODULE module,
                                 std::string &path) {
   std::vector<wchar_t> name(MAX_PATH);
@@ -408,7 +410,15 @@ 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);
+      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);
@@ -551,6 +561,11 @@ void NativeProcessWindows::OnDebuggerConnected(lldb::addr_t image_base) {
 
   if (got_info) {
     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 0000000000000..e2607a0b08a7c
--- /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 0000000000000..cde581ae87bcc
--- /dev/null
+++ b/lldb/test/API/functionalities/module_aliased_path/TestModuleAliasedPath.py
@@ -0,0 +1,90 @@
+"""
+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 requireWindows
+ at skipIfWindowsAndNoLLDBServer
+ at skipIfRemote
+class TestModuleAliasedPath(TestBase):
+    NO_DEBUG_INFO_TESTCASE = True
+
+    def make_junction(self, real_dir):
+        """Create a junction to real_dir, skipping the test if the host can't
+        create one."""
+        alias_dir = real_dir + ".alias"
+        try:
+            if os.path.lexists(alias_dir):
+                os.rmdir(alias_dir)
+            subprocess.run(
+                'mklink /J "%s" "%s"' % (alias_dir, real_dir),
+                shell=True,
+                check=True,
+                stdout=subprocess.DEVNULL,
+                stderr=subprocess.PIPE,
+                text=True,
+            )
+        except (OSError, subprocess.CalledProcessError) as e:
+            detail = getattr(e, "stderr", None) or e
+            self.skipTest("could not create a junction: %s" % detail)
+        self.addTearDownHook(lambda: os.rmdir(alias_dir))
+        self.assertNotEqual(os.path.realpath(alias_dir), alias_dir)
+        return alias_dir
+
+    def test_launch_through_aliased_path(self):
+        self.build()
+
+        alias_dir = self.make_junction(self.getBuildDir())
+
+        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 0000000000000..375a5207d2654
--- /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 0000000000000..3bd932f5e291e
--- /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;
+}

``````````

</details>


https://github.com/llvm/llvm-project/pull/226492


More information about the lldb-commits mailing list