Skip to content

feat(examine): report every code object manager library, not just the first - #384

Open
volen-silo wants to merge 1 commit into
mainfrom
feat/report-comgr-copies
Open

volen-silo wants to merge 1 commit into
mainfrom
feat/report-comgr-copies

Conversation

@volen-silo

Copy link
Copy Markdown
Collaborator

What

HIP compiles device code at run time through libamd_comgr, and a machine can hold more than one copy — a system ROCm install and a ROCm Python wheel each ship one. When the copy the loader picks does not belong to the active runtime, compilation fails with a general error naming neither the library nor the second copy.

This CLI installs the second copy itself. Its install path puts ROCm wheels into a managed environment, so a user who follows it on a host that already has system ROCm ends up holding both, having done nothing unusual and been warned about nothing.

Nothing looked past the first match, so the second copy was invisible. The inspection now records every copy in loader search order, which one would load, and its version.

Reporting only. The conflict rule and the catalog entry that states the user's options follow in a second PR. That is where the risk of a false report on a healthy managed install sits — every managed environment this CLI creates legitimately holds two copies — so it belongs with the rule it threatens rather than bundled with an uncontroversial probe.

Non-obvious decisions

No dlopen. The ticket specified reading the version via amd_comgr_get_version through dlopen. That runs the library's ELF initialisers — code from an unknown library, on a machine the tool was called to precisely because something is already wrong — and pulls a possibly conflicting HIP stack permanently into the process. The version is read from the versioned soname instead. A renamed file yields an empty version, which is honest; the conflict rule never needs the version, only the report text does.

Deduplicated on the resolved path. ROCm ships libamd_comgr.so.2.8.0 beside an unversioned symlink. Counting those as two copies would invent a conflict on an ordinary install — the false report that matters most, since it would fire on healthy machines.

It emulates the loader; it is not the loader. No account is taken of RUNPATH/RPATH, ld.so.preload, or a container remapping paths. The doc comment says so, and the list of copies is the evidence for the verdict rather than a guarantee.

It runs on WSL2 too — a deliberate departure from the ticket's "Linux" scope. The WSL2 early return exists for kernel driver questions, and the code says so while still running the framework probe. A shadowed comgr copy is a run-time compilation failure inside the framework, and ROCm on WSL2 is a supported configuration where the two copies collide identically. Skipping it would leave a WSL user unable to see a conflict that is really there.

One existing helper was made pub(crate) rather than restating where a managed runtime keeps its libraries. A second description of that layout is a second thing to keep correct.

Corrections to the ticket

  • The ticket says examine already reports the active runtime's owning installation. It does not — Examination records the system install only. That knowledge lives in a separate rocm examine surface. Part 2 needs it, and rocm-core's own runtime helpers can resolve managed runtime roots, so no cross-crate dependency is required.
  • The ticket asks for the copies to appear in the human-readable report. Examination has no text renderer — it surfaces through --json and feeds diagnose. Putting them in the separate text report would mean a second, independent probe. Deferred to part 2, where the diagnosis puts the conflict in front of the user in prose.

Verification

  • cargo test --workspace --all-targets --exclude e2e-cucumber — clean; rocm-core 344 passed
  • cargo clippy --locked --workspace --all-targets, cargo fmt --all --check — clean
  • cargo xtask e2e -- -n examine — 15 scenarios, 14 passed. The one failure is examine-detects-gpu-and-driver, whose Given requires an AMD GPU; this dev host reports detected_gfx_target: <unknown> and "wsl gpu plumbing missing". That value comes from a report this diff touches only to change a visibility modifier.

The new unit test was verified to fail, not just to pass. Reverting the walker to stop at the first hit — the old behaviour — fails the_library_path_is_searched_left_to_right_and_every_copy_is_kept and nothing else, confirming it pins the defect rather than the implementation.

Also checked against the real binary with two planted copies and a symlink: both distinct copies reported in path order, the symlink pair collapsed to one, version read from the resolved file, and the note naming the winner.

Coverage boundary

The e2e suite cannot install a second ROCm stack, so it cannot prove the two-copy case. It proves the surface every lane has: the inspection answers the question, the reported copies carry enough to act on, and the selected copy agrees with the list. The two-copy, symlink-dedupe and version-parsing rules are proven by unit tests that build the directory layout directly. This is a real boundary, not an oversight.

Risk

Low. Additive: four new fields and a probe that only reads. Nothing existing changes behaviour except the LD_LIBRARY_PATH walker, which was generalised so the HIP probe and this one share one implementation — the HIP probe still takes the first hit and its field is unchanged.

The Examination field set is a frozen wire contract with a test guarding it; that test fired on this change and was updated deliberately, which is the guard working as intended.

@volen-silo
volen-silo force-pushed the feat/report-comgr-copies branch 3 times, most recently from 4e5fe59 to 6f617c8 Compare September 15, 2026 13:18
@volen-silo
volen-silo force-pushed the feat/report-comgr-copies branch from 6f617c8 to 09476f8 Compare September 28, 2026 07:53
@volen-silo
volen-silo marked this pull request as ready for review September 28, 2026 10:04
@volen-silo
volen-silo requested a review from a team as a code owner September 28, 2026 10:04
@volen-silo
volen-silo requested a review from rominf September 28, 2026 10:04

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · 09476f8

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

