diff --git a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/descriptor_catalog.py b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/descriptor_catalog.py index d745b2bd68c..6eaadc693f5 100644 --- a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/descriptor_catalog.py +++ b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/descriptor_catalog.py @@ -59,9 +59,9 @@ def _ctk_windows_wheel_dirs(cuda13_bin_dir: str, cuda12_dir: str) -> WindowsSear class DescriptorSpec: name: str packaged_with: PackagedWith + # Keep declared SONAMEs in oldest -> newest order. linux_sonames: tuple[str, ...] = () windows_dlls: tuple[str, ...] = () - windows_dll_fallback_globs: tuple[str, ...] = () supported_windows_arch: tuple[WindowsArch, ...] = () site_packages_linux: tuple[str, ...] = () site_packages_windows: WindowsSearchDirs = WindowsSearchDirs() @@ -124,7 +124,7 @@ class DescriptorSpec: DescriptorSpec( name="nvvm", packaged_with="ctk", - linux_sonames=("libnvvm.so.4",), + linux_sonames=("libnvvm.so", "libnvvm.so.4"), windows_dlls=("nvvm64.dll", "nvvm64_40_0.dll", "nvvm70.dll"), supported_windows_arch=("x64", "arm64"), site_packages_linux=("nvidia/cu13/lib", "nvidia/cuda_nvcc/nvvm/lib64"), @@ -377,7 +377,6 @@ class DescriptorSpec: "cupti64_2026.2.1.dll", "cupti64_2026.3.0.dll", ), - windows_dll_fallback_globs=("cupti64_*.dll",), supported_windows_arch=("x64", "arm64"), site_packages_linux=("nvidia/cu13/lib", "nvidia/cuda_cupti/lib"), site_packages_windows=_ctk_windows_wheel_dirs("nvidia/cu13/bin", "nvidia/cuda_cupti/bin"), @@ -414,7 +413,7 @@ class DescriptorSpec: DescriptorSpec( name="cufftMp", packaged_with="other", - linux_sonames=("libcufftMp.so.12", "libcufftMp.so.11"), + linux_sonames=("libcufftMp.so.11", "libcufftMp.so.12"), site_packages_linux=("nvidia/cufftmp/cu13/lib", "nvidia/cufftmp/cu12/lib"), dependencies=("nvshmem_host",), requires_rtld_deepbind=True, diff --git a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/lib_descriptor.py b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/lib_descriptor.py index f5e643e28fa..433f636c10e 100644 --- a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/lib_descriptor.py +++ b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/lib_descriptor.py @@ -22,3 +22,9 @@ #: Canonical registry of all known libraries. LIB_DESCRIPTORS: dict[str, LibDescriptor] = {desc.name: desc for desc in DESCRIPTOR_CATALOG} + + +def linux_soname_candidates(desc: LibDescriptor) -> tuple[str, ...]: + """Return declared Linux SONAMEs in runtime preference order.""" + # The catalog is authored oldest -> newest; loading prefers newest -> oldest. + return tuple(reversed(desc.linux_sonames)) diff --git a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/load_dl_linux.py b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/load_dl_linux.py index 684cb9e83e5..429d37b5f62 100644 --- a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/load_dl_linux.py +++ b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/load_dl_linux.py @@ -10,6 +10,7 @@ import sys from typing import TYPE_CHECKING, cast +from cuda.pathfinder._dynamic_libs.lib_descriptor import linux_soname_candidates from cuda.pathfinder._dynamic_libs.load_dl_common import LoadedDL if TYPE_CHECKING: @@ -129,17 +130,10 @@ def abs_path_for_dynamic_library(libname: str, handle: ctypes.CDLL) -> str: return os.path.join(l_origin, os.path.basename(l_name)) -def _candidate_sonames(desc: LibDescriptor) -> list[str]: - # Reverse tabulated names to achieve new -> old search order. - candidates = list(reversed(desc.linux_sonames)) - candidates.append(f"lib{desc.name}.so") - return candidates - - if sys.platform == "linux": def check_if_already_loaded_from_elsewhere(desc: LibDescriptor) -> LoadedDL | None: - for soname in _candidate_sonames(desc): + for soname in linux_soname_candidates(desc): try: handle = ctypes.CDLL(soname, mode=os.RTLD_NOLOAD) except OSError: @@ -177,7 +171,7 @@ def load_with_system_search(desc: LibDescriptor) -> LoadedDL | None: A LoadedDL object if successful, None if the library cannot be loaded """ - for soname in _candidate_sonames(desc): + for soname in linux_soname_candidates(desc): try: handle = _load_lib(desc, soname) except OSError: diff --git a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/search_platform.py b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/search_platform.py index 7b284714710..a9afb545409 100644 --- a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/search_platform.py +++ b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/search_platform.py @@ -1,4 +1,4 @@ -# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 """Platform abstraction for filesystem search steps. @@ -10,57 +10,54 @@ from __future__ import annotations -import glob import os from collections.abc import Sequence from dataclasses import dataclass from pathlib import PurePath from typing import Protocol, cast -from cuda.pathfinder._dynamic_libs.lib_descriptor import LibDescriptor +from cuda.pathfinder._dynamic_libs.lib_descriptor import LibDescriptor, linux_soname_candidates from cuda.pathfinder._dynamic_libs.supported_nvidia_libs import is_suppressed_dll_file from cuda.pathfinder._utils.find_sub_dirs import find_sub_dirs_all_sitepackages -from cuda.pathfinder._utils.path_sort import numeric_aware_path_sort_key from cuda.pathfinder._utils.platform_aware import IS_WINDOWS from cuda.pathfinder._utils.windows_arch import windows_pe_matches_arch, windows_python_arch def _no_such_file_in_sub_dirs( - sub_dirs: Sequence[str], file_wild: str, error_messages: list[str], attachments: list[str] + sub_dirs: Sequence[str], file_description: str, error_messages: list[str], attachments: list[str] ) -> None: - error_messages.append(f"No such file: {file_wild}") + error_messages.append(f"No such file: {file_description}") for sub_dir in find_sub_dirs_all_sitepackages(sub_dirs): attachments.append(f' listdir("{sub_dir}"):') for node in sorted(os.listdir(sub_dir)): attachments.append(f" {node}") +def _find_descriptor_so_under_dir(dirpath: str, desc: LibDescriptor) -> str | None: + for soname in linux_soname_candidates(desc): + path = os.path.join(dirpath, soname) + if os.path.isfile(path): + return path + return None + + def _find_so_in_rel_dirs( rel_dirs: tuple[str, ...], - so_basename: str, + desc: LibDescriptor, + file_description: str, error_messages: list[str], attachments: list[str], ) -> str | None: sub_dirs_searched: list[tuple[str, ...]] = [] - file_wild = so_basename + "*" for rel_dir in rel_dirs: sub_dir = PurePath(rel_dir).parts for abs_dir in find_sub_dirs_all_sitepackages(sub_dir): - # Exact unversioned match first; fall back to versioned names because some - # distros only ship lib.so. (e.g. conda libcupti). Only one match - # is expected in practice. Sort in reverse so the newest-sorting name wins if - # multiple coexist, matching the newest-first bias elsewhere in pathfinder - # (see LinuxSearchPlatform.find_in_lib_dir and load_dl_linux._candidate_sonames). - # Issue #1732 tracks the deferred question of raising on true ambiguity. - so_name = os.path.join(abs_dir, so_basename) - if os.path.isfile(so_name): - return so_name - for so_name in sorted(glob.glob(os.path.join(abs_dir, file_wild)), reverse=True): - if os.path.isfile(so_name): - return so_name + so_path = _find_descriptor_so_under_dir(abs_dir, desc) + if so_path is not None: + return so_path sub_dirs_searched.append(sub_dir) for sub_dir in sub_dirs_searched: - _no_such_file_in_sub_dirs(sub_dir, file_wild, error_messages, attachments) + _no_such_file_in_sub_dirs(sub_dir, file_description, error_messages, attachments) return None @@ -76,19 +73,11 @@ def candidate_is_usable(path: str) -> bool: return False return target_arch is None or windows_pe_matches_arch(path, target_arch) - # Prefer the descriptor's known DLL names in its established search order. - # Explicit globs provide a collision-safe forward-compatible fallback for - # libraries whose full version is encoded in the filename (for example CUPTI). + # Try the descriptor's known DLL names in its established search order. for dll_basename in reversed(cast(tuple[str, ...], desc.windows_dlls)): path = os.path.join(dirpath, dll_basename) if candidate_is_usable(path): return path - - for dll_glob in desc.windows_dll_fallback_globs: - file_wild = os.path.join(dirpath, dll_glob) - for path in sorted(glob.glob(file_wild), key=numeric_aware_path_sort_key, reverse=True): - if candidate_is_usable(path): - return path return None @@ -115,7 +104,7 @@ def _find_dll_in_rel_dirs( class SearchPlatform(Protocol): - def lib_searched_for(self, libname: str) -> str: ... + def lib_searched_for(self, desc: LibDescriptor) -> str: ... def site_packages_rel_dirs(self, desc: LibDescriptor) -> tuple[str, ...]: ... @@ -133,7 +122,6 @@ def find_in_site_packages( self, rel_dirs: tuple[str, ...], desc: LibDescriptor, - lib_searched_for: str, error_messages: list[str], attachments: list[str], ) -> str | None: ... @@ -142,7 +130,6 @@ def find_in_lib_dir( self, lib_dir: str, desc: LibDescriptor, - lib_searched_for: str, error_messages: list[str], attachments: list[str], ) -> str | None: ... @@ -150,8 +137,8 @@ def find_in_lib_dir( @dataclass(frozen=True, slots=True) class LinuxSearchPlatform: - def lib_searched_for(self, libname: str) -> str: - return f"lib{libname}.so" + def lib_searched_for(self, desc: LibDescriptor) -> str: + return " or ".join(linux_soname_candidates(desc)) def site_packages_rel_dirs(self, desc: LibDescriptor) -> tuple[str, ...]: return cast(tuple[str, ...], desc.site_packages_linux) @@ -174,36 +161,23 @@ def program_files_root_globs(self, _desc: LibDescriptor) -> tuple[str, ...]: def find_in_site_packages( self, rel_dirs: tuple[str, ...], - _desc: LibDescriptor, - lib_searched_for: str, + desc: LibDescriptor, error_messages: list[str], attachments: list[str], ) -> str | None: - return _find_so_in_rel_dirs(rel_dirs, lib_searched_for, error_messages, attachments) + return _find_so_in_rel_dirs(rel_dirs, desc, self.lib_searched_for(desc), error_messages, attachments) def find_in_lib_dir( self, lib_dir: str, - _desc: LibDescriptor, - lib_searched_for: str, + desc: LibDescriptor, error_messages: list[str], attachments: list[str], ) -> str | None: - # Most libraries have both unversioned and versioned files/symlinks (exact match first) - so_name = os.path.join(lib_dir, lib_searched_for) - if os.path.isfile(so_name): - return so_name - # Some libraries only exist as versioned files (e.g., libcupti.so.13 in conda), - # so the glob fallback is needed - file_wild = lib_searched_for + "*" - # Only one match is expected, but to ensure deterministic behavior in unexpected - # situations, and to be internally consistent, we sort in reverse order with the - # intent to return the newest version first. Issue #1732 tracks the deferred - # question of raising on true ambiguity. - for so_name in sorted(glob.glob(os.path.join(lib_dir, file_wild)), reverse=True): - if os.path.isfile(so_name): - return so_name - error_messages.append(f"No such file: {file_wild}") + so_path = _find_descriptor_so_under_dir(lib_dir, desc) + if so_path is not None: + return so_path + error_messages.append(f"No such file: {self.lib_searched_for(desc)}") attachments.append(f' listdir("{lib_dir}"):') if not os.path.isdir(lib_dir): attachments.append(" DIRECTORY DOES NOT EXIST") @@ -217,8 +191,8 @@ def find_in_lib_dir( class WindowsSearchPlatform: target_arch: str - def lib_searched_for(self, libname: str) -> str: - return f"{libname}*.dll" + def lib_searched_for(self, desc: LibDescriptor) -> str: + return f"known {desc.name} DLL" def site_packages_rel_dirs(self, desc: LibDescriptor) -> tuple[str, ...]: return cast(tuple[str, ...], desc.site_packages_windows.for_arch(self.target_arch)) @@ -250,7 +224,6 @@ def find_in_site_packages( self, rel_dirs: tuple[str, ...], desc: LibDescriptor, - lib_searched_for: str, error_messages: list[str], attachments: list[str], ) -> str | None: @@ -258,7 +231,7 @@ def find_in_site_packages( rel_dirs, desc, self.target_arch, - lib_searched_for, + self.lib_searched_for(desc), error_messages, attachments, ) @@ -267,19 +240,18 @@ def find_in_lib_dir( self, lib_dir: str, desc: LibDescriptor, - _lib_searched_for: str, error_messages: list[str], attachments: list[str], ) -> str | None: - file_wild = desc.name + "*.dll" target_arch = self.target_arch if desc.requires_windows_binary_arch_check else None dll_name = _find_descriptor_dll_under_dir(lib_dir, desc, target_arch) if dll_name is not None: return dll_name + lib_searched_for = self.lib_searched_for(desc) if target_arch is None: - error_messages.append(f"No such file: {file_wild}") + error_messages.append(f"No such file: {lib_searched_for}") else: - error_messages.append(f"No {target_arch}-compatible PE file: {file_wild}") + error_messages.append(f"No {target_arch}-compatible PE file: {lib_searched_for}") attachments.append(f' listdir("{lib_dir}"):') if not os.path.isdir(lib_dir): attachments.append(" DIRECTORY DOES NOT EXIST") diff --git a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/search_steps.py b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/search_steps.py index cad842447b1..80779f72ae2 100644 --- a/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/search_steps.py +++ b/cuda_pathfinder/cuda/pathfinder/_dynamic_libs/search_steps.py @@ -59,7 +59,7 @@ def libname(self) -> str: @property def lib_searched_for(self) -> str: - return cast(str, self.platform.lib_searched_for(self.libname)) + return cast(str, self.platform.lib_searched_for(self.desc)) def raise_not_found(self) -> NoReturn: err = ", ".join(self.error_messages) @@ -102,7 +102,6 @@ def _find_using_lib_dir(ctx: SearchContext, lib_dir: str | None) -> str | None: ctx.platform.find_in_lib_dir( lib_dir, ctx.desc, - ctx.lib_searched_for, ctx.error_messages, ctx.attachments, ), @@ -195,7 +194,6 @@ def find_in_site_packages(ctx: SearchContext) -> FindResult | None: abs_path = ctx.platform.find_in_site_packages( rel_dirs, ctx.desc, - ctx.lib_searched_for, ctx.error_messages, ctx.attachments, ) diff --git a/cuda_pathfinder/docs/nv-versions.json b/cuda_pathfinder/docs/nv-versions.json index 48b45fdf873..6c629101974 100644 --- a/cuda_pathfinder/docs/nv-versions.json +++ b/cuda_pathfinder/docs/nv-versions.json @@ -3,6 +3,10 @@ "version": "latest", "url": "https://nvidia.github.io/cuda-python/cuda-pathfinder/latest/" }, + { + "version": "1.7.1", + "url": "https://nvidia.github.io/cuda-python/cuda-pathfinder/1.7.1/" + }, { "version": "1.7.0", "url": "https://nvidia.github.io/cuda-python/cuda-pathfinder/1.7.0/" diff --git a/cuda_pathfinder/docs/source/release/1.7.1-notes.rst b/cuda_pathfinder/docs/source/release/1.7.1-notes.rst new file mode 100644 index 00000000000..edfd4862544 --- /dev/null +++ b/cuda_pathfinder/docs/source/release/1.7.1-notes.rst @@ -0,0 +1,24 @@ +.. SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +.. SPDX-License-Identifier: Apache-2.0 + +.. py:currentmodule:: cuda.pathfinder + +``cuda-pathfinder`` 1.7.1 Release notes +======================================= + +Bugfixes +-------- + +* Restrict Windows CUPTI filesystem discovery to descriptor-declared DLL + names. Removing broad wildcard matches keeps candidate selection consistent + with already-loaded detection and native loading, and prevents undeclared + DLL names from being selected. + (`PR #2689 `_) + +* Make Linux filesystem discovery, already-loaded detection, and native loading + use one descriptor-declared, newest-first library filename list. This prevents + broad matches from selecting undeclared ABI or patch-version filenames; + explicit directory layouts must expose a declared name. Explicitly declare + the unversioned NVVM wheel filename to preserve CUDA 12.9 wheel discovery, + and correct the ``cufftMp`` catalog order so ABI 12 is preferred over ABI 11. + (`PR #2689 `_) diff --git a/cuda_pathfinder/tests/test_ctk_root_discovery.py b/cuda_pathfinder/tests/test_ctk_root_discovery.py index 731d38fdc0a..cac4343e3cb 100644 --- a/cuda_pathfinder/tests/test_ctk_root_discovery.py +++ b/cuda_pathfinder/tests/test_ctk_root_discovery.py @@ -73,7 +73,7 @@ def _create_nvvm_in_ctk(ctk_root): else: nvvm_dir = ctk_root / "nvvm" / "lib64" nvvm_dir.mkdir(parents=True) - nvvm_lib = nvvm_dir / "libnvvm.so" + nvvm_lib = nvvm_dir / "libnvvm.so.4" nvvm_lib.write_bytes(b"fake") return nvvm_lib @@ -91,7 +91,7 @@ def _create_cudart_in_ctk(ctk_root): else: lib_dir = ctk_root / "lib64" lib_dir.mkdir(parents=True) - lib_file = lib_dir / "libcudart.so" + lib_file = lib_dir / "libcudart.so.13" lib_file.write_bytes(b"fake") return lib_file diff --git a/cuda_pathfinder/tests/test_descriptor_catalog.py b/cuda_pathfinder/tests/test_descriptor_catalog.py index e4a6a609c6c..a4b3227018b 100644 --- a/cuda_pathfinder/tests/test_descriptor_catalog.py +++ b/cuda_pathfinder/tests/test_descriptor_catalog.py @@ -9,12 +9,12 @@ from __future__ import annotations -import fnmatch import re import pytest from cuda.pathfinder._dynamic_libs.descriptor_catalog import DESCRIPTOR_CATALOG, DescriptorSpec, WindowsSearchDirs +from cuda.pathfinder._utils.path_sort import numeric_aware_path_sort_key _VALID_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") _VALID_PACKAGED_WITH_VALUES = {"ctk", "other", "driver"} @@ -91,41 +91,15 @@ def test_linux_sonames_look_like_sonames(spec: DescriptorSpec): @pytest.mark.parametrize("spec", DESCRIPTOR_CATALOG, ids=lambda s: s.name) -def test_windows_dlls_look_like_dlls(spec: DescriptorSpec): - for dll in spec.windows_dlls: - assert dll.endswith(".dll"), f"Unexpected Windows DLL format: {dll}" - - -@pytest.mark.parametrize("spec", DESCRIPTOR_CATALOG, ids=lambda s: s.name) -@pytest.mark.agent_authored(model="gpt-5.6-sol") -def test_windows_dll_fallback_globs_are_basenames(spec: DescriptorSpec): - for dll_glob in spec.windows_dll_fallback_globs: - assert "*" in dll_glob - assert dll_glob.endswith(".dll") - assert "/" not in dll_glob - assert "\\" not in dll_glob - - @pytest.mark.agent_authored(model="gpt-5.6-sol") -def test_only_cupti_uses_forward_compatible_windows_dll_glob(): - specs_with_globs = {spec.name for spec in DESCRIPTOR_CATALOG if spec.windows_dll_fallback_globs} - - assert specs_with_globs == {"cupti"} - assert _CATALOG_BY_NAME["cupti"].windows_dll_fallback_globs == ("cupti64_*.dll",) +def test_linux_sonames_are_ordered_oldest_to_newest(spec: DescriptorSpec): + assert spec.linux_sonames == tuple(sorted(spec.linux_sonames, key=numeric_aware_path_sort_key)) @pytest.mark.parametrize("spec", DESCRIPTOR_CATALOG, ids=lambda s: s.name) -@pytest.mark.agent_authored(model="gpt-5.6-sol") -def test_windows_dll_fallback_globs_do_not_match_other_descriptors(spec: DescriptorSpec): - for dll_glob in spec.windows_dll_fallback_globs: - sibling_matches = { - (other.name, dll) - for other in DESCRIPTOR_CATALOG - if other is not spec - for dll in other.windows_dlls - if fnmatch.fnmatchcase(dll.casefold(), dll_glob.casefold()) - } - assert not sibling_matches, f"{spec.name} glob {dll_glob!r} also matches {sibling_matches}" +def test_windows_dlls_look_like_dlls(spec: DescriptorSpec): + for dll in spec.windows_dlls: + assert dll.endswith(".dll"), f"Unexpected Windows DLL format: {dll}" @pytest.mark.parametrize("spec", DESCRIPTOR_CATALOG, ids=lambda s: s.name) @@ -188,7 +162,6 @@ def test_cudnn_metadata_matches_supported_layouts(): assert spec.packaged_with == "other" assert spec.linux_sonames == ("libcudnn.so.9",) assert spec.windows_dlls == ("cudnn64_9.dll",) - assert not spec.windows_dll_fallback_globs assert spec.supported_windows_arch == ("x64", "arm64") assert spec.site_packages_linux == ("nvidia/cudnn/lib",) assert spec.site_packages_windows == WindowsSearchDirs.x64_only("nvidia/cudnn/bin") diff --git a/cuda_pathfinder/tests/test_lib_descriptor.py b/cuda_pathfinder/tests/test_lib_descriptor.py index 8715f9181cc..cdfac333c9d 100644 --- a/cuda_pathfinder/tests/test_lib_descriptor.py +++ b/cuda_pathfinder/tests/test_lib_descriptor.py @@ -6,7 +6,7 @@ import pytest -from cuda.pathfinder._dynamic_libs.lib_descriptor import LIB_DESCRIPTORS +from cuda.pathfinder._dynamic_libs.lib_descriptor import LIB_DESCRIPTORS, linux_soname_candidates from cuda.pathfinder._dynamic_libs.supported_nvidia_libs import ( DIRECT_DEPENDENCIES, LIBNAMES_REQUIRING_OS_ADD_DLL_DIRECTORY, @@ -153,3 +153,26 @@ def test_descriptor_is_frozen(): desc = LIB_DESCRIPTORS["cudart"] with pytest.raises(AttributeError): desc.name = "bogus" # type: ignore[misc] + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_linux_soname_candidates_are_declared_names_newest_first(): + desc = LIB_DESCRIPTORS["cudart"] + + assert desc.linux_sonames == ("libcudart.so.12", "libcudart.so.13") + assert linux_soname_candidates(desc) == ("libcudart.so.13", "libcudart.so.12") + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_linux_soname_candidates_preserve_explicit_unversioned_name(): + desc = LIB_DESCRIPTORS["nvcudla"] + + assert linux_soname_candidates(desc) == ("libnvcudla.so",) + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_linux_soname_candidates_prefer_versioned_name_over_declared_unversioned_name(): + desc = LIB_DESCRIPTORS["nvvm"] + + assert desc.linux_sonames == ("libnvvm.so", "libnvvm.so.4") + assert linux_soname_candidates(desc) == ("libnvvm.so.4", "libnvvm.so") diff --git a/cuda_pathfinder/tests/test_load_dl_linux.py b/cuda_pathfinder/tests/test_load_dl_linux.py new file mode 100644 index 00000000000..cfcf0ef29f8 --- /dev/null +++ b/cuda_pathfinder/tests/test_load_dl_linux.py @@ -0,0 +1,56 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +import sys + +import pytest + +if sys.platform != "linux": + pytest.skip("Linux dynamic-loader tests", allow_module_level=True) + +from cuda.pathfinder._dynamic_libs import load_dl_linux +from cuda.pathfinder._dynamic_libs.descriptor_catalog import DescriptorSpec + + +def _descriptor() -> DescriptorSpec: + return DescriptorSpec( + name="probe", + packaged_with="other", + linux_sonames=("libprobe.so.12", "libprobe.so.13"), + ) + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_already_loaded_library_checks_only_declared_sonames_newest_first(mocker): + queried_sonames: list[tuple[str, int]] = [] + + def cdll(soname, mode): + queried_sonames.append((soname, mode)) + raise OSError + + mocker.patch.object(load_dl_linux.ctypes, "CDLL", side_effect=cdll) + + loaded = load_dl_linux.check_if_already_loaded_from_elsewhere(_descriptor()) + + assert loaded is None + assert queried_sonames == [ + ("libprobe.so.13", os.RTLD_NOLOAD), + ("libprobe.so.12", os.RTLD_NOLOAD), + ] + + +@pytest.mark.agent_authored(model="gpt-5.6-sol") +def test_system_search_checks_only_declared_sonames_newest_first(mocker): + queried_sonames: list[str] = [] + + def load_lib(_desc, soname): + queried_sonames.append(soname) + raise OSError + + mocker.patch.object(load_dl_linux, "_load_lib", side_effect=load_lib) + + loaded = load_dl_linux.load_with_system_search(_descriptor()) + + assert loaded is None + assert queried_sonames == ["libprobe.so.13", "libprobe.so.12"] diff --git a/cuda_pathfinder/tests/test_search_steps.py b/cuda_pathfinder/tests/test_search_steps.py index 9e0858acecb..85f60861dab 100644 --- a/cuda_pathfinder/tests/test_search_steps.py +++ b/cuda_pathfinder/tests/test_search_steps.py @@ -95,16 +95,16 @@ def test_libname_delegates_to_descriptor(self): assert ctx.libname == "nvrtc" def test_lib_searched_for_linux(self): - ctx = SearchContext(_make_desc(name="cublas"), platform=LinuxSearchPlatform()) - assert ctx.lib_searched_for == "libcublas.so" + ctx = SearchContext(LIB_DESCRIPTORS["cublas"], platform=LinuxSearchPlatform()) + assert ctx.lib_searched_for == "libcublas.so.13 or libcublas.so.12" def test_lib_searched_for_windows(self): ctx = SearchContext(_make_desc(name="cublas"), platform=WindowsSearchPlatform(target_arch="x64")) - assert ctx.lib_searched_for == "cublas*.dll" + assert ctx.lib_searched_for == "known cublas DLL" def test_raise_not_found_includes_messages(self): ctx = _ctx() - ctx.error_messages.append("No such file: libcudart.so*") + ctx.error_messages.append("No such file: libcudart.so") ctx.attachments.append(' listdir("/some/dir"):') with pytest.raises(DynamicLibNotFoundError, match="No such file"): ctx.raise_not_found() @@ -289,6 +289,24 @@ def test_found_linux(self, mocker, tmp_path): assert result.abs_path == str(so_file) assert result.found_via == "site-packages" + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_nvvm_linux_wheel_accepts_declared_unversioned_filename(self, mocker, tmp_path): + lib_dir = tmp_path / "nvidia" / "cuda_nvcc" / "nvvm" / "lib64" + lib_dir.mkdir(parents=True) + so_file = lib_dir / "libnvvm.so" + so_file.touch() + + mocker.patch( + f"{_PLAT_MOD}.find_sub_dirs_all_sitepackages", + return_value=[str(lib_dir)], + ) + + result = find_in_site_packages(_ctx(LIB_DESCRIPTORS["nvvm"], platform=LinuxSearchPlatform())) + + assert result is not None + assert result.abs_path == str(so_file) + assert result.found_via == "site-packages" + def test_found_windows(self, mocker, tmp_path): bin_dir = tmp_path / "nvidia" / "cuda_runtime" / "bin" bin_dir.mkdir(parents=True) @@ -331,43 +349,21 @@ def test_windows_exact_names_reject_cudnn_sidecar(self, mocker, tmp_path): assert result is None @pytest.mark.agent_authored(model="gpt-5.6-sol") - def test_windows_explicit_glob_finds_unlisted_future_cupti(self, mocker, tmp_path): + def test_windows_matching_rejects_unlisted_cupti_name(self, mocker, tmp_path): bin_dir = tmp_path / "nvidia" / "cuda_cupti" / "bin" bin_dir.mkdir(parents=True) - dll = bin_dir / "cupti64_2027.1.0.dll" - dll.touch() + (bin_dir / "cupti64_14.dll").touch() mocker.patch( f"{_PLAT_MOD}.find_sub_dirs_all_sitepackages", return_value=[str(bin_dir)], ) - mocker.patch(f"{_PLAT_MOD}.is_suppressed_dll_file", return_value=False) - - result = find_in_site_packages( - _ctx(LIB_DESCRIPTORS["cupti"], platform=WindowsSearchPlatform(target_arch="x64")) - ) - - assert result == FindResult(str(dll), "site-packages") - - @pytest.mark.agent_authored(model="gpt-5.6-sol") - def test_windows_explicit_glob_prefers_known_cupti_name(self, mocker, tmp_path): - bin_dir = tmp_path / "nvidia" / "cuda_cupti" / "bin" - bin_dir.mkdir(parents=True) - known_dll = bin_dir / "cupti64_2026.3.0.dll" - known_dll.touch() - (bin_dir / "cupti64_2027.1.0.dll").touch() - - mocker.patch( - f"{_PLAT_MOD}.find_sub_dirs_all_sitepackages", - return_value=[str(bin_dir)], - ) - mocker.patch(f"{_PLAT_MOD}.is_suppressed_dll_file", return_value=False) result = find_in_site_packages( _ctx(LIB_DESCRIPTORS["cupti"], platform=WindowsSearchPlatform(target_arch="x64")) ) - assert result == FindResult(str(known_dll), "site-packages") + assert result is None @pytest.mark.agent_authored(model="gpt-5.6-sol") def test_windows_known_cupti_names_prefer_newest(self, tmp_path): @@ -489,15 +485,8 @@ def test_not_found_appends_error(self, mocker, tmp_path): assert result is None assert any("No such file" in m for m in ctx.error_messages) - # The next three tests cover the Linux glob fallback in - # cuda.pathfinder._dynamic_libs.search_platform._find_so_in_rel_dirs. - # The fallback triggers when the unversioned libfoo.so is absent but - # versioned libfoo.so. files exist (e.g. some conda layouts). - # Issue #1732 tracks the decision to return the newest-sorting match - # deterministically; these tests lock in that policy at the - # site-packages call site. - - def test_glob_fallback_returns_single_versioned_match(self, mocker, tmp_path): + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_linux_declared_versioned_soname_does_not_need_unversioned_link(self, mocker, tmp_path): lib_dir = tmp_path / "nvidia" / "cuda_runtime" / "lib" lib_dir.mkdir(parents=True) versioned = lib_dir / "libcudart.so.13" @@ -508,12 +497,14 @@ def test_glob_fallback_returns_single_versioned_match(self, mocker, tmp_path): return_value=[str(lib_dir)], ) - result = find_in_site_packages(_ctx(platform=LinuxSearchPlatform())) + desc = _make_desc(linux_sonames=("libcudart.so.12", "libcudart.so.13")) + result = find_in_site_packages(_ctx(desc, platform=LinuxSearchPlatform())) assert result is not None assert result.abs_path == str(versioned) assert result.found_via == "site-packages" - def test_glob_fallback_returns_newest_of_multiple_matches(self, mocker, tmp_path): + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_linux_declared_sonames_prefer_newest(self, mocker, tmp_path): lib_dir = tmp_path / "nvidia" / "cuda_runtime" / "lib" lib_dir.mkdir(parents=True) older = lib_dir / "libcudart.so.12" @@ -526,25 +517,33 @@ def test_glob_fallback_returns_newest_of_multiple_matches(self, mocker, tmp_path return_value=[str(lib_dir)], ) - result = find_in_site_packages(_ctx(platform=LinuxSearchPlatform())) + desc = _make_desc(linux_sonames=("libcudart.so.12", "libcudart.so.13")) + result = find_in_site_packages(_ctx(desc, platform=LinuxSearchPlatform())) assert result is not None assert result.abs_path == str(newer) assert result.found_via == "site-packages" - def test_glob_fallback_zero_matches_returns_none(self, mocker, tmp_path): + @pytest.mark.parametrize( + "undeclared_filename", + ("libcudart.so", "libcudart.so.14", "libcudart.so.13.5.0", "libcudart.so.13.backup"), + ) + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_linux_rejects_undeclared_filename(self, mocker, tmp_path, undeclared_filename): lib_dir = tmp_path / "nvidia" / "cuda_runtime" / "lib" lib_dir.mkdir(parents=True) - (lib_dir / "unrelated.txt").touch() + (lib_dir / undeclared_filename).touch() mocker.patch( f"{_PLAT_MOD}.find_sub_dirs_all_sitepackages", return_value=[str(lib_dir)], ) - ctx = _ctx(platform=LinuxSearchPlatform()) + desc = _make_desc(linux_sonames=("libcudart.so.12", "libcudart.so.13")) + ctx = _ctx(desc, platform=LinuxSearchPlatform()) result = find_in_site_packages(ctx) assert result is None - assert any("No such file" in m and "libcudart.so" in m for m in ctx.error_messages) + assert ctx.error_messages + assert all("*" not in message for message in ctx.error_messages) # --------------------------------------------------------------------------- @@ -604,15 +603,8 @@ def test_found_windows_arm64_prefers_arch_dir(self, mocker, tmp_path): assert result.abs_path == str(arm64_dll) assert result.found_via == "conda" - # The next three tests cover the Linux glob fallback in - # cuda.pathfinder._dynamic_libs.search_platform.LinuxSearchPlatform.find_in_lib_dir, - # which is exercised by find_in_conda (and find_in_cuda_path) when the - # resolved lib dir contains only versioned libfoo.so. files. - # Issue #1732 tracks the decision to return the newest-sorting match - # deterministically; these tests lock in that policy at the conda / - # CUDA_PATH call site. - - def test_glob_fallback_returns_single_versioned_match(self, mocker, tmp_path): + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_linux_declared_versioned_soname_found_in_lib_dir(self, mocker, tmp_path): lib_dir = tmp_path / "lib" lib_dir.mkdir() versioned = lib_dir / "libcudart.so.13" @@ -620,37 +612,30 @@ def test_glob_fallback_returns_single_versioned_match(self, mocker, tmp_path): mocker.patch.dict(os.environ, {"CONDA_PREFIX": str(tmp_path)}) - result = find_in_conda(_ctx(platform=LinuxSearchPlatform())) + desc = _make_desc(linux_sonames=("libcudart.so.12", "libcudart.so.13")) + result = find_in_conda(_ctx(desc, platform=LinuxSearchPlatform())) assert result is not None assert result.abs_path == str(versioned) assert result.found_via == "conda" - def test_glob_fallback_returns_newest_of_multiple_matches(self, mocker, tmp_path): - lib_dir = tmp_path / "lib" - lib_dir.mkdir() - older = lib_dir / "libcudart.so.12" - newer = lib_dir / "libcudart.so.13" - older.touch() - newer.touch() - - mocker.patch.dict(os.environ, {"CONDA_PREFIX": str(tmp_path)}) - - result = find_in_conda(_ctx(platform=LinuxSearchPlatform())) - assert result is not None - assert result.abs_path == str(newer) - assert result.found_via == "conda" - - def test_glob_fallback_zero_matches_returns_none(self, mocker, tmp_path): + @pytest.mark.parametrize( + "undeclared_filename", + ("libcudart.so", "libcudart.so.14", "libcudart.so.13.5.0", "libcudart.so.13.backup"), + ) + @pytest.mark.agent_authored(model="gpt-5.6-sol") + def test_linux_rejects_undeclared_filename_in_lib_dir(self, mocker, tmp_path, undeclared_filename): lib_dir = tmp_path / "lib" lib_dir.mkdir() - (lib_dir / "unrelated.txt").touch() + (lib_dir / undeclared_filename).touch() mocker.patch.dict(os.environ, {"CONDA_PREFIX": str(tmp_path)}) - ctx = _ctx(platform=LinuxSearchPlatform()) + desc = _make_desc(linux_sonames=("libcudart.so.12", "libcudart.so.13")) + ctx = _ctx(desc, platform=LinuxSearchPlatform()) result = find_in_conda(ctx) assert result is None - assert any("No such file" in m and "libcudart.so" in m for m in ctx.error_messages) + assert ctx.error_messages + assert all("*" not in message for message in ctx.error_messages) # ---------------------------------------------------------------------------