Skip to content

fix(ep-plugin): keep the plugin library mapped after ORT unregisters it - #1987

Merged
justinchuby merged 2 commits into
mainfrom
seb/1672-pin-plugin-library
Aug 24, 2026
Merged

justinchuby merged 2 commits into
mainfrom
seb/1672-pin-plugin-library

Conversation

@justinchuby

Copy link
Copy Markdown
Owner

RegisterExecutionProviderLibrary dlopens the plugin cdylib and UnregisterExecutionProviderLibrary drops that reference, after which the loader is free to unmap its text. Nothing withdraws the pointers this library has already published into the process:

  • status::set_host_api caches the host OrtApi in a process-global that lives in this library's data segment;
  • every thread_local! with drop glue that a kernel touches registers a destructor whose function pointer is in this library's text, against whichever thread ran the kernel — including ORT's own intra-op threads, which we neither own nor join;
  • instrumented builds register a profile writer at load and run it at process exit, also from this library's text.

Each is a function pointer into a hole once the library is unmapped.

The measurement

Linux, three runs each, on main before this change:

sequence mapping entries for the cdylib after unregister
register → unregister 0 (unmapped)
register → session → Run → unregister 4 (retained)

Running a kernel pins the DSO as a side effect: a thread_local! with drop glue registers a destructor against the object and glibc will not unload an object with pending TLS destructors. So on Linux, whether the unmap window opens depends on whether a kernel happened to run first — an accident of the loader, not a property this library establishes. Windows FreeLibrary grants no such reprieve.

That is the standing hypothesis for #1672 / #983: plugin_ort_e2e on Rust coverage (Windows x86_64) reports every test ok and then exits 0xC0000005. Under coverage the instrumented cdylib registers a profile writer that runs at process exit; each test unregisters the library. I cannot test Windows locally, so this is a hypothesis with a Linux-verified mechanism, not a proven fix — the Windows coverage lane is its falsifier.

The fix

One never-released reference to our own module, taken on the way in, next to the global that motivates it. init_host_api is the single choke point every entry point reaches, so export_ep_factories!, the CPU plugin's hand-written CreateEpFactories and the shared-EP path are all covered without adding a third lockstep site to a file that already warns about lockstep drift.

  • unix: dladdr → dlopen(RTLD_NOLOAD | RTLD_NODELETE | RTLD_LAZY), handle deliberately never dlclosed.
  • windows: GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_PIN | ..._FROM_ADDRESS).

No new packages enter the build — libc and windows-sys 0.61 are already in the lockfile; only two dependency edges are added.

Why the test has no Run in it

Deliberately. The no-Run arm is the only arm on Linux where the pin is load-bearing; a test that ran a model would pass with or without this change and assert glibc's accident instead of our property. The module doc says so, so a later "simplification" cannot silently make it vacuous.

Verified by mutation, not by assumption:

mutant result
remove the pin_plugin_library() call fails 3/3, 4 mapping entries -> 0
drop RTLD_LAZY from the dlopen mode fails, invalid mode for dlopen()

The second is a bug this test actually caught: POSIX requires exactly one of RTLD_LAZY/RTLD_NOW in every mode, and my first version had neither, so the pin silently reported Failed while everything still passed. The instrument found it in one run.

PinOutcome::NotDynamicallyLoaded exists for the same reason: unit tests link the crate into an executable, where dlopen of the main program fails on glibc. Detecting that case explicitly keeps a benign failure from masking a real one.

Also in this PR

The ORT_EP_LOCK comment in plugin_ort_e2e.rs documented the lock as keeping the register/unregister cycle count below the threshold of a use-after-free at cycle ≥6. That bug was fixed in c92838dba and is now guarded by a deliberate 25-cycle stress test — and serialising tests cannot lower a per-process cycle count in any case, it only orders the same cycles. The same file's own evidence block concludes "cycle count is therefore not the variable; library load/unload is". Anyone triaging #1672 from that comment is sent to a mechanism the file itself rules out; I was, for a while.

Validation

  • cargo test -p onnx-runtime-ep-cpu-plugin -p onnx-runtime-ep-plugin green on the rebased tree, including plugin_ort_e2e 56/56 and stress_register_run_unregister_cycles — the pin does not disturb register/unregister cycling.
  • cargo clippy --all-targets clean on both crates; cargo fmt clean.
  • cargo doc error count unchanged at 21 (all pre-existing, none in the touched files).