Adds a Linux probe that records every libamd_comgr copy on the machine (path, resolved path, version, source), plus four new Examination JSON fields, four unit tests and one Gherkin scenario — outcome: Needs work (one blocking issue). Verified: ran the rocm-core examine test module (41 pass) on a throwaway copy and mutated it per branch — reverting the multi-hit walk, the resolve-path dedup and the digits-only version filter each fails exactly one test, but removing the intra-directory sort(), flipping copies.first() to .last(), and gutting probe_comgr to an immediate return all leave every test green; confirmed examine --json flattens Examination so the new keys really surface, that no other consumer reads the new fields or hip_libs_on_ld_path, that the managed-runtime layout claim matches runtime.rs, and that no comgr probe existed on the base at all (so the title's "not just the first" describes a new field rather than a widened one). Checks at review time: 24 success, 2 failures, 1 skipped, 1 cancelled. Blocking: 1 · Non-blocking: 4.

🚫 Blocking (must fix before merge)

crates/rocm-core/src/examine.rs:1982 — comgr_paths_in_loader_cache gates on which("ldconfig"), and which (same file, ~line 616) walks only the process PATH with no /sbin fallback. This re-introduces a bug the same crate already fixed and documented: crates/rocm-core/src/lib.rs:2543-2560 defines ldconfig_cache() whose doc comment says in as many words that "ldconfig lives in /sbin, which is not on a non-root user's PATH on Debian and derivatives. Looking it up by bare name there yields nothing, and an empty cache is indistinguishable from a cache that does not list the library" — and records the user-visible harm it caused last time (a correctly installed library read as "not registered with the linker"). Its fix is to try ["ldconfig", "/sbin/ldconfig", "/usr/sbin/ldconfig"].

Why it blocks: on those hosts the whole loader-cache tier silently contributes nothing, and that tier is the one that finds the copy the loader actually resolves. The consequences are exactly the ones this feature exists to prevent — a copy registered only in ld.so.cache (a ROCm install outside /opt, or one whose directory is on ld.so.conf but not LD_LIBRARY_PATH) is either missed entirely, producing the "no libamd_comgr found …" note on a machine that has one, or is ranked below a rocm-install/managed-runtime entry so comgr_selected names the wrong copy. It also silently degrades rather than reporting "could not ask", which is the distinction the sibling function was written to preserve. AGENTS.md §5 requires checking sibling implementations before adding a probe.

Fix: drop the which gate and reuse the existing helper — make ldconfig_cache() pub(crate) (the same treatment this PR already applies to collect_sdk_library_paths) and parse its Option<String>, treating None as "could not ask" rather than "no copies". It is timeout-bounded (capture_optional_command → OPTIONAL_COMMAND_TIMEOUT), so this does not trade away the 5s bound the current run(..., SHORT) gives. A smaller variant — mirroring the three-candidate list locally — works too but leaves two descriptions of the same trap to keep in sync.

Non-blocking

  • crates/rocm-core/src/examine.rs:236,239,1875 — "in loader search order" / "the copy the loader would pick" overclaims: the rocm-install and managed-runtime tiers are not loader search locations at all unless they also appear on LD_LIBRARY_PATH/ld.so.conf, yet the hedge names only RUNPATH, ld.so.preload and container remapping; one sentence saying the last two tiers are evidence of what exists rather than of what loads would make the source field's purpose explicit and keep the future conflict rule honest.
  • crates/rocm-core/src/examine.rs:1889-1917 — probe_comgr itself has no test: replacing its body with an immediate return leaves all 41 examine tests green, and the new scenario passes too (the fields serialise as []/null from Default), so on every lane without ROCm present the scenario proves only that two keys exist; the copies.first() selection rule and the multi-copy note are unproven, and a variant taking its search roots as a parameter would make both testable.
  • crates/rocm-core/src/examine.rs:1861 — deleting matches.sort() fails nothing, so the "sorted so the result does not depend on directory iteration order" promise has no test; the ordering test uses two separate directories and never exercises intra-directory order.
  • crates/rocm-core/src/examine.rs:457 — the comment justifying the call site says the search consults LD_LIBRARY_PATH "(read by probe_env)", but probe_comgr re-reads the variable itself and uses nothing probe_env stored; only the probe_rocm_install half of the stated ordering dependency is real.
  • crates/rocm-core/src/examine.rs:1907 — the "N copies … would load" note fires on any host holding a system ROCm install alongside a runtime this CLI installed, which the commit message itself calls the normal case; harmless while Examination.notes is JSON-only (no consumer prints it today), but it will read as a warning the moment one does.

@juhovainio juhovainio left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice write-up, and the reasoning behind the non-obvious calls (no dlopen, resolved-path dedup, WSL2 inclusion) all held up.

One thing I think is a real gap: the managed-runtime search path doesn't look like it actually covers the wheel-install case this PR is meant to catch. managed_sdk_ld_library_path (the existing helper) finds a wheel's comgr copy by walking site_packages for _rocm_sdk_* siblings, because the actual libraries live in a sibling package, not under the root it's handed. comgr_search_dirs's new managed_runtime_roots() path reuses collect_sdk_library_paths on the root directly but skips that site_packages walk entirely - so for a wheel-format install this probe likely never finds the copy that's the PR's own headline scenario. Left an inline comment at the spot.

Two smaller notes, not blocking:

  • The /opt/rocm* sibling-install scan isn't mentioned in the PR description, which frames this as strictly system-vs-wheel. Worth a line in the write-up.
  • collect_libraries_in_dir now sorts before taking the first match, which does change the HIP probe's tie-break order when a directory has multiple matching files - minor, but the Risk section's "unchanged" claim isn't quite true for that edge case.

Also a couple of judgement-call code-smell notes worth a look when convenient (not blockers): the same 4-line "Unix-only" test comment is duplicated across three tests in examine.rs, and ComgrCopy.source is a closed 4-value set that reads like it wants to be an enum rather than a bare String.

Comment thread crates/rocm-core/src/examine.rs Outdated
// managed runtime keeps its libraries is a second thing to keep correct.
for root in managed_runtime_roots() {
let mut paths = Vec::new();
crate::collect_sdk_library_paths(&root, &mut paths);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This only walks root via collect_sdk_library_paths. The existing managed_sdk_ld_library_path (lib.rs) additionally walks candidate.site_packages for _rocm_sdk_* sibling packages, because that's where the actual libraries live for a wheel install - this loop doesn't have (and doesn't look up) an equivalent site_packages value for managed_runtime_roots()'s roots, so it likely misses a wheel-installed comgr copy entirely.

// Sibling ROCm installs the active one does not cover. A versioned install
// left behind by an upgrade is one of the two copies this entry exists to
// find.
if let Ok(entries) = std::fs::read_dir("/opt") {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This /opt/rocm* sibling-install scan isn't mentioned in the PR description (which frames the conflict as system-install vs. wheel-install only) - worth a line explaining it's in scope too.

.filter(|entry| entry.file_name().to_string_lossy().starts_with(prefix))
.map(|entry| entry.path().to_string_lossy().into_owned())
.collect();
matches.sort();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This sort changes the HIP probe's tie-break order when a directory has more than one matching file (previously OS-arbitrary read_dir order, now sorted). The Risk section says "the HIP probe still takes the first hit and its field is unchanged" - true for the single-match case, not quite for this edge case.

@volen-silo
volen-silo force-pushed the feat/report-comgr-copies branch 2 times, most recently from d9b0a75 to f2bd0c9 Compare September 29, 2026 12:05
@volen-silo
volen-silo dismissed stale reviews from siloteemu and juhovainio September 29, 2026 12:18

Addressed

@volen-silo
volen-silo force-pushed the feat/report-comgr-copies branch 2 times, most recently from 6ea3a21 to ee48870 Compare September 29, 2026 13:23
@siloteemu

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · ee48870

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

The change generalises the code object manager scan from find-first to find-all, adding four Examination fields plus a shared library-path walker; the plurality itself holds up end to end, but a descoped ownership feature left behind an assertion and a doc claim that the shipped code cannot satisfy — Needs work. Verified: ran the touched crate's unit tests (51 pass) and five targeted mutations on a scratch copy — reverting the walker to first-hit kills exactly the_library_path_is_searched_left_to_right_and_every_copy_is_kept (the PR's headline claim, confirmed), breaking canonicalisation kills the symlink test, breaking the version parser kills two, dropping the no-registry fallback kills its test; also confirmed ComgrCopy has exactly four fields (no install_root), that comgr_selected/comgr_version reading index 0 is the intended loader semantics and the e2e step pins selected == copies[0], that the zero-copy and one-copy paths are both handled, that no deny_unknown_fields exists and #[serde(default)] genuinely suffices for the cross-machine deserialiser (checked against a real fixture lacking the fields), that the new note cannot alter status, that no test asserts exact notes contents, and that the PR body's "Examination has no text renderer" is accurate (the human report is built from a different type). The full suite was not run here. Checks at review time: 19 success, 1 failure, 1 skipped. Blocking: 2 · Non-blocking: 4.

On the red check: I did find a defect in this diff that would deterministically fail any run that actually executes the new GPU-gated scenario (blocker 1). That scenario resolves to skip rather than fail where no AMD GPU is present, so whether it is the failing check depends on the lane; I cannot confirm which check failed and am not inferring one. Either way the defect is real and independent of the base branch's intermittency.

🚫 Blocking (must fix before merge)

  • tests/e2e-cucumber/tests/e2e/examine_steps.rs:859-872 — the new step for scenario examine-17 asserts that every copy whose source is managed-runtime carries a non-empty install_root. ComgrCopy (crates/rocm-core/src/examine.rs:159-177) has exactly four fields — path, real_path, version, source — and nothing anywhere in the workspace emits install_root on a comgr copy. copy.get("install_root") therefore always yields None, unwrap_or_default() gives "", and the assertion fails on every execution. This is a test that cannot pass for the reason its message states, and it is the headline coverage the PR text offers for the managed case ("examine-17 asserts that on a GPU lane"), so the case is in fact uncovered and the scenario is a guaranteed red wherever a GPU is present. Fix: delete the for copy in managed { … } loop and its preceding comment (lines 859-872). What remains — copies non-empty, and at least one with source == "managed-runtime" — is still a non-vacuous assertion of the thing the scenario names. If the owning-install data is wanted now rather than in the follow-up, the alternative is to add the field to ComgrCopy and populate it in record_comgr_copy, but that is the deferred work and should not be bolted on here.

  • crates/rocm-core/src/lib.rs:4172-4177 — the doc comment on collect_sdk_package_library_paths states "Attribution is by longest known root and no _rocm_sdk_* directory is a known root, so each resolves to its runtime — which is what keeps a healthy managed install from looking like several installations in conflict." No attribution-by-longest-root mechanism exists in this diff or in the base; nothing computes an owning installation for a comgr copy, and comgr_matches_runtime is documented as permanently unset. The comment presents a safety property as provided when the code provides nothing of the kind, which is precisely the guard-in-prose-only pattern — and it is the same descoped feature that produced blocker 1. The commit body carries the same claim twice ("recording every copy with the installation that owns it, plus the same for the HIP runtime so the two can be compared" and "Ownership is matched against known installation roots, longest first, and never derived by walking up from the file"); a maintainer reading either will believe attribution shipped. Fix: cut the second and third sentences of the doc comment, keeping "They belong to the runtime that contains them, not to themselves.", and drop the two ownership paragraphs from the commit body when the branch is next amended.

Non-blocking

  • crates/rocm-core/src/examine.rs:1876 — removing matches.sort(); fails no test at all (verified by mutation), yet the comment beside it correctly flags it as a deliberate change to the existing HIP probe's tie-break, not a no-op. A two-file-in-one-directory case would pin it cheaply.
  • The PR text's "Nothing existing changes behaviour except the LD_LIBRARY_PATH walker" understates the diff: managed_sdk_ld_library_path gains a new fallback that scans the predictable venv layout when no site-packages was recorded, which changes the loader path handed to the agent enumerator during gfx-target detection. The shared helper's new branch is tested; its effect through that caller is not, and both existing tests there populate site_packages.
  • "One existing helper was made pub(crate)" — four items were: the probe-candidate builder, the SDK library-path collector, the candidate struct and two of its fields, plus the new shared helper.
  • comgr_matches_runtime ships permanently None and no diagnosis check reads any of the four new fields, so the data is inert on the wire today. Deferring is disclosed and defensible; worth confirming the follow-up is what makes it non-inert. Relatedly, examine-16 passes vacuously on a host holding zero copies — the PR discloses this accurately, and the zero/selection consistency it does assert is real.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · ee48870

Change request filed by automation. The findings below are the blocking half of the round published in the report comment on this pull request; the non-blocking notes stay there. This will be withdrawn once they are addressed — no human needs to clear it.

🚫 Blocking (must fix before merge)

  • tests/e2e-cucumber/tests/e2e/examine_steps.rs:859-872 — the new step for scenario examine-17 asserts that every copy whose source is managed-runtime carries a non-empty install_root. ComgrCopy (crates/rocm-core/src/examine.rs:159-177) has exactly four fields — path, real_path, version, source — and nothing anywhere in the workspace emits install_root on a comgr copy. copy.get("install_root") therefore always yields None, unwrap_or_default() gives "", and the assertion fails on every execution. This is a test that cannot pass for the reason its message states, and it is the headline coverage the PR text offers for the managed case ("examine-17 asserts that on a GPU lane"), so the case is in fact uncovered and the scenario is a guaranteed red wherever a GPU is present. Fix: delete the for copy in managed { … } loop and its preceding comment (lines 859-872). What remains — copies non-empty, and at least one with source == "managed-runtime" — is still a non-vacuous assertion of the thing the scenario names. If the owning-install data is wanted now rather than in the follow-up, the alternative is to add the field to ComgrCopy and populate it in record_comgr_copy, but that is the deferred work and should not be bolted on here.

  • crates/rocm-core/src/lib.rs:4172-4177 — the doc comment on collect_sdk_package_library_paths states "Attribution is by longest known root and no _rocm_sdk_* directory is a known root, so each resolves to its runtime — which is what keeps a healthy managed install from looking like several installations in conflict." No attribution-by-longest-root mechanism exists in this diff or in the base; nothing computes an owning installation for a comgr copy, and comgr_matches_runtime is documented as permanently unset. The comment presents a safety property as provided when the code provides nothing of the kind, which is precisely the guard-in-prose-only pattern — and it is the same descoped feature that produced blocker 1. The commit body carries the same claim twice ("recording every copy with the installation that owns it, plus the same for the HIP runtime so the two can be compared" and "Ownership is matched against known installation roots, longest first, and never derived by walking up from the file"); a maintainer reading either will believe attribution shipped. Fix: cut the second and third sentences of the doc comment, keeping "They belong to the runtime that contains them, not to themselves.", and drop the two ownership paragraphs from the commit body when the branch is next amended.

@volen-silo
volen-silo force-pushed the feat/report-comgr-copies branch from ee48870 to a8749a3 Compare September 30, 2026 06:49
@siloteemu
siloteemu dismissed their stale review September 30, 2026 08:10

Superseded: this objection was filed against an earlier commit and is replaced by a fresh round at the current head. The earlier blocking item is discharged in the tree; a new, unrelated blocking finding is filed separately.

@siloteemu

Copy link
Copy Markdown

🔴 Automated review · pr-review-watcher · a8749a3

This automation never files a GitHub approval, so no approving review will appear here whatever the outcome — the merge decision stays with a human reviewer.

Summary

The change replaces the first-match-only libamd_comgr lookup with a walker that records every copy on the machine, in the order the search consults, together with which one would load and its version; it is reporting only, with the conflict rule deferred. Outcome: Needs work — one scenario does not exercise the code it names on the lanes where it is the only coverage. Verified: the eight new unit tests in the core crate were run on a scratch copy and pass, then production lines were mutated one at a time on that copy — dropping the recorded-site-packages arm kills two tests, dropping the no-record fallback kills one, deduplicating on the given path instead of the resolved one kills the symlink test, removing the digits-and-dots version guard kills the version table, and restoring stop-at-first-hit kills the ordering test, so the headline defect and the managed-runtime layout are genuinely covered; removing the new matches.sort() kills nothing; and with both probe_comgr call sites deleted, replaying examine-16's exact assertion sequence against the serialized examination still passes. Both items from the previous round are discharged in the tree — the install_root loop is gone and what replaced it is non-vacuous, and the attribution-by-longest-root sentences are gone from both the doc comment and the commit body. The previous round's non-blocking notes were not re-checked in this round. The full test suite, the e2e suite and clippy were not run here. Checks at review time: success=16, skipped=6, failure=4, pending=2; at publication, success=16, skipped=6, failure=5, pending=1. Blocking: 1 · Non-blocking: 5.

🚫 Blocking (must fix before merge)

  • tests/e2e-cucumber/features/examine.feature:184-194 — examine-16 is untagged, so it runs on every lane, and its comment claims "What every lane can prove is that the inspection answers the question at all rather than staying silent, and that finding none is reported as a finding rather than a failure — which is the case the mock lane actually has." The code does not provide that; the struct defaults do. All four new fields are #[serde(default)] and Examination::default() already yields comgr_paths: [] and comgr_selected: null, which is exactly what both steps assert when no copy is found. Measured, not read: with both probe_comgr(&mut e) calls deleted, the assertion sequence of assert_comgr_copies_reported and assert_comgr_selection_is_stated (tests/e2e-cucumber/tests/e2e/examine_steps.rs:773, :800) still passes against the serialized examination. That is the state of every Windows lane, where the probe is never called at all, and of the mock lane the comment names. So on precisely the lanes where examine-16 is the only coverage of this change, it passes with the change reverted, while its prose says it proves the opposite — and examine-17 cannot compensate because it is @requires-gpu. Fix: tag the scenario @requires-os:linux (the probe is Linux and WSL2 only, so the Windows pass is vacuous by construction), and in the empty branch of assert_comgr_selection_is_stated assert that notes contains the "no libamd_comgr found ..." entry that crates/rocm-core/src/examine.rs:1927-1930 pushes, in addition to comgr_selected being null. That note is produced only by probe_comgr running and finding nothing, so it distinguishes "probed, found none" from "never probed" — which is the distinction the scenario's own sentence claims to make, and the same distinction the PR already builds into comgr_matches_runtime. Assert on a substring of the note rather than the whole string so the wording stays free to change; do not assert it in the non-empty branch, where a single copy pushes no note at all.

Non-blocking

  • crates/rocm-core/src/examine.rs:246,249 — the "emulates the loader, is not the loader" caveat lives only on the private function; the two public field docs state "in loader search order", "the one that would load" and "the copy the loader would pick" as fact, and the rocm-install and managed-runtime tiers are not loader-searched at all absent RPATH/RUNPATH, so a consumer reading only the wire contract gets a stronger promise than the code makes.
  • crates/rocm-core/src/examine.rs:1876 — the commit body calls the new sort out as "a change, not a no-op", but no test pins it: removing matches.sort() leaves all eight new unit tests green, because every fixture directory holds at most one matching file.
  • crates/rocm-core/src/examine.rs:1996 — the loader-cache tier has no test at any level, so the /sbin gating fix the commit body describes is entirely unverified; note also that the base branch has no which("ldconfig") gate and this function is new, so that paragraph reads as fixing shipped code when it describes an earlier revision of this branch.
  • tests/e2e-cucumber/tests/e2e/examine_steps.rs:400 — the new When step runs examine --json and stores stdout in cli_output, which is exactly what user_inspects_both_ways at :389 already does; two step names for one action is the second description this change elsewhere takes care to avoid.
  • tests/e2e-cucumber/tests/e2e/examine_steps.rs:818-828 — asserting comgr_selected.path == comgr_paths[0].path restates the single line that assigns it, so it can only fail if the two fields are decoupled later; worth keeping as a contract check, but it is not evidence that the search order itself is right.

@siloteemu siloteemu left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Automated review · pr-review-watcher · a8749a3

Change request filed by the automated review round at this commit. The full round, including non-blocking notes, is in the comment posted alongside it.

🚫 Blocking (must fix before merge)

  • tests/e2e-cucumber/features/examine.feature:184-194 — examine-16 is untagged, so it runs on every lane, and its comment claims "What every lane can prove is that the inspection answers the question at all rather than staying silent, and that finding none is reported as a finding rather than a failure — which is the case the mock lane actually has." The code does not provide that; the struct defaults do. All four new fields are #[serde(default)] and Examination::default() already yields comgr_paths: [] and comgr_selected: null, which is exactly what both steps assert when no copy is found. Measured, not read: with both probe_comgr(&mut e) calls deleted, the assertion sequence of assert_comgr_copies_reported and assert_comgr_selection_is_stated (tests/e2e-cucumber/tests/e2e/examine_steps.rs:773, :800) still passes against the serialized examination. That is the state of every Windows lane, where the probe is never called at all, and of the mock lane the comment names. So on precisely the lanes where examine-16 is the only coverage of this change, it passes with the change reverted, while its prose says it proves the opposite — and examine-17 cannot compensate because it is @requires-gpu. Fix: tag the scenario @requires-os:linux (the probe is Linux and WSL2 only, so the Windows pass is vacuous by construction), and in the empty branch of assert_comgr_selection_is_stated assert that notes contains the "no libamd_comgr found ..." entry that crates/rocm-core/src/examine.rs:1927-1930 pushes, in addition to comgr_selected being null. That note is produced only by probe_comgr running and finding nothing, so it distinguishes "probed, found none" from "never probed" — which is the distinction the scenario's own sentence claims to make, and the same distinction the PR already builds into comgr_matches_runtime. Assert on a substring of the note rather than the whole string so the wording stays free to change; do not assert it in the non-empty branch, where a single copy pushes no note at all.

volen-silo added a commit that referenced this pull request Oct 1, 2026
…s own

Builds on the copy search: with every copy of libamd_comgr known, the
catalog can say when the one that loads belongs to a different
installation than the HIP runtime that loads, and device code
compilation therefore fails with an error naming neither.

The rule is symmetric, and that is what makes it safe. The design note
for this entry proposed a special case -- "when the active runtime is
the managed runtime, the matching copy is the wheel copy" -- without
which it "fires on every healthy CLI installation". That is a patch over
a rule stated asymmetrically. Asking one question of both libraries
instead, does the libamd_comgr that would load come from the same
installation as the libamdhip64 that would load, makes every case fall
out of the rule: a healthy managed install takes both from the managed
runtime, so nothing differs and nothing is reported. A control that has
to be written as an exception is a rule that has not been stated
correctly yet.

That turns on attributing a copy to its installation correctly. A
managed runtime spreads its libraries across separate _rocm_sdk_*
packages, so ownership is decided by matching against known installation
roots, longest first -- never by walking up from the file, which would
call each package its own install and fire on the most common install we
ship. That is the property the mutation check targets.

Two further guards on firing: an unattributed copy is a gap in what the
search knows rather than a finding about the machine, and the runtime
must actually ship a copy of its own, or there is nothing to point the
user at.

The entry is print-only and ranks neither remedy. Removing a stack and
reordering the search path can each break a working Python environment,
and which is right depends on which stack the user means to keep.

The finding says out loud that it describes the environment outside the
managed runtimes. `rocm serve` puts a managed runtime's libraries first
on purpose and gets a different, correct answer, and a report that did
not name the environment it examined would read as a claim about one it
never looked at.

The rule above is now stated exactly once, as `comgr_matches_runtime` in
examine.rs, and both this entry and `rocm examine --json`'s field of the
same name call it rather than each restating the conditions -- so the
two surfaces cannot disagree about one machine, and a regression test
pins that. Fix option (b)'s advice matched an earlier, asymmetric
statement of the rule; it now says to prefer the wheel's own copy of
both libraries, which is what the symmetric rule actually asks the user
to do. The machine-readable inspection now carries the same
`install_root` attribution for the HIP runtime side that it already
carried for the code object manager side, since the conflict question is
about both libraries and a reader could not previously check either the
attribution or the selection for one of them.

Known roots are deduplicated across the whole list, not only where
repeats happen to land next to each other. `dedup_by` drops consecutive
duplicates, and these roots arrive from three independent sources -- the
active install, a sorted /opt scan, and the managed runtimes -- so the
active install colliding with a sibling was caught or missed depending on
where the path sorted. A root recorded twice can make a machine holding
one stack read as a machine holding two, which is the false conflict this
entry exists not to report.

hip_paths and hip_selected are `serde(default)` for the same reason the
comgr fields are: an examination read back over the remote path may have
been produced by an older CLI that never wrote them.

Fix CI: the managed-runtime comgr/HIP search resolved the data directory
through `crate::runtime::default_data_dir` (`$HOME/.rocm` or the OS
default) instead of `AppPaths::discover`, so it disagreed with the rest
of the CLI, and found nothing at all, on any host where
`ROCM_CLI_DATA_DIR` relocates the data directory -- which is exactly what
every GPU e2e scenario does for isolation. That is why the search saw no
managed runtime whatsoever and the GPU e2e lane
(`examine-finds-the-managed-runtimes-own-compilation-library`) failed
identically on every GPU family. Separately, once a runtime is found, its
root resolves to its `_rocm_sdk_devel` package directory, and the search
then guessed a venv layout (`root/lib/<python>/site-packages`) to find
its sibling packages; that guess does not hold for a real install either,
so the search now reads back the library directories the SDK probe
already recorded (`library_paths`) instead of re-deriving them. Also
fixes a needless-`collect` clippy lint in the e2e step, and tags
diagnose-21 `@requires-os:linux`: fix-18-comgr-conflict is registered for
linux/wsl only, so applying it for real on a native Windows lane hit the
fix's own platform gate before ever reaching the advisory behavior under
test.

That data-dir fix cleared the scenario everywhere except the Strix Halo
Windows lane, where it still failed CI after the rest of this commit
landed: `comgr_paths`/`hip_paths` came back empty even with the managed
runtime found and the right data dir in hand, because the whole search --
`LD_LIBRARY_PATH`, the loader cache, and the `lib/` directory scan -- is
built around ELF shared-object names (`libamd_comgr`, `libamdhip64`).
Native Windows ships the equivalent libraries under different names and
has neither `LD_LIBRARY_PATH` nor a loader cache, so none of it applies
there; WSL is unaffected, since it reports `os_family` "linux" and the
runtime underneath really does carry a `.so`. This is the same boundary
`fix-18-comgr-conflict` is already gated on (`&["linux", "wsl"]`,
addressed above for diagnose-21), just missed for this scenario when it
was introduced in #384 -- so examine-17 now carries the same
`@requires-os:linux` tag, pre-existing gap, not a regression from this
commit.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
… first

HIP compiles device code at run time through libamd_comgr, and a machine
can hold more than one copy. The loader picks one. When the copy it picks
does not belong to the active HIP runtime, compilation fails with an
error naming neither the library nor the second copy.

The search stopped at the first match and looked for one library only, so
the second copy could not be seen at all. It is now one walker serving
both searches, recording every copy found, plus the same for the HIP
runtime so the two can be compared. Attributing each copy to the
installation that owns it is deferred; comgr_matches_runtime stays
permanently unset until that lands.

The managed search now reads the runtime registry rather than listing
<data>/runtimes on disk. A directory listing yields a root and nothing
else, and the root alone does not locate a wheel runtime's libraries --
those live in a sibling _rocm_sdk_* package inside the interpreter's
site-packages, which is why the loader path the CLI sets for its own
processes walks that directory. Searching the root alone therefore missed
the copy this CLI installs itself, which is the case the entry exists for.

That knowledge now lives in one place. collect_managed_runtime_library_paths
is shared by the comgr search and the loader path, so a managed runtime's
layout is described once rather than twice. A second description is a
second thing to keep correct, and the two had already diverged.

Listing the directory was also a second answer to "which runtimes exist",
where the registry is the first one, and only the registry records which
site-packages an interpreter uses.

The record is preferred but not required. A read-only probe cannot depend
on one being present and current, and a host whose registry is missing or
stale is exactly the kind this command is called on: reporting no copies
there would read as a machine with nothing wrong rather than one we
failed to inspect. With no record the predictable venv layout is read
directly, and a miss costs a directory that is simply not reported.

No dlopen. Reading the version through amd_comgr_get_version would run an
unknown library's initialisers on a machine called on precisely because
something is already wrong, and load a possibly conflicting HIP stack
permanently into the process. The version is read from the versioned
soname instead, and a name carrying none yields an empty version rather
than a confident wrong one.

Directory matches are now sorted, which makes the HIP probe's tie-break
deterministic where one directory holds several matching files. It used
to take whatever read_dir yielded first. Better, but a change, not a
no-op.

The loader-cache tier used to gate on which("ldconfig"), which only walks
$PATH -- and ldconfig lives in /sbin, off a non-root user's PATH on Debian
and derivatives. That silently dropped the one tier that finds a copy
registered only in ld.so.cache; it now reuses the fallback search
ldconfig_cache() already does for the same reason.

Covered at two levels, because neither is sufficient alone. Unit tests
build a wheel-format layout directly, which proves the search understands
a layout we described; only a real managed runtime proves it matches the
one the installer produces, so examine-18 asserts that on a GPU lane.

The four new fields are `serde(default)`. This structure is read back
from another machine: `rocm remote doctor` deserializes an examination
the remote's own CLI produced, and that CLI may predate these fields.
Without a default, adding one here refuses every remote running an older
build, reported as "the remote CLI is probably a different version" --
true, and useless, since the older CLI is the one that cannot be changed.

Fix CI: managed_runtime_roots resolved the data directory through
crate::runtime::default_data_dir (`$HOME/.rocm` or the OS default)
instead of AppPaths::discover, so it disagreed with the rest of the CLI
and found nothing at all on any host where ROCM_CLI_DATA_DIR relocates
the data directory -- which is exactly what every GPU e2e lane sets for
isolation. That is why the new GPU scenario
(examine-finds-the-managed-runtimes-own-compilation-library) failed
identically on every GPU family; it now resolves through AppPaths::discover
like everything else in the CLI. Also fixes a needless-collect clippy
lint in the e2e step, and tags that scenario @requires-os:linux: the
search looks only for libamd_comgr by its ELF soname, which native
Windows does not ship.

Rebasing onto main picked up an unrelated examine-16 (EAI-8950, #444), so
this commit's two new scenarios are renumbered to examine-17 and
examine-18 to keep every scenario number in the file unique.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>

Review: examine-17 (the host-independent code-object-manager scenario) was
untagged and its empty-result assertion matched `Examination::default()`
alone, so it would pass with `probe_comgr` deleted outright -- on native
Windows, where the probe never runs, that is not a hypothetical. Tagged
@requires-os:linux and the assertion now also requires the "no libamd_comgr
found" note that only a probe which actually ran and came up empty pushes.
Verified the new assertion fails by temporarily suppressing that note and
watching the scenario go red, then restored it.
@volen-silo
volen-silo force-pushed the feat/report-comgr-copies branch from a8749a3 to 51a20ff Compare October 1, 2026 13:17
volen-silo added a commit that referenced this pull request Oct 1, 2026
…s own

Builds on the copy search: with every copy of libamd_comgr known, the
catalog can say when the one that loads belongs to a different
installation than the HIP runtime that loads, and device code
compilation therefore fails with an error naming neither.

The rule is symmetric, and that is what makes it safe. The design note
for this entry proposed a special case -- "when the active runtime is
the managed runtime, the matching copy is the wheel copy" -- without
which it "fires on every healthy CLI installation". That is a patch over
a rule stated asymmetrically. Asking one question of both libraries
instead, does the libamd_comgr that would load come from the same
installation as the libamdhip64 that would load, makes every case fall
out of the rule: a healthy managed install takes both from the managed
runtime, so nothing differs and nothing is reported. A control that has
to be written as an exception is a rule that has not been stated
correctly yet.

That turns on attributing a copy to its installation correctly. A
managed runtime spreads its libraries across separate _rocm_sdk_*
packages, so ownership is decided by matching against known installation
roots, longest first -- never by walking up from the file, which would
call each package its own install and fire on the most common install we
ship. That is the property the mutation check targets.

Two further guards on firing: an unattributed copy is a gap in what the
search knows rather than a finding about the machine, and the runtime
must actually ship a copy of its own, or there is nothing to point the
user at.

The entry is print-only and ranks neither remedy. Removing a stack and
reordering the search path can each break a working Python environment,
and which is right depends on which stack the user means to keep.

The finding says out loud that it describes the environment outside the
managed runtimes. `rocm serve` puts a managed runtime's libraries first
on purpose and gets a different, correct answer, and a report that did
not name the environment it examined would read as a claim about one it
never looked at.

The rule above is now stated exactly once, as `comgr_matches_runtime` in
examine.rs, and both this entry and `rocm examine --json`'s field of the
same name call it rather than each restating the conditions -- so the
two surfaces cannot disagree about one machine, and a regression test
pins that. Fix option (b)'s advice matched an earlier, asymmetric
statement of the rule; it now says to prefer the wheel's own copy of
both libraries, which is what the symmetric rule actually asks the user
to do. The machine-readable inspection now carries the same
`install_root` attribution for the HIP runtime side that it already
carried for the code object manager side, since the conflict question is
about both libraries and a reader could not previously check either the
attribution or the selection for one of them.

Known roots are deduplicated across the whole list, not only where
repeats happen to land next to each other. `dedup_by` drops consecutive
duplicates, and these roots arrive from three independent sources -- the
active install, a sorted /opt scan, and the managed runtimes -- so the
active install colliding with a sibling was caught or missed depending on
where the path sorted. A root recorded twice can make a machine holding
one stack read as a machine holding two, which is the false conflict this
entry exists not to report.

hip_paths and hip_selected are `serde(default)` for the same reason the
comgr fields are: an examination read back over the remote path may have
been produced by an older CLI that never wrote them.

Once a managed runtime is found, its root resolves to its
`_rocm_sdk_devel` package directory, and the search used to guess a venv
layout (`root/lib/<python>/site-packages`) to find its sibling packages;
that guess does not hold for a real install, so the search now reads
back the library directories the SDK probe already recorded
(`library_paths`) instead of re-deriving them. This generalises the
walker introduced in #384 (`find_library_copies`/`install_library_dirs`)
so it serves the HIP side too, which is why `managed_runtime_roots` now
returns `library_paths` plural rather than a single `site_packages`
guess.

The AppPaths::discover data-dir fix and the @requires-os:linux platform
tags this commit originally carried for examine-17/18 belong with the
scenario they correct, which #384 introduced -- moved there so the
parent doesn't depend on the child to pass its own CI, and this commit
now only rebases on top of that fix rather than restating it.

Sweeping the same gap once it was visible: examine-17 (the
host-independent code-object-manager scenario, renumbered by #384's
rebase) asserts `hip_paths`/`hip_selected` the same way it asserts
`comgr_paths`/`comgr_selected`, and the empty branch had the identical
problem #384's review flagged for the comgr side -- `hip_paths: []` /
`hip_selected: null` also hold by nothing more than `Examination`'s own
defaults, so the assertion could not tell "probed, found none" from
"never probed". `probe_comgr` now pushes a "no libamdhip64 found" note
the same way it already did for libamd_comgr, and the HIP assertion now
requires it in the empty branch. Verified by temporarily suppressing the
note and watching the scenario go red, then restoring it.

Signed-off-by: Eugene Volen <Eugene.Volen@amd.com>
@volen-silo
volen-silo dismissed siloteemu’s stale review October 1, 2026 13:41

Addressed: examine-17 (renumbered) is now tagged @requires-os:linux and its empty-result assertion requires the "no libamd_comgr found" note, so it can no longer pass with probe_comgr reverted or never called. Verified by mutation-testing the note (suppressed it, watched the scenario fail, restored it).

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.

3 participants