diff options
| author | Valentin Popov <valentin@popov.link> | 2026-06-23 22:42:20 +0300 |
|---|---|---|
| committer | Valentin Popov <valentin@popov.link> | 2026-06-23 22:42:20 +0300 |
| commit | c71e706d6969f516152142bbeebf5f836d38db9b (patch) | |
| tree | 0ba128f72c476959205666fc3ab9c8a4e263ec10 | |
| parent | aa2133d82b2a9d92fdbdce2b60eec103536fe484 (diff) | |
| download | fparkan-c71e706d6969f516152142bbeebf5f836d38db9b.tar.xz fparkan-c71e706d6969f516152142bbeebf5f836d38db9b.zip | |
feat: add native smoke window preflight
| -rw-r--r-- | Cargo.lock | 1 | ||||
| -rw-r--r-- | adapters/fparkan-platform-winit/src/lib.rs | 67 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/Cargo.toml | 1 | ||||
| -rw-r--r-- | apps/fparkan-vulkan-smoke/src/main.rs | 233 | ||||
| -rw-r--r-- | fixtures/acceptance/coverage.tsv | 3 | ||||
| -rw-r--r-- | fixtures/acceptance/stage_0_2_roadmap.md | 1 |
6 files changed, 280 insertions, 26 deletions
@@ -767,6 +767,7 @@ dependencies = [ name = "fparkan-vulkan-smoke" version = "0.1.0" dependencies = [ + "fparkan-platform-winit", "fparkan-render-vulkan", ] diff --git a/adapters/fparkan-platform-winit/src/lib.rs b/adapters/fparkan-platform-winit/src/lib.rs index 9a9941c..d8efa3e 100644 --- a/adapters/fparkan-platform-winit/src/lib.rs +++ b/adapters/fparkan-platform-winit/src/lib.rs @@ -33,6 +33,8 @@ use winit::platform::scancode::PhysicalKeyExtScancode; use winit::window::Window; static NEXT_WINDOW_HANDLE_ID: AtomicU64 = AtomicU64::new(1); +const DEFAULT_SMOKE_WIDTH: u32 = 1280; +const DEFAULT_SMOKE_HEIGHT: u32 = 720; fn next_window_id() -> u64 { NEXT_WINDOW_HANDLE_ID.fetch_add(1, Ordering::Relaxed) @@ -144,6 +146,44 @@ impl EventSource for WinitEventSource { } } +/// Window creation plan for native smoke entrypoints. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct WinitWindowPlan { + /// Requested drawable width in physical pixels. + pub width: u32, + /// Requested drawable height in physical pixels. + pub height: u32, + /// Whether native window/display handles are required by the caller. + pub requires_native_handles: bool, +} + +impl WinitWindowPlan { + /// Returns the Stage 0 native smoke window plan. + #[must_use] + pub const fn smoke() -> Self { + Self { + width: DEFAULT_SMOKE_WIDTH, + height: DEFAULT_SMOKE_HEIGHT, + requires_native_handles: true, + } + } + + /// Validates the window plan before a native event loop is entered. + /// + /// # Errors + /// + /// Returns [`PlatformError`] when the drawable extent is zero. + pub fn validate(self) -> Result<Self, PlatformError> { + if self.width == 0 || self.height == 0 { + return Err(PlatformError::Backend { + context: "winit window plan", + message: "drawable extent must be non-zero".to_string(), + }); + } + Ok(self) + } +} + /// Minimal window view over a `winit` window. #[derive(Clone, Debug)] pub struct WinitWindow { @@ -277,6 +317,33 @@ mod tests { } #[test] + fn smoke_window_plan_requires_native_handles_and_nonzero_extent() -> Result<(), PlatformError> { + let plan = WinitWindowPlan::smoke().validate()?; + + assert_eq!(plan.width, DEFAULT_SMOKE_WIDTH); + assert_eq!(plan.height, DEFAULT_SMOKE_HEIGHT); + assert!(plan.requires_native_handles); + Ok(()) + } + + #[test] + fn smoke_window_plan_rejects_zero_extent() { + let plan = WinitWindowPlan { + width: 0, + height: DEFAULT_SMOKE_HEIGHT, + requires_native_handles: true, + }; + + assert!(matches!( + plan.validate(), + Err(PlatformError::Backend { + context: "winit window plan", + .. + }) + )); + } + + #[test] fn window_events_push_expected_platform_events() { let mut source = WinitEventSource::new(); let size = winit::dpi::PhysicalSize::new(1024u32, 768u32); diff --git a/apps/fparkan-vulkan-smoke/Cargo.toml b/apps/fparkan-vulkan-smoke/Cargo.toml index 793a4c4..f872920 100644 --- a/apps/fparkan-vulkan-smoke/Cargo.toml +++ b/apps/fparkan-vulkan-smoke/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true repository.workspace = true [dependencies] +fparkan-platform-winit = { path = "../../adapters/fparkan-platform-winit" } fparkan-render-vulkan = { path = "../../adapters/fparkan-render-vulkan" } [lints] diff --git a/apps/fparkan-vulkan-smoke/src/main.rs b/apps/fparkan-vulkan-smoke/src/main.rs index e6fc800..a05a510 100644 --- a/apps/fparkan-vulkan-smoke/src/main.rs +++ b/apps/fparkan-vulkan-smoke/src/main.rs @@ -11,6 +11,7 @@ #![allow(clippy::print_stderr, clippy::print_stdout)] //! Native Vulkan smoke runner entrypoint. +use fparkan_platform_winit::WinitWindowPlan; use fparkan_render_vulkan::{ create_vulkan_instance_probe, plan_vulkan_surface, probe_vulkan_loader, triangle_shader_manifest, validate_shader_manifest, VulkanInstanceConfig, @@ -57,9 +58,7 @@ struct SmokeOptions { frames: u32, resize_count: u32, validation_error_count: Option<u32>, - probe_loader: bool, - probe_instance: bool, - probe_surface: bool, + probes: ProbeOptions, reason: Option<String>, } @@ -71,9 +70,7 @@ impl SmokeOptions { let mut frames = 0; let mut resize_count = 0; let mut validation_error_count = None; - let mut probe_loader = false; - let mut probe_instance = false; - let mut probe_surface = false; + let mut probes = ProbeOptions::default(); let mut reason = None; let mut iter = args.iter(); while let Some(arg) = iter.next() { @@ -115,16 +112,17 @@ impl SmokeOptions { validation_error_count = Some(parse_u32("--validation-error-count", value)?); } "--probe-loader" => { - probe_loader = true; + probes.vulkan = probes.vulkan.max(VulkanProbeDepth::Loader); } "--probe-instance" => { - probe_loader = true; - probe_instance = true; + probes.vulkan = probes.vulkan.max(VulkanProbeDepth::Instance); + } + "--probe-window" => { + probes.window = true; } "--probe-surface" => { - probe_loader = true; - probe_instance = true; - probe_surface = true; + probes.vulkan = probes.vulkan.max(VulkanProbeDepth::Surface); + probes.window = true; } "--reason" => { let value = iter @@ -142,9 +140,7 @@ impl SmokeOptions { frames, resize_count, validation_error_count, - probe_loader, - probe_instance, - probe_surface, + probes, reason, }) } @@ -156,6 +152,35 @@ fn parse_u32(name: &str, value: &str) -> Result<u32, String> { .map_err(|_| format!("invalid {name} value: {value}")) } +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +struct ProbeOptions { + vulkan: VulkanProbeDepth, + window: bool, +} + +#[derive(Clone, Copy, Debug, Default, Eq, Ord, PartialEq, PartialOrd)] +enum VulkanProbeDepth { + #[default] + None, + Loader, + Instance, + Surface, +} + +impl VulkanProbeDepth { + const fn includes_loader(self) -> bool { + matches!(self, Self::Loader | Self::Instance | Self::Surface) + } + + const fn includes_instance(self) -> bool { + matches!(self, Self::Instance | Self::Surface) + } + + const fn includes_surface(self) -> bool { + matches!(self, Self::Surface) + } +} + #[derive(Clone, Debug, Eq, PartialEq)] struct VulkanBootstrapProbe { loader_status: VulkanLoaderStatus, @@ -164,13 +189,17 @@ struct VulkanBootstrapProbe { instance_status: VulkanInstanceStatus, instance_error: Option<String>, portability_enumeration: bool, + window_status: WinitWindowStatus, + window_width: Option<u32>, + window_height: Option<u32>, + window_error: Option<String>, surface_status: VulkanSurfaceStatus, surface_error: Option<String>, } impl VulkanBootstrapProbe { fn run(options: &SmokeOptions) -> Self { - if !options.probe_loader { + if !options.probes.vulkan.includes_loader() { return Self { loader_status: VulkanLoaderStatus::Skipped, instance_api: None, @@ -178,6 +207,10 @@ impl VulkanBootstrapProbe { instance_status: VulkanInstanceStatus::Skipped, instance_error: None, portability_enumeration: false, + window_status: WinitWindowStatus::Skipped, + window_width: None, + window_height: None, + window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, }; @@ -191,6 +224,10 @@ impl VulkanBootstrapProbe { instance_status: VulkanInstanceStatus::Skipped, instance_error: None, portability_enumeration: false, + window_status: WinitWindowStatus::Skipped, + window_width: None, + window_height: None, + window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, }, @@ -201,12 +238,31 @@ impl VulkanBootstrapProbe { instance_status: VulkanInstanceStatus::Skipped, instance_error: None, portability_enumeration: false, + window_status: WinitWindowStatus::Skipped, + window_width: None, + window_height: None, + window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, }, }; - if options.probe_instance && probe.loader_status == VulkanLoaderStatus::Available { + if options.probes.window { + match WinitWindowPlan::smoke().validate() { + Ok(plan) => { + probe.window_status = WinitWindowStatus::Planned; + probe.window_width = Some(plan.width); + probe.window_height = Some(plan.height); + } + Err(err) => { + probe.window_status = WinitWindowStatus::Failed; + probe.window_error = Some(err.to_string()); + } + } + } + if options.probes.vulkan.includes_instance() + && probe.loader_status == VulkanLoaderStatus::Available + { let config = VulkanInstanceConfig::smoke("fparkan-vulkan-smoke"); probe.portability_enumeration = config.enable_portability_enumeration; match create_vulkan_instance_probe(&config) { @@ -220,7 +276,9 @@ impl VulkanBootstrapProbe { } } } - if options.probe_surface && probe.instance_status == VulkanInstanceStatus::Created { + if options.probes.vulkan.includes_surface() + && probe.instance_status == VulkanInstanceStatus::Created + { match plan_vulkan_surface(None) { Ok(_) => { probe.surface_status = VulkanSurfaceStatus::Planned; @@ -270,6 +328,23 @@ impl VulkanInstanceStatus { } #[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WinitWindowStatus { + Skipped, + Planned, + Failed, +} + +impl WinitWindowStatus { + const fn as_str(self) -> &'static str { + match self { + Self::Skipped => "skipped", + Self::Planned => "planned", + Self::Failed => "failed", + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] enum VulkanSurfaceStatus { Skipped, Planned, @@ -373,6 +448,11 @@ fn validate_smoke_options( "passed native smoke report requires successful --probe-instance".to_string(), ); } + if bootstrap.window_status != WinitWindowStatus::Planned { + return Err( + "passed native smoke report requires successful --probe-window".to_string(), + ); + } if bootstrap.surface_status != VulkanSurfaceStatus::Planned { return Err( "passed native smoke report requires successful --probe-surface".to_string(), @@ -408,6 +488,16 @@ fn render_smoke_report_json( .instance_error .as_ref() .map_or_else(|| "null".to_string(), |value| json_string(value)); + let window_width = bootstrap + .window_width + .map_or_else(|| "null".to_string(), |value| value.to_string()); + let window_height = bootstrap + .window_height + .map_or_else(|| "null".to_string(), |value| value.to_string()); + let window_error = bootstrap + .window_error + .as_ref() + .map_or_else(|| "null".to_string(), |value| json_string(value)); let surface_error = bootstrap .surface_error .as_ref() @@ -430,6 +520,10 @@ fn render_smoke_report_json( " \"vulkan_instance_status\": \"{}\",\n", " \"vulkan_instance_error\": {},\n", " \"vulkan_portability_enumeration\": {},\n", + " \"window_status\": \"{}\",\n", + " \"window_width\": {},\n", + " \"window_height\": {},\n", + " \"window_error\": {},\n", " \"vulkan_surface_status\": \"{}\",\n", " \"vulkan_surface_error\": {},\n", " \"reason\": {}\n", @@ -454,6 +548,10 @@ fn render_smoke_report_json( } else { "false" }, + bootstrap.window_status.as_str(), + window_width, + window_height, + window_error, bootstrap.surface_status.as_str(), surface_error, reason @@ -526,7 +624,7 @@ mod tests { assert_eq!(options.platform, SmokePlatform::Linux); assert_eq!(options.status, SmokeStatus::Blocked); - assert!(options.probe_loader); + assert_eq!(options.probes.vulkan, VulkanProbeDepth::Loader); assert_eq!(options.reason.as_deref(), Some("runner unavailable")); validate_smoke_options( &options, @@ -537,6 +635,10 @@ mod tests { instance_status: VulkanInstanceStatus::Skipped, instance_error: None, portability_enumeration: false, + window_status: WinitWindowStatus::Skipped, + window_width: None, + window_height: None, + window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, }, @@ -571,6 +673,10 @@ mod tests { instance_status: VulkanInstanceStatus::Created, instance_error: None, portability_enumeration: false, + window_status: WinitWindowStatus::Planned, + window_width: Some(1280), + window_height: Some(720), + window_error: None, surface_status: VulkanSurfaceStatus::Planned, surface_error: None, }, @@ -607,6 +713,10 @@ mod tests { instance_status: VulkanInstanceStatus::Skipped, instance_error: None, portability_enumeration: false, + window_status: WinitWindowStatus::Skipped, + window_width: None, + window_height: None, + window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, }, @@ -644,6 +754,10 @@ mod tests { instance_status: VulkanInstanceStatus::Skipped, instance_error: None, portability_enumeration: false, + window_status: WinitWindowStatus::Skipped, + window_width: None, + window_height: None, + window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, }, @@ -653,6 +767,47 @@ mod tests { } #[test] + fn rejects_passed_without_window_probe() { + let options = SmokeOptions::parse(&strings(&[ + "--platform", + "linux", + "--out", + "target/native.json", + "--status", + "passed", + "--frames", + "300", + "--resize-count", + "1", + "--validation-error-count", + "0", + "--probe-instance", + ])) + .expect("options"); + + assert_eq!( + validate_smoke_options( + &options, + &VulkanBootstrapProbe { + loader_status: VulkanLoaderStatus::Available, + instance_api: Some("1.3.0".to_string()), + loader_error: None, + instance_status: VulkanInstanceStatus::Created, + instance_error: None, + portability_enumeration: false, + window_status: WinitWindowStatus::Skipped, + window_width: None, + window_height: None, + window_error: None, + surface_status: VulkanSurfaceStatus::Planned, + surface_error: None, + }, + ), + Err("passed native smoke report requires successful --probe-window".to_string()) + ); + } + + #[test] fn rejects_passed_without_surface_probe() { let options = SmokeOptions::parse(&strings(&[ "--platform", @@ -667,6 +822,7 @@ mod tests { "1", "--validation-error-count", "0", + "--probe-window", "--probe-instance", ])) .expect("options"); @@ -681,6 +837,10 @@ mod tests { instance_status: VulkanInstanceStatus::Created, instance_error: None, portability_enumeration: false, + window_status: WinitWindowStatus::Planned, + window_width: Some(1280), + window_height: Some(720), + window_error: None, surface_status: VulkanSurfaceStatus::Skipped, surface_error: None, }, @@ -711,6 +871,10 @@ mod tests { instance_status: VulkanInstanceStatus::Skipped, instance_error: None, portability_enumeration: true, + window_status: WinitWindowStatus::Planned, + window_width: Some(1280), + window_height: Some(720), + window_error: None, surface_status: VulkanSurfaceStatus::MissingWindowHandles, surface_error: Some( "native window/display handles are required for Vulkan surface creation" @@ -730,6 +894,10 @@ mod tests { assert!(json.contains("\"vulkan_instance_status\": \"skipped\"")); assert!(json.contains("\"vulkan_instance_error\": null")); assert!(json.contains("\"vulkan_portability_enumeration\": true")); + assert!(json.contains("\"window_status\": \"planned\"")); + assert!(json.contains("\"window_width\": 1280")); + assert!(json.contains("\"window_height\": 720")); + assert!(json.contains("\"window_error\": null")); assert!(json.contains("\"vulkan_surface_status\": \"missing_window_handles\"")); assert!(json.contains( "\"vulkan_surface_error\": \"native window/display handles are required for Vulkan surface creation\"" @@ -750,9 +918,25 @@ mod tests { "runner unavailable", ]))?; - assert!(options.probe_loader); - assert!(options.probe_instance); - assert!(!options.probe_surface); + assert_eq!(options.probes.vulkan, VulkanProbeDepth::Instance); + assert!(!options.probes.window); + Ok(()) + } + + #[test] + fn parses_window_probe_without_vulkan_probes() -> Result<(), String> { + let options = SmokeOptions::parse(&strings(&[ + "--platform", + "linux", + "--out", + "target/native.json", + "--probe-window", + "--reason", + "runner unavailable", + ]))?; + + assert_eq!(options.probes.vulkan, VulkanProbeDepth::None); + assert!(options.probes.window); Ok(()) } @@ -768,9 +952,8 @@ mod tests { "runner unavailable", ]))?; - assert!(options.probe_loader); - assert!(options.probe_instance); - assert!(options.probe_surface); + assert_eq!(options.probes.vulkan, VulkanProbeDepth::Surface); + assert!(options.probes.window); Ok(()) } diff --git a/fixtures/acceptance/coverage.tsv b/fixtures/acceptance/coverage.tsv index 7bbcb3d..c886430 100644 --- a/fixtures/acceptance/coverage.tsv +++ b/fixtures/acceptance/coverage.tsv @@ -28,6 +28,7 @@ S0-CLI-001 covered cargo test -p fparkan-cli --offline stable_exit_codes_are_map S0-CLI-002 covered cargo test -p fparkan-cli --offline accepts_json_format_option archive_json_has_schema_version S0-PLAT-001 covered cargo test -p fparkan-platform-winit --offline window_port_reports_default_request_profile S0-PLAT-002 covered cargo clippy -p fparkan-platform -p fparkan-platform-winit --all-targets --all-features --locked -- -D warnings +S0-PLAT-003 covered cargo test -p fparkan-platform-winit --offline smoke_window_plan_requires_native_handles_and_nonzero_extent smoke_window_plan_rejects_zero_extent S0-VK-001 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_render_request_and_presents S0-VK-002 covered cargo test -p fparkan-render-vulkan --offline device_scoring_is_deterministic_and_prefers_discrete_unified_queue S0-VK-003 covered cargo test -p fparkan-render-vulkan --offline portability_subset_is_reported_and_enabled_when_exposed @@ -53,7 +54,7 @@ S0-VK-022 covered cargo test -p fparkan-render-vulkan --offline backend_tracks_r 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-VK-025 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_instance_probe parses_instance_probe_as_loader_probe -S0-VK-026 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_surface_probe parses_surface_probe_as_instance_probe +S0-VK-026 covered cargo test -p fparkan-vulkan-smoke --offline rejects_passed_without_window_probe rejects_passed_without_surface_probe parses_surface_probe_as_instance_probe 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 L1-P1-NRES-001 covered cargo test -p fparkan-nres --offline licensed_corpora_nres_roundtrip_gates diff --git a/fixtures/acceptance/stage_0_2_roadmap.md b/fixtures/acceptance/stage_0_2_roadmap.md index 0977b2c..5fc1f4b 100644 --- a/fixtures/acceptance/stage_0_2_roadmap.md +++ b/fixtures/acceptance/stage_0_2_roadmap.md @@ -28,6 +28,7 @@ `S0-CLI-002` `S0-PLAT-001` `S0-PLAT-002` +`S0-PLAT-003` `S0-VK-001` `S0-VK-002` `S0-VK-003` |
