From 70813154f22301d8a8eb5dd23d836e503f06ec6c Mon Sep 17 00:00:00 2001 From: Valentin Popov Date: Thu, 25 Jun 2026 07:29:43 +0400 Subject: fix(vulkan-smoke): emit structured timeout reports --- .github/workflows/ci.yml | 1 + README.md | 3 +- apps/fparkan-vulkan-smoke/src/main.rs | 342 +++++++++++++++++++++++++--------- 3 files changed, 261 insertions(+), 85 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 008b481..3ca3066 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,7 @@ jobs: run: > cargo run -p fparkan-vulkan-smoke --locked -- --out "target/fparkan/native-smoke/${{ matrix.smoke_platform }}.json" + --timeout-seconds 120 - name: Upload acceptance audit if: always() uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 diff --git a/README.md b/README.md index eea91b1..4bf8627 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,8 @@ FPARKAN_CORPORA_MANIFEST=/private/tmp/fparkan-corpora.toml \ ```bash cargo run -p fparkan-vulkan-smoke --locked -- \ - --out target/fparkan/native-smoke/local.json + --out target/fparkan/native-smoke/local.json \ + --timeout-seconds 120 ``` Перед запуском убедитесь, что на машине доступен Vulkan loader и рабочий ICD: diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index ade568c..03d3edb 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -18,6 +18,7 @@ use fparkan_render_vulkan::{ use serde::Serialize; use std::path::PathBuf; use std::process::Command; +use std::time::{Duration, Instant}; use winit::application::ApplicationHandler; use winit::dpi::PhysicalSize; use winit::event::WindowEvent; @@ -29,6 +30,7 @@ const DEFAULT_TARGET_FRAMES: u32 = 300; const DEFAULT_RESIZE_FRAME: u32 = 120; const DEFAULT_RESIZE_WIDTH: u32 = 960; const DEFAULT_RESIZE_HEIGHT: u32 = 540; +const DEFAULT_TIMEOUT_SECONDS: u64 = 120; fn main() { let args = std::env::args().skip(1).collect::>(); @@ -49,9 +51,9 @@ fn run(args: &[String]) -> Result { let options = SmokeOptions::parse(args)?; let event_loop = EventLoop::new().map_err(|err| format!("winit event loop: {err}"))?; let mut app = SmokeApp::new(options); - event_loop - .run_app(&mut app) - .map_err(|err| format!("winit event loop: {err}"))?; + if let Err(err) = event_loop.run_app(&mut app) { + app.error = Some(format!("winit event loop: {err}")); + } app.finish() } @@ -60,6 +62,7 @@ struct SmokeOptions { out: PathBuf, frames: u32, resize_frame: u32, + timeout_seconds: u64, } impl SmokeOptions { @@ -67,6 +70,7 @@ impl SmokeOptions { let mut out = None; let mut frames = DEFAULT_TARGET_FRAMES; let mut resize_frame = DEFAULT_RESIZE_FRAME; + let mut timeout_seconds = DEFAULT_TIMEOUT_SECONDS; let mut iter = args.iter(); while let Some(arg) = iter.next() { match arg.as_str() { @@ -91,6 +95,13 @@ impl SmokeOptions { .parse() .map_err(|_| "--resize-frame must be an integer".to_string())?; } + "--timeout-seconds" => { + timeout_seconds = iter + .next() + .ok_or_else(|| "--timeout-seconds requires a value".to_string())? + .parse() + .map_err(|_| "--timeout-seconds must be an integer".to_string())?; + } _ => return Err(format!("unknown native smoke option: {arg}")), } } @@ -100,10 +111,14 @@ impl SmokeOptions { "native smoke requires --frames >= {DEFAULT_TARGET_FRAMES}" )); } + if timeout_seconds == 0 { + return Err("native smoke requires --timeout-seconds >= 1".to_string()); + } Ok(Self { out, frames, resize_frame, + timeout_seconds, }) } } @@ -119,10 +134,11 @@ struct SmokeApp { resize_count: u32, resize_requested: bool, last_size: Option<(u32, u32)>, + started_at: Instant, } impl SmokeApp { - const fn new(options: SmokeOptions) -> Self { + fn new(options: SmokeOptions) -> Self { Self { options, window_id: None, @@ -134,15 +150,20 @@ impl SmokeApp { resize_count: 0, resize_requested: false, last_size: None, + started_at: Instant::now(), } } - fn finish(self) -> Result { - if let Some(error) = self.error { - return Err(error); + fn finish(mut self) -> Result { + if let Some(output) = self.output.take() { + return Ok(output); } - self.output - .ok_or_else(|| "native smoke exited before producing a report".to_string()) + let error = self + .error + .clone() + .unwrap_or_else(|| "native smoke exited before producing a report".to_string()); + self.write_failure_report(&error)?; + Err(error) } fn schedule_next_redraw(&self) { @@ -151,6 +172,124 @@ impl SmokeApp { } } + fn write_report(&self, report: &str) -> Result<(), String> { + if let Some(parent) = self.options.out.parent() { + std::fs::create_dir_all(parent) + .map_err(|err| format!("{}: {err}", parent.display()))?; + } + std::fs::write(&self.options.out, report) + .map_err(|err| format!("{}: {err}", self.options.out.display())) + } + + fn render_report( + &self, + status: &'static str, + failure_reason: Option<&str>, + ) -> Result { + let renderer = self.renderer.as_ref(); + let validation = renderer.map_or( + fparkan_render_vulkan::VulkanValidationReport { + warning_count: 0, + error_count: 0, + vuids: Vec::new(), + }, + VulkanSmokeRenderer::validation_report, + ); + let smoke_report = SmokeReport { + schema_version: SCHEMA_VERSION, + commit_sha: current_git_commit_sha(), + git_dirty: current_git_dirty(), + runner_identity: measured_runner_identity(), + rust_toolchain: current_rustc_release(), + target_triple: current_rustc_host_triple(), + platform: actual_platform(), + status, + failure_reason, + frames: self.frames_presented, + resize_count: self.resize_count, + swapchain_recreate_count: renderer + .map_or(0, VulkanSmokeRenderer::swapchain_recreate_count), + validation_warning_count: validation.warning_count, + validation_error_count: validation.error_count, + validation_vuids: &validation.vuids, + requested_frames: self.options.frames, + timeout_seconds: self.options.timeout_seconds, + shader_manifest_hash: renderer + .map_or("", |value| value.report().shader_manifest_hash.as_str()), + vulkan_loader_status: if renderer.is_some() { + "available" + } else { + "failed" + }, + vulkan_instance_status: if renderer.is_some() { + "created" + } else { + "failed" + }, + window_status: if self.window.is_some() { + "created" + } else { + "failed" + }, + vulkan_surface_status: if renderer.is_some() { + "created" + } else { + "failed" + }, + vulkan_device_status: if renderer.is_some() { + "selected" + } else { + "failed" + }, + vulkan_device_name: renderer.map_or("", |value| value.report().device_name.as_str()), + vulkan_logical_device_status: if renderer.is_some() { + "created" + } else { + "failed" + }, + vulkan_logical_device_graphics_queue_family: renderer + .map_or(0, |value| value.report().graphics_queue_family), + vulkan_logical_device_present_queue_family: renderer + .map_or(0, |value| value.report().present_queue_family), + vulkan_logical_device_enabled_extension_count: renderer + .map_or(0, |value| value.report().enabled_extension_count), + vulkan_swapchain_status: if renderer.is_some() { + "created" + } else { + "failed" + }, + vulkan_swapchain_width: renderer.map_or(0, |value| value.report().swapchain_extent.0), + vulkan_swapchain_height: renderer.map_or(0, |value| value.report().swapchain_extent.1), + vulkan_swapchain_image_count: renderer + .map_or(0, |value| value.report().swapchain_image_count), + vulkan_portability_enumeration: renderer + .is_some_and(|value| value.report().portability_enumeration), + }; + serde_json::to_string_pretty(&smoke_report) + .map(|json| format!("{json}\n")) + .map_err(|err| format!("native smoke report serialization failed: {err}")) + } + + fn write_failure_report(&self, failure_reason: &str) -> Result<(), String> { + let report = self.render_report("failed", Some(failure_reason))?; + self.write_report(&report) + } + + fn abort_if_timed_out(&mut self, event_loop: &ActiveEventLoop) -> bool { + if self.output.is_some() || self.error.is_some() { + return false; + } + if self.started_at.elapsed() <= Duration::from_secs(self.options.timeout_seconds) { + return false; + } + self.error = Some(format!( + "native smoke timed out after {} seconds", + self.options.timeout_seconds + )); + event_loop.exit(); + true + } + fn complete(&mut self, event_loop: &ActiveEventLoop) { let Some(renderer) = self.renderer.as_ref() else { self.error = Some("native smoke renderer was not initialized".to_string()); @@ -179,15 +318,8 @@ impl SmokeApp { event_loop.exit(); return; } - let report = match render_smoke_report_json( - &self.options, - renderer, - self.frames_presented, - self.resize_count, - validation.warning_count, - validation.error_count, - &validation.vuids, - ) { + let _ = renderer; + let report = match self.render_report("passed", None) { Ok(report) => report, Err(err) => { self.error = Some(err); @@ -195,15 +327,8 @@ impl SmokeApp { return; } }; - if let Some(parent) = self.options.out.parent() { - if let Err(err) = std::fs::create_dir_all(parent) { - self.error = Some(format!("{}: {err}", parent.display())); - event_loop.exit(); - return; - } - } - if let Err(err) = std::fs::write(&self.options.out, &report) { - self.error = Some(format!("{}: {err}", self.options.out.display())); + if let Err(err) = self.write_report(&report) { + self.error = Some(err); event_loop.exit(); return; } @@ -226,6 +351,9 @@ impl SmokeApp { impl ApplicationHandler for SmokeApp { fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.abort_if_timed_out(event_loop) { + return; + } if self.window.is_some() { return; } @@ -280,6 +408,9 @@ impl ApplicationHandler for SmokeApp { window_id: WindowId, event: WindowEvent, ) { + if self.abort_if_timed_out(event_loop) { + return; + } if Some(window_id) != self.window_id { return; } @@ -341,7 +472,10 @@ impl ApplicationHandler for SmokeApp { } } - fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) { + if self.abort_if_timed_out(event_loop) { + return; + } if self.output.is_none() && self.error.is_none() { self.schedule_next_redraw(); } @@ -357,7 +491,9 @@ struct SmokeReport<'a> { rust_toolchain: String, target_triple: String, platform: &'static str, - status: &'static str, + status: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + failure_reason: Option<&'a str>, frames: u32, resize_count: u32, swapchain_recreate_count: u32, @@ -365,72 +501,25 @@ struct SmokeReport<'a> { validation_error_count: u32, validation_vuids: &'a [String], requested_frames: u32, + timeout_seconds: u64, shader_manifest_hash: &'a str, - vulkan_loader_status: &'static str, - vulkan_instance_status: &'static str, - window_status: &'static str, - vulkan_surface_status: &'static str, - vulkan_device_status: &'static str, + vulkan_loader_status: &'a str, + vulkan_instance_status: &'a str, + window_status: &'a str, + vulkan_surface_status: &'a str, + vulkan_device_status: &'a str, vulkan_device_name: &'a str, - vulkan_logical_device_status: &'static str, + vulkan_logical_device_status: &'a str, vulkan_logical_device_graphics_queue_family: u32, vulkan_logical_device_present_queue_family: u32, vulkan_logical_device_enabled_extension_count: u32, - vulkan_swapchain_status: &'static str, + vulkan_swapchain_status: &'a str, vulkan_swapchain_width: u32, vulkan_swapchain_height: u32, vulkan_swapchain_image_count: u32, vulkan_portability_enumeration: bool, } -fn render_smoke_report_json( - options: &SmokeOptions, - renderer: &VulkanSmokeRenderer, - frames_presented: u32, - resize_count: u32, - validation_warning_count: u32, - validation_error_count: u32, - validation_vuids: &[String], -) -> Result { - let report = renderer.report(); - let smoke_report = SmokeReport { - schema_version: SCHEMA_VERSION, - commit_sha: current_git_commit_sha(), - git_dirty: current_git_dirty(), - runner_identity: measured_runner_identity(), - rust_toolchain: current_rustc_release(), - target_triple: current_rustc_host_triple(), - platform: actual_platform(), - status: "passed", - frames: frames_presented, - resize_count, - swapchain_recreate_count: renderer.swapchain_recreate_count(), - validation_warning_count, - validation_error_count, - validation_vuids, - requested_frames: options.frames, - shader_manifest_hash: &report.shader_manifest_hash, - vulkan_loader_status: "available", - vulkan_instance_status: "created", - window_status: "created", - vulkan_surface_status: "created", - vulkan_device_status: "selected", - vulkan_device_name: &report.device_name, - vulkan_logical_device_status: "created", - vulkan_logical_device_graphics_queue_family: report.graphics_queue_family, - vulkan_logical_device_present_queue_family: report.present_queue_family, - vulkan_logical_device_enabled_extension_count: report.enabled_extension_count, - vulkan_swapchain_status: "created", - vulkan_swapchain_width: report.swapchain_extent.0, - vulkan_swapchain_height: report.swapchain_extent.1, - vulkan_swapchain_image_count: report.swapchain_image_count, - vulkan_portability_enumeration: report.portability_enumeration, - }; - serde_json::to_string_pretty(&smoke_report) - .map(|json| format!("{json}\n")) - .map_err(|err| format!("native smoke report serialization failed: {err}")) -} - fn actual_platform() -> &'static str { match std::env::consts::OS { "macos" => "macos", @@ -521,6 +610,7 @@ mod tests { out: PathBuf::from("target/report.json"), frames: DEFAULT_TARGET_FRAMES, resize_frame: DEFAULT_RESIZE_FRAME, + timeout_seconds: DEFAULT_TIMEOUT_SECONDS, }) ); } @@ -540,6 +630,41 @@ mod tests { ); } + #[test] + fn parses_timeout_seconds() { + let parsed = SmokeOptions::parse(&[ + "--out".to_string(), + "target/report.json".to_string(), + "--timeout-seconds".to_string(), + "45".to_string(), + ]); + + assert_eq!( + parsed, + Ok(SmokeOptions { + out: PathBuf::from("target/report.json"), + frames: DEFAULT_TARGET_FRAMES, + resize_frame: DEFAULT_RESIZE_FRAME, + timeout_seconds: 45, + }) + ); + } + + #[test] + fn rejects_zero_timeout_seconds() { + let parsed = SmokeOptions::parse(&[ + "--out".to_string(), + "target/report.json".to_string(), + "--timeout-seconds".to_string(), + "0".to_string(), + ]); + + assert_eq!( + parsed, + Err("native smoke requires --timeout-seconds >= 1".to_string()) + ); + } + #[test] fn rejects_deprecated_self_assertion_flags() { for flag in [ @@ -571,6 +696,7 @@ mod tests { target_triple: "aarch64-apple-darwin".to_string(), platform: "macos", status: "passed", + failure_reason: None, frames: 300, resize_count: 1, swapchain_recreate_count: 1, @@ -578,6 +704,7 @@ mod tests { validation_error_count: 0, validation_vuids: &["VUID-A".to_string(), "VUID-B".to_string()], requested_frames: 300, + timeout_seconds: DEFAULT_TIMEOUT_SECONDS, shader_manifest_hash: "deadbeef", vulkan_loader_status: "available", vulkan_instance_status: "created", @@ -601,4 +728,51 @@ mod tests { assert!(json.contains("\"validation_vuids\": [")); assert!(json.contains("\"vulkan_device_name\": \"Apple GPU\"")); } + + #[test] + fn finish_writes_failure_artifact() { + let root = std::env::temp_dir().join(format!( + "fparkan-native-smoke-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time") + .as_nanos() + )); + std::fs::create_dir_all(&root).expect("temp dir"); + let out = root.join("report.json"); + + let result = SmokeApp { + options: SmokeOptions { + out: out.clone(), + frames: DEFAULT_TARGET_FRAMES, + resize_frame: DEFAULT_RESIZE_FRAME, + timeout_seconds: 7, + }, + window_id: None, + window: None, + renderer: None, + error: Some("native smoke timed out after 7 seconds".to_string()), + output: None, + frames_presented: 42, + resize_count: 0, + resize_requested: false, + last_size: None, + started_at: Instant::now(), + } + .finish(); + + assert_eq!( + result, + Err("native smoke timed out after 7 seconds".to_string()) + ); + + let json = std::fs::read_to_string(&out).expect("failure report"); + assert!(json.contains("\"status\": \"failed\"")); + assert!(json.contains("\"failure_reason\": \"native smoke timed out after 7 seconds\"")); + assert!(json.contains("\"timeout_seconds\": 7")); + + std::fs::remove_file(out).expect("cleanup report"); + std::fs::remove_dir(root).expect("cleanup dir"); + } } -- cgit v1.2.3