Skip to content
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
9b3a242
Add unit test for fix in PR #28396
adrianlizarraga May 9, 2026
5773f53
Add unit test for fix in PR #28396
adrianlizarraga May 9, 2026
55ae8a5
Address PR review: use baseline comparison instead of drain loop
adrianlizarraga May 11, 2026
6a88946
Use GTEST_SKIP when library is already loaded
adrianlizarraga May 11, 2026
c7c3c3f
Move handle leak test to separate binary for reliable detection
adrianlizarraga May 11, 2026
1a1d423
Clean up
adrianlizarraga May 11, 2026
6bad160
Merge branch 'main' into adrianl/EpLoadRefCountLeak_UnitTest
adrianlizarraga May 11, 2026
712b1ff
Exclude test_handle_leak.cc from main autoep test binary
adrianlizarraga May 11, 2026
9e529b2
Revert to GTEST_SKIP: remove separate binary to fix ARM64 Debug OOM
adrianlizarraga May 11, 2026
2583a17
Address PR review: add missing headers and guard RTLD_NOLOAD
adrianlizarraga May 11, 2026
e8fbb5e
Return std::optional<bool> from IsLibraryLoaded for clarity
adrianlizarraga May 11, 2026
98f2469
Address more comments
adrianlizarraga May 11, 2026
0044615
Merge branch 'main' into adrianl/EpLoadRefCountLeak_UnitTest
adrianlizarraga May 13, 2026
404a187
Use temp-copied library in handle leak test for process-state indepen…
adrianlizarraga May 13, 2026
fd63fc9
Clean up
adrianlizarraga May 13, 2026
37fdf7a
Merge branch 'main' into adrianl/EpLoadRefCountLeak_UnitTest
adrianlizarraga May 15, 2026
798f774
Clean up lib registration with RAII; Use static_cast
adrianlizarraga May 18, 2026
98c8ab6
Use smaller scope
adrianlizarraga May 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions onnxruntime/test/autoep/test_handle_leak.cc
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

#include <filesystem>
Comment thread
adrianlizarraga marked this conversation as resolved.
#include <gsl/gsl>
#include <memory>
#include <optional>
#include <string>
#include <gtest/gtest.h>

#include "core/session/onnxruntime_cxx_api.h"

#include "test/autoep/test_autoep_utils.h"
#include "test/util/include/file_util.h"
#include "test/util/include/temp_dir.h"

#if defined(_WIN32)
#include <windows.h>
#else
#include <dlfcn.h>
#endif

extern std::unique_ptr<Ort::Env> ort_env;
Comment thread
adrianlizarraga marked this conversation as resolved.

namespace onnxruntime {
namespace test {

namespace {

// Returns whether the library is currently mapped in the process, or std::nullopt if the platform
// does not support querying loaded-library state without side effects.
// On Windows, GetModuleHandleW queries by filename without incrementing the refcount.
// On Linux/macOS, dlopen with RTLD_NOLOAD probes without loading; if it succeeds it adds a
// refcount that we immediately release with dlclose.
std::optional<bool> IsLibraryLoaded(const std::filesystem::path& library_path) {
#if defined(_WIN32)
return GetModuleHandleW(library_path.filename().wstring().c_str()) != nullptr;
#else
#ifdef RTLD_NOLOAD
void* handle = dlopen(library_path.c_str(), RTLD_NOLOAD | RTLD_NOW);
Comment thread
adrianlizarraga marked this conversation as resolved.
if (handle) {
dlclose(handle); // Undo the refcount added by the RTLD_NOLOAD probe.
return true;
}
return false;
#else
// RTLD_NOLOAD is not available on this platform; cannot probe without loading.
static_cast<void>(library_path);
return std::nullopt;
#endif
#endif
}

} // namespace

// Verify that registering and unregistering a plugin EP library does not leak the library handle.
//
// ProviderLibrary::Load() loads the library then probes for the "GetProvider" symbol. Most plugin EP
// libraries do not export "GetProvider", so the probe fails. Without the fix (PR #28396),
// Load() returned the error without calling Unload(), leaving a leaked refcount. After
// UnregisterExecutionProviderLibrary released only the EpLibraryPlugin's reference, the library
// remained mapped in the process.
//
// To ensure this test is independent of process state (other tests may load the same EP library),
// we copy the library to a temporary directory with a unique filename. This guarantees the copy
// has never been loaded, so we can reliably detect refcount leaks via IsLibraryLoaded.
TEST(OrtEpLibrary, RegisterUnregisterDoesNotLeakLibraryHandle) {
const std::filesystem::path& original_library_path = Utils::example_ep_info.library_path;

// Use a unique registration name to avoid conflicts with other tests that may have
// registered the same EP library and failed to unregister it.
const std::string registration_name = "handle_leak_test_ep";

// Copy the EP library to the temp directory with a unique filename so it is guaranteed to
// not already be loaded in this process.
TemporaryDirectory temp_dir(ORT_TSTR("test_handle_leak_temp"));
const std::filesystem::path temp_library_path =
std::filesystem::path(temp_dir.Path()) /
GetSharedLibraryFileName(ORT_TSTR("handle_leak_test_ep"));

std::error_code ec;
std::filesystem::copy_file(original_library_path, temp_library_path,
std::filesystem::copy_options::overwrite_existing, ec);
ASSERT_FALSE(ec) << "Failed to copy EP library to temp directory: " << ec.message();

std::optional<bool> loaded_before = IsLibraryLoaded(temp_library_path);
if (!loaded_before.has_value()) {
GTEST_SKIP() << "Platform does not support querying loaded-library state.";
}

// The copy should not be loaded yet since we just created it with a unique name.
ASSERT_FALSE(*loaded_before) << "Freshly copied library should not already be loaded in the process.";

// Register the plugin EP library inside a smaller scope so that the gsl::finally cleanup
// calls UnregisterExecutionProviderLibrary exactly once when leaving the scope.
{
ASSERT_NO_THROW(ort_env->RegisterExecutionProviderLibrary(registration_name.c_str(), temp_library_path.c_str()));
auto cleanup_lib = gsl::finally([&registration_name] {
Ort::Status ignored{Ort::GetApi().UnregisterExecutionProviderLibrary(*ort_env, registration_name.c_str())};
Comment thread
adrianlizarraga marked this conversation as resolved.
});

// The library should be loaded now.
ASSERT_TRUE(IsLibraryLoaded(temp_library_path).value_or(false)) << "Library should be loaded after registration.";
}

// If the fix is applied, the library should be fully unloaded (refcount == 0).
// Without the fix, ProviderLibrary::Load() leaks a refcount so the library remains mapped.
EXPECT_FALSE(IsLibraryLoaded(temp_library_path).value_or(true))
<< "Library handle leaked: EP library is still loaded after UnregisterExecutionProviderLibrary. "
"This indicates ProviderLibrary::Load() did not call Unload() on GetProvider symbol miss.";
}

} // namespace test
} // namespace onnxruntime
Loading