Repository navigation
Fix plugin-state leaks, callback-table lifetime, and unload gating in the nxmem plugin ABI - #1448
justinchuby wants to merge 1 commit into
Conversation
… nxmem Revision of the Phase 6 plugin memory ABI after review rejection. The architecture and the ABI design are unchanged; this closes the specific safety defects and the test holes that let four mutations pass unnoticed. The two lifetime defects were the same mistake at two scales. `open_allocator` took on a debt when the plugin returned `Ok` and then dropped it on six rejection paths, and `AllocatorCore` pinned the plugin's *code* while freeing the callback context the plugin still held a pointer into. Both are now carried by the type system rather than by remembering: an `AbandonOnDrop` guard covers rejection paths that do not exist yet, and the bridge and callback table live in one boxed unit whose teardown is gated on the outstanding-release count. The abandon path could not have worked as written. It re-read the vtable through `read_prefix`, which validates, and the only way to reach it was a `read_prefix` failure — so the re-read failed identically and returned before calling `release`. Splitting out `read_prefix_unvalidated` is what makes the release reachable at all; the guard alone would have fixed nothing. `MemoryPlugin` now has a `Drop`. Without one, every early return and unwind skipped the unload gate silently and left `dlclose` free to unmap a module with live objects in it. Whether that actually unmaps is a property of the loader, not of this code, so the drop keeps the module mapped and counts it. The test holes shared a root cause: a fixture that lies about its size cannot exhibit an out-of-bounds read, because the bytes past the declaration are still valid. The short-struct and poisoned-tail fixtures are now backed by allocations that really end where they say they do, which is what lets Miri see the over-read. Assertions are on status codes and sizes rather than on substrings of human-readable messages. `live_views` is wired rather than removed: a shared prefix committed into a live allocation is a genuine plugin-side object, and views retire when the block is removed rather than when its bytes are freed. The header now pins 49 field offsets as well as 23 sizes, and the layout tests no longer skip on Windows — MSVC is the toolchain most likely to disagree about `#[repr(C)]` packing, which is the whole point of the test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## justinchuby-phase-5-process-memory-manager #1448 +/- ##
==============================================================================
- Coverage 80.01% 79.34% -0.68%
==============================================================================
Files 366 373 +7
Lines 164950 165793 +843
Branches 164950 165793 +843
==============================================================================
- Hits 131980 131543 -437
- Misses 28150 29407 +1257
- Partials 4820 4843 +23
Flags with carried forward coverage won't be shown. Click here to find out more.
🚀 New features to boost your workflow:
|
🔴 Benchmark Regression DetectedComparison of criterion micro-benchmarks: PR head vs merge-base, measured on the same runner in the same job (base first → PR second).
Visual flags: Host infoWhat this cannot catch
|
> **A100 safety update (head `13c37a7f`):** expected shared-prefix admission failures are now all-or-private: K/V commit is transactional, a failed V share rolls K back before enqueue, and only a successful rollback permits private-KV fallback. Fatal kernel faults remain process-fatal; the new cross-process VMM probe proves a worker fault does not affect an actively executing peer, while the current server is still in-process and does not yet consume physical shared-prefix metadata. See the [final verified conclusion](#1579 (comment)). Collapses phases 1–7 of the #1186 memory architecture rework onto current `main` as a single merge. The stack forked 270 commits ago (176 of them touching `crates/`), so rebasing it layer by layer would mean solving the same 17 conflicts fourteen times, with no reviewer ever looking at the intermediate states. Closes the stack: #1252 #1263 #1279 #1283 #1301 #1341 #1349 #1426 #1440 #1448 #1454 #1462 #1465 #1468 #1533. **A review guide is in the first comment.** It is the part worth reading — this diff is 103 files, but only three decisions in it are ones a compiler cannot check. ## What was already reviewed, and what wasn't Every phase was reviewed and approved by a session that was not its author, under the rejection-lockout rule (a rejected author never writes the next revision). | Phase | PR | Verdict | |---|---|---| | 1–5 | #1252 → #1426 | approved | | 6 | #1440, #1448, #1454 | **rejected** ×3 | | 6 | #1462 | approved | | 7 | #1465 | **rejected** | | 7 | #1468 | approved | | 7 test fixes | #1533 | approved, A100-verified | The four rejected rounds were not replaced — the later rounds are stacked **on top of** them. So the tree here is the approved state, but the history contains the rejected commits. **#1440 / #1448 / #1454 / #1465 should not be reviewed individually**; they close automatically. **Not reviewed anywhere:** the conflict resolutions themselves. That is what this PR is for. ## Verification Run here, on this merged tree: - `cargo check --workspace --all-targets` — clean - `cargo check -p onnx-genai-engine --features cuda,native-backend --all-targets` — clean. Worth calling out: the default feature set does **not** compile the `cfg(cuda)` code, which is where the riskiest edits are. Checking only the default set would have missed a real break (see the guide). - `cargo test` over 7 crates — **1105 passed, 2 failed, 88 ignored** - `cargo clippy --workspace --all-targets` - `cargo fmt` **The 2 failures and the 1 clippy error are pre-existing and proven so, not merge damage:** - `platform_capacity::{disk_capacity_is_measured_for_the_working_directory, an_explicit_byte_limit_is_honored_without_a_device_query}` — `platform_capacity.rs` is byte-identical to `main` (`git diff origin/main HEAD -- ` that file is empty). The cause is a macOS-only FFI layout bug: `fsblkcnt_t` is 4 bytes on macOS (confirmed: `sizeof(struct statvfs)=64`, `sizeof(fsblkcnt_t)=4`) while the Rust struct declares `f_blocks`/`f_bfree`/`f_bavail` as `u64`. CI is Linux, where it is 8 bytes. Left alone: it is a real bug but not this PR's. - `optimizer.rs` clippy `approx_constant` — present on both parents; the merged file is byte-identical to `main`. - Three files had `rustfmt` drift already present on `main` (`matmul_nbits.rs`, `normalization.rs`, `optimizer.rs`). Reverted rather than swept in, to keep the diff readable. **Final A100 revalidation (head `13c37a7f`):** full CUDA-memory GPU suite passed; CUDA EP default-parallel lib suite passed (**488 passed / 17 ignored**); real fp16 GQA covered three requests × two interleaved decode steps with two shared peers and one transactional private fallback, byte-identical to independent GPU and CPU references. A device-started/event-gated worker remained healthy through a peer process `CUDA_ERROR_ILLEGAL_ADDRESS`, owner exit, replacement worker, and process restart. Governor tests, targeted Miri, CI Clippy, fmt, and CUDA honesty also passed. ## Still open after this merges - `memory-plugin-provider-wiring` — the back half of Phase 6 criterion 8 (provider/context pinning), which fell in the gap between phases. **#1186 must not be closed until it lands.** This merge makes it more tractable, not less: see decision 3 in the guide. - `memory-deferred-invariant-asserts` — two surviving mutants found by #1462's reviewer, adjudicated NON-BLOCKING and not defects. - #1533's CUDA honesty guard warns on a legitimately portable anchor. Should be moved out of the CUDA test binary or allow-listed rather than left warning. --------- Signed-off-by: Justin Chu <justinchuby@users.noreply.github.com> Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <copilot@github.com> Copilot-Session: 46c5d75b-8146-489c-b82f-08ee29c27ce4 Copilot-Session: 39ff6824-d35f-4d3b-8f5f-043a7119a100 Copilot-Session: c80f8522-983c-47f7-8241-2155a823aabe
|
Closing: superseded by #1579, which collapsed phases 1–7 into a single merge — landed as This PR was rejected under the lockout rule and superseded by the approved revision that was stacked on top of it (not by a replacement of it). Per #1579's own guidance, it was never meant to be reviewed individually. Containment verified, not assumed: Every one of the 15 stack heads (#1252 → #1533) is a literal ancestor of the merged head, and I separately confirmed the merged head's content reached Why this didn't close itself: #1579's body says #1186 stays open: #1579 explicitly gates it on |
Part of #1186 — Phase 6 only.
Supersedes #1440, which an independent review rejected. #1440 stays open as the record of that attempt; nothing in it was closed, modified or pushed to. This branch starts from its head (
03faeeba) rather than from scratch — the review found the crate split, the vtable/version/status design, the C header and theread_prefixclamp sound, and none of those are churned here.Base is
justinchuby-phase-5-process-memory-manager.c65cf036(Phase 5 tip) is already merged in below.The two lifetime defects were the same mistake at two scales
Both findings come down to a debt recorded in a comment instead of in a type.
open_allocatordocumented its own obligation — once the plugin returnsOkit has created state and the host owes it arelease, whatever the host then decides about the vtable — and then dropped that obligation on every rejection path that was added after the comment was written.AllocatorCorepinned the plugin's code through anArc<PluginModule>while freeing the callback context the plugin still held a raw pointer into, because the SAFETY comment reasoned about the synchronousreleaseand the queued case was never in view.The fix in both places is to make the obligation something you cannot forget rather than something you must remember.
Finding 1 — plugin state stranded on post-
OkrejectionThe review listed five unreleased paths. There are six: the missing-required-capabilities check between the mechanism-id check and the first
read_capabilitywas not in the list.That is itself the argument for the guard over per-path calls. A reviewer reading the function carefully still missed one, and a seventh path costs nothing to add and nothing to notice.
AbandonOnDropis armed the instant the plugin returnsOkand disarmed on the last line before the allocator is published, so a path added later is covered before it is written.A second defect, not in the review: the abandon path could never have released anything.
abandon_allocatorre-read the vtable throughNxmemAllocatorVtable::read_prefix, which callsvalidate_requiredinternally. The only route intoabandon_allocatorwas aread_prefixfailure. The re-read is deterministic over the same bytes, so it failed identically and returned before reachingrelease. Every post-Okpath leaked, including the two the review credited as correct.This also corrects the review's note that
validate_required"already catches and releases" themechanism_id == 0case. It caught it. It did not release it.read_prefixis now a thin wrapper overread_prefix_unvalidated, which performs every memory-safety check — null, alignment, self-consistency, bounded copy, level clamp — and skips only the required-slot validation. Its doc comment says plainly that it exists for one caller and thatreleaseis the only slot safe to invoke on its result.Finding 2 — callback table freed while queued releases were outstanding
The pinning was at the wrong granularity, exactly as the review said.
bridgeandcallbacksare now oneHostCallbackContextbehind a single box, andHostBridgecarries anoutstanding_releasescounter thatenqueue_releasebumps before the ABI call and unwinds on failure.AllocatorCore::dropnow drains, frees, releases, and only then decides the context's fate. If anything is still outstanding it incrementsLEAKED_CALLBACK_TABLESandmem::forgets the context. Freeing it would be a use-after-free the moment a plugin worker thread reports; leaking it is bounded by exactly the condition that already keeps the module mapped.The related half the review flagged —
dropnot draining at all, leavingqueued_releasesnon-zero forever — is fixed byretire_queued_releases_on_drop. It is bounded at 16 passes, not a loop to completion, and stops early when a pass retires nothing. A mechanism is entitled to refuse to drain (stickydoes; a real stream-ordered mechanism may simply not be ready) and spinning would hang the caller. When the plugin declines, the count stays non-zero and the table is leaked — the honest outcome, not a hang.Finding 4 — dropping a
MemoryPluginbypassed the gateMemoryPluginnow has aDropthat re-runs both halves of the gate and, when it is shut, incrementsFORCED_MODULE_LEAKSandmem::forgets anArcclone sodlclosenever runs.The review's point about platform luck is the right one and the docs now say so: glibc unmaps a refcount-zero DSO without
DF_1_NODELETEand Rust cdylibs are not marked; macOS merely declining to unmap is not a safety property.try_unloadcould no longer destructureselfonce aDropexisted (E0509). It now clears the factories, clones theArc, dropsself, and unwraps.Dropre-runs the gate on the clean path too, which is one extra plugin call that passes trivially and records no leak — pinned bydropping_a_plugin_with_live_objects_keeps_the_module_mapped.The test holes shared one root cause
A fixture that merely lies about its size cannot exhibit an out-of-bounds read, because the bytes past the declaration are still valid memory. That is why the short-struct test passed for the wrong reason (Finding 3) and why the bounded read had no coverage at all (Finding 5) — the same fixture served both.
The fixtures are now backed by allocations that really end where they say they do (
leak_vtable_bytes,poisoned_buffer), which is what lets a sanitiser see the over-read. Assertions are onNxmemStatusCodeand on declared/required sizes, not on substrings of human-readable messages;PluginError::status_code()was added for that.New
poisoned-tailmechanism: an allocation of exactlysize_of::<NxmemAllocatorVtable>()filled with0xAB, with only the minor-0 prefix written over it andrelease_allocationdeliberately populated inside the poisoned region. A host that reads past the declaration or skips the clamp ends up holding it. Asserted through a newpublishes_structured_release_slot()accessor, becausestructured_release_slot()short-circuits onabi_minor < 1and would hide the difference.Offsets are pinned as well as sizes.
MIN_STRUCT_SIZE_MINOR_0is derived fromoffset_of!(Self, pending_release_count), so a field inserted mid-struct keeps the total size plausible while silently moving the boundary every older peer reads up to.Finding 6 — layout contract on Windows
#[cfg(unix)]dropped fromnxmem_c_example_compiles;#[cfg(all(unix, target_pointer_width = "64"))]reduced to#[cfg(target_pointer_width = "64")].find_cc()now triescl(probed with/?, since it has no--version) and returns a compiler description so args are built MSVC-style. The loud-failure property is kept: a missing compiler is apanic!, never a skip.The header carries 49 new
NXMEM_LAYOUT_FIELD:offset annotations alongside its 23 sizes, checked two independent ways — against Rust directly viaoffset_of!(no C compiler needed), and against a C compiler via_Static_assert(offsetof(...)). Therust_offsettable is spelled out by hand rather than generated, because a table derived from the definition would agree with it by construction and prove nothing.I could not verify the MSVC path. This host is macOS/arm64. The
clflags and the/std:c11requirement for_Static_assertare from documentation, not from a run. Saying otherwise would be the same dishonesty this PR exists to fix.M3 — plugin-report gate
Covered by a new
self-retainingmechanism, which takes a reference to its own allocator state the host never learns about. Every ordinary scenario leaves the host holding something too, so the host's check fires first and the plugin's is never the reason for refusal; a host-zero / plugin-non-zero arrangement is the only way to make that gate load-bearing.live_views— wired, not removedWired. A shared prefix committed into a live allocation is a genuine plugin-side object with a lifetime bounded by the allocation it looks into, and it is one the host cannot count for itself.
Blockgained aviewscounter;plugin_commit_shared_prefixincrements both.Views retire at block removal, not at
free_block— the deferred-release path removes the block long before the bytes are freed, so retiring on free would leave the axis non-zero across the whole deferred window.Removing the axis instead would have been an ABI and header change, and would have cost the gate an axis that definitely has a referent once an EP is wired in.
Test binary split
Three gate tests each live in their own binary. Module counters live in the loaded
.soand are process-global;stickynever retires andself-retainingnever releases, so each permanently poisons them. Two such tests in one binary read each other's residue and force exact assertions to be softened into inequalities — which is precisely the dishonesty that got #1440 rejected. The pre-existingqueued_releases >= 1 || ...OR-assertion in the unload-gate test was tightened into two exactassert_eq!s for the same reason.Mutation evidence
Every fix was mutation-tested: break the production check, confirm red, restore. All mutations are removed from the tree (
grepfor the markers returns nothing).copy_prefix:if false && declared_size < requireda_short_allocator_vtable_is_refused_before_any_slot_is_read. Previously the integration test passed on the status code's name.try_unload:if falseon thereport.total() != 0gateunload_is_refused_when_only_the_plugin_still_owns_something. Was all-green before.copy_prefix:let readable = size_of::<T>()copy_prefix_reads_exactly_the_declared_prefix_and_zeroes_the_rest. Under Miri:Undefined Behavior: attempting to access 128 bytes, but got alloc357122 which is only 120 bytes from the end of the allocation.effective_minor < 1nulling removeda_poisoned_tail_never_reaches_the_hosts_view_of_the_vtable. Previously all 24 integration tests stayed green.AbandonOnDrop::drop→ no-opabandon_allocatorback to the validatingread_prefixAllocatorCore::dropdropping_an_allocator_with_a_queued_release_leaks_its_callback_table.retire_queued_releases_on_drop()Drop for MemoryPlugin→ no-opdropping_a_plugin_with_live_objects_keeps_the_module_mapped.the_header_field_offsets_match_rustanda_c_compiler_agrees_with_the_rust_layouts.One honest caveat on M7. It is caught deterministically only at unit level, plus non-deterministically under Miri. It cannot be made value-observable at integration level, and this is structural rather than a gap I chose to leave:
read_prefix_unvalidatedrejects any vtable wheredeclared_size < required_struct_size(declared_minor), andrequired_struct_size(1) == size_of::<Self>(), so no vtable can be both short and at effective minor ≥ 1.release_allocationis the only field past the minor-0 prefix, and the clamp nulls it independently of the bound. Destroying the bound alone therefore changes no observable value — it is purely an out-of-bounds read, and the only honest way to see it is a sanitiser on an exactly-sized allocation. That is what the Miri result above is.The exact-size fixture also had to be
Vec<u64>-backed rather thanVec<u8>. AVec<u8>is byte-aligned; the system allocator happens to return 8-aligned blocks but Miri does not, so a byte-backed fixture failed under Miri for the wrong reason — the alignment check, not the bound. Miri caught that too.Scope: criterion 8 is half done
Deferred release now keeps the plugin module and host callback table pinned, and that half is tested.
The provider/context half is not implemented. No execution provider is wired to this ABI in this phase — there is no
onnx-runtime-ep-cudaintegration anddeferred_release.rsis untouched — so there is nothing to pin. That scope decision was accepted by review; it is stated here and indocs/memory/MEMORY_PLUGIN_ABI.mdso it cannot be mistaken for done, and it is tracked for Phase 7.Validation
macOS/arm64, no CUDA. Nothing in these three crates touches CUDA.
cargo test -p onnx-runtime-memory-abi -p onnx-runtime-memory-host -p onnx-runtime-memory-testplugin— 101 passed, 0 failed (abi lib 50, header_contract 4, host lib 9, nxmem_abi 29, callback_pinning 1, plugin_gate 1, unload_gate 1, testplugin lib 6). Baseline at03faeebawas 86.cargo clippy --all-targets -- -D warningsscoped to the three crates — clean.cargo fmt --check— clean for everything this PR touches.cargo doc --no-depsfor the three crates — zero warnings.cargo +nightly miri test -p onnx-runtime-memory-abi --lib— 50 passed, 0 failed, and it flags M7 as UB. Not run on the host integration tests: theydlopena real cdylib, which Miri cannot execute.minimal_plugin.cand a 72-assertion layout probe (23 sizes + 49 offsets) both compile withcc -std=c11 -Wall -Wextra -Werroragainst the copied header alone.git diff 03faeeba --statis 12 files, all inside the three crates plusdocs/memory/MEMORY_PLUGIN_ABI.md. Noonnx-runtime-memory-governordependency was added, so the Phase 1–5 lock graph is untouched by construction. No host lock is held across a plugin call. The three accounting axes stay distinct and no quarantine path refunds.Known pre-existing, not from this PR and not fixed here:
completions.rsrustfmt drift (verified byte-identical to base —git diff 03faeebaon that file is empty),matmul_nbits_marlin_numerics, Windows CUDAunnecessary_unwrap, macOS MLAS, governor rustdoc intra-doc links.CI will sit pending for hours on this repo's backlog. Local validation above is the evidence.
Do not merge, do not enable auto-merge, do not enqueue.