Skip to content

Commit a387e77

Browse files
test: cover the Vulkan probe end to end (#18)
1 parent e891462 commit a387e77

4 files changed

Lines changed: 351 additions & 17 deletions

File tree

‎src/lib.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -724,6 +724,41 @@ mod tests {
724724
assert_eq!(rocm_host(), rocm_host());
725725
}
726726

727+
#[test]
728+
fn vulkan_version_renders_with_patch() {
729+
assert_eq!(VulkanVersion::new(1, 3, 280).to_string(), "1.3.280");
730+
assert_eq!(VulkanVersion::new(1, 0, 0).to_string(), "1.0.0");
731+
// A three-digit patch is the norm for Vulkan headers, and must not be
732+
// packed or truncated the way `1.3` alone would be.
733+
assert_eq!(VulkanVersion::new(1, 10, 5).to_string(), "1.10.5");
734+
}
735+
736+
#[test]
737+
fn vulkan_host_is_stable_across_calls() {
738+
// Environment-dependent: a host with no loader is a valid, passing
739+
// environment. A filesystem read must not vary between calls.
740+
assert_eq!(vulkan_host(), vulkan_host());
741+
}
742+
743+
#[test]
744+
fn vulkan_host_carries_only_an_api_version() {
745+
// No `arch_target` counterpart, deliberately: SPIR-V is portable and
746+
// driver-compiled, so there is no per-GPU target to match. This pins
747+
// that shape, and the `Copy`/`Eq` derives callers rely on.
748+
let host = VulkanHost {
749+
api_version: VulkanVersion::new(1, 3, 280),
750+
};
751+
let copied = host;
752+
assert_eq!(copied, host);
753+
assert_eq!(copied.api_version, VulkanVersion::new(1, 3, 280));
754+
assert_ne!(
755+
host,
756+
VulkanHost {
757+
api_version: VulkanVersion::new(1, 2, 0)
758+
}
759+
);
760+
}
761+
727762
#[test]
728763
fn detect_never_panics() {
729764
// Environment-dependent (may be empty on headless CI); exercise the

‎src/vulkan.rs‎

Lines changed: 275 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,43 +46,118 @@ fn parse_icd_api_version(content: &str) -> Option<VulkanVersion> {
4646
})
4747
}
4848