Refs #1672, #983

`RegisterExecutionProviderLibrary` dlopens the plugin cdylib and
`UnregisterExecutionProviderLibrary` drops that reference, after which the
loader is free to unmap its text. Nothing withdrew the pointers this
library publishes into the process first:

* `status::set_host_api` caches the host `OrtApi` in a process-global that
  lives in this library's data segment;
* every `thread_local!` with drop glue that a kernel touches registers a
  destructor whose function pointer is in this library's text, against
  whichever thread ran the kernel -- including ORT's own intra-op threads,
  which we neither own nor join;
* instrumented builds register a profile writer at load and run it at
  process exit, also from this library's text.

Take one never-released reference to our own module on the way in, next to
the global that motivates it, so the mapping outlives ORT's reference.
`init_host_api` is the single choke point every entry point reaches, so the
macro, the CPU plugin's hand-written `CreateEpFactories` and the shared-EP
path are all covered without a third lockstep site.

Measured on Linux, three runs each, before the pin: a bare
register -> unregister unmaps the cdylib outright (4 mapping entries -> 0),
while the same sequence after a real `Run` retains all 4, because running a
kernel pins the DSO on glibc as a side effect. So whether the window is open
depended on whether a kernel happened to run first -- an accident of the
loader, not a property. Windows `FreeLibrary` grants no such reprieve, which
is the standing hypothesis for the `0xC0000005` at process exit on the
`Rust coverage (Windows x86_64)` lane (#1672, #983): all tests report ok,
then the process faults during teardown.

The new test asserts the no-`Run` arm specifically, since it is the only arm
on Linux where the pin is load-bearing; a version with a `Run` in it would
pass with or without this change. Verified by mutation: dropping the
`pin_plugin_library()` call fails it 3/3 with 0 entries, and dropping
`RTLD_LAZY` from the dlopen mode fails it too (glibc rejects a mode with
neither `RTLD_LAZY` nor `RTLD_NOW`) -- a real bug this test caught.

Also corrects the `ORT_EP_LOCK` comment, which described the lock as keeping
the register/unregister cycle count below the threshold of a use-after-free.
That bug was fixed in c92838d and is now guarded by a deliberate 25-cycle
stress test, and serialising tests cannot lower a per-process cycle count in
any case -- it only orders the same cycles. Anyone triaging #1672 from that
comment is sent to a mechanism the same file's own evidence block rules out.

Refs #1672, #983

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Aug 24, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 73.25581% with 23 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.04%. Comparing base (81f3728) to head (6f5758e).
⚠️ Report is 7 commits behind head on main.

Files with missing lines Patch % Lines
crates/onnx-runtime-ep-plugin/src/pin.rs 72.94% 20 Missing and 3 partials ⚠️
Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff             @@
##             main    #1987      +/-   ##
==========================================
+ Coverage   80.25%   81.04%   +0.79%     
==========================================
  Files         408      425      +17     
  Lines      189125   208146   +19021     
  Branches   189125   208146   +19021     
==========================================
+ Hits       151773   168683   +16910     
- Misses      31941    33820    +1879     
- Partials     5411     5643     +232     
Flag Coverage Δ
cli-ort-linux 72.51% <ø> (?)
cli-ort-windows 72.10% <ø> (?)
mlas 85.33% <ø> (?)
offline 81.19% <73.25%> (+0.94%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
crates/onnx-runtime-ep-plugin/src/factory.rs 69.41% <100.00%> (+0.03%) ⬆️
crates/onnx-runtime-ep-plugin/src/lib.rs 100.00% <ø> (ø)
crates/onnx-runtime-ep-plugin/src/pin.rs 72.94% <72.94%> (ø)

... and 66 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…namicallyLoaded is silent

Both raised by independent adversarial review of the pin: neither is a
defect, but each was an unstated deliberate choice, which is how a
deliberate choice later becomes an unnoticed hole.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby
justinchuby marked this pull request as ready for review August 24, 2026 14:25
@justinchuby

Copy link
Copy Markdown
Owner Author

Independent adversarial review (Opus), and what it changed

Ran a separate reviewer against this branch with instructions to falsify, not to praise. It independently reproduced both mutations rather than taking my table on trust — removing the pin_plugin_library() call fails 3/3 with 4 -> 0, dropping RTLD_LAZY fails with glibc's invalid mode for dlopen() — and re-ran both suites (plugin_ort_e2e 56/56, the 25-cycle stress test, 313 plugin unit tests) clean.

No serious findings. Two things it verified that I had asserted but not proven:

  • The hazard is not rhetorical. It found the concrete instance: host_pool.rs:484's POOL_CACHE is a thread_local! whose PoolSlot has a real impl Drop (:409, owns a NonNull<HostPool>). A kernel touching it on an ORT intra-op thread registers a TLS destructor whose function pointer is in this cdylib's text. That is both the thing that faults on unmap and the thing that pins the DSO on glibc after a Run — which is why the measured table has two different answers.
  • The Windows API usage, read against the vendored windows-sys 0.61.2 source rather than from memory: PCWSTR = *const u16 so the FROM_ADDRESS cast is the correct idiom, HMODULE = *mut c_void so is_null() is right, BOOL = i32 so ok == 0 is right, and the two feature gates match the modules used.
  • ICF/COMDAT folding cannot move the anchor: both /OPT:ICF and --icf operate within one linked image, so they cannot attribute address_anchor to a different module.

Two minor findings addressed in 6f5758e54, both "an unstated deliberate choice, which is how a deliberate choice later becomes an unnoticed hole":

  1. init_host_api's error paths return without pinning. Deliberate — nothing ORT-reachable was published, and an instrumented build's profile writer runs at unload rather than dangling, while pinning there would strand the mapping of a plugin that failed to load. Now written down.
  2. PinOutcome::NotDynamicallyLoaded is silent. Deliberate — it is the correct answer for every statically linked test binary, and warning there would train the reader to ignore the warning. The cost is that a misdetection under ORT would skip the pin quietly, so the production property is not left to this function's own reporting; the integration test is what guards it. Now written down.

Limitations it named that I am accepting rather than fixing, so they are on the record:

  • The Windows arm has no end-to-end test on the platform the bug is on. pin::tests only asserts GetModuleHandleExW returned success, not that the module survives unregister. The Rust coverage (Windows x86_64) lane is the falsifier.
  • On non-Linux the integration test compiles to zero tests and reports ok — green, not a loud skip. Standard for a /proc/self/maps test, but it is a coverage hole that reads as pass.
  • It could not confirm ORT actually FreeLibrarys the plugin on Windows, nor that PIN prevents this specific 0xC0000005. The plugin_ort_e2e crashes with 0xC0000005 on the Windows x86_64 coverage lane #1672 attribution stays a hypothesis; the Linux unmap it fixes is measured.
  • cargo check --target x86_64-pc-windows-msvc fails in this environment on onnx-genai-ort-sys's build script (no Windows SDK), not on pin.rs — so Windows compilation of the new code is unverified locally and rests on CI.

@justinchuby

Copy link
Copy Markdown
Owner Author

Verified this on a Windows host before merging, since Windows is the platform the PR argues is worse.

The fix is genuinely cross-platform. pin_once has a real Windows arm using GetModuleHandleExW with GET_MODULE_HANDLE_EX_FLAG_PIN | ..._FROM_ADDRESS, which is the right API for "never unmap this" and leaves no handle to leak. It is exercised here: pin::tests::pinning_never_fails_and_is_idempotent ... ok. Full crate suite: 311 passed; 0 failed, plus the four smaller targets all green.

One observation worth recording, not a blocker. The 182-line regression test carries a target-level #![cfg(target_os = "linux")], so on Windows the whole target is empty:

cargo test -p onnx-runtime-ep-cpu-plugin --test plugin_survives_unregister
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

0 passed; 0 ignored is indistinguishable from success at a glance, and this is the exact shape #1947 forbids for CUDA test targets — the target compiles, the suite is green, and it is green because the tests are not there. That scan only polices CUDA targets, so nothing catches it here.

The gating itself is defensible: the test reads /proc/self/maps, which has no Windows equivalent. But the asymmetry is worth stating plainly, because it runs against this PR's own thesis:

So the regression guard covers the platform where the bug is least likely to bite, and does not cover the one where it actually bit. The fix is right and I am merging it; a Windows-side guard — even just asserting pin_outcome() == Pinned after a register/run/unregister cycle, which needs no /proc — would be a worthwhile follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants