From 8f4def424a15a534b79a35ca7d073adcbf461e65 Mon Sep 17 00:00:00 2001 From: Andy Leiserson Date: Fri, 22 May 2026 12:18:02 -0700 Subject: [PATCH] test: Additional "life cycle" tests --- tests/tests/wgpu-gpu/life_cycle.rs | 512 +++++++++++++++++++++++++++-- 1 file changed, 491 insertions(+), 21 deletions(-) diff --git a/tests/tests/wgpu-gpu/life_cycle.rs b/tests/tests/wgpu-gpu/life_cycle.rs index 1e5d368c57..3c76f5e7c6 100644 --- a/tests/tests/wgpu-gpu/life_cycle.rs +++ b/tests/tests/wgpu-gpu/life_cycle.rs @@ -1,5 +1,7 @@ use wgpu::util::DeviceExt; -use wgpu_test::{fail, gpu_test, GpuTestConfiguration, GpuTestInitializer, TestParameters}; +use wgpu_test::{ + fail, gpu_test, GpuTestConfiguration, GpuTestInitializer, TestParameters, TestingContext, +}; pub fn all_tests(vec: &mut Vec) { vec.extend([ @@ -7,6 +9,8 @@ pub fn all_tests(vec: &mut Vec) { TEXTURE_DESTROY, BUFFER_DESTROY_BEFORE_SUBMIT, TEXTURE_DESTROY_BEFORE_SUBMIT, + EXTERNAL_TEXTURE_DESTROY_BEFORE_SUBMIT, + REPLACED_BIND_GROUP, ]); } @@ -126,12 +130,195 @@ static TEXTURE_DESTROY: GpuTestConfiguration = GpuTestConfiguration::new() texture.destroy(); }); -// Test that destroying a buffer between command buffer recording and -// submission fails gracefully. -#[gpu_test] -static BUFFER_DESTROY_BEFORE_SUBMIT: GpuTestConfiguration = GpuTestConfiguration::new() - .parameters(TestParameters::default().enable_noop()) - .run_sync(|ctx| { +#[derive(Copy, Clone)] +enum UsageKind { + Direct, + RenderPass, + ComputePass, + RenderBundle, +} + +const BUFFER_RENDER_SHADER: &str = "\ +@group(0) @binding(0) var buf: vec4; +@vertex fn vs() -> @builtin(position) vec4 { return buf; } +@fragment fn fs() -> @location(0) vec4 { return vec4(0); }"; + +const BUFFER_COMPUTE_SHADER: &str = "\ +@group(0) @binding(0) var buf: vec4; +@compute @workgroup_size(1) fn main() { _ = buf; }"; + +const TEXTURE_RENDER_SHADER: &str = "\ +@group(0) @binding(0) var tex: texture_2d; +@vertex fn vs() -> @builtin(position) vec4 { return vec4(0); } +@fragment fn fs() -> @location(0) vec4 { return textureLoad(tex, vec2(0), 0); }"; + +const TEXTURE_COMPUTE_SHADER: &str = "\ +@group(0) @binding(0) var tex: texture_2d; +@compute @workgroup_size(1) fn main() { _ = textureLoad(tex, vec2(0), 0); }"; + +const EXTERNAL_TEXTURE_RENDER_SHADER: &str = "\ +@group(0) @binding(0) var tex: texture_external; +@vertex fn vs() -> @builtin(position) vec4 { return vec4(0); } +@fragment fn fs() -> @location(0) vec4 { return textureLoad(tex, vec2(0)); }"; + +const EXTERNAL_TEXTURE_COMPUTE_SHADER: &str = "\ +@group(0) @binding(0) var tex: texture_external; +@compute @workgroup_size(1) fn main() { _ = textureLoad(tex, vec2(0)); }"; + +fn create_render_target(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) +} + +fn create_render_pipeline(device: &wgpu::Device, shader_src: &str) -> wgpu::RenderPipeline { + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Wgsl(shader_src.into()), + }); + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: None, + layout: None, + vertex: wgpu::VertexState { + module: &module, + entry_point: None, + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: None, + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8Unorm, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + multiview_mask: None, + cache: None, + }) +} + +fn create_compute_pipeline(device: &wgpu::Device, shader_src: &str) -> wgpu::ComputePipeline { + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: None, + source: wgpu::ShaderSource::Wgsl(shader_src.into()), + }); + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: None, + layout: None, + module: &module, + entry_point: None, + compilation_options: wgpu::PipelineCompilationOptions::default(), + cache: None, + }) +} + +/// Records a bind group usage into an encoder and returns the encoder. +fn record_encoder_with_resource( + ctx: &TestingContext, + usage: UsageKind, + resource: wgpu::BindingResource<'_>, + render_shader: &str, + compute_shader: &str, +) -> wgpu::CommandEncoder { + let (_render_target, rt_view) = create_render_target(&ctx.device); + let mut encoder = ctx + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + + match usage { + UsageKind::Direct => unreachable!(), + UsageKind::RenderPass | UsageKind::RenderBundle => { + let pipeline = create_render_pipeline(&ctx.device, render_shader); + let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &pipeline.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource, + }], + }); + + let color_attachment = [Some(wgpu::RenderPassColorAttachment { + view: &rt_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })]; + + if matches!(usage, UsageKind::RenderPass) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &color_attachment, + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..0, 0..0); + } else { + let mut rbe = + ctx.device + .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor { + label: None, + color_formats: &[Some(wgpu::TextureFormat::Rgba8Unorm)], + depth_stencil: None, + sample_count: 1, + multiview: None, + }); + rbe.set_pipeline(&pipeline); + rbe.set_bind_group(0, &bind_group, &[]); + rbe.draw(0..0, 0..0); + let bundle = rbe.finish(&wgpu::RenderBundleDescriptor::default()); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &color_attachment, + ..Default::default() + }); + pass.execute_bundles([&bundle]); + } + } + UsageKind::ComputePass => { + let pipeline = create_compute_pipeline(&ctx.device, compute_shader); + let bind_group = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &pipeline.get_bind_group_layout(0), + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource, + }], + }); + + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor::default()); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.dispatch_workgroups(0, 0, 0); + } + } + + encoder +} + +fn test_buffer_destroy_before_submit(ctx: &TestingContext, usage: UsageKind) { + if matches!(usage, UsageKind::Direct) { let buffer_source = ctx .device .create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -154,21 +341,55 @@ static BUFFER_DESTROY_BEFORE_SUBMIT: GpuTestConfiguration = GpuTestConfiguration buffer_source.destroy(); buffer_dest.destroy(); - let cmd_buffer = encoder.finish(); - fail( &ctx.device, - || ctx.queue.submit([cmd_buffer]), + || ctx.queue.submit([encoder.finish()]), Some("Buffer with '' label has been destroyed"), ); + return; + } + + let buffer = ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: 16, + usage: wgpu::BufferUsages::UNIFORM, + mapped_at_creation: false, }); -// Test that destroying a texture between command buffer recording and -// submission fails gracefully. + let encoder = record_encoder_with_resource( + ctx, + usage, + buffer.as_entire_binding(), + BUFFER_RENDER_SHADER, + BUFFER_COMPUTE_SHADER, + ); + + buffer.destroy(); + + fail( + &ctx.device, + || ctx.queue.submit([encoder.finish()]), + Some("Buffer with '' label has been destroyed"), + ); +} + +// Test that destroying a buffer between command encoding and submission fails gracefully. #[gpu_test] -static TEXTURE_DESTROY_BEFORE_SUBMIT: GpuTestConfiguration = GpuTestConfiguration::new() - .parameters(TestParameters::default().enable_noop()) +static BUFFER_DESTROY_BEFORE_SUBMIT: GpuTestConfiguration = GpuTestConfiguration::new() + .parameters( + TestParameters::default() + .test_features_limits() + .enable_noop(), + ) .run_sync(|ctx| { + test_buffer_destroy_before_submit(&ctx, UsageKind::Direct); + test_buffer_destroy_before_submit(&ctx, UsageKind::RenderPass); + test_buffer_destroy_before_submit(&ctx, UsageKind::ComputePass); + test_buffer_destroy_before_submit(&ctx, UsageKind::RenderBundle); + }); + +fn test_texture_destroy_before_submit(ctx: &TestingContext, usage: UsageKind) { + if matches!(usage, UsageKind::Direct) { let descriptor = wgpu::TextureDescriptor { label: None, size: wgpu::Extent3d { @@ -177,12 +398,10 @@ static TEXTURE_DESTROY_BEFORE_SUBMIT: GpuTestConfiguration = GpuTestConfiguratio depth_or_array_layers: 1, }, mip_level_count: 1, - sample_count: 1, // multisampling is not supported for clear + sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Snorm, - usage: wgpu::TextureUsages::COPY_DST - | wgpu::TextureUsages::COPY_SRC - | wgpu::TextureUsages::TEXTURE_BINDING, + usage: wgpu::TextureUsages::TEXTURE_BINDING, view_formats: &[], }; @@ -215,11 +434,262 @@ static TEXTURE_DESTROY_BEFORE_SUBMIT: GpuTestConfiguration = GpuTestConfiguratio texture_1.destroy(); texture_2.destroy(); - let cmd_buffer = encoder.finish(); - fail( &ctx.device, - || ctx.queue.submit([cmd_buffer]), + || ctx.queue.submit([encoder.finish()]), Some("Texture with '' label has been destroyed"), ); + return; + } + + let texture = ctx.device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let encoder = record_encoder_with_resource( + ctx, + usage, + wgpu::BindingResource::TextureView(&view), + TEXTURE_RENDER_SHADER, + TEXTURE_COMPUTE_SHADER, + ); + + texture.destroy(); + + fail( + &ctx.device, + || ctx.queue.submit([encoder.finish()]), + Some("Texture with '' label has been destroyed"), + ); +} + +// Test that destroying a texture between command encoding and submission fails gracefully. +#[gpu_test] +static TEXTURE_DESTROY_BEFORE_SUBMIT: GpuTestConfiguration = GpuTestConfiguration::new() + .parameters( + TestParameters::default() + .test_features_limits() + .enable_noop() + .features(wgpu::Features::CLEAR_TEXTURE), + ) + .run_sync(|ctx| { + test_texture_destroy_before_submit(&ctx, UsageKind::Direct); + test_texture_destroy_before_submit(&ctx, UsageKind::RenderPass); + test_texture_destroy_before_submit(&ctx, UsageKind::ComputePass); + test_texture_destroy_before_submit(&ctx, UsageKind::RenderBundle); + }); + +fn test_external_texture_destroy_before_submit(ctx: &TestingContext, usage: UsageKind) { + let plane_texture = ctx.device.create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + + let external_texture = ctx.device.create_external_texture( + &wgpu::ExternalTextureDescriptor { + label: None, + width: 1, + height: 1, + format: wgpu::ExternalTextureFormat::Rgba, + yuv_conversion_matrix: [0.0; 16], + gamut_conversion_matrix: [0.0; 9], + src_transfer_function: Default::default(), + dst_transfer_function: Default::default(), + sample_transform: [0.0; 6], + load_transform: [0.0; 6], + }, + &[&plane_texture.create_view(&wgpu::TextureViewDescriptor::default())], + ); + + let encoder = record_encoder_with_resource( + ctx, + usage, + wgpu::BindingResource::ExternalTexture(&external_texture), + EXTERNAL_TEXTURE_RENDER_SHADER, + EXTERNAL_TEXTURE_COMPUTE_SHADER, + ); + + plane_texture.destroy(); + external_texture.destroy(); + + // External textures use a buffer and several textures internally. We consider which one + // triggers the error to be an implementation detail and match either. + fail( + &ctx.device, + || ctx.queue.submit([encoder.finish()]), + Some("with '' label has been destroyed"), + ); +} + +// Test that destroying an external texture between command encoding and submission fails +// gracefully. +#[gpu_test] +static EXTERNAL_TEXTURE_DESTROY_BEFORE_SUBMIT: GpuTestConfiguration = GpuTestConfiguration::new() + .parameters( + TestParameters::default() + .test_features_limits() + .enable_noop() + .features(wgpu::Features::EXTERNAL_TEXTURE | wgpu::Features::CLEAR_TEXTURE), + ) + .run_sync(|ctx| { + // UsageKind::Direct does not apply because external textures only support TEXTURE_BINDING. + test_external_texture_destroy_before_submit(&ctx, UsageKind::RenderPass); + test_external_texture_destroy_before_submit(&ctx, UsageKind::ComputePass); + test_external_texture_destroy_before_submit(&ctx, UsageKind::RenderBundle); + }); + +fn test_replaced_bind_group(ctx: &TestingContext, usage: UsageKind) { + let buffer_a = ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: 16, + usage: wgpu::BufferUsages::UNIFORM, + mapped_at_creation: false, + }); + let buffer_b = ctx.device.create_buffer(&wgpu::BufferDescriptor { + label: None, + size: 16, + usage: wgpu::BufferUsages::UNIFORM, + mapped_at_creation: false, + }); + + let (_render_target, rt_view) = create_render_target(&ctx.device); + let mut encoder = ctx + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor::default()); + + match usage { + UsageKind::RenderPass | UsageKind::RenderBundle => { + let pipeline = create_render_pipeline(&ctx.device, BUFFER_RENDER_SHADER); + let layout = pipeline.get_bind_group_layout(0); + let bind_group_a = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buffer_a.as_entire_binding(), + }], + }); + let bind_group_b = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buffer_b.as_entire_binding(), + }], + }); + + let color_attachment = [Some(wgpu::RenderPassColorAttachment { + view: &rt_view, + depth_slice: None, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })]; + + if matches!(usage, UsageKind::RenderPass) { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &color_attachment, + ..Default::default() + }); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind_group_a, &[]); + pass.set_bind_group(0, &bind_group_b, &[]); + pass.draw(0..0, 0..0); + } else { + let mut rbe = + ctx.device + .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor { + label: None, + color_formats: &[Some(wgpu::TextureFormat::Rgba8Unorm)], + depth_stencil: None, + sample_count: 1, + multiview: None, + }); + rbe.set_pipeline(&pipeline); + rbe.set_bind_group(0, &bind_group_a, &[]); + rbe.set_bind_group(0, &bind_group_b, &[]); + rbe.draw(0..0, 0..0); + let bundle = rbe.finish(&wgpu::RenderBundleDescriptor::default()); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + color_attachments: &color_attachment, + ..Default::default() + }); + pass.execute_bundles([&bundle]); + } + } + UsageKind::ComputePass => { + let pipeline = create_compute_pipeline(&ctx.device, BUFFER_COMPUTE_SHADER); + let layout = pipeline.get_bind_group_layout(0); + let bind_group_a = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buffer_a.as_entire_binding(), + }], + }); + let bind_group_b = ctx.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: buffer_b.as_entire_binding(), + }], + }); + + let mut pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor::default()); + pass.set_pipeline(&pipeline); + pass.set_bind_group(0, &bind_group_a, &[]); + pass.set_bind_group(0, &bind_group_b, &[]); + pass.dispatch_workgroups(0, 0, 0); + } + UsageKind::Direct => unreachable!(), + } + + buffer_a.destroy(); + + fail( + &ctx.device, + || ctx.queue.submit([encoder.finish()]), + Some("Buffer with '' label has been destroyed"), + ); +} + +/// Test that bind groups that are replaced before use in a draw/dispatch are still +/// considered in submit-time liveness checks. +#[gpu_test] +static REPLACED_BIND_GROUP: GpuTestConfiguration = GpuTestConfiguration::new() + .parameters( + TestParameters::default() + .test_features_limits() + .enable_noop(), + ) + .run_sync(|ctx| { + test_replaced_bind_group(&ctx, UsageKind::RenderPass); + test_replaced_bind_group(&ctx, UsageKind::ComputePass); + test_replaced_bind_group(&ctx, UsageKind::RenderBundle); });