feat: add Vulkan loader probe to smoke runner

This commit is contained in:
2026-06-23 23:33:42 +04:00
parent 99bcbf388f
commit f15ea95bf2
4 changed files with 167 additions and 10 deletions
+1
View File
@@ -77,6 +77,7 @@ jobs:
--platform "${{ matrix.smoke_platform }}" --platform "${{ matrix.smoke_platform }}"
--out "target/fparkan/native-smoke/${{ runner.os }}.json" --out "target/fparkan/native-smoke/${{ runner.os }}.json"
--status blocked --status blocked
--probe-loader
--reason "native Vulkan smoke runner is not enabled on this CI lane yet" --reason "native Vulkan smoke runner is not enabled on this CI lane yet"
- name: Upload acceptance evidence - name: Upload acceptance evidence
if: always() if: always()
+163 -9
View File
@@ -11,7 +11,9 @@
#![allow(clippy::print_stderr, clippy::print_stdout)] #![allow(clippy::print_stderr, clippy::print_stdout)]
//! Native Vulkan smoke runner entrypoint. //! Native Vulkan smoke runner entrypoint.
use fparkan_render_vulkan::{triangle_shader_manifest, validate_shader_manifest}; use fparkan_render_vulkan::{
probe_vulkan_loader, triangle_shader_manifest, validate_shader_manifest,
};
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
@@ -35,8 +37,9 @@ fn main() {
fn run(args: &[String]) -> Result<String, String> { fn run(args: &[String]) -> Result<String, String> {
let options = SmokeOptions::parse(args)?; let options = SmokeOptions::parse(args)?;
validate_smoke_options(&options)?; let bootstrap = VulkanBootstrapProbe::run(&options);
let report = render_smoke_report_json(&options)?; validate_smoke_options(&options, &bootstrap)?;
let report = render_smoke_report_json(&options, &bootstrap)?;
if let Some(parent) = options.out.parent() { if let Some(parent) = options.out.parent() {
std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?; std::fs::create_dir_all(parent).map_err(|err| format!("{}: {err}", parent.display()))?;
} }
@@ -53,6 +56,7 @@ struct SmokeOptions {
frames: u32, frames: u32,
resize_count: u32, resize_count: u32,
validation_error_count: Option<u32>, validation_error_count: Option<u32>,
probe_loader: bool,
reason: Option<String>, reason: Option<String>,
} }
@@ -64,6 +68,7 @@ impl SmokeOptions {
let mut frames = 0; let mut frames = 0;
let mut resize_count = 0; let mut resize_count = 0;
let mut validation_error_count = None; let mut validation_error_count = None;
let mut probe_loader = false;
let mut reason = None; let mut reason = None;
let mut iter = args.iter(); let mut iter = args.iter();
while let Some(arg) = iter.next() { while let Some(arg) = iter.next() {
@@ -104,6 +109,9 @@ impl SmokeOptions {
.ok_or_else(|| "--validation-error-count requires a value".to_string())?; .ok_or_else(|| "--validation-error-count requires a value".to_string())?;
validation_error_count = Some(parse_u32("--validation-error-count", value)?); validation_error_count = Some(parse_u32("--validation-error-count", value)?);
} }
"--probe-loader" => {
probe_loader = true;
}
"--reason" => { "--reason" => {
let value = iter let value = iter
.next() .next()
@@ -120,6 +128,7 @@ impl SmokeOptions {
frames, frames,
resize_count, resize_count,
validation_error_count, validation_error_count,
probe_loader,
reason, reason,
}) })
} }
@@ -131,6 +140,55 @@ fn parse_u32(name: &str, value: &str) -> Result<u32, String> {
.map_err(|_| format!("invalid {name} value: {value}")) .map_err(|_| format!("invalid {name} value: {value}"))
} }
#[derive(Clone, Debug, Eq, PartialEq)]
struct VulkanBootstrapProbe {
loader_status: VulkanLoaderStatus,
instance_api: Option<String>,
error: Option<String>,
}
impl VulkanBootstrapProbe {
fn run(options: &SmokeOptions) -> Self {
if !options.probe_loader {
return Self {
loader_status: VulkanLoaderStatus::Skipped,
instance_api: None,
error: None,
};
}
match probe_vulkan_loader() {
Ok(report) => Self {
loader_status: VulkanLoaderStatus::Available,
instance_api: Some(format_api_version(report.instance_api_version)),
error: None,
},
Err(err) => Self {
loader_status: VulkanLoaderStatus::Unavailable,
instance_api: None,
error: Some(err.to_string()),
},
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum VulkanLoaderStatus {
Skipped,
Available,
Unavailable,
}
impl VulkanLoaderStatus {
const fn as_str(self) -> &'static str {
match self {
Self::Skipped => "skipped",
Self::Available => "available",
Self::Unavailable => "unavailable",
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)] #[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum SmokePlatform { enum SmokePlatform {
Windows, Windows,
@@ -180,7 +238,10 @@ impl SmokeStatus {
} }
} }
fn validate_smoke_options(options: &SmokeOptions) -> Result<(), String> { fn validate_smoke_options(
options: &SmokeOptions,
bootstrap: &VulkanBootstrapProbe,
) -> Result<(), String> {
match options.status { match options.status {
SmokeStatus::Blocked => { SmokeStatus::Blocked => {
if options if options
@@ -205,12 +266,20 @@ fn validate_smoke_options(options: &SmokeOptions) -> Result<(), String> {
"passed native smoke report requires --validation-error-count 0".to_string(), "passed native smoke report requires --validation-error-count 0".to_string(),
); );
} }
if bootstrap.loader_status != VulkanLoaderStatus::Available {
return Err(
"passed native smoke report requires successful --probe-loader".to_string(),
);
}
} }
} }
Ok(()) Ok(())
} }
fn render_smoke_report_json(options: &SmokeOptions) -> Result<String, String> { fn render_smoke_report_json(
options: &SmokeOptions,
bootstrap: &VulkanBootstrapProbe,
) -> Result<String, String> {
let shader_manifest = validate_shader_manifest(&triangle_shader_manifest()) let shader_manifest = validate_shader_manifest(&triangle_shader_manifest())
.map_err(|err| format!("shader manifest: {err}"))?; .map_err(|err| format!("shader manifest: {err}"))?;
let validation_error_count = options let validation_error_count = options
@@ -220,6 +289,14 @@ fn render_smoke_report_json(options: &SmokeOptions) -> Result<String, String> {
.reason .reason
.as_ref() .as_ref()
.map_or_else(|| "null".to_string(), |value| json_string(value)); .map_or_else(|| "null".to_string(), |value| json_string(value));
let instance_api = bootstrap
.instance_api
.as_ref()
.map_or_else(|| "null".to_string(), |value| json_string(value));
let bootstrap_error = bootstrap
.error
.as_ref()
.map_or_else(|| "null".to_string(), |value| json_string(value));
Ok(format!( Ok(format!(
concat!( concat!(
"{{\n", "{{\n",
@@ -232,6 +309,9 @@ fn render_smoke_report_json(options: &SmokeOptions) -> Result<String, String> {
" \"resize_count\": {},\n", " \"resize_count\": {},\n",
" \"validation_error_count\": {},\n", " \"validation_error_count\": {},\n",
" \"shader_manifest_hash\": \"{}\",\n", " \"shader_manifest_hash\": \"{}\",\n",
" \"vulkan_loader_status\": \"{}\",\n",
" \"vulkan_instance_api\": {},\n",
" \"vulkan_bootstrap_error\": {},\n",
" \"reason\": {}\n", " \"reason\": {}\n",
"}}\n" "}}\n"
), ),
@@ -244,10 +324,20 @@ fn render_smoke_report_json(options: &SmokeOptions) -> Result<String, String> {
options.resize_count, options.resize_count,
validation_error_count, validation_error_count,
json_escape(&shader_manifest.manifest_hash), json_escape(&shader_manifest.manifest_hash),
bootstrap.loader_status.as_str(),
instance_api,
bootstrap_error,
reason reason
)) ))
} }
fn format_api_version(version: u32) -> String {
let major = version >> 22;
let minor = (version >> 12) & 0x03ff;
let patch = version & 0x0fff;
format!("{major}.{minor}.{patch}")
}
fn current_git_commit_sha() -> String { fn current_git_commit_sha() -> String {
Command::new("git") Command::new("git")
.args(["rev-parse", "HEAD"]) .args(["rev-parse", "HEAD"])
@@ -300,14 +390,23 @@ mod tests {
"target/native.json", "target/native.json",
"--status", "--status",
"blocked", "blocked",
"--probe-loader",
"--reason", "--reason",
"runner unavailable", "runner unavailable",
]))?; ]))?;
assert_eq!(options.platform, SmokePlatform::Linux); assert_eq!(options.platform, SmokePlatform::Linux);
assert_eq!(options.status, SmokeStatus::Blocked); assert_eq!(options.status, SmokeStatus::Blocked);
assert!(options.probe_loader);
assert_eq!(options.reason.as_deref(), Some("runner unavailable")); assert_eq!(options.reason.as_deref(), Some("runner unavailable"));
validate_smoke_options(&options) validate_smoke_options(
&options,
&VulkanBootstrapProbe {
loader_status: VulkanLoaderStatus::Unavailable,
instance_api: None,
error: Some("Vulkan loader is unavailable".to_string()),
},
)
} }
#[test] #[test]
@@ -329,13 +428,51 @@ mod tests {
.expect("options"); .expect("options");
assert_eq!( assert_eq!(
validate_smoke_options(&options), validate_smoke_options(
&options,
&VulkanBootstrapProbe {
loader_status: VulkanLoaderStatus::Available,
instance_api: Some("1.3.0".to_string()),
error: None,
},
),
Err("passed native smoke report requires --frames >= 300".to_string()) Err("passed native smoke report requires --frames >= 300".to_string())
); );
} }
#[test] #[test]
fn blocked_report_includes_shader_manifest_hash() -> Result<(), String> { fn rejects_passed_without_loader_probe() {
let options = SmokeOptions::parse(&strings(&[
"--platform",
"linux",
"--out",
"target/native.json",
"--status",
"passed",
"--frames",
"300",
"--resize-count",
"1",
"--validation-error-count",
"0",
]))
.expect("options");
assert_eq!(
validate_smoke_options(
&options,
&VulkanBootstrapProbe {
loader_status: VulkanLoaderStatus::Skipped,
instance_api: None,
error: None,
},
),
Err("passed native smoke report requires successful --probe-loader".to_string())
);
}
#[test]
fn blocked_report_includes_shader_manifest_and_bootstrap_status() -> Result<(), String> {
let options = SmokeOptions::parse(&strings(&[ let options = SmokeOptions::parse(&strings(&[
"--platform", "--platform",
"macos", "macos",
@@ -347,13 +484,30 @@ mod tests {
"runner unavailable", "runner unavailable",
]))?; ]))?;
let json = render_smoke_report_json(&options)?; let json = render_smoke_report_json(
&options,
&VulkanBootstrapProbe {
loader_status: VulkanLoaderStatus::Unavailable,
instance_api: None,
error: Some("Vulkan loader is unavailable: dlopen failed".to_string()),
},
)?;
assert!(json.contains("\"schema_version\": \"fparkan-native-smoke-v1\"")); assert!(json.contains("\"schema_version\": \"fparkan-native-smoke-v1\""));
assert!(json.contains("\"platform\": \"macos\"")); assert!(json.contains("\"platform\": \"macos\""));
assert!(json.contains("\"status\": \"blocked\"")); assert!(json.contains("\"status\": \"blocked\""));
assert!(json.contains("\"shader_manifest_hash\": \"")); assert!(json.contains("\"shader_manifest_hash\": \""));
assert!(json.contains("\"vulkan_loader_status\": \"unavailable\""));
assert!(json.contains("\"vulkan_instance_api\": null"));
assert!(json.contains(
"\"vulkan_bootstrap_error\": \"Vulkan loader is unavailable: dlopen failed\""
));
assert!(json.contains("\"reason\": \"runner unavailable\"")); assert!(json.contains("\"reason\": \"runner unavailable\""));
Ok(()) Ok(())
} }
#[test]
fn formats_vulkan_api_version() {
assert_eq!(format_api_version((1 << 22) | (3 << 12) | 280), "1.3.280");
}
} }
+2 -1
View File
@@ -50,7 +50,8 @@ S0-VK-019 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_
S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers S0-VK-020 covered cargo test -p fparkan-render-vulkan --offline shader_manifest_rejects_invalid_spirv_containers
S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable S0-VK-021 covered cargo test -p fparkan-render-vulkan --offline frame_submission_plan_json_is_stable
S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents
S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_hash S0-VK-023 covered cargo test -p fparkan-vulkan-smoke --offline rejects_false_pass_without_full_evidence blocked_report_includes_shader_manifest_and_bootstrap_status
S0-VK-024 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_loader_probe formats_vulkan_api_version
S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow S0-LIMIT-001 covered cargo test -p fparkan-binary --offline rejects_count_stride_overflow
S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read S0-LIMIT-002 covered cargo test -p fparkan-binary --offline rejects_oversized_declared_allocation_before_read
L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates
1 # Acceptance coverage manifest.
50 S0-VK-020
51 S0-VK-021
52 S0-VK-022
53 S0-VK-023
54 S0-VK-024
55 S0-LIMIT-001
56 S0-LIMIT-002
57 L1-P1-NRES-001
+1
View File
@@ -51,6 +51,7 @@
`S0-VK-021` `S0-VK-021`
`S0-VK-022` `S0-VK-022`
`S0-VK-023` `S0-VK-023`
`S0-VK-024`
`S0-LIMIT-001` `S0-LIMIT-001`
`S0-LIMIT-002` `S0-LIMIT-002`
`L1-P1-NRES-001` `L1-P1-NRES-001`