diff --git a/CHANGELOG.md b/CHANGELOG.md index 72707fc..8866164 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,23 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.30.28] - 2026-07-30 + +### Fixed + +- **Resource lifecycle: BindGroup/Pipeline Release() bypasses ref-counting** — + `Release()` called `dq.Defer(lastSubmissionIndex)` directly, ignoring the + `ResourceRef` ref-counting system. On shared encoder path, this caused + use-after-free: HAL resource destroyed while GPU still processing commands. + Now `Release()` calls `ref.Drop()` — HAL destruction deferred until ALL + refs (user + GPU) are dropped. Matches Rust wgpu `Arc` pattern. + Applied to: BindGroup, RenderPipeline, ComputePipeline. (ADR-056, #287) + +- **DestroyQueue deadlock: Triage → onZero → Defer re-entry** — + `Triage()` held mutex while executing callbacks. When `onZero` fired and + called `Defer()`, it tried to acquire the same mutex → deadlock. Now + callbacks execute outside the lock. Same fix applied to `FlushAll()`. + ## [0.30.27] - 2026-07-30 ### Fixed diff --git a/bind_native.go b/bind_native.go index 390817a..41e306e 100644 --- a/bind_native.go +++ b/bind_native.go @@ -17,11 +17,9 @@ import ( // the BindGroup itself — runtime.AddCleanup requires the callback argument to // be independent of the cleaned-up object. type bindGroupCleanupRef struct { - label string - released *atomic.Bool - destroyQueue *core.DestroyQueue - lastSubIdx func() uint64 - destroyFn func() + label string + released *atomic.Bool + ref *core.ResourceRef // for ref.Drop() in GC path (ADR-056) } // BindGroupLayout defines the structure of resource bindings for shaders. @@ -200,14 +198,15 @@ type BindGroup struct { boundTextures []*Texture } -// Release marks the bind group for destruction. The underlying HAL BindGroup -// (and its descriptor heap slots) is not freed immediately — it is deferred via -// DestroyQueue until the GPU completes any submission that may reference it. -// This prevents descriptor use-after-free on DX12 with maxFramesInFlight=2 -// (BUG-DX12-007). +// Release marks the bind group for destruction. Drops the application's +// ownership reference via ResourceRef.Drop(). If the bind group is still +// referenced by in-flight GPU submissions (Clone'd via SetBindGroup), the +// HAL bind group stays alive until the GPU completes and Triage drops all +// tracked refs. The onZero callback (set at CreateBindGroup) fires only +// when the last reference drops, deferring HAL destruction via DestroyQueue. // -// Matches Rust wgpu pattern: BindGroup::drop() only fires after -// triage_submissions confirms fence completion. +// This matches Rust wgpu's Arc Drop behavior — deterministic, +// refcount-driven destruction. ADR-056: unified resource lifecycle. func (g *BindGroup) Release() { if g.released == nil || !g.released.CompareAndSwap(false, true) { return @@ -216,71 +215,26 @@ func (g *BindGroup) Release() { // Cancel the GC cleanup — we are destroying explicitly. g.cleanup.Stop() - if g.device == nil { - return - } - - halDevice := g.device.halDevice() - if halDevice == nil { - return + if g.ref != nil { + g.ref.Drop() } - - dq := g.device.destroyQueue() - if dq == nil { - halDevice.DestroyBindGroup(g.hal) - return - } - - subIdx := g.device.lastSubmissionIndex() - halBG := g.hal - dq.Defer(subIdx, "BindGroup", func() { - halDevice.DestroyBindGroup(halBG) - }) } // registerBindGroupCleanup registers a runtime.AddCleanup handler on the bind group. // When GC collects the bind group without an explicit Release(), the cleanup -// schedules deferred destruction via DestroyQueue — the same path as Release(). -func registerBindGroupCleanup(bg *BindGroup, dev *Device, label string) runtime.Cleanup { - halDevice := dev.halDevice() - if halDevice == nil { - // No HAL device — nothing to destroy. - return runtime.Cleanup{} - } - - halBG := bg.hal - destroyFn := func() { - halDevice.DestroyBindGroup(halBG) - } - - dq := dev.destroyQueue() - if dq == nil { - // No DestroyQueue — register cleanup that destroys immediately. - return runtime.AddCleanup(bg, func(ref bindGroupCleanupRef) { - if !ref.released.CompareAndSwap(false, true) { - return - } - slog.Warn("wgpu: BindGroup released by GC (missing explicit Release)", "label", ref.label) - ref.destroyFn() - }, bindGroupCleanupRef{ - label: label, - released: bg.released, - destroyFn: destroyFn, - }) - } - +// drops the ResourceRef — the same refcount-driven path as Release(). ADR-056. +func registerBindGroupCleanup(bg *BindGroup, _ *Device, label string) runtime.Cleanup { return runtime.AddCleanup(bg, func(ref bindGroupCleanupRef) { if !ref.released.CompareAndSwap(false, true) { return } slog.Warn("wgpu: BindGroup released by GC (missing explicit Release)", "label", ref.label) - subIdx := ref.lastSubIdx() - ref.destroyQueue.Defer(subIdx, "BindGroup(GC):"+ref.label, ref.destroyFn) + if ref.ref != nil { + ref.ref.Drop() + } }, bindGroupCleanupRef{ - label: label, - released: bg.released, - destroyQueue: dq, - lastSubIdx: dev.lastSubmissionIndex, - destroyFn: destroyFn, + label: label, + released: bg.released, + ref: bg.ref, }) } diff --git a/core/destroy_queue.go b/core/destroy_queue.go index 5a7b1b8..6b76752 100644 --- a/core/destroy_queue.go +++ b/core/destroy_queue.go @@ -99,17 +99,24 @@ func (q *DestroyQueue) TrackSubmission(index uint64, refs []*ResourceRef) { // // Also Drop()'s ResourceRefs from completed TrackedSubmissions (Phase 2). // +// Callbacks and ref Drop()'s are executed OUTSIDE the mutex to prevent deadlocks +// when an onZero callback re-enters the DestroyQueue (ADR-056: ref.Drop() -> +// onZero -> dq.Defer() must not deadlock with Triage holding the lock). +// // This should be called after each Queue.Submit() with the result of // hal.Queue.PollCompleted(). func (q *DestroyQueue) Triage(completedIndex uint64) { + // Collect completed items under the lock; execute callbacks outside. + var completedDestroys []func() + var completedRefs []*ResourceRef + q.mu.Lock() - defer q.mu.Unlock() // Triage deferred destroys (Phase 1). n := 0 for i := range q.pending { if q.pending[i].submissionIndex <= completedIndex { - q.pending[i].destroyFn() + completedDestroys = append(completedDestroys, q.pending[i].destroyFn) } else { q.pending[n] = q.pending[i] n++ @@ -125,9 +132,7 @@ func (q *DestroyQueue) Triage(completedIndex uint64) { tn := 0 for i := range q.tracked { if q.tracked[i].index <= completedIndex { - for _, ref := range q.tracked[i].refs { - ref.Drop() - } + completedRefs = append(completedRefs, q.tracked[i].refs...) } else { q.tracked[tn] = q.tracked[i] tn++ @@ -137,26 +142,49 @@ func (q *DestroyQueue) Triage(completedIndex uint64) { q.tracked[i] = TrackedSubmission{} } q.tracked = q.tracked[:tn] + + q.mu.Unlock() + + // Execute Phase 1 destroy callbacks outside the lock. + for _, fn := range completedDestroys { + fn() + } + + // Drop Phase 2 refs outside the lock. + // onZero callbacks may call q.Defer() — safe because the lock is released. + for _, ref := range completedRefs { + ref.Drop() + } } // FlushAll destroys all pending resources regardless of GPU completion status. // Called during device shutdown when all GPU work is (or should be) complete. // Also Drop()'s all tracked submission refs (Phase 2). +// +// Callbacks and ref Drop()'s are executed outside the mutex to prevent deadlocks +// when an onZero callback re-enters the DestroyQueue (ADR-056). func (q *DestroyQueue) FlushAll() { q.mu.Lock() - defer q.mu.Unlock() - for i := range q.pending { - q.pending[i].destroyFn() - } + // Take ownership of slices and clear under the lock. + destroys := q.pending + tracked := q.tracked q.pending = nil + q.tracked = nil - for i := range q.tracked { - for _, ref := range q.tracked[i].refs { + q.mu.Unlock() + + // Execute Phase 1 destroy callbacks outside the lock. + for i := range destroys { + destroys[i].destroyFn() + } + + // Drop Phase 2 refs outside the lock. + for i := range tracked { + for _, ref := range tracked[i].refs { ref.Drop() } } - q.tracked = nil } // Len returns the number of pending deferred destructions. For testing only. diff --git a/device_native.go b/device_native.go index f5c97ca..3bf996e 100644 --- a/device_native.go +++ b/device_native.go @@ -437,13 +437,30 @@ func (d *Device) CreateBindGroup(desc *BindGroupDescriptor) (*BindGroup, error) // Collect buffer and texture references for submit-time validation (VAL-A6). boundBuffers, boundTextures := collectBindGroupResources(desc.Entries) + // Initialize ResourceRef with onZero callback for refcount-driven destruction. + // When the last reference drops (either from explicit Release or Phase 2 + // Triage after GPU completion), onZero fires and defers HAL destruction via + // DestroyQueue. This matches Rust wgpu's Arc Drop behavior. ADR-056. + halBG := halGroup + bgOnZero := func() { + dq := d.destroyQueue() + if dq != nil { + subIdx := d.lastSubmissionIndex() + dq.Defer(subIdx, "BindGroup", func() { + halDevice.DestroyBindGroup(halBG) + }) + } else { + halDevice.DestroyBindGroup(halBG) + } + } + bg := &BindGroup{ hal: halGroup, device: d, released: new(atomic.Bool), layout: desc.Layout, lateBufferBindingInfos: lateInfos, - ref: core.NewResourceRef("BindGroup:"+desc.Label, nil), + ref: core.NewResourceRef("BindGroup:"+desc.Label, bgOnZero), boundBuffers: boundBuffers, boundTextures: boundTextures, } @@ -538,6 +555,23 @@ func (d *Device) CreateRenderPipeline(desc *RenderPipelineDescriptor) (*RenderPi lateGroups := makeLateSizedBufferGroups(shaderBindingSizes, bgLayouts) + // Initialize ResourceRef with onZero callback for refcount-driven destruction. + // When the last reference drops (either from explicit Release or Phase 2 + // Triage after GPU completion), onZero fires and defers HAL destruction via + // DestroyQueue. ADR-056: unified resource lifecycle. + halRP := halPipeline + rpOnZero := func() { + dq := d.destroyQueue() + if dq != nil { + subIdx := d.lastSubmissionIndex() + dq.Defer(subIdx, "RenderPipeline", func() { + halDevice.DestroyRenderPipeline(halRP) + }) + } else { + halDevice.DestroyRenderPipeline(halRP) + } + } + return &RenderPipeline{ hal: halPipeline, device: d, @@ -547,7 +581,7 @@ func (d *Device) CreateRenderPipeline(desc *RenderPipelineDescriptor) (*RenderPi blendConstantRequired: needsBlendConstant, stripIndexFormat: desc.Primitive.StripIndexFormat, lateSizedBufferGroups: lateGroups, - ref: core.NewResourceRef("RenderPipeline:"+desc.Label, nil), + ref: core.NewResourceRef("RenderPipeline:"+desc.Label, rpOnZero), }, nil } @@ -633,13 +667,28 @@ func (d *Device) CreateComputePipeline(desc *ComputePipelineDescriptor) (*Comput lateGroups := makeLateSizedBufferGroups(shaderBindingSizes, bgLayouts) + // Initialize ResourceRef with onZero callback for refcount-driven destruction. + // ADR-056: unified resource lifecycle. + halCP := halPipeline + cpOnZero := func() { + dq := d.destroyQueue() + if dq != nil { + subIdx := d.lastSubmissionIndex() + dq.Defer(subIdx, "ComputePipeline", func() { + halDevice.DestroyComputePipeline(halCP) + }) + } else { + halDevice.DestroyComputePipeline(halCP) + } + } + return &ComputePipeline{ hal: halPipeline, device: d, bindGroupCount: bgCount, bindGroupLayouts: bgLayouts, lateSizedBufferGroups: lateGroups, - ref: core.NewResourceRef("ComputePipeline:"+desc.Label, nil), + ref: core.NewResourceRef("ComputePipeline:"+desc.Label, cpOnZero), }, nil } diff --git a/lifecycle_test.go b/lifecycle_test.go new file mode 100644 index 0000000..fe68478 --- /dev/null +++ b/lifecycle_test.go @@ -0,0 +1,445 @@ +//go:build !rust && !(js && wasm) + +package wgpu_test + +import ( + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu" + "github.com/gogpu/wgpu/core" + + _ "github.com/gogpu/wgpu/hal/noop" +) + +// ============================================================================= +// ADR-056: Unified Resource Lifecycle tests +// +// These tests verify that Release() goes through ResourceRef.Drop() for all +// tracked resources (BindGroup, RenderPipeline, ComputePipeline), so that +// in-flight GPU references prevent premature HAL destruction. +// ============================================================================= + +// TestBindGroup_ReleaseUsesRefDrop verifies that BindGroup.Release() decrements +// the refcount via Drop() instead of directly calling dq.Defer(). When a +// SetBindGroup Clone'd the ref, Release() should NOT destroy the HAL resource +// immediately — the refcount stays at 1 (from the Clone) and the onZero callback +// fires only when Triage drops the tracked ref after GPU completion. +func TestBindGroup_ReleaseUsesRefDrop(t *testing.T) { + _, _, device := newDevice(t) + defer device.Release() + requireHAL(t, device) + + dq := device.TestDestroyQueue() + if dq == nil { + t.Skip("device has no DestroyQueue") + } + + layout, err := device.CreateBindGroupLayout(&wgpu.BindGroupLayoutDescriptor{ + Label: "lifecycle-bgl", + Entries: []wgpu.BindGroupLayoutEntry{}, + }) + if err != nil { + t.Fatalf("CreateBindGroupLayout: %v", err) + } + defer layout.Release() + + bg, err := device.CreateBindGroup(&wgpu.BindGroupDescriptor{ + Label: "lifecycle-bg", + Layout: layout, + }) + if err != nil { + t.Fatalf("CreateBindGroup: %v", err) + } + + ref := bg.TestRef() + if ref == nil { + t.Fatal("BindGroup should have a non-nil ResourceRef") + } + + // Initial refcount should be 1 (application owner). + if got := ref.RefCount(); got != 1 { + t.Fatalf("initial refcount: want 1, got %d", got) + } + + // Simulate SetBindGroup → trackRef → Clone. + ref.Clone() + if got := ref.RefCount(); got != 2 { + t.Fatalf("after Clone: want 2, got %d", got) + } + + // Record DestroyQueue state before Release. + pendingBefore := dq.Len() + + // Release drops the application reference: refcount 2 → 1. + bg.Release() + if got := ref.RefCount(); got != 1 { + t.Fatalf("after Release (with in-flight clone): want 1, got %d", got) + } + + // The HAL resource should NOT be destroyed yet — the Clone still holds a ref. + // With the old Phase 1 code, Release() would call dq.Defer() directly, + // ignoring the Clone. With ADR-056, onZero doesn't fire until refcount = 0. + pendingAfterRelease := dq.Len() + if pendingAfterRelease != pendingBefore { + t.Errorf("dq.Defer should NOT be called while refs remain: pending before=%d, after=%d", + pendingBefore, pendingAfterRelease) + } + + // Simulate GPU completion → Triage → Drop the cloned ref. + ref.Drop() + if got := ref.RefCount(); got != 0 { + t.Fatalf("after final Drop: want 0, got %d", got) + } + + // NOW the onZero callback should have fired, scheduling deferred destruction. + pendingAfterDrop := dq.Len() + if pendingAfterDrop <= pendingBefore { + t.Errorf("onZero should schedule dq.Defer: pending before=%d, after final drop=%d", + pendingBefore, pendingAfterDrop) + } +} + +// TestBindGroup_ReleaseWithoutClone_DestroysImmediately verifies that when +// a BindGroup is released without any in-flight Clone (never used in a pass), +// the refcount goes 1→0 and onZero fires immediately. +func TestBindGroup_ReleaseWithoutClone_DestroysImmediately(t *testing.T) { + _, _, device := newDevice(t) + defer device.Release() + requireHAL(t, device) + + dq := device.TestDestroyQueue() + if dq == nil { + t.Skip("device has no DestroyQueue") + } + + layout, err := device.CreateBindGroupLayout(&wgpu.BindGroupLayoutDescriptor{ + Label: "immediate-bgl", + Entries: []wgpu.BindGroupLayoutEntry{}, + }) + if err != nil { + t.Fatalf("CreateBindGroupLayout: %v", err) + } + defer layout.Release() + + bg, err := device.CreateBindGroup(&wgpu.BindGroupDescriptor{ + Label: "immediate-bg", + Layout: layout, + }) + if err != nil { + t.Fatalf("CreateBindGroup: %v", err) + } + + ref := bg.TestRef() + pendingBefore := dq.Len() + + // Release with no Clone → refcount 1→0 → onZero fires → dq.Defer. + bg.Release() + + if got := ref.RefCount(); got != 0 { + t.Fatalf("after Release without Clone: want refcount=0, got %d", got) + } + + pendingAfter := dq.Len() + if pendingAfter <= pendingBefore { + t.Errorf("onZero should schedule dq.Defer immediately: pending before=%d, after=%d", + pendingBefore, pendingAfter) + } +} + +// TestRenderPipeline_ReleaseUsesRefDrop verifies that RenderPipeline.Release() +// goes through ResourceRef.Drop() rather than dq.Defer() directly. +func TestRenderPipeline_ReleaseUsesRefDrop(t *testing.T) { + _, _, device := newDevice(t) + defer device.Release() + requireHAL(t, device) + + dq := device.TestDestroyQueue() + if dq == nil { + t.Skip("device has no DestroyQueue") + } + + mod, err := device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{ + Label: "lifecycle-shader", + WGSL: "@vertex fn vs_main() -> @builtin(position) vec4f { return vec4f(0.0); }", + }) + if err != nil { + t.Fatalf("CreateShaderModule: %v", err) + } + defer mod.Release() + + pipeline, err := device.CreateRenderPipeline(&wgpu.RenderPipelineDescriptor{ + Label: "lifecycle-rp", + Vertex: wgpu.VertexState{Module: mod, EntryPoint: "vs_main"}, + }) + if err != nil { + t.Fatalf("CreateRenderPipeline: %v", err) + } + + ref := pipeline.TestRef() + if ref == nil { + t.Fatal("RenderPipeline should have a non-nil ResourceRef") + } + + // Simulate SetPipeline → Clone. + ref.Clone() + + pendingBefore := dq.Len() + + // Release: refcount 2 → 1 (Clone still holds). + pipeline.Release() + if got := ref.RefCount(); got != 1 { + t.Fatalf("after Release with in-flight clone: want 1, got %d", got) + } + if dq.Len() != pendingBefore { + t.Error("dq.Defer should NOT be called while refs remain") + } + + // Simulate GPU completion → Drop cloned ref. + ref.Drop() + if got := ref.RefCount(); got != 0 { + t.Fatalf("after final Drop: want 0, got %d", got) + } + if dq.Len() <= pendingBefore { + t.Error("onZero should schedule dq.Defer after final Drop") + } +} + +// TestComputePipeline_ReleaseUsesRefDrop verifies that ComputePipeline.Release() +// goes through ResourceRef.Drop() rather than dq.Defer() directly. +func TestComputePipeline_ReleaseUsesRefDrop(t *testing.T) { + _, _, device := newDevice(t) + defer device.Release() + requireHAL(t, device) + + dq := device.TestDestroyQueue() + if dq == nil { + t.Skip("device has no DestroyQueue") + } + + mod, err := device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{ + Label: "lifecycle-cs", + WGSL: "@compute @workgroup_size(1) fn main() {}", + }) + if err != nil { + t.Fatalf("CreateShaderModule: %v", err) + } + defer mod.Release() + + pipeline, err := device.CreateComputePipeline(&wgpu.ComputePipelineDescriptor{ + Label: "lifecycle-cp", + Module: mod, + EntryPoint: "main", + }) + if err != nil { + t.Skipf("CreateComputePipeline not supported: %v", err) + } + + ref := pipeline.TestRef() + if ref == nil { + t.Fatal("ComputePipeline should have a non-nil ResourceRef") + } + + // Simulate SetPipeline → Clone. + ref.Clone() + + pendingBefore := dq.Len() + + // Release: refcount 2 → 1 (Clone still holds). + pipeline.Release() + if got := ref.RefCount(); got != 1 { + t.Fatalf("after Release with in-flight clone: want 1, got %d", got) + } + if dq.Len() != pendingBefore { + t.Error("dq.Defer should NOT be called while refs remain") + } + + // Simulate GPU completion → Drop cloned ref. + ref.Drop() + if got := ref.RefCount(); got != 0 { + t.Fatalf("after final Drop: want 0, got %d", got) + } + if dq.Len() <= pendingBefore { + t.Error("onZero should schedule dq.Defer after final Drop") + } +} + +// TestBindGroup_WithBuffer_ReleaseUsesRefDrop verifies the full scenario from +// Issue #287: a BindGroup containing a buffer is used via SetBindGroup (which +// Clone's the ref), then Released before the GPU completes. The HAL bind group +// must NOT be destroyed until the GPU submission finishes. +func TestBindGroup_WithBuffer_ReleaseUsesRefDrop(t *testing.T) { + _, _, device := newDevice(t) + defer device.Release() + requireHAL(t, device) + + dq := device.TestDestroyQueue() + if dq == nil { + t.Skip("device has no DestroyQueue") + } + + buf, err := device.CreateBuffer(&wgpu.BufferDescriptor{ + Label: "lifecycle-buf", + Size: 64, + Usage: wgpu.BufferUsageUniform | wgpu.BufferUsageCopyDst, + }) + if err != nil { + t.Fatalf("CreateBuffer: %v", err) + } + defer buf.Release() + + layout, err := device.CreateBindGroupLayout(&wgpu.BindGroupLayoutDescriptor{ + Label: "lifecycle-buf-bgl", + Entries: []wgpu.BindGroupLayoutEntry{ + { + Binding: 0, + Visibility: wgpu.ShaderStageVertex | wgpu.ShaderStageFragment, + Buffer: &gputypes.BufferBindingLayout{ + Type: gputypes.BufferBindingTypeUniform, + MinBindingSize: 64, + }, + }, + }, + }) + if err != nil { + t.Fatalf("CreateBindGroupLayout: %v", err) + } + defer layout.Release() + + bg, err := device.CreateBindGroup(&wgpu.BindGroupDescriptor{ + Label: "lifecycle-buf-bg", + Layout: layout, + Entries: []wgpu.BindGroupEntry{ + {Binding: 0, Buffer: buf, Offset: 0, Size: 64}, + }, + }) + if err != nil { + t.Fatalf("CreateBindGroup: %v", err) + } + + ref := bg.TestRef() + if ref == nil { + t.Fatal("BindGroup should have ResourceRef") + } + + // Simulate the encoder path: SetBindGroup → trackRef → Clone. + ref.Clone() + if got := ref.RefCount(); got != 2 { + t.Fatalf("after SetBindGroup Clone: want 2, got %d", got) + } + + pendingBefore := dq.Len() + + // User calls Release() while GPU is still processing. + bg.Release() + + // Refcount 2→1, NOT 0. HAL bind group is still alive. + if got := ref.RefCount(); got != 1 { + t.Fatalf("after Release with in-flight: want 1, got %d", got) + } + if dq.Len() != pendingBefore { + t.Error("HAL destruction should NOT be scheduled while refs remain") + } + + // Simulate GPU completion: Triage calls Drop on tracked ref. + ref.Drop() + if got := ref.RefCount(); got != 0 { + t.Fatalf("after GPU completion: want 0, got %d", got) + } + + // NOW onZero fires → dq.Defer schedules HAL destruction. + if dq.Len() <= pendingBefore { + t.Error("onZero should schedule HAL destruction after GPU completion") + } +} + +// TestMixedResourceLifecycle_TrackedSubmission verifies that when multiple +// resource types (BindGroup, RenderPipeline) are tracked in a single submission, +// Releasing them before GPU completion does not destroy HAL resources prematurely. +// After Triage, all onZero callbacks fire and schedule deferred destruction. +func TestMixedResourceLifecycle_TrackedSubmission(t *testing.T) { + _, _, device := newDevice(t) + defer device.Release() + requireHAL(t, device) + + dq := device.TestDestroyQueue() + if dq == nil { + t.Skip("device has no DestroyQueue") + } + + // Create resources. + layout, err := device.CreateBindGroupLayout(&wgpu.BindGroupLayoutDescriptor{ + Label: "mixed-bgl", + Entries: []wgpu.BindGroupLayoutEntry{}, + }) + if err != nil { + t.Fatalf("CreateBindGroupLayout: %v", err) + } + defer layout.Release() + + bg, err := device.CreateBindGroup(&wgpu.BindGroupDescriptor{ + Label: "mixed-bg", + Layout: layout, + }) + if err != nil { + t.Fatalf("CreateBindGroup: %v", err) + } + + mod, err := device.CreateShaderModule(&wgpu.ShaderModuleDescriptor{ + Label: "mixed-shader", + WGSL: "@vertex fn vs_main() -> @builtin(position) vec4f { return vec4f(0.0); }", + }) + if err != nil { + t.Fatalf("CreateShaderModule: %v", err) + } + defer mod.Release() + + pipeline, err := device.CreateRenderPipeline(&wgpu.RenderPipelineDescriptor{ + Label: "mixed-rp", + Vertex: wgpu.VertexState{Module: mod, EntryPoint: "vs_main"}, + }) + if err != nil { + t.Fatalf("CreateRenderPipeline: %v", err) + } + + bgRef := bg.TestRef() + rpRef := pipeline.TestRef() + + // Simulate encoding: Clone refs for the submission. + bgRef.Clone() + rpRef.Clone() + + // Simulate Submit: TrackSubmission with cloned refs. + dq.TrackSubmission(42, []*core.ResourceRef{bgRef, rpRef}) + + // User releases both resources before GPU completes. + bg.Release() + pipeline.Release() + + // Refcounts: both should be 1 (Clone from tracked submission). + if got := bgRef.RefCount(); got != 1 { + t.Fatalf("bg ref after Release: want 1, got %d", got) + } + if got := rpRef.RefCount(); got != 1 { + t.Fatalf("rp ref after Release: want 1, got %d", got) + } + + pendingBefore := dq.Len() + + // Simulate GPU completion: Triage drops the tracked refs. + dq.Triage(42) + + // Both refcounts should be 0, and onZero should have scheduled dq.Defer. + if got := bgRef.RefCount(); got != 0 { + t.Fatalf("bg ref after Triage: want 0, got %d", got) + } + if got := rpRef.RefCount(); got != 0 { + t.Fatalf("rp ref after Triage: want 0, got %d", got) + } + + pendingAfter := dq.Len() + if pendingAfter <= pendingBefore { + t.Errorf("onZero callbacks should schedule deferred destruction: pending before=%d, after=%d", + pendingBefore, pendingAfter) + } +} diff --git a/pipeline_native.go b/pipeline_native.go index e7d0318..6edb721 100644 --- a/pipeline_native.go +++ b/pipeline_native.go @@ -90,30 +90,21 @@ type RenderPipeline struct { ref *core.ResourceRef } -// Release destroys the render pipeline. Destruction is deferred until the GPU -// completes any submission that may reference this pipeline. +// Release drops the application's ownership reference to the render pipeline. +// +// If the pipeline is still referenced by in-flight GPU submissions (Clone'd +// via SetPipeline), the HAL pipeline stays alive until the GPU completes and +// Triage drops all tracked refs. The onZero callback (set at CreateRenderPipeline) +// fires only when the last reference drops. ADR-056: unified resource lifecycle. func (p *RenderPipeline) Release() { if p.released { return } p.released = true - halDevice := p.device.halDevice() - if halDevice == nil { - return - } - - dq := p.device.destroyQueue() - if dq == nil { - halDevice.DestroyRenderPipeline(p.hal) - return + if p.ref != nil { + p.ref.Drop() } - - subIdx := p.device.lastSubmissionIndex() - halPipeline := p.hal - dq.Defer(subIdx, "RenderPipeline", func() { - halDevice.DestroyRenderPipeline(halPipeline) - }) } // ComputePipeline represents a configured compute pipeline. @@ -137,28 +128,19 @@ type ComputePipeline struct { ref *core.ResourceRef } -// Release destroys the compute pipeline. Destruction is deferred until the GPU -// completes any submission that may reference this pipeline. +// Release drops the application's ownership reference to the compute pipeline. +// +// If the pipeline is still referenced by in-flight GPU submissions (Clone'd +// via SetPipeline), the HAL pipeline stays alive until the GPU completes and +// Triage drops all tracked refs. The onZero callback (set at CreateComputePipeline) +// fires only when the last reference drops. ADR-056: unified resource lifecycle. func (p *ComputePipeline) Release() { if p.released { return } p.released = true - halDevice := p.device.halDevice() - if halDevice == nil { - return - } - - dq := p.device.destroyQueue() - if dq == nil { - halDevice.DestroyComputePipeline(p.hal) - return + if p.ref != nil { + p.ref.Drop() } - - subIdx := p.device.lastSubmissionIndex() - halPipeline := p.hal - dq.Defer(subIdx, "ComputePipeline", func() { - halDevice.DestroyComputePipeline(halPipeline) - }) }