diff --git a/adapter_native.go b/adapter_native.go index 689711b..8b25370 100644 --- a/adapter_native.go +++ b/adapter_native.go @@ -39,15 +39,27 @@ func (a *Adapter) Limits() Limits { return a.limits } // RequestDevice creates a logical device from this adapter. // If desc is nil, default features and limits are used. func (a *Adapter) RequestDevice(desc *DeviceDescriptor) (*Device, error) { - if a.released { + if a == nil || a.released || a.instance == nil || a.instance.isReleased() { return nil, ErrReleased } + var ( + device *Device + err error + ) if a.core.HasHAL() { - return a.requestDeviceHAL(desc) + device, err = a.requestDeviceHAL(desc) + } else { + device, err = a.requestDeviceCore(desc) } - - return a.requestDeviceCore(desc) + if err != nil { + return nil, err + } + if err := a.instance.adoptDevice(device); err != nil { + device.Release() + return nil, err + } + return device, nil } func (a *Adapter) requestDeviceHAL(desc *DeviceDescriptor) (*Device, error) { @@ -143,7 +155,8 @@ type SurfaceCapabilities struct { // GetSurfaceCapabilities returns the capabilities of a surface for this adapter. // Returns nil if the adapter has no HAL (core-only path) or the surface is nil. func (a *Adapter) GetSurfaceCapabilities(surface *Surface) *SurfaceCapabilities { - if a.released || surface == nil { + if a == nil || a.released || a.instance == nil || a.instance.isReleased() || + surface == nil || surface.released || surface.instance != a.instance { return nil } @@ -154,7 +167,11 @@ func (a *Adapter) GetSurfaceCapabilities(surface *Surface) *SurfaceCapabilities } } - halCaps := a.core.HALAdapter().SurfaceCapabilities(surface.HAL()) + halSurface := surface.HAL() + if halSurface == nil { + return nil + } + halCaps := a.core.HALAdapter().SurfaceCapabilities(halSurface) if halCaps == nil { return nil } diff --git a/core/resource.go b/core/resource.go index ce00002..b4bbf87 100644 --- a/core/resource.go +++ b/core/resource.go @@ -1564,6 +1564,12 @@ type Surface struct { // acquiredTex is the currently acquired surface texture (nil when not acquired). acquiredTex hal.SurfaceTexture + // acquisition identifies the currently borrowed surface texture. A zero + // value means no acquisition is valid; nextAcquisition prevents an older + // wrapper from becoming valid after a later acquire. + acquisition uint64 + nextAcquisition uint64 + // prepareFrame is an optional platform hook called before acquiring a texture. prepareFrame PrepareFrameFunc @@ -1594,6 +1600,11 @@ func NewSurface( // RawSurface returns the underlying HAL surface. func (s *Surface) RawSurface() hal.Surface { + if s == nil { + return nil + } + s.mu.Lock() + defer s.mu.Unlock() return s.raw } @@ -1602,5 +1613,12 @@ func (s *Surface) RawSurface() hal.Surface { // instance (e.g., switching from Vulkan surface to software surface during // Configure when the device's backend differs from the original surface). func (s *Surface) SetRawSurface(raw hal.Surface) { + s.mu.Lock() + defer s.mu.Unlock() s.raw = raw + s.acquiredTex = nil + s.invalidateAcquisitionLocked() + s.device = nil + s.config = nil + s.state = SurfaceStateUnconfigured } diff --git a/core/surface.go b/core/surface.go index 4658ebb..a755720 100644 --- a/core/surface.go +++ b/core/surface.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "image" + "unsafe" "github.com/gogpu/wgpu/hal" ) @@ -77,6 +78,7 @@ func (s *Surface) Configure(device *Device, config *hal.SurfaceConfiguration) er return err } + s.invalidateAcquisitionLocked() s.device = device s.config = config s.state = SurfaceStateConfigured @@ -100,6 +102,7 @@ func (s *Surface) Unconfigure() { s.raw.DiscardTexture(s.acquiredTex) s.acquiredTex = nil } + s.invalidateAcquisitionLocked() halDevice := s.getHALDevice(s.device) if halDevice != nil { @@ -120,29 +123,53 @@ func (s *Surface) Unconfigure() { // After a successful acquire, the surface enters the Acquired state. // The caller must either Present or DiscardTexture before acquiring again. func (s *Surface) AcquireTexture(fence hal.Fence) (*hal.AcquiredSurfaceTexture, error) { + result, _, err := s.AcquireTextureWithLease(fence) + return result, err +} + +// AcquireTextureWithLease acquires a texture and returns its opaque lifetime +// lease. Call AcquisitionValid before converting a retained public wrapper to +// HAL; the lease expires on present, discard, unconfigure, or destruction. +func (s *Surface) AcquireTextureWithLease(fence hal.Fence) (*hal.AcquiredSurfaceTexture, uint64, error) { s.mu.Lock() defer s.mu.Unlock() if s.state == SurfaceStateAcquired { - return nil, ErrSurfaceAlreadyAcquired + return nil, 0, ErrSurfaceAlreadyAcquired } if s.state != SurfaceStateConfigured { - return nil, ErrSurfaceNotConfigured + return nil, 0, ErrSurfaceNotConfigured } // Call PrepareFrame hook if registered if err := s.applyPrepareFrame(); err != nil { - return nil, err + return nil, 0, err } result, err := s.raw.AcquireTexture(fence) if err != nil { - return nil, err + return nil, 0, err } s.acquiredTex = result.Texture + s.nextAcquisition++ + if s.nextAcquisition == 0 { + s.nextAcquisition++ + } + s.acquisition = s.nextAcquisition s.state = SurfaceStateAcquired - return result, nil + return result, s.acquisition, nil +} + +// AcquisitionValid reports whether lease still identifies this surface's +// current acquired texture. +func (s *Surface) AcquisitionValid(lease uint64) bool { + if s == nil || lease == 0 { + return false + } + s.mu.Lock() + defer s.mu.Unlock() + return s.state == SurfaceStateAcquired && s.acquisition == lease } // Present presents the acquired surface texture to the screen. @@ -173,6 +200,7 @@ func (s *Surface) PresentWithDamage(queue hal.Queue, damageRects []image.Rectang err := queue.Present(s.raw, s.acquiredTex, damageRects) s.acquiredTex = nil + s.invalidateAcquisitionLocked() s.state = SurfaceStateConfigured return err } @@ -212,6 +240,7 @@ func (s *Surface) PresentPixels(data []byte, width, height uint32, damageRects [ if s.state == SurfaceStateAcquired && s.acquiredTex != nil { s.raw.DiscardTexture(s.acquiredTex) s.acquiredTex = nil + s.invalidateAcquisitionLocked() s.state = SurfaceStateConfigured } @@ -235,9 +264,72 @@ func (s *Surface) DiscardTexture() { } s.acquiredTex = nil + s.invalidateAcquisitionLocked() s.state = SurfaceStateConfigured } +func (s *Surface) invalidateAcquisitionLocked() { + s.acquisition = 0 +} + +// RetireDevice clears the logical configuration after its HAL device has been +// destroyed. The public API calls this after backend device teardown so a +// retained surface can be released or configured with another device without +// retaining a valid acquisition from the old device. +func (s *Surface) RetireDevice(device *Device) { + if s == nil || device == nil { + return + } + s.mu.Lock() + if s.device != device { + s.mu.Unlock() + return + } + s.acquiredTex = nil + s.invalidateAcquisitionLocked() + s.device = nil + s.config = nil + s.state = SurfaceStateUnconfigured + s.mu.Unlock() +} + +// Destroy invalidates the active acquisition and releases the owned HAL +// surface. It deliberately does not call DiscardTexture: device-first instance +// teardown may already have retired the backend swapchain. +func (s *Surface) Destroy() { + if s == nil { + return + } + s.mu.Lock() + raw := s.raw + if raw == nil { + s.mu.Unlock() + return + } + acquiredTex := s.acquiredTex + halDevice := s.getHALDevice(s.device) + s.raw = nil + s.acquiredTex = nil + s.invalidateAcquisitionLocked() + s.device = nil + s.config = nil + s.state = SurfaceStateUnconfigured + s.mu.Unlock() + + // When the configured device is still alive, retire the current acquisition + // and configuration before destroying the platform surface. Device-first + // teardown leaves no HAL device here, so the backend has already retired (or + // abandoned) its device-owned children and only the platform handle remains. + if acquiredTex != nil { + raw.DiscardTexture(acquiredTex) + } + if halDevice != nil { + raw.Unconfigure(halDevice) + } + raw.Destroy() + untrackResource(uintptr(unsafe.Pointer(s))) //nolint:gosec // debug tracking uses pointer as unique ID +} + // State returns the current lifecycle state of the surface. func (s *Surface) State() SurfaceState { s.mu.Lock() diff --git a/core/surface_test.go b/core/surface_test.go index 0a3ceb4..18589a5 100644 --- a/core/surface_test.go +++ b/core/surface_test.go @@ -147,6 +147,90 @@ func TestSurfaceAcquirePresent(t *testing.T) { } } +func TestSurfaceAcquisitionLeaseExpiresAtLifecycleBoundaries(t *testing.T) { + surface, device, queue := newTestSurface(t) + if err := surface.Configure(device, testSurfaceConfig()); err != nil { + t.Fatalf("Configure: %v", err) + } + + _, first, err := surface.AcquireTextureWithLease(nil) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + if !surface.AcquisitionValid(first) { + t.Fatal("first lease was not valid after acquire") + } + if err := surface.Present(queue); err != nil { + t.Fatalf("Present: %v", err) + } + if surface.AcquisitionValid(first) { + t.Fatal("presented lease remained valid") + } + + _, second, err := surface.AcquireTextureWithLease(nil) + if err != nil { + t.Fatalf("second acquire: %v", err) + } + if second == first || !surface.AcquisitionValid(second) { + t.Fatalf("second lease = %d, first = %d, valid = %v", second, first, surface.AcquisitionValid(second)) + } + surface.DiscardTexture() + if surface.AcquisitionValid(second) { + t.Fatal("discarded lease remained valid") + } + + _, third, err := surface.AcquireTextureWithLease(nil) + if err != nil { + t.Fatalf("third acquire: %v", err) + } + surface.Unconfigure() + if surface.AcquisitionValid(third) { + t.Fatal("unconfigured lease remained valid") + } + + if err := surface.Configure(device, testSurfaceConfig()); err != nil { + t.Fatalf("reconfigure: %v", err) + } + _, fourth, err := surface.AcquireTextureWithLease(nil) + if err != nil { + t.Fatalf("fourth acquire: %v", err) + } + surface.Destroy() + if surface.AcquisitionValid(fourth) { + t.Fatal("destroyed lease remained valid") + } +} + +type surfaceDestroyObserver struct { + noop.Surface + discards int + unconfigures int + destroys int +} + +func (s *surfaceDestroyObserver) DiscardTexture(hal.SurfaceTexture) { s.discards++ } +func (s *surfaceDestroyObserver) Unconfigure(hal.Device) { s.unconfigures++ } +func (s *surfaceDestroyObserver) Destroy() { s.destroys++ } + +func TestSurfaceDestroyRetiresAcquisitionBeforePlatformSurface(t *testing.T) { + _, device, _ := newTestSurface(t) + raw := &surfaceDestroyObserver{} + surface := NewSurface(raw, "destroy-order-test") + if err := surface.Configure(device, testSurfaceConfig()); err != nil { + t.Fatalf("Configure: %v", err) + } + if _, _, err := surface.AcquireTextureWithLease(nil); err != nil { + t.Fatalf("AcquireTextureWithLease: %v", err) + } + + surface.Destroy() + surface.Destroy() + if raw.discards != 1 || raw.unconfigures != 1 || raw.destroys != 1 { + t.Fatalf("destroy calls = discard:%d unconfigure:%d destroy:%d; want 1,1,1", + raw.discards, raw.unconfigures, raw.destroys) + } +} + func TestSurfaceDoubleAcquire(t *testing.T) { surface, device, _ := newTestSurface(t) config := testSurfaceConfig() diff --git a/descriptor.go b/descriptor.go index b74c3dd..6ff3b46 100644 --- a/descriptor.go +++ b/descriptor.go @@ -209,6 +209,10 @@ func (e *BindGroupEntry) toHAL() gputypes.BindGroupEntry { entry := gputypes.BindGroupEntry{ Binding: e.Binding, } + var halView hal.TextureView + if e.TextureView != nil { + halView = e.TextureView.resolveHAL() + } switch { case e.Buffer != nil: @@ -224,9 +228,9 @@ func (e *BindGroupEntry) toHAL() gputypes.BindGroupEntry { entry.Resource = gputypes.SamplerBinding{ Sampler: e.Sampler.hal.NativeHandle(), } - case e.TextureView != nil && e.TextureView.hal != nil: + case halView != nil: entry.Resource = gputypes.TextureViewBinding{ - TextureView: e.TextureView.hal.NativeHandle(), + TextureView: halView.NativeHandle(), } } @@ -453,10 +457,10 @@ func (d *RenderPassDescriptor) toHAL() *hal.RenderPassDescriptor { ClearValue: ca.ClearValue, } if ca.View != nil { - halCA.View = ca.View.hal + halCA.View = ca.View.resolveHAL() } if ca.ResolveTarget != nil { - halCA.ResolveTarget = ca.ResolveTarget.hal + halCA.ResolveTarget = ca.ResolveTarget.resolveHAL() } halDesc.ColorAttachments = append(halDesc.ColorAttachments, halCA) } @@ -474,7 +478,7 @@ func (d *RenderPassDescriptor) toHAL() *hal.RenderPassDescriptor { StencilReadOnly: ds.StencilReadOnly, } if ds.View != nil { - halDS.View = ds.View.hal + halDS.View = ds.View.resolveHAL() } halDesc.DepthStencilAttachment = halDS } @@ -530,7 +534,7 @@ func (i *ImageCopyTexture) toHAL() *hal.ImageCopyTexture { } return &hal.ImageCopyTexture{ - Texture: i.Texture.hal, + Texture: i.Texture.resolveHAL(), MipLevel: i.MipLevel, Origin: i.Origin.toHAL(), Aspect: i.Aspect, @@ -580,7 +584,7 @@ type TextureBarrier struct { func (b TextureBarrier) toHAL() hal.TextureBarrier { var t hal.Texture if b.Texture != nil { - t = b.Texture.hal + t = b.Texture.resolveHAL() } return hal.TextureBarrier{ Texture: t, diff --git a/device_native.go b/device_native.go index b7cca90..4f12470 100644 --- a/device_native.go +++ b/device_native.go @@ -22,6 +22,7 @@ import ( type Device struct { core *core.Device queue *Queue + instance *Instance released atomic.Bool // cmdEncoderPool is the single shared encoder pool for the device. @@ -136,6 +137,10 @@ func (d *Device) CreateTextureView(texture *Texture, desc *TextureViewDescriptor if texture == nil { return nil, fmt.Errorf("wgpu: texture is nil") } + halTexture := texture.resolveHAL() + if halTexture == nil { + return nil, ErrReleased + } halDevice := d.halDevice() if halDevice == nil { @@ -154,12 +159,18 @@ func (d *Device) CreateTextureView(texture *Texture, desc *TextureViewDescriptor halDesc.ArrayLayerCount = desc.ArrayLayerCount } - halView, err := halDevice.CreateTextureView(texture.hal, halDesc) + halView, err := halDevice.CreateTextureView(halTexture, halDesc) if err != nil { return nil, fmt.Errorf("wgpu: failed to create texture view: %w", err) } - return &TextureView{hal: halView, device: d, texture: texture}, nil + return &TextureView{ + hal: halView, + device: d, + texture: texture, + surface: texture.surface, + surfaceLease: texture.surfaceLease, + }, nil } // CreateSampler creates a texture sampler. @@ -359,6 +370,9 @@ func (d *Device) CreateBindGroup(desc *BindGroupDescriptor) (*BindGroup, error) halEntries := make([]gputypes.BindGroupEntry, len(desc.Entries)) for i, entry := range desc.Entries { + if entry.TextureView != nil && entry.TextureView.resolveHAL() == nil { + return nil, ErrReleased + } halEntries[i] = entry.toHAL() } @@ -896,10 +910,12 @@ func (d *Device) maintainAfterIdle() { // Release releases the device and all associated resources. // Deferred resource destructions are flushed before the device is destroyed. // Shutdown order: -// 0. WaitIdle — block until ALL GPU submissions complete -// 1. Triage + FlushAll — deferred callbacks fire (encoders return to pool) -// 2. Destroy encoder pool (HAL device still alive) -// 3. Destroy core + HAL device +// 0. Retire active surface acquisitions +// 1. WaitIdle and maintain completed work +// 2. Discard unsubmitted queue writes +// 3. Triage + FlushAll — deferred callbacks fire (encoders return to pool) +// 4. Destroy encoder pool (HAL device still alive) +// 5. Destroy core + HAL device // // WaitIdle is required because FlushAll calls Triage(PollCompleted()), // but PollCompleted may return a stale index if GPU hasn't finished. @@ -910,23 +926,45 @@ func (d *Device) maintainAfterIdle() { // Rust avoids this via Arc ownership + maintain loop. In Go we must // be explicit: WaitIdle ensures PollCompleted returns final index. func (d *Device) Release() { - if d.released.Load() { + if d == nil || d.released.Swap(true) { return } - d.released.Store(true) + var configuredSurfaces []*Surface + if d.instance != nil { + configuredSurfaces = d.instance.surfacesForDevice(d) + } + // Retire active acquisitions while the backend device is still alive. + // Vulkan keeps the configured swapchain itself and retires it in + // hal.Device.Destroy; other backends also get a chance to release borrowed + // drawable state before their device disappears. + for _, surface := range configuredSurfaces { + surface.discardForDevice(d) + } + if d.instance != nil { + defer func() { + d.instance.unregisterDevice(d) + d.instance = nil + }() + } + + // Step 1: Wait for ALL GPU work to finish. Call HAL directly because the + // public device has already been atomically marked released to close new + // entry points. This ensures PollCompleted() returns the final submission + // index before pending encoders or resources are destroyed. + if halDevice := d.halDevice(); halDevice != nil { + _ = halDevice.WaitIdle() + d.maintainAfterIdle() + } + // Step 2: Pending writes that were never submitted can now be discarded; + // completed inflight batches were recycled by maintainAfterIdle above. if d.queue != nil { d.queue.release() } - // Step 0: Wait for ALL GPU work to finish. This ensures PollCompleted() + // Step 3: Flush deferred destructions. WaitIdle above ensures PollCompleted() // returns the final submission index, so Triage processes all submissions // and deferred encoder recycling callbacks fire correctly. - _ = d.WaitIdle() - - // Step 1: Flush deferred destructions. With GPU idle, Triage processes - // all submissions. Encoder recycling callbacks fire, returning encoders - // to cmdEncoderPool. HAL device is still alive. if d.core != nil && d.core.DestroyQueueRef() != nil { dq := d.core.DestroyQueueRef() // Triage with latest completion index (GPU is idle, all done). @@ -936,7 +974,7 @@ func (d *Device) Release() { dq.FlushAll() } - // Step 2: Destroy encoder pool. Each encoder.Destroy() calls + // Step 4: Destroy encoder pool. Each encoder.Destroy() calls // vkDestroyCommandPool / ID3D12CommandAllocator.Release on the still-alive // HAL device. After this, no native encoder resources remain. if d.cmdEncoderPool != nil { @@ -944,9 +982,12 @@ func (d *Device) Release() { d.cmdEncoderPool = nil } - // Step 3: Destroy core + HAL device. core.Destroy() calls FlushAll again + // Step 5: Destroy core + HAL device. core.Destroy() calls FlushAll again // (idempotent — already flushed) then halDevice.Destroy(). d.core.Destroy() + for _, surface := range configuredSurfaces { + surface.retireDevice(d) + } } // destroyQueue returns the device's DestroyQueue for deferred resource destruction. diff --git a/encoder_native.go b/encoder_native.go index accd4c1..e3a0f06 100644 --- a/encoder_native.go +++ b/encoder_native.go @@ -110,6 +110,10 @@ func (e *CommandEncoder) BeginRenderPass(desc *RenderPassDescriptor) (*RenderPas if e.released { return nil, ErrReleased } + if err := validateRenderPassTextureViews(desc); err != nil { + return nil, err + } + trackRenderPassTextureViews(e, desc) coreDesc := convertRenderPassDesc(desc) @@ -187,6 +191,18 @@ func (e *CommandEncoder) CopyTextureToBuffer(src *Texture, dst *Buffer, regions e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyTextureToBuffer: destination buffer is nil")) return } + halSrc := src.resolveHAL() + if halSrc == nil { + e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyTextureToBuffer: source texture is released: %w", ErrReleased)) + return + } + for _, region := range regions { + if region.TextureBase.Texture != nil && region.TextureBase.Texture.resolveHAL() == nil { + e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyTextureToBuffer: region texture is released: %w", ErrReleased)) + return + } + e.trackTexture(region.TextureBase.Texture) + } e.trackTexture(src) e.trackBuffer(dst) raw := e.core.RawEncoder() @@ -194,14 +210,14 @@ func (e *CommandEncoder) CopyTextureToBuffer(src *Texture, dst *Buffer, regions return } halDst := dst.halBuffer() - if src.hal == nil || halDst == nil { + if halDst == nil { return } halRegions := make([]hal.BufferTextureCopy, len(regions)) for i, r := range regions { halRegions[i] = r.toHAL() } - raw.CopyTextureToBuffer(src.hal, halDst, halRegions) + raw.CopyTextureToBuffer(halSrc, halDst, halRegions) } // CopyTextureToTexture copies data between textures using DMA hardware copy. @@ -218,20 +234,32 @@ func (e *CommandEncoder) CopyTextureToTexture(src, dst *Texture, regions []Textu e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyTextureToTexture: destination texture is nil")) return } + halSrc := src.resolveHAL() + halDst := dst.resolveHAL() + if halSrc == nil || halDst == nil { + e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyTextureToTexture: texture is released: %w", ErrReleased)) + return + } + for _, region := range regions { + if (region.Source.Texture != nil && region.Source.Texture.resolveHAL() == nil) || + (region.Destination.Texture != nil && region.Destination.Texture.resolveHAL() == nil) { + e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyTextureToTexture: region texture is released: %w", ErrReleased)) + return + } + e.trackTexture(region.Source.Texture) + e.trackTexture(region.Destination.Texture) + } e.trackTexture(src) e.trackTexture(dst) raw := e.core.RawEncoder() if raw == nil { return } - if src.hal == nil || dst.hal == nil { - return - } halRegions := make([]hal.TextureCopy, len(regions)) for i, r := range regions { halRegions[i] = r.toHAL() } - raw.CopyTextureToTexture(src.hal, dst.hal, halRegions) + raw.CopyTextureToTexture(halSrc, halDst, halRegions) } // TransitionTextures transitions texture states for synchronization. @@ -248,9 +276,14 @@ func (e *CommandEncoder) TransitionTextures(barriers []TextureBarrier) { } halBarriers := make([]hal.TextureBarrier, 0, len(barriers)) for _, b := range barriers { - if b.Texture == nil || b.Texture.hal == nil { + if b.Texture != nil && b.Texture.resolveHAL() == nil { + e.setError(fmt.Errorf("wgpu: CommandEncoder.TransitionTextures: texture is released: %w", ErrReleased)) + return + } + if b.Texture == nil || b.Texture.resolveHAL() == nil { continue } + e.trackTexture(b.Texture) halBarriers = append(halBarriers, b.toHAL()) } if len(halBarriers) > 0 { @@ -261,9 +294,24 @@ func (e *CommandEncoder) TransitionTextures(barriers []TextureBarrier) { // CopyBufferToTexture copies data from a buffer to a texture. // WebGPU spec: GPUCommandEncoder.copyBufferToTexture. func (e *CommandEncoder) CopyBufferToTexture(src *Buffer, dst *Texture, regions []BufferTextureCopy) { - if e.released || src == nil || dst == nil { + if e.released { + return + } + if src == nil { + e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyBufferToTexture: source buffer is nil")) + return + } + if dst == nil { + e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyBufferToTexture: destination texture is nil")) return } + halDst := dst.resolveHAL() + if halDst == nil { + e.setError(fmt.Errorf("wgpu: CommandEncoder.CopyBufferToTexture: destination texture is released: %w", ErrReleased)) + return + } + e.trackTexture(dst) + e.trackBuffer(src) raw := e.core.RawEncoder() if raw == nil { return @@ -277,14 +325,49 @@ func (e *CommandEncoder) CopyBufferToTexture(src *Buffer, dst *Texture, regions RowsPerImage: r.BufferLayout.RowsPerImage, }, TextureBase: hal.ImageCopyTexture{ - Texture: dst.hal, + Texture: halDst, MipLevel: r.TextureBase.MipLevel, Origin: hal.Origin3D(r.TextureBase.Origin), }, Size: hal.Extent3D(r.Size), } } - raw.CopyBufferToTexture(src.halBuffer(), dst.hal, halRegions) + raw.CopyBufferToTexture(src.halBuffer(), halDst, halRegions) +} + +func validateRenderPassTextureViews(desc *RenderPassDescriptor) error { + if desc == nil { + return nil + } + for _, attachment := range desc.ColorAttachments { + if attachment.View != nil && attachment.View.resolveHAL() == nil { + return fmt.Errorf("wgpu: BeginRenderPass: color attachment view is released: %w", ErrReleased) + } + if attachment.ResolveTarget != nil && attachment.ResolveTarget.resolveHAL() == nil { + return fmt.Errorf("wgpu: BeginRenderPass: resolve target view is released: %w", ErrReleased) + } + } + if attachment := desc.DepthStencilAttachment; attachment != nil && attachment.View != nil && attachment.View.resolveHAL() == nil { + return fmt.Errorf("wgpu: BeginRenderPass: depth/stencil attachment view is released: %w", ErrReleased) + } + return nil +} + +func trackRenderPassTextureViews(e *CommandEncoder, desc *RenderPassDescriptor) { + if e == nil || desc == nil { + return + } + for _, attachment := range desc.ColorAttachments { + if attachment.View != nil { + e.trackTexture(attachment.View.texture) + } + if attachment.ResolveTarget != nil { + e.trackTexture(attachment.ResolveTarget.texture) + } + } + if attachment := desc.DepthStencilAttachment; attachment != nil && attachment.View != nil { + e.trackTexture(attachment.View.texture) + } } // ClearBuffer clears a buffer region to zero. @@ -403,10 +486,10 @@ func convertRenderPassDesc(desc *RenderPassDescriptor) *core.RenderPassDescripto ClearValue: ca.ClearValue, } if ca.View != nil { - coreCA.View = &core.TextureView{HAL: ca.View.hal} + coreCA.View = &core.TextureView{HAL: ca.View.resolveHAL()} } if ca.ResolveTarget != nil { - coreCA.ResolveTarget = &core.TextureView{HAL: ca.ResolveTarget.hal} + coreCA.ResolveTarget = &core.TextureView{HAL: ca.ResolveTarget.resolveHAL()} } coreDesc.ColorAttachments = append(coreDesc.ColorAttachments, coreCA) } @@ -424,7 +507,7 @@ func convertRenderPassDesc(desc *RenderPassDescriptor) *core.RenderPassDescripto StencilReadOnly: ds.StencilReadOnly, } if ds.View != nil { - coreDSA.View = &core.TextureView{HAL: ds.View.hal} + coreDSA.View = &core.TextureView{HAL: ds.View.resolveHAL()} } coreDesc.DepthStencilAttachment = coreDSA } diff --git a/hal/dx12/device.go b/hal/dx12/device.go index 0db6033..e7ca5b3 100644 --- a/hal/dx12/device.go +++ b/hal/dx12/device.go @@ -502,6 +502,9 @@ func (d *Device) createCommandSignature(argType d3d12.D3D12_INDIRECT_ARGUMENT_TY // waitForGPU blocks until all GPU work completes. func (d *Device) waitForGPU() error { + if d == nil || d.directQueue == nil || d.fence == nil || d.fenceEvent == 0 { + return nil + } d.fenceMu.Lock() defer d.fenceMu.Unlock() diff --git a/hal/dx12/device_lifecycle_test.go b/hal/dx12/device_lifecycle_test.go new file mode 100644 index 0000000..0c34080 --- /dev/null +++ b/hal/dx12/device_lifecycle_test.go @@ -0,0 +1,14 @@ +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build windows && !(js && wasm) + +package dx12 + +import "testing" + +func TestWaitForGPUAfterDeviceCleanupIsNoop(t *testing.T) { + if err := (&Device{}).waitForGPU(); err != nil { + t.Fatalf("waitForGPU on cleaned device: %v", err) + } +} diff --git a/hal/dx12/surface.go b/hal/dx12/surface.go index 542017c..037b2cc 100644 --- a/hal/dx12/surface.go +++ b/hal/dx12/surface.go @@ -200,7 +200,7 @@ func (s *Surface) releaseBackBuffers() { s.backBuffers[i].resource = nil } // Recycle RTV descriptor slot for reuse (prevents heap exhaustion on resize) - if s.device != nil { + if s.device != nil && s.device.rtvHeap != nil { s.device.rtvHeap.Free(s.backBuffers[i].rtvIndex, 1) } } diff --git a/hal/vulkan/api.go b/hal/vulkan/api.go index f995b56..77d1861 100644 --- a/hal/vulkan/api.go +++ b/hal/vulkan/api.go @@ -307,6 +307,9 @@ func (s *Surface) Unconfigure(_ hal.Device) { s.swapchain.Destroy() s.swapchain = nil } + if s.device != nil { + s.device.unregisterConfiguredSurface(s) + } s.device = nil } @@ -354,12 +357,33 @@ func (s *Surface) Destroy() { s.swapchain.Destroy() s.swapchain = nil } + if s.device != nil { + s.device.unregisterConfiguredSurface(s) + s.device = nil + } if s.handle != 0 && s.instance != nil { s.instance.cmds.DestroySurfaceKHR(s.instance.handle, s.handle, nil) s.handle = 0 } } +// releaseConfiguredDevice detaches the swapchain while VkDevice is still +// alive. Device.Destroy calls this after one device-wide idle operation. +func (s *Surface) releaseConfiguredDevice(device *Device, drained bool) { + if s == nil || s.device != device { + return + } + if s.swapchain != nil { + if drained { + s.swapchain.destroyAfterIdle() + } else { + s.swapchain.abandonDeviceResources() + } + s.swapchain = nil + } + s.device = nil +} + // Helper functions func vkMakeVersion(major, minor, patch uint32) uint32 { diff --git a/hal/vulkan/device.go b/hal/vulkan/device.go index 67a4ca5..f4fbd7a 100644 --- a/hal/vulkan/device.go +++ b/hal/vulkan/device.go @@ -108,6 +108,55 @@ type Device struct { // Vulkan object creation (PERF-VK-001). Not thread-safe — setObjectName // is only called during resource creation which is single-threaded per device. debugNameBuf []byte + + // configuredSurfaces contains surfaces whose live swapchains belong to this + // device. Device teardown retires them before destroying VkDevice. + surfaceMu sync.Mutex + configuredSurfaces map[*Surface]struct{} + destroying bool +} + +func (d *Device) registerConfiguredSurface(surface *Surface) error { + if d == nil || surface == nil { + return fmt.Errorf("vulkan: cannot register a nil configured surface") + } + d.surfaceMu.Lock() + defer d.surfaceMu.Unlock() + if d.destroying || d.handle == 0 { + return hal.ErrDeviceLost + } + if d.configuredSurfaces == nil { + d.configuredSurfaces = make(map[*Surface]struct{}) + } + d.configuredSurfaces[surface] = struct{}{} + return nil +} + +func (d *Device) unregisterConfiguredSurface(surface *Surface) { + if d == nil || surface == nil { + return + } + d.surfaceMu.Lock() + delete(d.configuredSurfaces, surface) + d.surfaceMu.Unlock() +} + +func (d *Device) beginDestroy() ([]*Surface, bool) { + if d == nil { + return nil, false + } + d.surfaceMu.Lock() + defer d.surfaceMu.Unlock() + if d.destroying || d.handle == 0 { + return nil, false + } + d.destroying = true + surfaces := make([]*Surface, 0, len(d.configuredSurfaces)) + for surface := range d.configuredSurfaces { + surfaces = append(surfaces, surface) + } + clear(d.configuredSurfaces) + return surfaces, true } // initAllocator initializes the memory allocator for this device. @@ -1476,12 +1525,16 @@ func (d *Device) GetFenceStatus(fence hal.Fence) (bool, error) { // Destroy releases the device. func (d *Device) Destroy() { - // Wait for all in-flight frames to complete before destroying resources. - // Without this, fences may still be in use by the GPU, causing - // "vkResetFences: pFences[0] is in use" validation errors. - // Both paths (timeline and binary pool) are handled by waitForLatest. - if d.timelineFence != nil { - _ = d.timelineFence.waitForLatest(d.cmds, d.handle, 5_000_000_000) + surfaces, ok := d.beginDestroy() + if !ok { + return + } + + // One device-wide drain covers every configured swapchain. On device loss, + // abandon child handles and let vkDestroyDevice reclaim native storage. + drained := vkDeviceWaitIdle(d) == vk.Success + for _, surface := range surfaces { + surface.releaseConfiguredDevice(d, drained) } // Destroy unified fence (timeline semaphore or fencePool). diff --git a/hal/vulkan/device_surface_lifecycle_test.go b/hal/vulkan/device_surface_lifecycle_test.go new file mode 100644 index 0000000..5608b69 --- /dev/null +++ b/hal/vulkan/device_surface_lifecycle_test.go @@ -0,0 +1,69 @@ +// Copyright 2026 The GoGPU Authors +// SPDX-License-Identifier: MIT + +//go:build !(js && wasm) + +package vulkan + +import ( + "errors" + "testing" + + "github.com/gogpu/wgpu/hal" +) + +func TestDeviceOwnsConfiguredSurfacesUntilDestroyBegins(t *testing.T) { + device := &Device{handle: 1} + first := &Surface{} + second := &Surface{} + if err := device.registerConfiguredSurface(first); err != nil { + t.Fatalf("register first surface: %v", err) + } + if err := device.registerConfiguredSurface(second); err != nil { + t.Fatalf("register second surface: %v", err) + } + + surfaces, ok := device.beginDestroy() + if !ok { + t.Fatal("beginDestroy rejected a live device") + } + got := make(map[*Surface]bool, len(surfaces)) + for _, surface := range surfaces { + got[surface] = true + } + if !got[first] || !got[second] || len(got) != 2 { + t.Fatalf("owned surfaces = %v, want both configured surfaces", surfaces) + } + if _, ok := device.beginDestroy(); ok { + t.Fatal("second beginDestroy succeeded") + } + if err := device.registerConfiguredSurface(&Surface{}); !errors.Is(err, hal.ErrDeviceLost) { + t.Fatalf("register while destroying = %v, want ErrDeviceLost", err) + } +} + +func TestSurfaceAbandonsSwapchainHandlesAfterDeviceLoss(t *testing.T) { + device := &Device{handle: 1} + surface := &Surface{device: device} + texture := &SwapchainTexture{handle: 2, view: 3} + swapchain := &Swapchain{ + handle: 4, + device: device, + surface: surface, + surfaceTextures: []*SwapchainTexture{texture}, + imageAcquired: true, + } + texture.swapchain = swapchain + surface.swapchain = swapchain + + surface.releaseConfiguredDevice(device, false) + if surface.device != nil || surface.swapchain != nil { + t.Fatal("surface retained device-owned swapchain state") + } + if swapchain.device != nil || swapchain.surface != nil || swapchain.handle != 0 { + t.Fatal("swapchain retained native ownership after device loss") + } + if texture.handle != 0 || texture.view != 0 || texture.swapchain != nil { + t.Fatal("retained texture exposed abandoned swapchain handles") + } +} diff --git a/hal/vulkan/swapchain.go b/hal/vulkan/swapchain.go index 7568ad4..22e8853 100644 --- a/hal/vulkan/swapchain.go +++ b/hal/vulkan/swapchain.go @@ -403,8 +403,16 @@ func (s *Surface) createSwapchain(device *Device, config *hal.SurfaceConfigurati tex.swapchain = swapchain } + if err := device.registerConfiguredSurface(s); err != nil { + swapchain.Destroy() + return fmt.Errorf("vulkan: register configured surface: %w", err) + } + oldDevice := s.device s.swapchain = swapchain s.device = device + if oldDevice != nil && oldDevice != device { + oldDevice.unregisterConfiguredSurface(s) + } return nil } @@ -423,6 +431,13 @@ func (sc *Swapchain) releaseSyncResources() { // TODO: For better responsiveness, implement render thread architecture // like Ebiten (separate threads for events, game logic, rendering). vkDeviceWaitIdle(sc.device) + sc.releaseSyncResourcesAfterIdle() +} + +func (sc *Swapchain) releaseSyncResourcesAfterIdle() { + if sc.device == nil { + return + } // Destroy acquire semaphores for i, sem := range sc.acquireSemaphores { @@ -455,6 +470,13 @@ func (sc *Swapchain) destroyResources() { // Release sync resources if not already done sc.releaseSyncResources() + sc.destroyResourcesAfterIdle() +} + +func (sc *Swapchain) destroyResourcesAfterIdle() { + if sc.device == nil { + return + } // Destroy image views for _, view := range sc.imageViews { @@ -494,6 +516,48 @@ func (sc *Swapchain) Destroy() { } } +func (sc *Swapchain) destroyAfterIdle() { + if sc == nil { + return + } + sc.releaseSyncResourcesAfterIdle() + sc.destroyResourcesAfterIdle() + if sc.handle != 0 && sc.device != nil { + vkDestroySwapchainKHR(sc.device, sc.handle, nil) + } + sc.handle = 0 + sc.device = nil + sc.surface = nil +} + +func (sc *Swapchain) abandonDeviceResources() { + if sc == nil { + return + } + for _, texture := range sc.surfaceTextures { + if texture != nil { + texture.handle = 0 + texture.view = 0 + texture.swapchain = nil + } + } + sc.handle = 0 + sc.images = nil + sc.imageViews = nil + sc.acquireSemaphores = nil + sc.acquireFenceValues = nil + sc.presentSemaphores = nil + sc.surfaceTextures = nil + sc.imageLayouts = nil + sc.acquireFence = 0 + sc.barrierFence = 0 + sc.barrierPool = 0 + sc.currentAcquireSem = 0 + sc.imageAcquired = false + sc.device = nil + sc.surface = nil +} + // acquireNextImage acquires the next available swapchain image. // Uses rotating acquire semaphores like wgpu to avoid reuse conflicts. // Returns (nil, false, nil) if the frame should be skipped (timeout). diff --git a/instance_lifecycle_native_test.go b/instance_lifecycle_native_test.go new file mode 100644 index 0000000..2b8397d --- /dev/null +++ b/instance_lifecycle_native_test.go @@ -0,0 +1,171 @@ +//go:build !rust && !(js && wasm) + +package wgpu + +import ( + "slices" + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/core" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/noop" +) + +type instanceLifecycleDevice struct { + noop.Device + events *[]string +} + +func (d *instanceLifecycleDevice) Destroy() { + *d.events = append(*d.events, "device-destroy") +} + +type instanceLifecycleSurface struct { + noop.Surface + events *[]string +} + +func (s *instanceLifecycleSurface) Destroy() { + *s.events = append(*s.events, "surface-destroy") +} + +type instanceLifecycleConfiguredSurface struct { + noop.Surface + events *[]string +} + +func (s *instanceLifecycleConfiguredSurface) DiscardTexture(hal.SurfaceTexture) { + *s.events = append(*s.events, "surface-discard") +} + +func (s *instanceLifecycleConfiguredSurface) Unconfigure(hal.Device) { + *s.events = append(*s.events, "surface-unconfigure") +} + +func (s *instanceLifecycleConfiguredSurface) Destroy() { + *s.events = append(*s.events, "surface-destroy") +} + +func TestInstanceReleaseDestroysDevicesBeforeSurfaces(t *testing.T) { + events := []string{} + instance := &Instance{core: core.NewInstanceWithMock(nil)} + rawDevice := &instanceLifecycleDevice{events: &events} + queue := &Queue{hal: &noop.Queue{}, halDevice: rawDevice} + device := &Device{ + core: core.NewDevice(rawDevice, nil, 0, gputypes.DefaultLimits(), "lifecycle-test"), + queue: queue, + } + queue.device = device + if err := instance.adoptDevice(device); err != nil { + t.Fatalf("adopt device: %v", err) + } + surface := &Surface{core: core.NewSurface(&instanceLifecycleSurface{events: &events}, "lifecycle-test")} + if err := instance.adoptSurface(surface); err != nil { + t.Fatalf("adopt surface: %v", err) + } + + instance.Release() + want := []string{"device-destroy", "surface-destroy"} + if !slices.Equal(events, want) { + t.Fatalf("release events = %v, want %v", events, want) + } + if surface.HAL() != nil { + t.Fatal("retained surface exposed HAL after instance release") + } + + instance.Release() + surface.Release() + device.Release() + if !slices.Equal(events, want) { + t.Fatalf("idempotent releases changed events to %v", events) + } +} + +func TestInstanceReleaseRetiresAcquisitionBeforeDeviceAndPlatformSurface(t *testing.T) { + events := []string{} + instance := &Instance{core: core.NewInstanceWithMock(nil)} + rawDevice := &instanceLifecycleDevice{events: &events} + queue := &Queue{hal: &noop.Queue{}, halDevice: rawDevice} + device := &Device{ + core: core.NewDevice(rawDevice, nil, 0, gputypes.DefaultLimits(), "configured-lifecycle-test"), + queue: queue, + } + queue.device = device + if err := instance.adoptDevice(device); err != nil { + t.Fatalf("adopt device: %v", err) + } + + rawSurface := &instanceLifecycleConfiguredSurface{events: &events} + surface := &Surface{ + core: core.NewSurface(rawSurface, "configured-lifecycle-test"), + surfaceCreated: true, + currentBackend: gputypes.BackendEmpty, + } + if err := instance.adoptSurface(surface); err != nil { + t.Fatalf("adopt surface: %v", err) + } + if err := surface.Configure(device, &SurfaceConfiguration{ + Width: 1, + Height: 1, + Format: gputypes.TextureFormatRGBA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeAuto, + }); err != nil { + t.Fatalf("Configure: %v", err) + } + if _, _, err := surface.GetCurrentTexture(); err != nil { + t.Fatalf("GetCurrentTexture: %v", err) + } + + instance.Release() + want := []string{"surface-discard", "device-destroy", "surface-destroy"} + if !slices.Equal(events, want) { + t.Fatalf("release events = %v, want %v", events, want) + } + if surface.core != nil { + t.Fatal("surface retained core state after instance release") + } +} + +func TestSurfaceConfigureRejectsDeviceFromAnotherInstance(t *testing.T) { + first := &Instance{core: core.NewInstanceWithMock(nil)} + second := &Instance{core: core.NewInstanceWithMock(nil)} + defer first.Release() + defer second.Release() + + rawDevice := &noop.Device{} + queue := &Queue{hal: &noop.Queue{}, halDevice: rawDevice} + device := &Device{ + core: core.NewDevice(rawDevice, nil, 0, gputypes.DefaultLimits(), "cross-instance-test"), + queue: queue, + } + queue.device = device + if err := first.adoptDevice(device); err != nil { + t.Fatalf("adopt device: %v", err) + } + + surface := &Surface{ + core: core.NewSurface(&noop.Surface{}, "cross-instance-test"), + surfaceCreated: true, + currentBackend: gputypes.BackendEmpty, + } + if err := second.adoptSurface(surface); err != nil { + t.Fatalf("adopt surface: %v", err) + } + err := surface.Configure(device, &SurfaceConfiguration{ + Width: 1, + Height: 1, + Format: gputypes.TextureFormatRGBA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeAuto, + }) + if err == nil { + t.Fatal("Configure accepted a device from another instance") + } + if surface.core.State() != core.SurfaceStateUnconfigured { + t.Fatalf("surface state = %v, want unconfigured", surface.core.State()) + } +} diff --git a/instance_native.go b/instance_native.go index 28242aa..85256ea 100644 --- a/instance_native.go +++ b/instance_native.go @@ -4,6 +4,7 @@ package wgpu import ( "fmt" + "sync" "github.com/gogpu/gputypes" "github.com/gogpu/wgpu/core" @@ -24,7 +25,10 @@ type InstanceDescriptor struct { // must not be called concurrently with other methods. type Instance struct { core *core.Instance + mu sync.Mutex released bool + devices map[*Device]struct{} + surfaces map[*Surface]struct{} } // CreateInstance creates a new GPU instance. @@ -51,7 +55,7 @@ func CreateInstance(desc *InstanceDescriptor) (*Instance, error) { // the surface's GL context. This follows the WebGPU spec pattern where // requestAdapter accepts a compatible surface hint. func (i *Instance) RequestAdapter(opts *RequestAdapterOptions) (*Adapter, error) { - if i.released { + if i.isReleased() { return nil, ErrReleased } @@ -113,11 +117,118 @@ func (i *Instance) RequestAdapter(opts *RequestAdapterOptions) (*Adapter, error) }, nil } -// Release releases the instance and all associated resources. -func (i *Instance) Release() { +func (i *Instance) isReleased() bool { + if i == nil { + return true + } + i.mu.Lock() + defer i.mu.Unlock() + return i.released +} + +func (i *Instance) adoptDevice(device *Device) error { + if device == nil { + return fmt.Errorf("wgpu: cannot adopt a nil device") + } + i.mu.Lock() + defer i.mu.Unlock() + if i.released { + return ErrReleased + } + if i.devices == nil { + i.devices = make(map[*Device]struct{}) + } + i.devices[device] = struct{}{} + device.instance = i + return nil +} + +func (i *Instance) unregisterDevice(device *Device) { + if i == nil || device == nil { + return + } + i.mu.Lock() + delete(i.devices, device) + i.mu.Unlock() +} + +func (i *Instance) adoptSurface(surface *Surface) error { + if surface == nil { + return fmt.Errorf("wgpu: cannot adopt a nil surface") + } + i.mu.Lock() + defer i.mu.Unlock() if i.released { + return ErrReleased + } + if i.surfaces == nil { + i.surfaces = make(map[*Surface]struct{}) + } + i.surfaces[surface] = struct{}{} + surface.instance = i + return nil +} + +func (i *Instance) unregisterSurface(surface *Surface) { + if i == nil || surface == nil { return } + i.mu.Lock() + delete(i.surfaces, surface) + i.mu.Unlock() +} + +func (i *Instance) surfacesForDevice(device *Device) []*Surface { + if i == nil || device == nil { + return nil + } + i.mu.Lock() + defer i.mu.Unlock() + surfaces := make([]*Surface, 0, len(i.surfaces)) + for surface := range i.surfaces { + if surface != nil && surface.device == device { + surfaces = append(surfaces, surface) + } + } + return surfaces +} + +func (i *Instance) beginRelease() ([]*Device, []*Surface, bool) { + if i == nil { + return nil, nil, false + } + i.mu.Lock() + defer i.mu.Unlock() + if i.released { + return nil, nil, false + } i.released = true + + devices := make([]*Device, 0, len(i.devices)) + for device := range i.devices { + devices = append(devices, device) + } + surfaces := make([]*Surface, 0, len(i.surfaces)) + for surface := range i.surfaces { + surfaces = append(surfaces, surface) + } + return devices, surfaces, true +} + +// Release releases the instance and all associated resources. +func (i *Instance) Release() { + devices, surfaces, ok := i.beginRelease() + if !ok { + return + } + + // Devices own configured swapchains, surfaces own platform surface handles, + // and the instance owns the native instance. Release in that order. + for _, device := range devices { + device.Release() + } + for _, surface := range surfaces { + surface.Release() + } i.core.Destroy() } diff --git a/pending_writes.go b/pending_writes.go index 3b22717..1f9b63f 100644 --- a/pending_writes.go +++ b/pending_writes.go @@ -65,8 +65,12 @@ type pendingWrites struct { // not on hal::Buffer). Used by flush() to compute COPY_DST → read-state barriers. dstBuffers map[hal.Buffer]gputypes.BufferUsage - // dstTextures tracks textures that have pending writes. - dstTextures map[hal.Texture]struct{} + // dstTextures tracks textures that have pending writes. The wrapper list + // preserves every public lifetime that contributed commands for the raw + // texture. This matters for swapchains, which may reuse the same HAL texture + // across acquisitions: every recorded acquisition must still be live when + // the batch is flushed. + dstTextures map[hal.Texture][]*Texture // inflight tracks staging buffers and encoders from previous submissions, // keyed by submission index. Cleaned up when PollCompleted advances past them. @@ -115,7 +119,7 @@ func newPendingWrites(halDevice hal.Device, halQueue hal.Queue, pool *encoderPoo halDevice: halDevice, halQueue: halQueue, dstBuffers: make(map[hal.Buffer]gputypes.BufferUsage), - dstTextures: make(map[hal.Texture]struct{}), + dstTextures: make(map[hal.Texture][]*Texture), usesBatching: halQueue.SupportsCommandBufferCopies(), } if pw.usesBatching { @@ -224,6 +228,19 @@ func (pw *pendingWrites) writeTexture( data []byte, layout *hal.ImageDataLayout, size *hal.Extent3D, +) error { + return pw.writeTextureFor(nil, dst, data, layout, size) +} + +// writeTextureFor records a texture write and retains the public wrapper whose +// lifetime authorized it. Queue.WriteTexture supplies owner; the ownerless +// form above remains available to focused HAL-level tests. +func (pw *pendingWrites) writeTextureFor( + owner *Texture, + dst *hal.ImageCopyTexture, + data []byte, + layout *hal.ImageDataLayout, + size *hal.Extent3D, ) error { pw.mu.Lock() defer pw.mu.Unlock() @@ -333,14 +350,27 @@ func (pw *pendingWrites) writeTexture( // Track destination texture. AddPendingRef prevents premature Destroy (BUG-DX12-006). // Staging buffer is managed by the belt (not tracked individually). - if _, already := pw.dstTextures[dst.Texture]; !already { + owners, already := pw.dstTextures[dst.Texture] + if !already { dst.Texture.AddPendingRef() } - pw.dstTextures[dst.Texture] = struct{}{} + if owner != nil && !containsTextureOwner(owners, owner) { + owners = append(owners, owner) + } + pw.dstTextures[dst.Texture] = owners return nil } +func containsTextureOwner(owners []*Texture, target *Texture) bool { + for _, owner := range owners { + if owner == target { + return true + } + } + return false +} + // copyTextureDataAligned copies texture data with row pitch alignment padding. // If no padding is needed (alignedBytesPerRow == bytesPerRow), returns data directly. // Callers that can supply a reusable scratch buffer should use alignTextureDataInto @@ -441,6 +471,19 @@ func (pw *pendingWrites) flush() (hal.CommandBuffer, hal.CommandEncoder, []hal.T return nil, nil, nil, nil, nil } + // Queue writes are recorded lazily, so a surface acquisition can retire + // between WriteTexture and Submit. Revalidate every contributing wrapper + // before any command buffer reaches HAL. The raw texture comparison also + // protects against a wrapper being rebound to a different resource. + for raw, owners := range pw.dstTextures { + for _, owner := range owners { + if owner.resolveHAL() != raw { + pw.discardRecording() + return nil, nil, nil, nil, fmt.Errorf("wgpu: pending writes: destination texture retired before submit: %w", ErrReleased) + } + } + } + // Transition destination buffers from COPY_DEST to their primary read usage // before closing the encoder. Without this barrier, buffers remain in COPY_DEST // state when the render pass tries to use them as VERTEX/INDEX/UNIFORM — @@ -475,11 +518,7 @@ func (pw *pendingWrites) flush() (hal.CommandBuffer, hal.CommandEncoder, []hal.T cmdBuf, err := pw.encoder.EndEncoding() if err != nil { - pw.encoder.DiscardEncoding() - pw.isRecording = false - pw.encoder = nil - clear(pw.dstBuffers) - clear(pw.dstTextures) + pw.discardRecording() return nil, nil, nil, nil, fmt.Errorf("wgpu: pending writes: end encoding: %w", err) } @@ -514,6 +553,55 @@ func (pw *pendingWrites) flush() (hal.CommandBuffer, hal.CommandEncoder, []hal.T return cmdBuf, flushedEncoder, flushedDstTextures, flushedDstBuffers, nil } +// discardRecording abandons the current batch and restores every resource to +// its pre-batch ownership state. Must be called with pw.mu held. +func (pw *pendingWrites) discardRecording() { + if pw.encoder != nil { + if pw.isRecording { + pw.encoder.DiscardEncoding() + } + pw.encoder.ResetAll(nil) + if pw.pool != nil { + pw.pool.release(pw.encoder) + } else { + pw.encoder.Destroy() + } + } + + for raw := range pw.dstTextures { + raw.DecPendingRef() + } + if pw.belt != nil { + pw.belt.discardActive() + } + + pw.isRecording = false + pw.encoder = nil + clear(pw.dstBuffers) + clear(pw.dstTextures) +} + +// cancelFlush releases a batch that was closed but rejected by HAL Submit. +// Must be called with pw.mu held. +func (pw *pendingWrites) cancelFlush(cmdBuf hal.CommandBuffer, encoder hal.CommandEncoder, dstTextures []hal.Texture) { + if pw.belt != nil { + pw.belt.discardLastClosed() + } + for _, texture := range dstTextures { + texture.DecPendingRef() + } + if encoder != nil { + encoder.ResetAll([]hal.CommandBuffer{cmdBuf}) + if pw.pool != nil { + pw.pool.release(encoder) + } else { + encoder.Destroy() + } + } else if cmdBuf != nil { + pw.halDevice.FreeCommandBuffer(cmdBuf) + } +} + // maintain frees staging buffers and returns encoders to the pool from // completed submissions. Must be called with pw.mu held. func (pw *pendingWrites) maintain(completedIndex uint64) { @@ -565,16 +653,8 @@ func (pw *pendingWrites) destroy() { pw.mu.Lock() defer pw.mu.Unlock() - // Discard any in-progress encoding. - if pw.isRecording && pw.encoder != nil { - pw.encoder.DiscardEncoding() - } - // Destroy the current encoder if it wasn't flushed. - if pw.encoder != nil { - pw.encoder.Destroy() - } - pw.isRecording = false - pw.encoder = nil + // Discard any in-progress encoding and balance its pending references. + pw.discardRecording() // Destroy pending staging buffers. for _, buf := range pw.staging { @@ -594,6 +674,9 @@ func (pw *pendingWrites) destroy() { } else if sub.cmdBuf != nil { pw.halDevice.FreeCommandBuffer(sub.cmdBuf) } + for _, tex := range sub.dstTextures { + tex.DecPendingRef() + } } pw.inflight = nil diff --git a/pending_writes_test.go b/pending_writes_test.go index 1b2be85..4d8910b 100644 --- a/pending_writes_test.go +++ b/pending_writes_test.go @@ -18,6 +18,8 @@ type mockBatchingQueue struct { noop.Queue writeBufferCalls int writeTextureCalls int + submitCalls int + submitErr error lastWriteBuf hal.Buffer lastWriteOffset uint64 lastWriteData []byte @@ -25,6 +27,14 @@ type mockBatchingQueue struct { func (q *mockBatchingQueue) SupportsCommandBufferCopies() bool { return true } +func (q *mockBatchingQueue) Submit(commandBuffers []hal.CommandBuffer) (uint64, error) { + q.submitCalls++ + if q.submitErr != nil { + return 0, q.submitErr + } + return q.Queue.Submit(commandBuffers) +} + func (q *mockBatchingQueue) WriteBuffer(buffer hal.Buffer, offset uint64, data []byte) error { q.writeBufferCalls++ q.lastWriteBuf = buffer diff --git a/queue_native.go b/queue_native.go index b399725..9ae31ed 100644 --- a/queue_native.go +++ b/queue_native.go @@ -45,6 +45,18 @@ func (q *Queue) Submit(commandBuffers ...*CommandBuffer) (uint64, error) { return 0, fmt.Errorf("wgpu: queue not available") } + // Validate user command buffers before closing the pending-write encoder. + // A validation error leaves buffered writes intact for a later valid Submit + // and cannot strand an ended internal command buffer. + for i, cb := range commandBuffers { + if cb == nil { + return 0, fmt.Errorf("wgpu: command buffer at index %d is nil", i) + } + if err := validateCommandBufferForSubmit(cb, i); err != nil { + return 0, err + } + } + // Flush pending writes under lock, then release lock before HAL submit. var pendingCmdBuf hal.CommandBuffer var flushedEncoder hal.CommandEncoder @@ -61,20 +73,6 @@ func (q *Queue) Submit(commandBuffers ...*CommandBuffer) (uint64, error) { } } - // --- VAL-A6: Submit-time resource state validation --- - // Matches Rust wgpu-core validate_command_buffer (device/queue.rs:1764-1828). - // Each command buffer is checked for: valid state, buffer destroyed/mapped, - // texture destroyed. - for i, cb := range commandBuffers { - if cb == nil { - return 0, fmt.Errorf("wgpu: command buffer at index %d is nil", i) - } - if err := validateCommandBufferForSubmit(cb, i); err != nil { - return 0, err - } - } - // --- end VAL-A6 --- - // Build combined command buffer list: pending first, then user buffers. var allBuffers []hal.CommandBuffer if pendingCmdBuf != nil { @@ -90,6 +88,11 @@ func (q *Queue) Submit(commandBuffers ...*CommandBuffer) (uint64, error) { subIdx, err := q.hal.Submit(allBuffers) if err != nil { + if q.pending != nil && pendingCmdBuf != nil { + q.pending.mu.Lock() + q.pending.cancelFlush(pendingCmdBuf, flushedEncoder, flushedDstTextures) + q.pending.mu.Unlock() + } return 0, fmt.Errorf("wgpu: submit failed: %w", err) } @@ -295,7 +298,10 @@ func (q *Queue) WriteTexture(dst *ImageCopyTexture, data []byte, layout *ImageDa if q.hal == nil || dst == nil { return fmt.Errorf("wgpu: WriteTexture: queue or destination is nil") } - if dst.Texture == nil || dst.Texture.hal == nil { + if dst.Texture != nil && dst.Texture.resolveHAL() == nil { + return ErrReleased + } + if dst.Texture == nil { return fmt.Errorf("wgpu: WriteTexture: destination texture is invalid") } if layout == nil { @@ -310,7 +316,7 @@ func (q *Queue) WriteTexture(dst *ImageCopyTexture, data []byte, layout *ImageDa halSize := size.toHAL() if q.pending != nil { - return q.pending.writeTexture(halDst, data, &halLayout, &halSize) + return q.pending.writeTextureFor(dst.Texture, halDst, data, &halLayout, &halSize) } return q.hal.WriteTexture(halDst, data, &halLayout, &halSize) @@ -394,7 +400,7 @@ func validateCommandBufferForSubmit(cb *CommandBuffer, index int) error { // 3. Check referenced textures (matches Rust queue.rs:1791-1808). for tex := range cb.usedTextures { - if tex.released { + if tex.resolveHAL() == nil { return fmt.Errorf("wgpu: Submit: command buffer at index %d references released texture: %w", index, ErrSubmitTextureDestroyed) } diff --git a/staging_belt.go b/staging_belt.go index ec6e857..ce2ae72 100644 --- a/staging_belt.go +++ b/staging_belt.go @@ -323,6 +323,43 @@ func (b *stagingBelt) finish(submissionIndex uint64) { b.oversized = nil } +// discardActive abandons allocations whose copy commands will not be +// submitted. Regular chunks return directly to the free pool; oversized +// one-off buffers are destroyed. The caller serializes access. +func (b *stagingBelt) discardActive() { + for i := range b.activeChunks { + b.activeChunks[i].offset = 0 + b.freeChunks = append(b.freeChunks, b.activeChunks[i]) + } + b.activeChunks = nil + + for _, buf := range b.oversized { + b.halDevice.DestroyBuffer(buf) + } + b.oversized = nil + + // chunkedAllocs only aliases buffers held by oversized. + b.chunkedAllocs = nil +} + +// discardLastClosed rolls back the batch most recently finished with the +// placeholder submission index. It is used only when HAL rejects Submit. +func (b *stagingBelt) discardLastClosed() { + last := len(b.closedSubmissions) - 1 + if last < 0 || b.closedSubmissions[last].submissionIndex != 0 { + return + } + submission := b.closedSubmissions[last] + b.closedSubmissions = b.closedSubmissions[:last] + for i := range submission.chunks { + submission.chunks[i].offset = 0 + b.freeChunks = append(b.freeChunks, submission.chunks[i]) + } + for _, buf := range submission.oversized { + b.halDevice.DestroyBuffer(buf) + } +} + // setLastSubmissionIndex updates the most recently finished batch with the // actual GPU submission index (known only after HAL Submit returns). // Must be called after finish() and after HAL Submit. diff --git a/surface_native.go b/surface_native.go index d8fbed3..eae6044 100644 --- a/surface_native.go +++ b/surface_native.go @@ -38,7 +38,7 @@ type Surface struct { // - Linux/X11: displayHandle=Display*, windowHandle=Window // - Linux/Wayland: displayHandle=wl_display*, windowHandle=wl_surface* func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (*Surface, error) { - if i.released { + if i.isReleased() { return nil, ErrReleased } @@ -62,14 +62,18 @@ func (i *Instance) CreateSurface(displayHandle, windowHandle uintptr) (*Surface, } coreSurface := core.NewSurface(halSurface, "") - return &Surface{ + surface := &Surface{ core: coreSurface, - instance: i, displayHandle: displayHandle, windowHandle: windowHandle, currentBackend: initialBackend, surfaceCreated: true, - }, nil + } + if err := i.adoptSurface(surface); err != nil { + halSurface.Destroy() + return nil, err + } + return surface, nil } // Configure configures the surface for presentation. @@ -84,6 +88,25 @@ func (s *Surface) Configure(device *Device, config *SurfaceConfiguration) error if device == nil { return fmt.Errorf("wgpu: device is nil") } + if device.released.Load() { + return ErrReleased + } + // Standard API objects must share an Instance so device teardown can find + // and retire every configured surface. Explicit HAL wrappers remain usable + // when both objects are intentionally unowned (instance == nil). + if s.instance != device.instance && (s.instance != nil || device.instance != nil) { + return fmt.Errorf("wgpu: surface and device belong to different instances") + } + // Reject reconfiguration while an image is acquired before touching the + // backend surface. ensureHALSurface may destroy and recreate the raw surface; + // doing that first would leave the active SurfaceTexture pointing at a + // destroyed image when core.Configure rejects the state transition. + if s.core == nil { + return ErrReleased + } + if s.core.State() == core.SurfaceStateAcquired { + return core.ErrSurfaceConfigureWhileAcquired + } halConfig := &hal.SurfaceConfiguration{ Width: config.Width, @@ -127,7 +150,7 @@ func (s *Surface) GetCurrentTexture() (*SurfaceTexture, bool, error) { return nil, false, fmt.Errorf("wgpu: surface not configured") } - acquired, err := s.core.AcquireTexture(nil) + acquired, lease, err := s.core.AcquireTextureWithLease(nil) if err != nil { return nil, false, err } @@ -136,6 +159,7 @@ func (s *Surface) GetCurrentTexture() (*SurfaceTexture, bool, error) { hal: acquired.Texture, surface: s, device: s.device, + lease: lease, }, acquired.Suboptimal, nil } @@ -167,6 +191,9 @@ func (s *Surface) PresentWithDamage(texture *SurfaceTexture, damageRects []image if texture == nil { return fmt.Errorf("wgpu: surface texture is nil") } + if texture.surface != s || !texture.isUsable() { + return ErrReleased + } return s.core.PresentWithDamage(s.device.queue.hal, damageRects) } @@ -280,6 +307,21 @@ func (s *Surface) DiscardTexture() { s.core.DiscardTexture() } +func (s *Surface) discardForDevice(device *Device) { + if s == nil || s.core == nil || s.device != device { + return + } + s.core.DiscardTexture() +} + +func (s *Surface) retireDevice(device *Device) { + if s == nil || s.core == nil || s.device != device { + return + } + s.core.RetireDevice(device.core) + s.device = nil +} + // ensureHALSurface creates or re-creates the HAL surface for the given backend. func (s *Surface) ensureHALSurface(backend gputypes.Backend) error { if s.surfaceCreated && s.currentBackend == backend { @@ -305,6 +347,9 @@ func (s *Surface) ensureHALSurface(backend gputypes.Backend) error { // HAL returns the underlying HAL surface for backward compatibility. // Prefer using Surface methods instead of direct HAL access. func (s *Surface) HAL() hal.Surface { + if s == nil || s.released || s.core == nil { + return nil + } return s.core.RawSurface() } @@ -314,8 +359,14 @@ func (s *Surface) Release() { return } s.released = true - s.core.RawSurface().Destroy() + if s.core != nil { + s.core.Destroy() + } s.core = nil + if s.instance != nil { + s.instance.unregisterSurface(s) + s.instance = nil + } } // SurfaceTexture is a texture acquired from a surface for rendering. @@ -323,6 +374,16 @@ type SurfaceTexture struct { hal hal.SurfaceTexture surface *Surface device *Device + lease uint64 +} + +func (st *SurfaceTexture) isUsable() bool { + if st == nil || st.hal == nil || st.surface == nil || + st.surface.released || st.surface.core == nil || + st.device == nil || st.device.released.Load() { + return false + } + return st.surface.core.AcquisitionValid(st.lease) } // AsTexture returns a lightweight Texture wrapper around this surface texture, @@ -332,14 +393,22 @@ type SurfaceTexture struct { // The returned Texture shares the underlying HAL resource — do not Release() it // independently. Its lifetime is tied to this SurfaceTexture. func (st *SurfaceTexture) AsTexture() *Texture { + if !st.isUsable() { + return nil + } return &Texture{ - hal: st.hal, - device: st.device, + hal: st.hal, + device: st.device, + surface: st.surface.core, + surfaceLease: st.lease, } } // CreateView creates a texture view of this surface texture. func (st *SurfaceTexture) CreateView(desc *TextureViewDescriptor) (*TextureView, error) { + if !st.isUsable() { + return nil, ErrReleased + } halDevice := st.device.halDevice() if halDevice == nil { return nil, ErrReleased @@ -364,5 +433,6 @@ func (st *SurfaceTexture) CreateView(desc *TextureViewDescriptor) (*TextureView, return nil, fmt.Errorf("wgpu: failed to create surface texture view: %w", err) } - return &TextureView{hal: halView, device: st.device, texture: st.AsTexture()}, nil + texture := &Texture{hal: st.hal, device: st.device, surface: st.surface.core, surfaceLease: st.lease} + return &TextureView{hal: halView, device: st.device, texture: texture, surface: st.surface.core, surfaceLease: st.lease}, nil } diff --git a/surface_texture_lifetime_native_test.go b/surface_texture_lifetime_native_test.go new file mode 100644 index 0000000..11991b4 --- /dev/null +++ b/surface_texture_lifetime_native_test.go @@ -0,0 +1,465 @@ +//go:build !rust && !(js && wasm) + +package wgpu + +import ( + "errors" + "testing" + + "github.com/gogpu/gputypes" + "github.com/gogpu/wgpu/core" + "github.com/gogpu/wgpu/hal" + "github.com/gogpu/wgpu/hal/noop" +) + +type surfaceTextureLifetimeDevice struct { + noop.Device + destroyedTextures int + destroyedViews int +} + +func (d *surfaceTextureLifetimeDevice) DestroyTexture(texture hal.Texture) { + d.destroyedTextures++ + d.Device.DestroyTexture(texture) +} + +func (d *surfaceTextureLifetimeDevice) DestroyTextureView(view hal.TextureView) { + d.destroyedViews++ + d.Device.DestroyTextureView(view) +} + +func newAcquiredSurfaceForLifetimeTest(t *testing.T) (*Surface, *SurfaceTexture, *Device, *surfaceTextureLifetimeDevice) { + t.Helper() + + rawDevice := &surfaceTextureLifetimeDevice{} + coreDevice := core.NewDevice(rawDevice, nil, 0, gputypes.DefaultLimits(), "surface-texture-lifetime-test") + queue := &Queue{hal: &noop.Queue{}, halDevice: rawDevice} + device := &Device{core: coreDevice, queue: queue} + queue.device = device + + rawSurface := &noop.Surface{} + surface := &Surface{ + core: core.NewSurface(rawSurface, "surface-texture-lifetime-test"), + device: device, + surfaceCreated: true, + currentBackend: gputypes.BackendEmpty, + } + config := &SurfaceConfiguration{ + Width: 1, + Height: 1, + Format: gputypes.TextureFormatRGBA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment | gputypes.TextureUsageCopyDst, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeAuto, + } + if err := surface.Configure(device, config); err != nil { + device.Release() + t.Fatalf("surface.Configure: %v", err) + } + texture, _, err := surface.GetCurrentTexture() + if err != nil { + surface.Release() + device.Release() + t.Fatalf("surface.GetCurrentTexture: %v", err) + } + return surface, texture, device, rawDevice +} + +func TestSurfaceTextureDerivedWrappersInvalidateAfterDiscard(t *testing.T) { + surface, surfaceTexture, device, rawDevice := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + + texture := surfaceTexture.AsTexture() + if texture == nil { + t.Fatal("AsTexture returned nil for acquired texture") + } + texture.Release() + if rawDevice.destroyedTextures != 0 { + t.Fatalf("borrowed surface texture destruction count = %d, want 0", rawDevice.destroyedTextures) + } + view, err := surfaceTexture.CreateView(nil) + if err != nil { + t.Fatalf("CreateView: %v", err) + } + texture = surfaceTexture.AsTexture() + if texture == nil { + t.Fatal("second AsTexture returned nil while acquisition was active") + } + + surface.DiscardTexture() + if got := surfaceTexture.AsTexture(); got != nil { + t.Fatal("AsTexture returned a wrapper after DiscardTexture") + } + if _, err := surfaceTexture.CreateView(nil); !errors.Is(err, ErrReleased) { + t.Fatalf("CreateView after DiscardTexture = %v, want ErrReleased", err) + } + if _, err := device.CreateTextureView(texture, nil); !errors.Is(err, ErrReleased) { + t.Fatalf("Device.CreateTextureView after DiscardTexture = %v, want ErrReleased", err) + } + if err := device.Queue().WriteTexture(&ImageCopyTexture{Texture: texture}, nil, nil, nil); !errors.Is(err, ErrReleased) { + t.Fatalf("Queue.WriteTexture after DiscardTexture = %v, want ErrReleased", err) + } + + view.Release() + if err := device.WaitIdle(); err != nil { + t.Fatalf("WaitIdle after stale view release: %v", err) + } + if rawDevice.destroyedViews != 1 { + t.Fatalf("stale surface view destruction count = %d, want 1", rawDevice.destroyedViews) + } + if view.Texture() != nil { + t.Fatal("TextureView.Texture returned parent after stale view release") + } + surface.Release() +} + +func TestPendingWriteTextureRejectsRetiredSurfaceAcquisition(t *testing.T) { + surface, surfaceTexture, device, rawDevice := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + defer surface.Release() + + // Install the batching path used by Vulkan, DX12, and Metal. The write is + // valid when recorded, then its acquisition retires before Submit. + rawQueue := &mockBatchingQueue{} + pool := newEncoderPool(rawDevice) + device.cmdEncoderPool = pool + device.queue.hal = rawQueue + device.queue.pending = newPendingWrites(rawDevice, rawQueue, pool) + + texture := surfaceTexture.AsTexture() + err := device.Queue().WriteTexture( + &ImageCopyTexture{Texture: texture}, + []byte{1, 2, 3, 4}, + &ImageDataLayout{BytesPerRow: 4, RowsPerImage: 1}, + &Extent3D{Width: 1, Height: 1, DepthOrArrayLayers: 1}, + ) + if err != nil { + t.Fatalf("WriteTexture while acquired: %v", err) + } + if !device.queue.pending.HasPendingWork() { + t.Fatal("WriteTexture did not create pending work") + } + + surface.DiscardTexture() + if _, err := device.Queue().Submit(); !errors.Is(err, ErrReleased) { + t.Fatalf("Submit after acquisition retirement = %v, want ErrReleased", err) + } + if rawQueue.submitCalls != 0 { + t.Fatalf("HAL Submit calls = %d, want 0", rawQueue.submitCalls) + } + if device.queue.pending.HasPendingWork() { + t.Fatal("rejected pending batch remained active") + } + stats := device.queue.pending.belt.stats() + if stats.ActiveChunks != 0 || stats.FreeChunks != 1 || stats.ClosedSubs != 0 { + t.Fatalf("staging belt after rejection = %+v, want one recycled free chunk", stats) + } +} + +func TestPendingWriteTextureSubmitFailureRecyclesBatch(t *testing.T) { + surface, surfaceTexture, device, rawDevice := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + defer surface.Release() + + submitErr := errors.New("submit rejected") + rawQueue := &mockBatchingQueue{submitErr: submitErr} + pool := newEncoderPool(rawDevice) + device.cmdEncoderPool = pool + device.queue.hal = rawQueue + device.queue.pending = newPendingWrites(rawDevice, rawQueue, pool) + + err := device.Queue().WriteTexture( + &ImageCopyTexture{Texture: surfaceTexture.AsTexture()}, + []byte{1, 2, 3, 4}, + &ImageDataLayout{BytesPerRow: 4, RowsPerImage: 1}, + &Extent3D{Width: 1, Height: 1, DepthOrArrayLayers: 1}, + ) + if err != nil { + t.Fatalf("WriteTexture: %v", err) + } + if _, err := device.Queue().Submit(); !errors.Is(err, submitErr) { + t.Fatalf("Submit = %v, want wrapped submit error", err) + } + if rawQueue.submitCalls != 1 { + t.Fatalf("HAL Submit calls = %d, want 1", rawQueue.submitCalls) + } + if device.queue.pending.HasPendingWork() { + t.Fatal("failed submitted batch remained active") + } + stats := device.queue.pending.belt.stats() + if stats.ActiveChunks != 0 || stats.FreeChunks != 1 || stats.ClosedSubs != 0 { + t.Fatalf("staging belt after failed submit = %+v, want one recycled free chunk", stats) + } +} + +func TestRetiredSurfaceWrappersFailAtPublicHALBoundaries(t *testing.T) { + t.Run("render attachment", func(t *testing.T) { + surface, surfaceTexture, device, _ := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + defer surface.Release() + + view, err := surfaceTexture.CreateView(nil) + if err != nil { + t.Fatalf("CreateView: %v", err) + } + defer view.Release() + surface.DiscardTexture() + + encoder, err := device.CreateCommandEncoder(nil) + if err != nil { + t.Fatalf("CreateCommandEncoder: %v", err) + } + defer encoder.DiscardEncoding() + _, err = encoder.BeginRenderPass(&RenderPassDescriptor{ + ColorAttachments: []RenderPassColorAttachment{{ + View: view, + LoadOp: gputypes.LoadOpClear, + StoreOp: gputypes.StoreOpStore, + }}, + }) + if !errors.Is(err, ErrReleased) { + t.Fatalf("BeginRenderPass with retired view = %v, want ErrReleased", err) + } + }) + + t.Run("texture copy", func(t *testing.T) { + surface, surfaceTexture, device, _ := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + defer surface.Release() + + texture := surfaceTexture.AsTexture() + encoder, err := device.CreateCommandEncoder(nil) + if err != nil { + t.Fatalf("CreateCommandEncoder: %v", err) + } + surface.DiscardTexture() + encoder.CopyTextureToTexture(texture, texture, nil) + if _, err := encoder.Finish(); !errors.Is(err, ErrReleased) { + t.Fatalf("Finish after copy from retired texture = %v, want ErrReleased", err) + } + }) + + t.Run("bind group", func(t *testing.T) { + surface, surfaceTexture, device, _ := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + defer surface.Release() + + view, err := surfaceTexture.CreateView(nil) + if err != nil { + t.Fatalf("CreateView: %v", err) + } + defer view.Release() + layout, err := device.CreateBindGroupLayout(&BindGroupLayoutDescriptor{ + Entries: []BindGroupLayoutEntry{{ + Binding: 0, + Visibility: ShaderStageFragment, + Texture: &gputypes.TextureBindingLayout{ + SampleType: gputypes.TextureSampleTypeFloat, + ViewDimension: gputypes.TextureViewDimension2D, + }, + }}, + }) + if err != nil { + t.Fatalf("CreateBindGroupLayout: %v", err) + } + defer layout.Release() + surface.DiscardTexture() + + _, err = device.CreateBindGroup(&BindGroupDescriptor{ + Layout: layout, + Entries: []BindGroupEntry{{Binding: 0, TextureView: view}}, + }) + if !errors.Is(err, ErrReleased) { + t.Fatalf("CreateBindGroup with retired view = %v, want ErrReleased", err) + } + }) + + t.Run("submit", func(t *testing.T) { + surface, surfaceTexture, device, _ := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + defer surface.Release() + + view, err := surfaceTexture.CreateView(nil) + if err != nil { + t.Fatalf("CreateView: %v", err) + } + defer view.Release() + encoder, err := device.CreateCommandEncoder(nil) + if err != nil { + t.Fatalf("CreateCommandEncoder: %v", err) + } + pass, err := encoder.BeginRenderPass(&RenderPassDescriptor{ + ColorAttachments: []RenderPassColorAttachment{{ + View: view, + LoadOp: gputypes.LoadOpClear, + StoreOp: gputypes.StoreOpStore, + }}, + }) + if err != nil { + t.Fatalf("BeginRenderPass: %v", err) + } + if err := pass.End(); err != nil { + t.Fatalf("RenderPass.End: %v", err) + } + commandBuffer, err := encoder.Finish() + if err != nil { + t.Fatalf("Finish: %v", err) + } + defer commandBuffer.Release() + + rawQueue := &mockBatchingQueue{} + device.queue.hal = rawQueue + surface.DiscardTexture() + if _, err := device.Queue().Submit(commandBuffer); !errors.Is(err, ErrSubmitTextureDestroyed) { + t.Fatalf("Submit with retired attachment = %v, want ErrSubmitTextureDestroyed", err) + } + if rawQueue.submitCalls != 0 { + t.Fatalf("HAL Submit calls = %d, want 0", rawQueue.submitCalls) + } + }) +} + +func TestSurfaceTextureDerivedWrappersInvalidateAfterPresentAndRelease(t *testing.T) { + surface, surfaceTexture, device, rawDevice := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + + texture := surfaceTexture.AsTexture() + view, err := surfaceTexture.CreateView(nil) + if err != nil { + t.Fatalf("CreateView: %v", err) + } + if err := surface.Present(surfaceTexture); err != nil { + t.Fatalf("surface.Present: %v", err) + } + if _, err := device.CreateTextureView(texture, nil); !errors.Is(err, ErrReleased) { + t.Fatalf("Device.CreateTextureView after Present = %v, want ErrReleased", err) + } + view.Release() + if err := device.WaitIdle(); err != nil { + t.Fatalf("WaitIdle after presented view release: %v", err) + } + if rawDevice.destroyedViews != 1 { + t.Fatalf("stale presented view destruction count = %d, want 1", rawDevice.destroyedViews) + } + + // A later acquisition is invalidated by surface teardown as well. + surfaceTexture, _, err = surface.GetCurrentTexture() + if err != nil { + t.Fatalf("second GetCurrentTexture: %v", err) + } + texture = surfaceTexture.AsTexture() + view, err = surfaceTexture.CreateView(nil) + if err != nil { + t.Fatalf("second CreateView: %v", err) + } + surface.Release() + if surfaceTexture.AsTexture() != nil { + t.Fatal("AsTexture returned a wrapper after Surface.Release") + } + if _, err := device.CreateTextureView(texture, nil); !errors.Is(err, ErrReleased) { + t.Fatalf("Device.CreateTextureView after Surface.Release = %v, want ErrReleased", err) + } + view.Release() + if err := device.WaitIdle(); err != nil { + t.Fatalf("WaitIdle after released-surface view release: %v", err) + } + if rawDevice.destroyedViews != 2 { + t.Fatalf("stale released-surface view destruction count = %d, want 2", rawDevice.destroyedViews) + } +} + +func TestSurfacePresentRejectsTextureFromAnotherAcquisition(t *testing.T) { + surface, surfaceTexture, device, _ := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + + other := &SurfaceTexture{hal: surfaceTexture.hal, surface: surface, device: device, lease: surfaceTexture.lease + 1} + if err := surface.Present(other); !errors.Is(err, ErrReleased) { + t.Fatalf("Present with foreign token = %v, want ErrReleased", err) + } + if surface.core.State() != core.SurfaceStateAcquired { + t.Fatalf("surface state after rejected Present = %v, want acquired", surface.core.State()) + } + surface.DiscardTexture() + surface.Release() +} + +func TestSurfaceTextureLeaseAcrossReconfigureAndUnconfigure(t *testing.T) { + surface, surfaceTexture, device, rawDevice := newAcquiredSurfaceForLifetimeTest(t) + defer device.Release() + + config := &SurfaceConfiguration{ + Width: 2, + Height: 2, + Format: gputypes.TextureFormatRGBA8Unorm, + Usage: gputypes.TextureUsageRenderAttachment | gputypes.TextureUsageCopyDst, + PresentMode: gputypes.PresentModeFifo, + AlphaMode: gputypes.CompositeAlphaModeAuto, + } + if err := surface.Configure(device, config); !errors.Is(err, core.ErrSurfaceConfigureWhileAcquired) { + t.Fatalf("Configure while acquired = %v, want ErrSurfaceConfigureWhileAcquired", err) + } + if err := surface.Present(surfaceTexture); err != nil { + t.Fatalf("Present after rejected Configure: %v", err) + } + if err := surface.Configure(device, config); err != nil { + t.Fatalf("Configure after Present: %v", err) + } + + surfaceTexture, _, err := surface.GetCurrentTexture() + if err != nil { + t.Fatalf("GetCurrentTexture after reconfigure: %v", err) + } + texture := surfaceTexture.AsTexture() + view, err := surfaceTexture.CreateView(nil) + if err != nil { + t.Fatalf("CreateView: %v", err) + } + surface.Unconfigure() + if surfaceTexture.AsTexture() != nil { + t.Fatal("AsTexture returned a wrapper after Unconfigure") + } + if _, err := device.CreateTextureView(texture, nil); !errors.Is(err, ErrReleased) { + t.Fatalf("Device.CreateTextureView after Unconfigure = %v, want ErrReleased", err) + } + if view.Texture() == nil { + t.Fatal("TextureView.Texture did not preserve its parent after Unconfigure") + } + + texture.Release() + texture.Release() + view.Release() + view.Release() + if err := device.WaitIdle(); err != nil { + t.Fatalf("WaitIdle after wrapper releases: %v", err) + } + if rawDevice.destroyedTextures != 0 || rawDevice.destroyedViews != 1 { + t.Fatalf("borrowed wrapper releases: textures=%d, views=%d; want texture=0 view=1", rawDevice.destroyedTextures, rawDevice.destroyedViews) + } + surface.Release() +} + +func TestSurfaceTextureDerivedWrappersInvalidateAfterDeviceRelease(t *testing.T) { + surface, surfaceTexture, device, rawDevice := newAcquiredSurfaceForLifetimeTest(t) + + texture := surfaceTexture.AsTexture() + view, err := surfaceTexture.CreateView(nil) + if err != nil { + t.Fatalf("CreateView: %v", err) + } + + device.Release() + if texture.resolveHAL() != nil { + t.Fatal("surface texture wrapper remained usable after Device.Release") + } + if view.resolveHAL() != nil { + t.Fatal("surface texture view remained usable after Device.Release") + } + if surfaceTexture.AsTexture() != nil { + t.Fatal("AsTexture returned a wrapper after Device.Release") + } + view.Release() + if rawDevice.destroyedViews != 0 { + t.Fatalf("post-device-release view destruction count = %d, want 0", rawDevice.destroyedViews) + } + surface.Release() +} diff --git a/texture_native.go b/texture_native.go index f01f376..3433299 100644 --- a/texture_native.go +++ b/texture_native.go @@ -3,15 +3,30 @@ package wgpu import ( + "github.com/gogpu/wgpu/core" "github.com/gogpu/wgpu/hal" ) // Texture represents a GPU texture. type Texture struct { - hal hal.Texture - device *Device - format TextureFormat - released bool + hal hal.Texture + device *Device + format TextureFormat + released bool + surface *core.Surface + surfaceLease uint64 +} + +// resolveHAL is the single boundary from a public texture wrapper to HAL. +// Surface textures are borrowed and are usable only for their acquisition. +func (t *Texture) resolveHAL() hal.Texture { + if t == nil || t.released || t.hal == nil || t.device == nil || t.device.released.Load() { + return nil + } + if t.surface != nil && !t.surface.AcquisitionValid(t.surfaceLease) { + return nil + } + return t.hal } // Format returns the texture format. @@ -24,6 +39,12 @@ func (t *Texture) Release() { if t.released { return } + // Surface textures are borrowed swapchain images. The wrapper never owns + // their HAL lifetime, even while the acquisition is still active. + if t.surface != nil { + t.released = true + return + } t.released = true halDevice := t.device.halDevice() @@ -46,15 +67,36 @@ func (t *Texture) Release() { // TextureView represents a view into a texture. type TextureView struct { - hal hal.TextureView - device *Device - texture *Texture - released bool + hal hal.TextureView + device *Device + texture *Texture + released bool + surface *core.Surface + surfaceLease uint64 +} + +// resolveHAL is the single boundary from a public texture-view wrapper to HAL. +func (v *TextureView) resolveHAL() hal.TextureView { + if v == nil || v.released || v.hal == nil || v.device == nil || v.device.released.Load() { + return nil + } + if v.surface != nil && !v.surface.AcquisitionValid(v.surfaceLease) { + return nil + } + if v.surface != nil && v.texture != nil && v.texture.resolveHAL() == nil { + return nil + } + return v.hal } // Texture returns the parent Texture that this view was created from. // Returns nil if the view has been released. -func (v *TextureView) Texture() *Texture { return v.texture } +func (v *TextureView) Texture() *Texture { + if v == nil || v.released { + return nil + } + return v.texture +} // Release marks the texture view for destruction. The underlying HAL TextureView // (and its descriptor heap slots) is not freed immediately — it is deferred via