From 5e2171c9054c399a1f0ac35c18de58cc181bcffc Mon Sep 17 00:00:00 2001 From: Bruno Oliveira Date: Tue, 25 Aug 2026 11:38:55 -0300 Subject: [PATCH] [ASIM-6857] Further improve error message when failing to load plugins When a plugin shared library fails to load, hookman now captures a diagnostic explaining the DLL search environment at load time: the effective PATH/LD_LIBRARY_PATH entries, the directories registered via os.add_dll_directory, and any library bundled with the plugin that is shadowed by a same-named file earlier in the search path. This is exactly the information that took manual investigation to find in ASIM-6769 ("Error 127 loading scaling.dll"): an anaconda3 directory earlier on PATH shadowed the plugin's own bundled splog.dll. - hookman.dll_diagnostics.LoadDiagnostics (frozen dataclass): shadowed_libraries, registered_dll_directories, path_entries, collection_error. str(diagnostics) renders a human-readable block; callers that want to act on the diagnosis can inspect the fields directly instead of parsing text back apart. - LoadDiagnostics.collect(shared_lib_path) is a classmethod that gathers this, never raising (any internal failure is captured in collection_error so it can never mask the original load error). - SharedLibraryLoadError.diagnostics / PluginLoadFailure.diagnostics are LoadDiagnostics | None (None when no load was attempted). - The generated C++ HookCaller::load_impls_from_library raises the same information on Windows, and reports dlerror() plus LD_LIBRARY_PATH on Linux; the diagnostics search itself reads LD_LIBRARY_PATH on POSIX and PATH on Windows. - New tests/test_dll_diagnostics.py covers the diagnostics module in isolation; existing hookman_utils/hooks tests extended for the new field. Co-Authored-By: Claude Opus 5 --- CHANGELOG.rst | 11 + setup.py | 1 + src/hookman/dll_diagnostics.py | 250 ++++++++++++++++++ src/hookman/exceptions.py | 17 +- src/hookman/hookman_generator.py | 128 ++++++++- src/hookman/hookman_utils.py | 12 +- src/hookman/hooks.py | 15 +- tests/test_dll_diagnostics.py | 239 +++++++++++++++++ tests/test_hookman_generator/HookCaller.hpp | 128 ++++++++- .../HookCallerNoPyd.hpp | 128 ++++++++- tests/test_hookman_utils.py | 4 + tests/test_hooks.py | 5 + 12 files changed, 912 insertions(+), 26 deletions(-) create mode 100644 src/hookman/dll_diagnostics.py create mode 100644 tests/test_dll_diagnostics.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index f518b8a..1e32bdd 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,6 +14,17 @@ UNRELEASED skips broken plugins with a warning, and the new ``get_plugins_available_and_failures`` method returns the successful plugins together with a list of ``PluginLoadFailure`` records so callers can surface the reason to the user. +- ``SharedLibraryLoadError`` and ``PluginLoadFailure`` now carry a ``diagnostics`` field, + a structured ``LoadDiagnostics | None`` describing the DLL search environment at load + time: the effective ``PATH`` entries, the directories registered via + ``os.add_dll_directory``, and any library bundled with the plugin that is shadowed by + a same-named file earlier on ``PATH`` (the failure mode behind a plugin resolving the + wrong copy of a dependency, e.g. a conflicting Anaconda installation). It is ``None`` + when no load was attempted (e.g. the shared library was never found); otherwise call + ``LoadDiagnostics.collect()`` to build one, inspect its fields directly, or use + ``str(diagnostics)`` for the same rendered block as before. The generated C++ + ``HookCaller::load_impls_from_library`` raises the same information on Windows, and + reports ``dlerror()`` plus ``LD_LIBRARY_PATH`` on Linux. 0.8.0 (2025-08-18) ================== diff --git a/setup.py b/setup.py index 8698b14..c7d5974 100644 --- a/setup.py +++ b/setup.py @@ -11,6 +11,7 @@ "invoke", "packaging", "strictyaml", + "typing_extensions", ] setup( diff --git a/src/hookman/dll_diagnostics.py b/src/hookman/dll_diagnostics.py new file mode 100644 index 0000000..2836335 --- /dev/null +++ b/src/hookman/dll_diagnostics.py @@ -0,0 +1,250 @@ +""" +Build a human-readable diagnostic block for shared library load failures. + +This is intentionally separate from `hookman_utils.py`: the formatting logic here does +not itself load any shared library, which keeps it easy to unit test in isolation. + +The block is meant to answer the question that ASIM-6769 took manual investigation to +answer: *why* did the OS loader resolve the wrong copy of a dependency DLL. The most +common cause observed so far is a conflicting Python distribution (e.g. Anaconda) placed +earlier on `PATH` than the plugin's own `artifacts/` directory, shadowing one of the +plugin's bundled dependencies. +""" + +import os +import sys +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path + +from typing_extensions import Self + +#: Directories explicitly registered via `os.add_dll_directory` by this process, +#: in registration order. `os` provides no API to enumerate them, so callers that add +#: directories to the DLL search path are expected to also record them here via +#: `register_dll_directory`. +_REGISTERED_DLL_DIRECTORIES: list[Path] = [] + + +def register_dll_directory(directory: Path) -> None: + """ + Record *directory* as having been added to the DLL search path. + + Callers of `os.add_dll_directory` (both within hookman and in downstream projects + such as alfasim) should call this alongside it, so that `LoadDiagnostics` can + report the full set of registered directories. + + *directory* is resolved before storing, so the same directory reached through a + relative path or a symlink from different call sites is still deduplicated to a + single entry. + + Idempotent: registering the same directory twice keeps a single entry. + """ + resolved_directory = directory.resolve() + if resolved_directory not in _REGISTERED_DLL_DIRECTORIES: + _REGISTERED_DLL_DIRECTORIES.append(resolved_directory) + + +def registered_dll_directories() -> Sequence[Path]: + """ + Return the directories registered so far via `register_dll_directory`, in + registration order. + """ + return tuple(_REGISTERED_DLL_DIRECTORIES) + + +def reset_registered_dll_directories() -> None: + """ + Clear the registered-directories registry. + + Intended for test isolation (this module-level state otherwise leaks between + tests within the same process); production code has no reason to call this, since + the registry is meant to reflect the process's DLL search directories for its + whole lifetime. + """ + _REGISTERED_DLL_DIRECTORIES.clear() + + +def search_env_var_name() -> str: + """ + Name of the environment variable the OS loader consults to resolve a shared + library's dependencies: `PATH` on Windows, `LD_LIBRARY_PATH` on POSIX (mirrors the + platform split in the generated C++ `load_impls_from_library`). + """ + return "PATH" if sys.platform.startswith("win") else "LD_LIBRARY_PATH" + + +def search_directories() -> Sequence[Path]: + """ + Return the effective library search directories, in search order, as given by the + OS: `PATH` entries on Windows, `LD_LIBRARY_PATH` entries on POSIX. + + Entries are not deduplicated or filtered, mirroring how the OS loader consults + the variable (empty entries from stray path-separators are dropped). + """ + path_env = os.environ.get(search_env_var_name(), "") + return [Path(entry) for entry in path_env.split(os.pathsep) if entry] + + +@dataclass(frozen=True) +class ShadowedLibrary: + """A library bundled with the plugin that is shadowed by a same-named file earlier + in the OS loader's search directories (`PATH` on Windows, `LD_LIBRARY_PATH` on + POSIX).""" + + library_name: str + """Basename of the shadowed library, e.g. ``splog.dll``.""" + + found_in: Path + """Directory earlier than the plugin's own directory in the search order, + containing a same-named file.""" + + plugin_copy: Path + """Path to the plugin's own copy of the library.""" + + +def _is_shared_library(entry: Path) -> bool: + """ + True if *entry* looks like a shared library: a `.dll`, or a `.so` optionally + followed by a version suffix (e.g. `libfoo.so.1.2.3`). + """ + return entry.suffix.lower() == ".dll" or ".so" in entry.suffixes + + +def find_shadowing_libraries(shared_lib_path: Path) -> Sequence[ShadowedLibrary]: + """ + Find libraries bundled alongside *shared_lib_path* that are shadowed by a same-named + file in an earlier search directory (see `search_env_var_name`). + + This is the check that would have flagged ASIM-6769: the plugin's ``artifacts/`` + directory bundles its own ``splog.dll``, but an `anaconda3` directory earlier on + `PATH` contains a different, incompatible ``splog.dll`` that the loader resolves + first. + + :param shared_lib_path: + Path to the plugin's main shared library; its siblings in the same directory + are the ones checked for shadowing. + """ + plugin_dir = shared_lib_path.parent + if not plugin_dir.is_dir(): + return [] + + bundled_library_names = sorted( + entry.name + for entry in plugin_dir.iterdir() + if entry.is_file() and _is_shared_library(entry) + ) + if not bundled_library_names: + return [] + + shadowed: list[ShadowedLibrary] = [] + for directory in search_directories(): + if directory == plugin_dir: + # The plugin's own directory does not shadow itself; entries after this + # point in PATH are irrelevant too, since the plugin's directory is added + # ahead of them for this very load (see `change_path_env`). + break + if not directory.is_dir(): + continue + for library_name in bundled_library_names: + if (directory / library_name).is_file(): + shadowed.append( + ShadowedLibrary( + library_name=library_name, + found_in=directory, + plugin_copy=plugin_dir / library_name, + ) + ) + return shadowed + + +@dataclass(frozen=True) +class LoadDiagnostics: + """ + Structured breakdown of the DLL search environment at load time. + + Kept separate from the plain-string `reason` on `SharedLibraryLoadError` / + `PluginLoadFailure` so a caller can inspect the individual fields (e.g. which + library was shadowed and by what directory) instead of having to parse `str(self)` + back apart. `str(self)` still renders the same block a caller only interested in + display can just log or show as-is. + """ + + shadowed_libraries: Sequence[ShadowedLibrary] + """Bundled libraries shadowed by a same-named file earlier on `PATH`.""" + + registered_dll_directories: Sequence[Path] + """Directories added via `os.add_dll_directory` over the process lifetime.""" + + path_entries: Sequence[Path] + """The effective library search directories, in search order (`PATH` entries on + Windows, `LD_LIBRARY_PATH` entries on POSIX).""" + + collection_error: str = "" + """Set instead of raising when gathering the diagnostics itself fails (e.g. a + permission error walking a `PATH` directory), so a bug here can never mask the + original load error. When set, the other fields are empty and `str(self)` reports + this instead of the normal block.""" + + @classmethod + def collect(cls, shared_lib_path: Path) -> Self: + """ + Collect the `LoadDiagnostics` explaining the DLL search environment for + *shared_lib_path*. + + Gathers, in order of actionability: same-named libraries shadowing one of the + plugin's own files earlier on `PATH`, the directories explicitly registered via + `os.add_dll_directory`, and the full `PATH` listing. + + Never raises: any failure while gathering this information is reported via + `collection_error` so it can never mask the original load error. Callers with + nothing to diagnose (e.g. no load was attempted) should use `None` instead of + calling this. + """ + try: + return cls( + shadowed_libraries=tuple(find_shadowing_libraries(shared_lib_path)), + registered_dll_directories=tuple(_REGISTERED_DLL_DIRECTORIES), + path_entries=tuple(search_directories()), + ) + except Exception as error: # noqa: BLE001 - diagnostics must never break error reporting + return cls( + shadowed_libraries=(), + registered_dll_directories=(), + path_entries=(), + collection_error=str(error), + ) + + def __str__(self) -> str: + if self.collection_error: + return f"(failed to collect diagnostics: {self.collection_error})" + + sections: list[str] = [] + + if self.shadowed_libraries: + lines = [ + f" - {entry.library_name} in {entry.found_in}\n" + f" (plugin also ships {entry.plugin_copy})" + for entry in self.shadowed_libraries + ] + sections.append( + "Possible conflicting libraries found earlier in the search path:\n" + + "\n".join(lines) + ) + + if self.registered_dll_directories: + lines = [ + f" {i}. {directory}" + for i, directory in enumerate(self.registered_dll_directories, 1) + ] + sections.append("DLL search directories (os.add_dll_directory):\n" + "\n".join(lines)) + + path_lines = [ + f" {i:3d}. {directory}{'' if directory.is_dir() else ' (does not exist)'}" + for i, directory in enumerate(self.path_entries, 1) + ] + sections.append( + f"{search_env_var_name()} ({len(self.path_entries)} entries):\n" + "\n".join(path_lines) + ) + + return "\n\n".join(sections) diff --git a/src/hookman/exceptions.py b/src/hookman/exceptions.py index d29a8a3..b54d70f 100644 --- a/src/hookman/exceptions.py +++ b/src/hookman/exceptions.py @@ -1,5 +1,7 @@ from pathlib import Path +from hookman.dll_diagnostics import LoadDiagnostics + class HookmanError(Exception): """ @@ -24,11 +26,24 @@ class SharedLibraryLoadError(HookmanError): :param shared_lib_path: Path to the shared library that failed to load. :param reason: Human-readable OS error description. + :param diagnostics: + Structured breakdown of the DLL search environment at load time (`PATH` + entries, registered `os.add_dll_directory` directories, and any bundled library + shadowed by a same-named file earlier on `PATH`), or `None` if it was not + collected. Kept separate from `reason` so callers can keep the one-line summary + short while still inspecting the full detail directly, or rendering it (via + `str()`) where there is room for it (e.g. logs, a collapsible GUI section). """ - def __init__(self, shared_lib_path: Path, reason: str) -> None: + def __init__( + self, + shared_lib_path: Path, + reason: str, + diagnostics: LoadDiagnostics | None = None, + ) -> None: self.shared_lib_path = shared_lib_path self.reason = reason + self.diagnostics = diagnostics super().__init__(f"Failed to load '{shared_lib_path}': {reason}") diff --git a/src/hookman/hookman_generator.py b/src/hookman/hookman_generator.py index ada9e5a..66150a6 100644 --- a/src/hookman/hookman_generator.py +++ b/src/hookman/hookman_generator.py @@ -487,9 +487,9 @@ def _hook_caller_hpp_content(self) -> str: "#include ", "#include ", "#include ", + "#include ", "", "#ifdef _WIN32", - " #include ", " #include ", "#else", " #include ", @@ -857,7 +857,8 @@ def _generate_windows_body(hooks: list[Hook]) -> list[str]: " FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, error_code, 0, error_buf, sizeof(error_buf), nullptr);", " std::string error_msg(error_buf);", " while (!error_msg.empty() && (error_msg.back() <= ' ')) { error_msg.pop_back(); }", - ' throw std::runtime_error("Error loading library " + utf8_filename + ": " + error_msg + " (code " + std::to_string(error_code) + ")");', + " std::string diagnostics = format_load_diagnostics(w_filename);", + ' throw std::runtime_error("Error loading library " + utf8_filename + ": " + error_msg + " (code " + std::to_string(error_code) + ")\\n\\n" + diagnostics);', " }", " this->handles.push_back(handle);", "", @@ -900,6 +901,108 @@ def _generate_windows_body(hooks: list[Hook]) -> list[str]: " }", "", "", + " static std::string wstring_to_utf8(const std::wstring& s) {", + " if (s.empty()) {", + " return std::string();", + " }", + " int required_size = WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, nullptr, 0, nullptr, nullptr);", + " std::string result;", + " if (required_size == 0) {", + " return result;", + " }", + " result.resize(required_size);", + " WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, &result[0], required_size, nullptr, nullptr);", + " // required_size counts the null terminator that WideCharToMultiByte writes; drop it.", + " if (!result.empty() && result.back() == '\\0') {", + " result.pop_back();", + " }", + " return result;", + " }", + "", + "", + " // Explains the DLL search environment for `filename`'s failed load: libraries", + " // bundled alongside it that are shadowed by a same-named file earlier on PATH", + " // (the failure mode behind ASIM-6769), followed by the full PATH listing.", + " // NOTE: mirrors LoadDiagnostics.__str__()/.collect() in", + " // dll_diagnostics.py - keep both in sync when changing what is reported or", + " // how it is formatted.", + " static std::string format_load_diagnostics(const std::wstring& filename) {", + ' std::wstring::size_type dir_name_size = filename.find_last_of(L"/\\\\");', + " std::wstring dir = filename.substr(0, dir_name_size);", + "", + " rsize_t path_len = 0;", + " wchar_t* path_buf = nullptr;", + ' errno_t path_err = _wdupenv_s(&path_buf, &path_len, L"PATH");', + " std::wstring path_env = (path_err == 0 && path_buf != nullptr)", + " ? std::wstring(path_buf)", + " : std::wstring();", + " if (path_buf != nullptr) {", + " free(path_buf);", + " }", + "", + " std::vector search_dirs;", + " std::wstring::size_type start = 0;", + " while (start <= path_env.size()) {", + " std::wstring::size_type sep = path_env.find(L';', start);", + " std::wstring entry = sep == std::wstring::npos", + " ? path_env.substr(start)", + " : path_env.substr(start, sep - start);", + " if (!entry.empty()) {", + " search_dirs.push_back(entry);", + " }", + " if (sep == std::wstring::npos) {", + " break;", + " }", + " start = sep + 1;", + " }", + "", + " // Libraries bundled alongside the plugin's own DLL (its `artifacts/` directory).", + " std::vector bundled_names;", + " WIN32_FIND_DATAW find_data;", + ' HANDLE find_handle = FindFirstFileW((dir + L"\\\\*.dll").c_str(), &find_data);', + " if (find_handle != INVALID_HANDLE_VALUE) {", + " do {", + " if (!(find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) {", + " bundled_names.push_back(find_data.cFileName);", + " }", + " } while (FindNextFileW(find_handle, &find_data));", + " FindClose(find_handle);", + " }", + "", + " std::string shadow_section;", + " for (const auto& search_dir : search_dirs) {", + " if (search_dir == dir) {", + " // Our own directory is appended to PATH by PathGuard for this load;", + " // entries from here on are irrelevant to shadowing.", + " break;", + " }", + " for (const auto& name : bundled_names) {", + ' std::wstring candidate = search_dir + L"\\\\" + name;', + " if (GetFileAttributesW(candidate.c_str()) != INVALID_FILE_ATTRIBUTES) {", + ' shadow_section += " - " + wstring_to_utf8(name) + " in " + wstring_to_utf8(search_dir) + "\\n";', + ' shadow_section += " (plugin also ships " + wstring_to_utf8(dir) + "\\\\" + wstring_to_utf8(name) + ")\\n";', + " }", + " }", + " }", + "", + " std::string result;", + " if (!shadow_section.empty()) {", + ' result += "Possible conflicting libraries found earlier in the search path:\\n" + shadow_section + "\\n";', + " }", + "", + ' result += "PATH (" + std::to_string(search_dirs.size()) + " entries):\\n";', + " int index = 1;", + " for (const auto& search_dir : search_dirs) {", + " bool exists = GetFileAttributesW(search_dir.c_str()) != INVALID_FILE_ATTRIBUTES;", + ' result += " " + std::to_string(index) + ". " + wstring_to_utf8(search_dir);', + ' result += exists ? "" : " (does not exist)";', + ' result += "\\n";', + " index++;", + " }", + " return result;", + " }", + "", + "", " class PathGuard {", " public:", " explicit PathGuard(std::wstring filename)", @@ -917,10 +1020,14 @@ def _generate_windows_body(hooks: list[Hook]) -> list[str]: " private:", " static std::wstring get_path() {", " rsize_t _len = 0;", - " wchar_t *buf;", - ' _wdupenv_s(&buf, &_len, L"PATH");', - " std::wstring path_env{ buf };", - " free(buf);", + " wchar_t *buf = nullptr;", + ' errno_t err = _wdupenv_s(&buf, &_len, L"PATH");', + " std::wstring path_env = (err == 0 && buf != nullptr)", + " ? std::wstring(buf)", + " : std::wstring();", + " if (buf != nullptr) {", + " free(buf);", + " }", " return path_env;", " } ", "", @@ -977,7 +1084,14 @@ def _generate_linux_body(hooks: list[Hook]) -> list[str]: " void load_impls_from_library(const std::string& utf8_filename, const std::string& plugin_id) {", " auto handle = dlopen(utf8_filename.c_str(), RTLD_LAZY);", " if (handle == nullptr) {", - ' throw std::runtime_error("Error loading library " + utf8_filename + ": dlopen failed");', + " const char* dlopen_error = dlerror();", + ' std::string error_msg = dlopen_error != nullptr ? dlopen_error : "unknown error";', + ' const char* ld_library_path = std::getenv("LD_LIBRARY_PATH");', + " std::string ld_library_path_str =", + ' ld_library_path != nullptr ? ld_library_path : "(not set)";', + " throw std::runtime_error(", + ' "Error loading library " + utf8_filename + ": " + error_msg +', + ' "\\n\\nLD_LIBRARY_PATH: " + ld_library_path_str);', " }", " this->handles.push_back(handle);", "", diff --git a/src/hookman/hookman_utils.py b/src/hookman/hookman_utils.py index 5f978c6..975a091 100644 --- a/src/hookman/hookman_utils.py +++ b/src/hookman/hookman_utils.py @@ -5,6 +5,7 @@ from contextlib import contextmanager from pathlib import Path +from hookman.dll_diagnostics import LoadDiagnostics, register_dll_directory from hookman.exceptions import SharedLibraryLoadError @@ -43,8 +44,10 @@ def change_path_env(shared_lib_path: str) -> Iterator[None]: handle = None if sys.platform.startswith("win"): # We explict opted to not cover this on windows. - os.environ["PATH"] = old_path + os.pathsep + os.path.dirname(shared_lib_path) - handle = os.add_dll_directory(os.path.dirname(shared_lib_path)) # pragma: no cover + plugin_dir = os.path.dirname(shared_lib_path) + os.environ["PATH"] = old_path + os.pathsep + plugin_dir + handle = os.add_dll_directory(plugin_dir) # pragma: no cover + register_dll_directory(Path(plugin_dir)) # pragma: no cover try: yield finally: @@ -111,7 +114,10 @@ def load_shared_lib(shared_lib_path: str) -> Iterator[ctypes.CDLL]: try: plugin_dll = ctypes.cdll.LoadLibrary(shared_lib_path) except OSError as error: - raise SharedLibraryLoadError(Path(shared_lib_path), str(error)) from error + # Built while PATH still holds the value `change_path_env` set for this + # load, so the diagnostic reflects the actual search environment. + diagnostics = LoadDiagnostics.collect(Path(shared_lib_path)) + raise SharedLibraryLoadError(Path(shared_lib_path), str(error), diagnostics) from error try: yield plugin_dll diff --git a/src/hookman/hooks.py b/src/hookman/hooks.py index ac7854d..28138f3 100644 --- a/src/hookman/hooks.py +++ b/src/hookman/hooks.py @@ -10,6 +10,7 @@ from pluggy import HookCaller from hookman import hookman_utils +from hookman.dll_diagnostics import LoadDiagnostics from hookman.exceptions import ( InvalidDestinationPathError, PluginAlreadyInstalledError, @@ -45,6 +46,11 @@ class PluginLoadFailure: reason: str """Human-readable description of why the plugin failed to load.""" + diagnostics: LoadDiagnostics | None = None + """Structured breakdown of the DLL search environment at load time (`PATH`, + registered DLL directories, and any shadowed bundled library). `None` when the + failure is a `SharedLibraryNotFoundError`, which carries no such environment.""" + class HookSpecs: """ @@ -256,12 +262,19 @@ def get_plugins_available_and_failures( plugin_info = PluginInfo(plugin_file, self.hooks_available) except (SharedLibraryLoadError, SharedLibraryNotFoundError) as error: reason = str(error) - _logger.warning("Plugin at '%s' failed to load: %s", plugin_file, reason) + diagnostics = ( + error.diagnostics if isinstance(error, SharedLibraryLoadError) else None + ) + diagnostics_suffix = f"\n{diagnostics}" if diagnostics is not None else "" + _logger.warning( + "Plugin at '%s' failed to load: %s%s", plugin_file, reason, diagnostics_suffix + ) failures.append( PluginLoadFailure( yaml_location=plugin_file, plugin_id=plugin_id, reason=reason, + diagnostics=diagnostics, ) ) continue diff --git a/tests/test_dll_diagnostics.py b/tests/test_dll_diagnostics.py new file mode 100644 index 0000000..4feefd0 --- /dev/null +++ b/tests/test_dll_diagnostics.py @@ -0,0 +1,239 @@ +# mypy: allow-untyped-defs +import os +import sys + +import pytest + +from hookman.dll_diagnostics import ( + LoadDiagnostics, + find_shadowing_libraries, + register_dll_directory, + registered_dll_directories, + reset_registered_dll_directories, + search_directories, + search_env_var_name, +) + + +@pytest.fixture(autouse=True) +def isolate_registered_dll_directories() -> None: + """Reset the module-level DLL-directory registry so tests don't leak state into + each other.""" + reset_registered_dll_directories() + + +def _make_plugin_dir(tmp_path, library_names): + plugin_dir = tmp_path / "plugin" / "artifacts" + plugin_dir.mkdir(parents=True) + for name in library_names: + (plugin_dir / name).write_text("fake library") + return plugin_dir + + +def set_search_path(monkeypatch, value: str) -> None: + """Set whichever environment variable `search_directories` reads on this platform + (`PATH` on Windows, `LD_LIBRARY_PATH` on POSIX).""" + monkeypatch.setenv(search_env_var_name(), value) + + +@pytest.mark.skipif(sys.platform != "win32", reason="checks the Windows-specific mapping") +def test_search_env_var_name_is_path_on_windows() -> None: + assert search_env_var_name() == "PATH" + + +@pytest.mark.skipif(sys.platform == "win32", reason="checks the POSIX-specific mapping") +def test_search_env_var_name_is_ld_library_path_on_posix() -> None: + assert search_env_var_name() == "LD_LIBRARY_PATH" + + +def test_search_directories_splits_path(monkeypatch, tmp_path) -> None: + first = tmp_path / "first" + second = tmp_path / "second" + set_search_path(monkeypatch, f"{first}{os.pathsep}{second}") + assert list(search_directories()) == [first, second] + + +def test_search_directories_drops_empty_entries(monkeypatch) -> None: + set_search_path(monkeypatch, f"{os.pathsep}{os.pathsep}") + assert search_directories() == [] + + +def test_register_dll_directory_deduplicates_unresolved_and_resolved_forms(tmp_path) -> None: + """Callers don't agree on whether to resolve before registering (hookman's own + `change_path_env` doesn't, alfasim's `dll_directory._register` used to) -- the same + directory reached through either form must still collapse to a single entry + (ASIM-6857).""" + real_dir = tmp_path / "artifacts" + real_dir.mkdir() + unresolved = tmp_path / "." / "artifacts" + + register_dll_directory(unresolved) + register_dll_directory(real_dir) + + assert registered_dll_directories() == (real_dir.resolve(),) + + +def test_find_shadowing_libraries_reports_earlier_same_named_file(monkeypatch, tmp_path) -> None: + """The scenario behind ASIM-6769: a conflicting copy earlier in the search path + shadows a library the plugin bundles.""" + plugin_dir = _make_plugin_dir(tmp_path, ["splog.dll"]) + decoy_dir = tmp_path / "anaconda3" / "Library" / "bin" + decoy_dir.mkdir(parents=True) + (decoy_dir / "splog.dll").write_text("conflicting library") + + set_search_path(monkeypatch, f"{decoy_dir}{os.pathsep}{plugin_dir}") + + [shadow] = find_shadowing_libraries(plugin_dir / "scaling.dll") + assert shadow.library_name == "splog.dll" + assert shadow.found_in == decoy_dir + assert shadow.plugin_copy == plugin_dir / "splog.dll" + + +def test_find_shadowing_libraries_ignores_entries_after_plugin_dir(monkeypatch, tmp_path) -> None: + """A same-named file that comes *after* the plugin's own directory in the search + path is not a shadowing hit: the plugin's own copy is the one that resolves first.""" + plugin_dir = _make_plugin_dir(tmp_path, ["splog.dll"]) + later_dir = tmp_path / "later" + later_dir.mkdir() + (later_dir / "splog.dll").write_text("irrelevant, comes after") + + set_search_path(monkeypatch, f"{plugin_dir}{os.pathsep}{later_dir}") + + assert find_shadowing_libraries(plugin_dir / "scaling.dll") == [] + + +@pytest.mark.skipif(sys.platform == "win32", reason="uses a versioned .so suffix") +def test_find_shadowing_libraries_reports_versioned_so_file(monkeypatch, tmp_path) -> None: + """A versioned shared object (e.g. `libsplog.so.1.2.3`) is still recognized as a + bundled library, not just a plain `.so` (ASIM-6857).""" + plugin_dir = _make_plugin_dir(tmp_path, ["libsplog.so.1.2.3"]) + decoy_dir = tmp_path / "conflicting_install" + decoy_dir.mkdir() + (decoy_dir / "libsplog.so.1.2.3").write_text("conflicting library") + + set_search_path(monkeypatch, f"{decoy_dir}{os.pathsep}{plugin_dir}") + + [shadow] = find_shadowing_libraries(plugin_dir / "scaling.so") + assert shadow.library_name == "libsplog.so.1.2.3" + assert shadow.found_in == decoy_dir + assert shadow.plugin_copy == plugin_dir / "libsplog.so.1.2.3" + + +def test_find_shadowing_libraries_no_conflict(monkeypatch, tmp_path) -> None: + plugin_dir = _make_plugin_dir(tmp_path, ["splog.dll"]) + clean_dir = tmp_path / "clean" + clean_dir.mkdir() + + set_search_path(monkeypatch, f"{clean_dir}{os.pathsep}{plugin_dir}") + + assert find_shadowing_libraries(plugin_dir / "scaling.dll") == [] + + +@pytest.mark.skipif(sys.platform != "win32", reason="uses .dll as the bundled library suffix") +def test_load_diagnostics_collect_includes_shadowed_libraries_on_windows( + monkeypatch, tmp_path +) -> None: + plugin_dir = _make_plugin_dir(tmp_path, ["splog.dll"]) + decoy_dir = tmp_path / "anaconda3" + decoy_dir.mkdir() + (decoy_dir / "splog.dll").write_text("conflicting library") + + set_search_path(monkeypatch, f"{decoy_dir}{os.pathsep}{plugin_dir}") + + diagnostics = LoadDiagnostics.collect(plugin_dir / "scaling.dll") + + # Structured fields are inspectable directly, without parsing str(diagnostics). + [shadow] = diagnostics.shadowed_libraries + assert shadow.library_name == "splog.dll" + assert shadow.found_in == decoy_dir + assert diagnostics.path_entries == (decoy_dir, plugin_dir) + assert diagnostics.collection_error == "" + + # str() still renders the same block callers only interested in display can log. + block = str(diagnostics) + assert "Possible conflicting libraries found earlier in the search path" in block + assert "splog.dll" in block + assert str(decoy_dir) in block + assert "PATH (2 entries)" in block + assert str(plugin_dir) in block + + +@pytest.mark.skipif(sys.platform == "win32", reason="uses .so as the bundled library suffix") +def test_load_diagnostics_collect_includes_shadowed_libraries_on_posix( + monkeypatch, tmp_path +) -> None: + """POSIX equivalent of the Windows test above: the shadowing search reads + `LD_LIBRARY_PATH`, not `PATH` (ASIM-6857).""" + plugin_dir = _make_plugin_dir(tmp_path, ["splog.so"]) + decoy_dir = tmp_path / "conflicting_install" + decoy_dir.mkdir() + (decoy_dir / "splog.so").write_text("conflicting library") + + set_search_path(monkeypatch, f"{decoy_dir}{os.pathsep}{plugin_dir}") + + diagnostics = LoadDiagnostics.collect(plugin_dir / "scaling.so") + + [shadow] = diagnostics.shadowed_libraries + assert shadow.library_name == "splog.so" + assert shadow.found_in == decoy_dir + assert diagnostics.path_entries == (decoy_dir, plugin_dir) + assert diagnostics.collection_error == "" + + block = str(diagnostics) + assert "Possible conflicting libraries found earlier in the search path" in block + assert "splog.so" in block + assert str(decoy_dir) in block + assert "LD_LIBRARY_PATH (2 entries)" in block + assert str(plugin_dir) in block + + +def test_load_diagnostics_collect_omits_shadow_section_when_clean(monkeypatch, tmp_path) -> None: + plugin_dir = _make_plugin_dir(tmp_path, ["splog.dll"]) + clean_dir = tmp_path / "clean" + clean_dir.mkdir() + + set_search_path(monkeypatch, f"{clean_dir}{os.pathsep}{plugin_dir}") + + diagnostics = LoadDiagnostics.collect(plugin_dir / "scaling.dll") + + assert diagnostics.shadowed_libraries == () + block = str(diagnostics) + assert "Possible conflicting libraries" not in block + assert f"{search_env_var_name()} (2 entries)" in block + + +def test_load_diagnostics_collect_marks_nonexistent_path_entries(monkeypatch, tmp_path) -> None: + plugin_dir = _make_plugin_dir(tmp_path, []) + missing_dir = tmp_path / "does_not_exist" + + set_search_path(monkeypatch, f"{missing_dir}{os.pathsep}{plugin_dir}") + + diagnostics = LoadDiagnostics.collect(plugin_dir / "scaling.dll") + + # Existence is a caller-side check, not snapshotted on the entry itself. + assert missing_dir in diagnostics.path_entries + assert not missing_dir.is_dir() + assert f"{missing_dir} (does not exist)" in str(diagnostics) + + +def test_load_diagnostics_collect_never_raises(monkeypatch, tmp_path) -> None: + """A failure while gathering diagnostics is captured as `collection_error` instead + of propagating, so it can never mask the original load error.""" + + def _boom(_shared_lib_path): + raise RuntimeError("boom") + + monkeypatch.setattr("hookman.dll_diagnostics.find_shadowing_libraries", _boom) + + diagnostics = LoadDiagnostics.collect(tmp_path / "scaling.dll") + + assert diagnostics.collection_error == "boom" + assert diagnostics.shadowed_libraries == () + assert "failed to collect diagnostics" in str(diagnostics) + + +def test_load_diagnostics_collect_returns_the_concrete_subclass(tmp_path) -> None: + """`collect()` returns `Self`, so a caller gets a `LoadDiagnostics` back, not some + other type -- a simple regression guard for the classmethod's return type.""" + diagnostics = LoadDiagnostics.collect(tmp_path / "scaling.dll") + assert isinstance(diagnostics, LoadDiagnostics) diff --git a/tests/test_hookman_generator/HookCaller.hpp b/tests/test_hookman_generator/HookCaller.hpp index ea85655..39af678 100644 --- a/tests/test_hookman_generator/HookCaller.hpp +++ b/tests/test_hookman_generator/HookCaller.hpp @@ -7,9 +7,9 @@ #include #include #include +#include #ifdef _WIN32 - #include #include #else #include @@ -75,7 +75,8 @@ class HookCaller { FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, error_code, 0, error_buf, sizeof(error_buf), nullptr); std::string error_msg(error_buf); while (!error_msg.empty() && (error_msg.back() <= ' ')) { error_msg.pop_back(); } - throw std::runtime_error("Error loading library " + utf8_filename + ": " + error_msg + " (code " + std::to_string(error_code) + ")"); + std::string diagnostics = format_load_diagnostics(w_filename); + throw std::runtime_error("Error loading library " + utf8_filename + ": " + error_msg + " (code " + std::to_string(error_code) + ")\n\n" + diagnostics); } this->handles.push_back(handle); @@ -116,6 +117,108 @@ class HookCaller { } + static std::string wstring_to_utf8(const std::wstring& s) { + if (s.empty()) { + return std::string(); + } + int required_size = WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, nullptr, 0, nullptr, nullptr); + std::string result; + if (required_size == 0) { + return result; + } + result.resize(required_size); + WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, &result[0], required_size, nullptr, nullptr); + // required_size counts the null terminator that WideCharToMultiByte writes; drop it. + if (!result.empty() && result.back() == '\0') { + result.pop_back(); + } + return result; + } + + + // Explains the DLL search environment for `filename`'s failed load: libraries + // bundled alongside it that are shadowed by a same-named file earlier on PATH + // (the failure mode behind ASIM-6769), followed by the full PATH listing. + // NOTE: mirrors LoadDiagnostics.__str__()/.collect() in + // dll_diagnostics.py - keep both in sync when changing what is reported or + // how it is formatted. + static std::string format_load_diagnostics(const std::wstring& filename) { + std::wstring::size_type dir_name_size = filename.find_last_of(L"/\\"); + std::wstring dir = filename.substr(0, dir_name_size); + + rsize_t path_len = 0; + wchar_t* path_buf = nullptr; + errno_t path_err = _wdupenv_s(&path_buf, &path_len, L"PATH"); + std::wstring path_env = (path_err == 0 && path_buf != nullptr) + ? std::wstring(path_buf) + : std::wstring(); + if (path_buf != nullptr) { + free(path_buf); + } + + std::vector search_dirs; + std::wstring::size_type start = 0; + while (start <= path_env.size()) { + std::wstring::size_type sep = path_env.find(L';', start); + std::wstring entry = sep == std::wstring::npos + ? path_env.substr(start) + : path_env.substr(start, sep - start); + if (!entry.empty()) { + search_dirs.push_back(entry); + } + if (sep == std::wstring::npos) { + break; + } + start = sep + 1; + } + + // Libraries bundled alongside the plugin's own DLL (its `artifacts/` directory). + std::vector bundled_names; + WIN32_FIND_DATAW find_data; + HANDLE find_handle = FindFirstFileW((dir + L"\\*.dll").c_str(), &find_data); + if (find_handle != INVALID_HANDLE_VALUE) { + do { + if (!(find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) { + bundled_names.push_back(find_data.cFileName); + } + } while (FindNextFileW(find_handle, &find_data)); + FindClose(find_handle); + } + + std::string shadow_section; + for (const auto& search_dir : search_dirs) { + if (search_dir == dir) { + // Our own directory is appended to PATH by PathGuard for this load; + // entries from here on are irrelevant to shadowing. + break; + } + for (const auto& name : bundled_names) { + std::wstring candidate = search_dir + L"\\" + name; + if (GetFileAttributesW(candidate.c_str()) != INVALID_FILE_ATTRIBUTES) { + shadow_section += " - " + wstring_to_utf8(name) + " in " + wstring_to_utf8(search_dir) + "\n"; + shadow_section += " (plugin also ships " + wstring_to_utf8(dir) + "\\" + wstring_to_utf8(name) + ")\n"; + } + } + } + + std::string result; + if (!shadow_section.empty()) { + result += "Possible conflicting libraries found earlier in the search path:\n" + shadow_section + "\n"; + } + + result += "PATH (" + std::to_string(search_dirs.size()) + " entries):\n"; + int index = 1; + for (const auto& search_dir : search_dirs) { + bool exists = GetFileAttributesW(search_dir.c_str()) != INVALID_FILE_ATTRIBUTES; + result += " " + std::to_string(index) + ". " + wstring_to_utf8(search_dir); + result += exists ? "" : " (does not exist)"; + result += "\n"; + index++; + } + return result; + } + + class PathGuard { public: explicit PathGuard(std::wstring filename) @@ -133,10 +236,14 @@ class HookCaller { private: static std::wstring get_path() { rsize_t _len = 0; - wchar_t *buf; - _wdupenv_s(&buf, &_len, L"PATH"); - std::wstring path_env{ buf }; - free(buf); + wchar_t *buf = nullptr; + errno_t err = _wdupenv_s(&buf, &_len, L"PATH"); + std::wstring path_env = (err == 0 && buf != nullptr) + ? std::wstring(buf) + : std::wstring(); + if (buf != nullptr) { + free(buf); + } return path_env; } @@ -179,7 +286,14 @@ class HookCaller { void load_impls_from_library(const std::string& utf8_filename, const std::string& plugin_id) { auto handle = dlopen(utf8_filename.c_str(), RTLD_LAZY); if (handle == nullptr) { - throw std::runtime_error("Error loading library " + utf8_filename + ": dlopen failed"); + const char* dlopen_error = dlerror(); + std::string error_msg = dlopen_error != nullptr ? dlopen_error : "unknown error"; + const char* ld_library_path = std::getenv("LD_LIBRARY_PATH"); + std::string ld_library_path_str = + ld_library_path != nullptr ? ld_library_path : "(not set)"; + throw std::runtime_error( + "Error loading library " + utf8_filename + ": " + error_msg + + "\n\nLD_LIBRARY_PATH: " + ld_library_path_str); } this->handles.push_back(handle); diff --git a/tests/test_hookman_generator/HookCallerNoPyd.hpp b/tests/test_hookman_generator/HookCallerNoPyd.hpp index 0a5ab61..d90f5db 100644 --- a/tests/test_hookman_generator/HookCallerNoPyd.hpp +++ b/tests/test_hookman_generator/HookCallerNoPyd.hpp @@ -7,9 +7,9 @@ #include #include #include +#include #ifdef _WIN32 - #include #include #else #include @@ -43,7 +43,8 @@ class HookCaller { FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, nullptr, error_code, 0, error_buf, sizeof(error_buf), nullptr); std::string error_msg(error_buf); while (!error_msg.empty() && (error_msg.back() <= ' ')) { error_msg.pop_back(); } - throw std::runtime_error("Error loading library " + utf8_filename + ": " + error_msg + " (code " + std::to_string(error_code) + ")"); + std::string diagnostics = format_load_diagnostics(w_filename); + throw std::runtime_error("Error loading library " + utf8_filename + ": " + error_msg + " (code " + std::to_string(error_code) + ")\n\n" + diagnostics); } this->handles.push_back(handle); @@ -74,6 +75,108 @@ class HookCaller { } + static std::string wstring_to_utf8(const std::wstring& s) { + if (s.empty()) { + return std::string(); + } + int required_size = WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, nullptr, 0, nullptr, nullptr); + std::string result; + if (required_size == 0) { + return result; + } + result.resize(required_size); + WideCharToMultiByte(CP_UTF8, 0, s.c_str(), -1, &result[0], required_size, nullptr, nullptr); + // required_size counts the null terminator that WideCharToMultiByte writes; drop it. + if (!result.empty() && result.back() == '\0') { + result.pop_back(); + } + return result; + } + + + // Explains the DLL search environment for `filename`'s failed load: libraries + // bundled alongside it that are shadowed by a same-named file earlier on PATH + // (the failure mode behind ASIM-6769), followed by the full PATH listing. + // NOTE: mirrors LoadDiagnostics.__str__()/.collect() in + // dll_diagnostics.py - keep both in sync when changing what is reported or + // how it is formatted. + static std::string format_load_diagnostics(const std::wstring& filename) { + std::wstring::size_type dir_name_size = filename.find_last_of(L"/\\"); + std::wstring dir = filename.substr(0, dir_name_size); + + rsize_t path_len = 0; + wchar_t* path_buf = nullptr; + errno_t path_err = _wdupenv_s(&path_buf, &path_len, L"PATH"); + std::wstring path_env = (path_err == 0 && path_buf != nullptr) + ? std::wstring(path_buf) + : std::wstring(); + if (path_buf != nullptr) { + free(path_buf); + } + + std::vector search_dirs; + std::wstring::size_type start = 0; + while (start <= path_env.size()) { + std::wstring::size_type sep = path_env.find(L';', start); + std::wstring entry = sep == std::wstring::npos + ? path_env.substr(start) + : path_env.substr(start, sep - start); + if (!entry.empty()) { + search_dirs.push_back(entry); + } + if (sep == std::wstring::npos) { + break; + } + start = sep + 1; + } + + // Libraries bundled alongside the plugin's own DLL (its `artifacts/` directory). + std::vector bundled_names; + WIN32_FIND_DATAW find_data; + HANDLE find_handle = FindFirstFileW((dir + L"\\*.dll").c_str(), &find_data); + if (find_handle != INVALID_HANDLE_VALUE) { + do { + if (!(find_data.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) { + bundled_names.push_back(find_data.cFileName); + } + } while (FindNextFileW(find_handle, &find_data)); + FindClose(find_handle); + } + + std::string shadow_section; + for (const auto& search_dir : search_dirs) { + if (search_dir == dir) { + // Our own directory is appended to PATH by PathGuard for this load; + // entries from here on are irrelevant to shadowing. + break; + } + for (const auto& name : bundled_names) { + std::wstring candidate = search_dir + L"\\" + name; + if (GetFileAttributesW(candidate.c_str()) != INVALID_FILE_ATTRIBUTES) { + shadow_section += " - " + wstring_to_utf8(name) + " in " + wstring_to_utf8(search_dir) + "\n"; + shadow_section += " (plugin also ships " + wstring_to_utf8(dir) + "\\" + wstring_to_utf8(name) + ")\n"; + } + } + } + + std::string result; + if (!shadow_section.empty()) { + result += "Possible conflicting libraries found earlier in the search path:\n" + shadow_section + "\n"; + } + + result += "PATH (" + std::to_string(search_dirs.size()) + " entries):\n"; + int index = 1; + for (const auto& search_dir : search_dirs) { + bool exists = GetFileAttributesW(search_dir.c_str()) != INVALID_FILE_ATTRIBUTES; + result += " " + std::to_string(index) + ". " + wstring_to_utf8(search_dir); + result += exists ? "" : " (does not exist)"; + result += "\n"; + index++; + } + return result; + } + + class PathGuard { public: explicit PathGuard(std::wstring filename) @@ -91,10 +194,14 @@ class HookCaller { private: static std::wstring get_path() { rsize_t _len = 0; - wchar_t *buf; - _wdupenv_s(&buf, &_len, L"PATH"); - std::wstring path_env{ buf }; - free(buf); + wchar_t *buf = nullptr; + errno_t err = _wdupenv_s(&buf, &_len, L"PATH"); + std::wstring path_env = (err == 0 && buf != nullptr) + ? std::wstring(buf) + : std::wstring(); + if (buf != nullptr) { + free(buf); + } return path_env; } @@ -137,7 +244,14 @@ class HookCaller { void load_impls_from_library(const std::string& utf8_filename, const std::string& plugin_id) { auto handle = dlopen(utf8_filename.c_str(), RTLD_LAZY); if (handle == nullptr) { - throw std::runtime_error("Error loading library " + utf8_filename + ": dlopen failed"); + const char* dlopen_error = dlerror(); + std::string error_msg = dlopen_error != nullptr ? dlopen_error : "unknown error"; + const char* ld_library_path = std::getenv("LD_LIBRARY_PATH"); + std::string ld_library_path_str = + ld_library_path != nullptr ? ld_library_path : "(not set)"; + throw std::runtime_error( + "Error loading library " + utf8_filename + ": " + error_msg + + "\n\nLD_LIBRARY_PATH: " + ld_library_path_str); } this->handles.push_back(handle); diff --git a/tests/test_hookman_utils.py b/tests/test_hookman_utils.py index b69bad7..34e5536 100644 --- a/tests/test_hookman_utils.py +++ b/tests/test_hookman_utils.py @@ -59,3 +59,7 @@ def test_load_shared_lib_raises_shared_library_load_error_for_corrupt_file(tmp_p assert exc_info.value.shared_lib_path == corrupt_lib assert exc_info.value.reason # Non-empty OS-dependent error description. + diagnostics = exc_info.value.diagnostics + assert diagnostics is not None # An OSError during load always collects one. + assert diagnostics.path_entries # Structured field, not a string to parse. + assert "PATH" in str(diagnostics) diff --git a/tests/test_hooks.py b/tests/test_hooks.py index 5b401ea..5434d6e 100644 --- a/tests/test_hooks.py +++ b/tests/test_hooks.py @@ -167,6 +167,9 @@ def test_get_plugins_available_and_failures_with_broken_plugin( assert failure.plugin_id == "broken_plugin" assert failure.yaml_location == broken_plugin_dir / "assets" / "plugin.yaml" assert failure.reason # Non-empty OS-dependent error message. + assert failure.diagnostics is not None # SharedLibraryLoadError always collects one. + assert failure.diagnostics.path_entries # Structured field, not a string to parse. + assert "PATH" in str(failure.diagnostics) def test_get_plugins_available_skips_failures(tmp_path, simple_plugin, acme_hook_specs) -> None: @@ -207,6 +210,8 @@ def test_get_plugins_available_and_failures_with_missing_dll(tmp_path, acme_hook assert failure.plugin_id == "missing_dll_plugin" assert failure.yaml_location == missing_plugin_dir / "assets" / "plugin.yaml" assert failure.reason + # SharedLibraryNotFoundError carries no load environment. + assert failure.diagnostics is None def test_get_plugins_available_and_failures_ignored_plugin_excluded_from_failures(