49-
/// Whether the Vulkan loader is installed. Presence of the shared object is
50-
/// the signal; nothing is opened or linked.
49+
/// Whether the Vulkan loader is installed under any of `dirs`. Presence of the
50+
/// shared object is the signal; nothing is opened or linked.
5151
///
52-
/// Unconditional, like the `ROCm` and `oneAPI` probes: the paths this checks
53-
/// are Linux-specific and simply do not exist on other platforms, so
54-
/// `.exists()` is `false` there without needing a `cfg`.
55-
fn loader_present() -> bool {
56-
LIB_DIRS
57-
.iter()
58-
.flat_map(|dir| LOADER_SONAMES.iter().map(move |so| Path::new(dir).join(so)))
52+
/// Only the sonames a program is linked against count: a bare
53+
/// `libvulkan.so.1.3.280` with no `libvulkan.so.1` beside it is a file the
54+
/// loader could not be found by, so it is not an install.
55+
///
56+
/// Takes its directories rather than reading `LIB_DIRS` directly, the way the
57+
/// `ROCm` probe takes an install root, so the search is testable against a
58+
/// fixture instead of whatever the host happens to have.
59+
fn loader_in<P: AsRef<Path>>(dirs: &[P]) -> bool {
60+
dirs.iter()
61+
.flat_map(|dir| LOADER_SONAMES.iter().map(move |so| dir.as_ref().join(so)))
5962
.any(|path| path.exists())
6063
}
6164

62-
/// The highest API version any installed ICD advertises.
65+
/// The highest API version any manifest under `dirs` advertises.
6366
///
6467
/// Highest rather than lowest: a host with both a software rasterizer and a
6568
/// real driver can run what the real driver supports, and the consumer is
6669
/// choosing one build for the machine.
67-
pub(crate) fn host() -> Option<VulkanHost> {
68-
if !loader_present() {
69-
return None;
70-
}
71-
let api_version = ICD_DIRS
72-
.iter()
70+
///
71+
/// Unreadable directories, non-`.json` files, and manifests that do not parse
72+
/// are skipped rather than failing the probe — a stray file in `icd.d` must
73+
/// not hide a working driver's manifest.
74+
fn highest_api_version<P: AsRef<Path>>(dirs: &[P]) -> Option<VulkanVersion> {
75+
dirs.iter()
7376
.filter_map(|dir| std::fs::read_dir(dir).ok())
7477
.flatten()
7578
.flatten()
7679
.filter(|entry| entry.path().extension().is_some_and(|e| e == "json"))
7780
.filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
7881
.filter_map(|content| parse_icd_api_version(&content))
79-
.max()?;
82+
.max()
83+
}
84+
85+
/// The runtime installed under `lib_dirs`/`icd_dirs`, if both halves are there.
86+
///
87+
/// Both are required: the loader alone cannot name a version, and manifests
88+
/// alone describe drivers nothing can dispatch to.
89+
fn host_in<L: AsRef<Path>, I: AsRef<Path>>(lib_dirs: &[L], icd_dirs: &[I]) -> Option<VulkanHost> {
90+
if !loader_in(lib_dirs) {
91+
return None;
92+
}
93+
let api_version = highest_api_version(icd_dirs)?;
8094
Some(VulkanHost { api_version })
8195
}
8296

97+
/// Probe the conventional locations.
98+
///
99+
/// Unconditional, like the `ROCm` and `oneAPI` probes: the paths this checks
100+
/// are Linux-specific and simply do not exist on other platforms, so the
101+
/// lookups come back empty there without needing a `cfg`.
102+
pub(crate) fn host() -> Option<VulkanHost> {
103+
host_in(&LIB_DIRS, &ICD_DIRS)
104+
}
105+
83106
#[cfg(test)]
84107
mod tests {
85108
use super::*;
109+
use std::path::PathBuf;
110+
use std::sync::atomic::{AtomicUsize, Ordering};
111+
112+
/// A real `radeon_icd.x86_64.json`, trimmed to the fields read here.
113+
const RADEON: &str = r#"{"file_format_version":"1.0.0",
114+
"ICD":{"library_path":"/usr/lib/libvulkan_radeon.so",
115+
"api_version":"1.3.280"}}"#;
116+
117+
/// Mesa's software rasterizer, which ships by default on many distros and
118+
/// is an ICD like any other — the case the `README` warns consumers about.
119+
const LAVAPIPE: &str = r#"{"file_format_version":"1.0.1",
120+
"ICD":{"library_path":"/usr/lib/libvulkan_lvp.so",
121+
"api_version":"1.3.255"}}"#;
122+
123+
/// A scratch directory that deletes itself on drop.
124+
///
125+
/// Written here rather than pulled in as a dev-dependency: this probe is a
126+
/// filesystem read, so testing the search means giving it real directories,
127+
/// and that needs nothing beyond `std`.
128+
struct TempTree(PathBuf);
129+
130+
impl TempTree {
131+
/// A fresh empty directory. The pid plus a counter keeps concurrent
132+
/// tests — and concurrent `cargo test` runs — off each other's fixtures.
133+
fn new(label: &str) -> Self {
134+
static COUNTER: AtomicUsize = AtomicUsize::new(0);
135+
let nth = COUNTER.fetch_add(1, Ordering::Relaxed);
136+
let path = std::env::temp_dir().join(format!(
137+
"gpu-probe-vulkan-{}-{label}-{nth}",
138+
std::process::id()
139+
));
140+
let _ = std::fs::remove_dir_all(&path);
141+
std::fs::create_dir_all(&path).expect("scratch directory is creatable");
142+
Self(path)
143+
}
144+
145+
/// Drop a file into the tree; chainable, so a fixture reads as a listing.
146+
fn with(&self, name: &str, content: &str) -> &Self {
147+
std::fs::write(self.0.join(name), content).expect("fixture file is writable");
148+
self
149+
}
150+
151+
fn path(&self) -> &Path {
152+
&self.0
153+
}
154+
}
155+
156+
impl Drop for TempTree {
157+
fn drop(&mut self) {
158+
let _ = std::fs::remove_dir_all(&self.0);
159+
}
160+
}
86161

87162
#[test]
88163
fn parses_an_icd_manifest_api_version() {
@@ -93,13 +168,55 @@ mod tests {
93168
parse_icd_api_version(icd),
94169
Some(VulkanVersion::new(1, 3, 280))
95170
);
171+
assert_eq!(
172+
parse_icd_api_version(LAVAPIPE),
173+
Some(VulkanVersion::new(1, 3, 255))
174+
);
175+
// NVIDIA's manifest carries extra top-level keys; unknown fields are
176+
// ignored rather than rejected.
177+
let nvidia = r#"{"file_format_version":"1.0.1",
178+
"ICD":{"library_path":"libGLX_nvidia.so.0",
179+
"api_version":"1.3.277",
180+
"is_portability_driver":false}}"#;
181+
assert_eq!(
182+
parse_icd_api_version(nvidia),
183+
Some(VulkanVersion::new(1, 3, 277))
184+
);
96185
}
97186

