effectcraft/crates/gpu/src/tests_init.rs

162 lines
8.1 KiB
Rust

//! Native constructor containment. Tiny original shaders; no frames or large allocations.
//! Set EFFECTCRAFT_REQUIRE_GPU=1 for acceptance: unavailable adapters/devices then fail
//! instead of taking the explicitly logged headless-CI skip path.
#![cfg(not(target_arch = "wasm32"))]
#![allow(clippy::expect_used)] // Test helpers require successful setup to exercise rejection.
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
#[cfg(target_os = "windows")]
use crate::context::GpuContext;
use crate::context::init_resource;
fn brief(message: impl std::fmt::Display) -> String {
message.to_string().chars().take(1024).collect()
}
fn native_device(desc: wgpu::InstanceDescriptor, required: bool) -> Option<(wgpu::Adapter, wgpu::Device, wgpu::Queue)> {
crate::tests::hold_gpu_lock();
let required = required || std::env::var("EFFECTCRAFT_REQUIRE_GPU").is_ok_and(|value| value == "1");
let instance = wgpu::Instance::new(desc);
let adapter = match pollster::block_on(
instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }),
) {
Ok(adapter) => adapter,
Err(error) => {
assert!(!required, "required initialization-test adapter unavailable: {}", brief(error));
eprintln!("initialization tests: no adapter, explicitly skipping");
return None;
}
};
let info = adapter.get_info();
eprintln!("initialization tests: adapter {} ({:?})", brief(&info.name), info.backend);
let limits = adapter.limits();
let desc = wgpu::DeviceDescriptor {
label: Some("initialization containment test"),
required_limits: wgpu::Limits {
max_texture_dimension_2d: limits.max_texture_dimension_2d.min(16384),
max_buffer_size: limits.max_buffer_size,
..wgpu::Limits::default()
},
..Default::default()
};
match pollster::block_on(adapter.request_device(&desc)) {
Ok((device, queue)) => Some((adapter, device, queue)),
Err(error) => {
assert!(!required, "required initialization-test device unavailable: {}", brief(error));
eprintln!("initialization tests: no device, explicitly skipping");
None
}
}
}
fn count_host_errors(device: &wgpu::Device) -> Arc<AtomicUsize> {
let calls = Arc::new(AtomicUsize::new(0));
let count = calls.clone();
device.on_uncaptured_error(Arc::new(move |_| {
count.fetch_add(1, Ordering::SeqCst);
}));
calls
}
fn invalid_module(device: &wgpu::Device) -> wgpu::ShaderModule {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("original deliberately invalid initialization-test module"),
source: wgpu::ShaderSource::Wgsl("this is deliberately not WGSL".into()),
})
}
fn valid_module(device: &wgpu::Device) -> wgpu::ShaderModule {
init_resource(device, "valid tiny module", || {
device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("original empty initialization-test kernel"),
source: wgpu::ShaderSource::Wgsl("@compute @workgroup_size(1) fn main() {}".into()),
})
})
.expect("a valid tiny module still compiles after rejection")
}
fn pipeline(device: &wgpu::Device, module: &wgpu::ShaderModule, entry: &str) -> wgpu::ComputePipeline {
device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor {
label: Some("initialization-test pipeline"),
layout: None,
module,
entry_point: Some(entry),
compilation_options: Default::default(),
cache: None,
})
}
fn valid_pipeline(device: &wgpu::Device) {
let module = valid_module(device);
init_resource(device, "valid tiny pipeline", || pipeline(device, &module, "main")).expect("a valid pipeline still compiles after rejection");
}
#[test]
fn scoped_module_and_pipeline_errors_preserve_outer_scope_and_host_handler() {
let Some((_, device, _queue)) = native_device(wgpu::InstanceDescriptor::new_without_display_handle().with_env(), false) else { return };
let host = count_host_errors(&device);
let outer = device.push_error_scope(wgpu::ErrorFilter::Validation);
let error = init_resource(&device, "invalid tiny module", || invalid_module(&device)).expect_err("invalid module must return Err");
assert!(error.contains("GPU initialization (invalid tiny module)"), "{}", brief(error));
let module = valid_module(&device);
let error = init_resource(&device, "missing tiny entry", || pipeline(&device, &module, "missing_entry")).expect_err("missing entry point must return Err");
assert!(error.contains("GPU initialization (missing tiny entry)"), "{}", brief(error));
assert_eq!(host.load(Ordering::SeqCst), 0, "contained errors must not reach the host");
valid_pipeline(&device);
// This belongs to our pre-existing caller scope. Its successful pop also proves that
// none of the three nested helper scopes remain on the native thread-local stack.
let _invalid = invalid_module(&device);
assert!(pollster::block_on(outer.pop()).is_some(), "the caller's own scope survives");
assert_eq!(host.load(Ordering::SeqCst), 0);
let _invalid = invalid_module(&device);
assert_eq!(host.load(Ordering::SeqCst), 1, "the original host handler still receives unscoped errors");
}
#[test]
fn initialization_unwind_drains_all_scopes_before_the_next_operation() {
let Some((_, device, _queue)) = native_device(wgpu::InstanceDescriptor::new_without_display_handle().with_env(), false) else { return };
let host = count_host_errors(&device);
let outer = device.push_error_scope(wgpu::ErrorFilter::Validation);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
init_resource::<()>(&device, "synthetic native unwind", || panic!("original synthetic initialization failure"))
}));
let error = result.expect("the initialization unwind is contained").expect_err("unwind becomes an error");
assert!(error.contains("synthetic native unwind") && error.contains("original synthetic initialization failure"), "{}", brief(error));
valid_pipeline(&device);
let _invalid = invalid_module(&device);
assert!(pollster::block_on(outer.pop()).is_some(), "all helper scopes must be drained after unwind");
assert_eq!(host.load(Ordering::SeqCst), 0);
let _invalid = invalid_module(&device);
assert_eq!(host.load(Ordering::SeqCst), 1, "unwind must neither replace the host handler nor leak a scope");
}
/// Acceptance for the captured RTX 5090 / FXC failure. Future compiler/shader fixes can
/// legitimately make FXC succeed; this diagnostic is deliberately outside portable CI.
#[test]
#[cfg(target_os = "windows")]
#[ignore = "requires the reproduced Windows DX12/FXC pointwise compiler failure; no adapter skips"]
fn reproduced_fxc_pointwise_failure_returns_error_without_escaping_or_notifying_host() {
let mut desc = wgpu::InstanceDescriptor::new_without_display_handle();
desc.backends = wgpu::Backends::DX12;
desc.backend_options.dx12.shader_compiler = wgpu::Dx12Compiler::Fxc;
let (adapter, device, queue) = native_device(desc, true).expect("required DX12 device");
assert_eq!(adapter.get_info().backend, wgpu::Backend::Dx12);
let host = count_host_errors(&device);
let outer = device.push_error_scope(wgpu::ErrorFilter::Validation);
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| GpuContext::new(&adapter, device.clone(), queue)));
let error = match result.expect("FXC compiler failure must not escape the constructor") {
Err(error) => error,
Ok(_) => panic!("the platform-specific FXC failure no longer reproduces; reassess this diagnostic"),
};
assert!(error.contains("GPU initialization (pointwise)") && error.contains("FXC D3DCompile"), "{}", brief(error));
assert!(pollster::block_on(outer.pop()).is_none(), "constructor must drain its own scopes and leave the caller's scope clean");
assert_eq!(host.load(Ordering::SeqCst), 0);
valid_pipeline(&device);
let _invalid = invalid_module(&device);
assert_eq!(host.load(Ordering::SeqCst), 1);
}