98187
#[test]
99188
fn rejects_an_icd_manifest_without_an_api_version() {
100189
let icd = r#"{"ICD":{"library_path":"libvulkan_radeon.so"}}"#;
101190
assert_eq!(parse_icd_api_version(icd), None);
102191
assert_eq!(parse_icd_api_version("not json"), None);
192+
assert_eq!(parse_icd_api_version(""), None);
193+
assert_eq!(parse_icd_api_version("{}"), None);
194+
// The version must be under `ICD`, and must be a string: a JSON number
195+
// would parse as 1.3 and silently lose the patch.
196+
assert_eq!(parse_icd_api_version(r#"{"api_version":"1.3.280"}"#), None);
197+
assert_eq!(
198+
parse_icd_api_version(r#"{"ICD":{"api_version":1.3}}"#),
199+
None
200+
);
201+
assert_eq!(parse_icd_api_version(r#"{"ICD":"1.3.280"}"#), None);
202+
assert_eq!(
203+
parse_icd_api_version(r#"{"ICD":[{"api_version":"1.3.0"}]}"#),
204+
None
205+
);
206+
}
207+
208+
#[test]
209+
fn rejects_manifests_whose_version_is_not_a_version() {
210+
let with = |version: &str| {
211+
parse_icd_api_version(&format!(r#"{{"ICD":{{"api_version":"{version}"}}}}"#))
212+
};
213+
// A two-part version is a legal shape for the shared parser; the spec
214+
// writes all three, but a truncated one still names an API level.
215+
assert_eq!(with("1.2"), Some(VulkanVersion::new(1, 2, 0)));
216+
assert_eq!(with("1"), None, "a bare major is not a version");
217+
assert_eq!(with(""), None);
218+
assert_eq!(with("one.three.zero"), None);
219+
assert_eq!(with("1.3.x"), None);
103220
}
104221

105222
#[test]
@@ -108,6 +225,147 @@ mod tests {
108225
assert!(VulkanVersion::new(2, 0, 0) > VulkanVersion::new(1, 9, 9));
109226
}
110227

228+
#[test]
229+
fn loader_is_found_under_either_soname() {
230+
let versioned = TempTree::new("loader-soname-1");
231+
versioned.with("libvulkan.so.1", "");
232+
assert!(loader_in(&[versioned.path()]));
233+
234+
// The development symlink alone is enough; the set is unordered.
235+
let unversioned = TempTree::new("loader-soname-dev");
236+
unversioned.with("libvulkan.so", "");
237+
assert!(loader_in(&[unversioned.path()]));
238+
239+
// Any one directory in the list satisfies the search.
240+
let empty = TempTree::new("loader-empty-first");
241+
assert!(loader_in(&[empty.path(), versioned.path()]));
242+
}
243+
244+
#[test]
245+
fn a_host_without_the_loader_reports_no_install() {
246+
let empty = TempTree::new("loader-absent");
247+
assert!(!loader_in(&[empty.path()]));
248+
assert!(!loader_in(&[Path::new("/nonexistent-vulkan-libdir")]));
249+
assert!(!loader_in::<&Path>(&[]));
250+
251+
// The real file with no soname symlink beside it: nothing can dlopen
252+
// `libvulkan.so.1` here, so this is not an installed loader.
253+
let unlinked = TempTree::new("loader-unlinked");
254+
unlinked.with("libvulkan.so.1.3.280", "");
255+
assert!(!loader_in(&[unlinked.path()]));
256+
}
257+
258+
#[cfg(unix)]
259+
#[test]
260+
fn a_dangling_loader_symlink_is_not_an_install() {
261+
// A package removed without its symlink leaves the name behind. The
262+
// check follows the link, so the broken one does not count.
263+
let tree = TempTree::new("loader-dangling");
264+
std::os::unix::fs::symlink("libvulkan.so.1.3.280", tree.path().join("libvulkan.so.1"))
265+
.expect("symlink is creatable");
266+
assert!(!loader_in(&[tree.path()]));
267+
268+
tree.with("libvulkan.so.1.3.280", "");
269+
assert!(loader_in(&[tree.path()]), "the same link now resolves");
270+
}
271+
272+
#[test]
273+
fn highest_api_version_wins_across_manifests() {
274+
// A machine with the software rasterizer installed alongside a real
275+
// driver: the real driver's level is the one a build can target.
276+
let icd = TempTree::new("icd-highest");
277+
icd.with("lvp_icd.x86_64.json", LAVAPIPE)
278+
.with("radeon_icd.x86_64.json", RADEON);
279+
assert_eq!(
280+
highest_api_version(&[icd.path()]),
281+
Some(VulkanVersion::new(1, 3, 280))
282+
);
283+
284+
// The two search directories are a union, not a preference order, so
285+
// the highest wins whichever side it sits on.
286+
let local = TempTree::new("icd-local");
287+
local.with("lvp_icd.x86_64.json", LAVAPIPE);
288+
let shared = TempTree::new("icd-shared");
289+
shared.with("radeon_icd.x86_64.json", RADEON);
290+
assert_eq!(
291+
highest_api_version(&[local.path(), shared.path()]),
292+
Some(VulkanVersion::new(1, 3, 280))
293+
);
294+
assert_eq!(
295+
highest_api_version(&[shared.path(), local.path()]),
296+
Some(VulkanVersion::new(1, 3, 280)),
297+
"directory order must not change the answer",
298+
);
299+
}
300+
301+
#[test]
302+
fn only_json_manifests_are_read() {
303+
// `icd.d` collects editor backups and disabled drivers; the loader
304+
// reads `.json` and so does this.
305+
let icd = TempTree::new("icd-extensions");
306+
icd.with("lvp_icd.x86_64.json", LAVAPIPE)
307+
.with("radeon_icd.x86_64.json.disabled", RADEON)
308+
.with("radeon_icd.x86_64.json.bak", RADEON)
309+
.with("notes.txt", RADEON)
310+
.with("README", RADEON);
311+
assert_eq!(
312+
highest_api_version(&[icd.path()]),
313+
Some(VulkanVersion::new(1, 3, 255)),
314+
"only the .json manifest counts, so lavapipe's version stands",
315+
);
316+
}
317+
318+
#[test]
319+
fn a_malformed_manifest_does_not_hide_a_good_one() {
320+
let icd = TempTree::new("icd-malformed");
321+
icd.with("broken_icd.json", "{ not json")
322+
.with("empty_icd.json", "")
323+
.with("versionless_icd.json", r#"{"ICD":{"library_path":"x.so"}}"#)
324+
.with("radeon_icd.x86_64.json", RADEON);
325+
assert_eq!(
326+
highest_api_version(&[icd.path()]),
327+
Some(VulkanVersion::new(1, 3, 280))
328+
);
329+
}
330+
331+
#[test]
332+
fn missing_or_empty_icd_directories_report_nothing() {
333+
let empty = TempTree::new("icd-empty");
334+
assert_eq!(highest_api_version(&[empty.path()]), None);
335+
assert_eq!(
336+
highest_api_version(&[Path::new("/nonexistent-vulkan-icd-dir")]),
337+
None,
338+
"an absent directory is skipped, not an error",
339+
);
340+
assert_eq!(highest_api_version::<&Path>(&[]), None);
341+
}
342+
343+
#[test]
344+
fn both_halves_are_required() {
345+
let lib = TempTree::new("host-lib");
346+
lib.with("libvulkan.so.1", "");
347+
let icd = TempTree::new("host-icd");
348+
icd.with("radeon_icd.x86_64.json", RADEON);
349+
let empty = TempTree::new("host-empty");
350+
351+
assert_eq!(
352+
host_in(&[lib.path()], &[icd.path()]),
353+
Some(VulkanHost {
354+
api_version: VulkanVersion::new(1, 3, 280)
355+
})
356+
);
357+
assert_eq!(
358+
host_in(&[empty.path()], &[icd.path()]),
359+
None,
360+
"manifests describe drivers nothing can dispatch to without a loader",
361+
);
362+
assert_eq!(
363+
host_in(&[lib.path()], &[empty.path()]),
364+
None,
365+
"a loader with no ICD advertises no version to report",
366+
);
367+
}
368+
111369
#[test]
112370
fn host_lookup_never_panics() {
113371
// Environment-dependent: asserts invariants, not the presence of a driver.

‎tests/fd_leak.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,10 @@ fn probing_does_not_leak_file_descriptors() {
5454
assert_flat("cuda_host()", || {
5555
let _ = gpu_probe::cuda_host();
5656
});
57+
// `vulkan_host()` opens a directory and a file per manifest on every call,
58+
// which is the same shape of mistake in a different probe: a caller
59+
// polling it on a timer must not accumulate descriptors either.
60+
assert_flat("vulkan_host()", || {
61+
let _ = gpu_probe::vulkan_host();
62+
});
5763
}

0 commit comments

Comments
 (0)