wgpu 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +18 -2
  4. data/docs/README.md +2 -2
  5. data/docs/api_coverage.md +23 -15
  6. data/docs/async.md +13 -1
  7. data/docs/command_encoding.md +25 -0
  8. data/docs/errors.md +13 -0
  9. data/docs/getting_started_compute.md +1 -0
  10. data/docs/getting_started_rendering.md +5 -0
  11. data/docs/pipeline_descriptors.md +7 -2
  12. data/docs/releasing.md +11 -1
  13. data/docs/resource_lifetime.md +25 -2
  14. data/docs/texture_readback.md +27 -0
  15. data/docs/troubleshooting.md +5 -0
  16. data/docs/upgrading_wgpu_native.md +16 -5
  17. data/lib/wgpu/async_task.rb +19 -0
  18. data/lib/wgpu/commands/command_buffer.rb +14 -1
  19. data/lib/wgpu/commands/command_encoder.rb +82 -27
  20. data/lib/wgpu/commands/compute_pass.rb +49 -6
  21. data/lib/wgpu/commands/render_bundle.rb +9 -1
  22. data/lib/wgpu/commands/render_bundle_encoder.rb +63 -9
  23. data/lib/wgpu/commands/render_pass.rb +116 -14
  24. data/lib/wgpu/core/adapter.rb +95 -13
  25. data/lib/wgpu/core/async_waiter.rb +30 -4
  26. data/lib/wgpu/core/canvas_context.rb +37 -2
  27. data/lib/wgpu/core/device.rb +377 -70
  28. data/lib/wgpu/core/instance.rb +20 -0
  29. data/lib/wgpu/core/queue.rb +143 -43
  30. data/lib/wgpu/core/surface.rb +55 -7
  31. data/lib/wgpu/data_types.rb +16 -0
  32. data/lib/wgpu/descriptor_helpers.rb +87 -0
  33. data/lib/wgpu/error.rb +15 -0
  34. data/lib/wgpu/native/abi_verifier.rb +37 -3
  35. data/lib/wgpu/native/callbacks.rb +6 -0
  36. data/lib/wgpu/native/capabilities.rb +16 -2
  37. data/lib/wgpu/native/distribution.rb +63 -0
  38. data/lib/wgpu/native/enum_helper.rb +17 -0
  39. data/lib/wgpu/native/enums.rb +8 -0
  40. data/lib/wgpu/native/fixtures/webgpu-v27.0.4.0-enums.h +848 -0
  41. data/lib/wgpu/native/functions.rb +14 -0
  42. data/lib/wgpu/native/installer.rb +55 -18
  43. data/lib/wgpu/native/loader.rb +22 -0
  44. data/lib/wgpu/native/structs.rb +18 -1
  45. data/lib/wgpu/native_resource.rb +190 -3
  46. data/lib/wgpu/pipeline/bind_group.rb +16 -7
  47. data/lib/wgpu/pipeline/bind_group_layout.rb +20 -7
  48. data/lib/wgpu/pipeline/compute_pipeline.rb +25 -37
  49. data/lib/wgpu/pipeline/pipeline_layout.rb +12 -4
  50. data/lib/wgpu/pipeline/render_pipeline.rb +33 -48
  51. data/lib/wgpu/pipeline/shader_module.rb +57 -32
  52. data/lib/wgpu/resources/buffer.rb +330 -59
  53. data/lib/wgpu/resources/query_set.rb +13 -1
  54. data/lib/wgpu/resources/sampler.rb +10 -3
  55. data/lib/wgpu/resources/texture.rb +49 -12
  56. data/lib/wgpu/resources/texture_view.rb +25 -5
  57. data/lib/wgpu/texture_format.rb +14 -0
  58. data/lib/wgpu/version.rb +1 -1
  59. data/lib/wgpu/window.rb +26 -0
  60. data/sig/wgpu.rbs +85 -5
  61. metadata +12 -4
@@ -1,29 +1,75 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "monitor"
4
+
3
5
  module WGPU
4
6
  class Device
5
7
  attr_reader :handle, :queue, :adapter
6
8
 
7
9
  LIMIT_FIELDS = Native::Limits.members.freeze
8
10
 
11
+ # Requests a logical device and waits for the native callback.
12
+ #
13
+ # @param adapter [Adapter] adapter that will create the device
14
+ # @param label [String, nil] optional debugging label
15
+ # @param required_features [Array<Symbol, Integer>] features the device must enable
16
+ # @param required_limits [Hash, nil] minimum required limits
17
+ # @param timeout [Numeric, nil] maximum wait time in seconds
18
+ # @return [Device] requested device
19
+ # @raise [DeviceError] if device creation fails
20
+ # @raise [TimeoutError] if the request exceeds +timeout+
21
+ # @raise [ArgumentError] if +timeout+ is used without an instance-backed adapter
9
22
  def self.request(adapter, label: nil, required_features: [], required_limits: nil, timeout: nil)
23
+ timeout = AsyncWaiter.normalize_timeout(timeout)
24
+ if timeout && !adapter.instance
25
+ raise ArgumentError,
26
+ "Device.request timeout requires an instance-backed adapter; " \
27
+ "pass instance: to Adapter.from_handle"
28
+ end
29
+
10
30
  device_ptr = FFI::MemoryPointer.new(:pointer)
11
- status_holder = { done: false, value: nil, message: nil }
31
+ status_holder = {
32
+ done: false,
33
+ value: nil,
34
+ message: nil,
35
+ abandoned: false,
36
+ cleanup_claimed: false,
37
+ mutex: Mutex.new
38
+ }
12
39
  device_callback_state = {
13
40
  mutex: Mutex.new,
14
41
  uncaptured_error: nil,
15
42
  device_lost: nil
16
43
  }
17
44
 
45
+ callback_token = nil
18
46
  callback = FFI::Function.new(
19
47
  :void, [:uint32, :pointer, Native::StringView.by_value, :pointer, :pointer]
20
48
  ) do |status, device, message, _userdata1, _userdata2|
21
- status_holder[:value] = Native::RequestDeviceStatus[status]
22
- if message[:data] && !message[:data].null? && message[:length] > 0
23
- status_holder[:message] = message[:data].read_string(message[:length])
49
+ cleanup_abandoned = false
50
+ begin
51
+ status_holder[:mutex].synchronize do
52
+ status_holder[:value] = Native::RequestDeviceStatus[status]
53
+ if message[:data] && !message[:data].null? && message[:length] > 0
54
+ status_holder[:message] = message[:data].read_string(message[:length])
55
+ end
56
+ device_ptr.write_pointer(device)
57
+ status_holder[:done] = true
58
+ if status_holder[:abandoned] && !status_holder[:cleanup_claimed]
59
+ status_holder[:cleanup_claimed] = true
60
+ cleanup_abandoned = true
61
+ end
62
+ end
63
+ if cleanup_abandoned
64
+ if status_holder[:value] == :success && device && !device.null?
65
+ release_abandoned_device(device, device_callback_state)
66
+ else
67
+ release_device_callback_keepalive(device_callback_state)
68
+ end
69
+ end
70
+ ensure
71
+ CallbackKeepalive.release(adapter, callback_token)
24
72
  end
25
- device_ptr.write_pointer(device)
26
- status_holder[:done] = true
27
73
  end
28
74
 
29
75
  queue_desc = Native::QueueDescriptor.new
@@ -81,35 +127,90 @@ module WGPU
81
127
  callback_info[:userdata1] = nil
82
128
  callback_info[:userdata2] = nil
83
129
 
130
+ device_lost_token = CallbackKeepalive.retain(adapter, device_lost_callback)
131
+ uncaptured_error_token = CallbackKeepalive.retain(adapter, uncaptured_error_callback)
132
+ configure_device_callback_keepalive(
133
+ device_callback_state,
134
+ owner: adapter,
135
+ tokens: [device_lost_token, uncaptured_error_token]
136
+ )
84
137
  callback_token = CallbackKeepalive.retain(adapter, callback)
138
+ future =
139
+ begin
140
+ Native.wgpuAdapterRequestDevice(NativeResource.checked_handle(adapter, expected_class: Adapter), desc, callback_info)
141
+ rescue StandardError
142
+ CallbackKeepalive.release(adapter, callback_token)
143
+ release_device_callback_keepalive(device_callback_state)
144
+ raise
145
+ end
146
+
85
147
  begin
86
- future = Native.wgpuAdapterRequestDevice(adapter.handle, desc, callback_info)
87
148
  AsyncWaiter.wait(
88
149
  status_holder: status_holder,
89
150
  instance: adapter.instance,
90
151
  future: future,
91
152
  timeout: timeout
92
153
  )
93
- ensure
94
- CallbackKeepalive.release(adapter, callback_token)
154
+ rescue StandardError
155
+ abandoned_device = nil
156
+ cleanup_abandoned = status_holder[:mutex].synchronize do
157
+ status_holder[:abandoned] = true
158
+ next false unless status_holder[:done] && !status_holder[:cleanup_claimed]
159
+
160
+ status_holder[:cleanup_claimed] = true
161
+ abandoned_device = device_ptr.read_pointer
162
+ true
163
+ end
164
+ if cleanup_abandoned
165
+ if status_holder[:value] == :success && abandoned_device && !abandoned_device.null?
166
+ release_abandoned_device(abandoned_device, device_callback_state)
167
+ else
168
+ release_device_callback_keepalive(device_callback_state)
169
+ end
170
+ end
171
+ raise
95
172
  end
96
173
 
97
174
  handle = device_ptr.read_pointer
98
175
  if handle.null? || status_holder[:value] != :success
176
+ release_device_callback_keepalive(device_callback_state)
99
177
  msg = status_holder[:message] || "Unknown error"
100
178
  raise DeviceError, "Failed to request device: #{msg}"
101
179
  end
102
180
 
103
- device = new(handle, adapter: adapter, label: label, callback_state: device_callback_state)
104
- device.send(:retain_device_callback, device_lost_callback)
105
- device.send(:retain_device_callback, uncaptured_error_callback)
106
- device
181
+ begin
182
+ callback_lifetime = DeviceCallbackLifetime.new do
183
+ release_device_callback_keepalive(device_callback_state)
184
+ end
185
+ device_callback_state[:mutex].synchronize do
186
+ device_callback_state[:callback_lifetime] = callback_lifetime
187
+ end
188
+ device = new(
189
+ handle,
190
+ adapter: adapter,
191
+ label: label,
192
+ callback_state: device_callback_state,
193
+ callback_lifetime: callback_lifetime
194
+ )
195
+ device.send(:adopt_device_callback_keepalive)
196
+ device
197
+ rescue StandardError
198
+ device&.release
199
+ release_device_callback_keepalive(device_callback_state)
200
+ raise
201
+ end
107
202
  end
108
203
 
109
- def initialize(handle, adapter: nil, label: nil, callback_state: nil)
204
+ # Wraps a native device and obtains its default queue.
205
+ # @param handle [FFI::Pointer] native device handle
206
+ # @param adapter [Adapter, nil] adapter that created the device
207
+ # @param label [String, nil] optional debug label
208
+ # @param callback_lifetime [DeviceCallbackLifetime, nil] shared native callback lifetime
209
+ def initialize(handle, adapter: nil, label: nil, callback_state: nil, callback_lifetime: nil)
110
210
  @handle = handle
111
211
  @adapter = adapter
112
212
  @label = label
213
+ @device_callback_lifetime = callback_lifetime
113
214
  @device_callback_state = callback_state || {
114
215
  mutex: Mutex.new,
115
216
  uncaptured_error: nil,
@@ -119,14 +220,20 @@ module WGPU
119
220
  @queue = Queue.new(Native.wgpuDeviceGetQueue(@handle), device: self)
120
221
  end
121
222
 
223
+ # Returns information about the adapter that created this device.
224
+ # @return [Hash, nil]
122
225
  def adapter_info
123
226
  @adapter&.info
124
227
  end
125
228
 
229
+ # Creates a GPU buffer.
230
+ # @return [Buffer]
126
231
  def create_buffer(label: nil, size:, usage:, mapped_at_creation: false)
127
232
  Buffer.new(self, label: label, size: size, usage: usage, mapped_at_creation: mapped_at_creation)
128
233
  end
129
234
 
235
+ # Creates a shader module from WGSL or SPIR-V source.
236
+ # @return [ShaderModule]
130
237
  def create_shader_module(label: nil, code: nil, spirv: nil, compilation_hints: [], validate: false)
131
238
  ShaderModule.new(
132
239
  self,
@@ -138,32 +245,46 @@ module WGPU
138
245
  )
139
246
  end
140
247
 
248
+ # Creates a command encoder.
249
+ # @return [CommandEncoder]
141
250
  def create_command_encoder(label: nil)
142
251
  CommandEncoder.new(self, label: label)
143
252
  end
144
253
 
254
+ # Creates a bind group layout.
255
+ # @return [BindGroupLayout]
145
256
  def create_bind_group_layout(label: nil, entries:)
146
257
  BindGroupLayout.new(self, label: label, entries: entries)
147
258
  end
148
259
 
260
+ # Creates a bind group.
261
+ # @return [BindGroup]
149
262
  def create_bind_group(label: nil, layout:, entries:)
150
263
  BindGroup.new(self, label: label, layout: layout, entries: entries)
151
264
  end
152
265
 
266
+ # Creates a pipeline layout.
267
+ # @return [PipelineLayout]
153
268
  def create_pipeline_layout(label: nil, bind_group_layouts:)
154
269
  PipelineLayout.new(self, label: label, bind_group_layouts: bind_group_layouts)
155
270
  end
156
271
 
272
+ # Creates a compute pipeline.
273
+ # @return [ComputePipeline]
157
274
  def create_compute_pipeline(label: nil, layout:, compute:)
158
275
  ComputePipeline.new(self, label: label, layout: layout, compute: compute)
159
276
  end
160
277
 
278
+ # Creates a compute pipeline on a background task.
279
+ # @return [AsyncTask] task yielding a {ComputePipeline}
161
280
  def create_compute_pipeline_async(label: nil, layout:, compute:)
162
281
  AsyncTask.new do
163
282
  create_compute_pipeline(label: label, layout: layout, compute: compute)
164
283
  end
165
284
  end
166
285
 
286
+ # Creates a render pipeline.
287
+ # @return [RenderPipeline]
167
288
  def create_render_pipeline(label: nil, layout:, vertex:, primitive: {}, depth_stencil: nil, multisample: {}, fragment: nil)
168
289
  RenderPipeline.new(self,
169
290
  label: label,
@@ -176,6 +297,8 @@ module WGPU
176
297
  )
177
298
  end
178
299
 
300
+ # Creates a render pipeline on a background task.
301
+ # @return [AsyncTask] task yielding a {RenderPipeline}
179
302
  def create_render_pipeline_async(label: nil, layout:, vertex:, primitive: {}, depth_stencil: nil, multisample: {}, fragment: nil)
180
303
  AsyncTask.new do
181
304
  create_render_pipeline(
@@ -190,6 +313,8 @@ module WGPU
190
313
  end
191
314
  end
192
315
 
316
+ # Creates a texture.
317
+ # @return [Texture]
193
318
  def create_texture(label: nil, size:, format:, usage:, dimension: :d2, mip_level_count: 1, sample_count: 1, view_formats: [])
194
319
  Texture.new(self,
195
320
  label: label,
@@ -203,6 +328,8 @@ module WGPU
203
328
  )
204
329
  end
205
330
 
331
+ # Creates a texture sampler.
332
+ # @return [Sampler]
206
333
  def create_sampler(label: nil, address_mode_u: :clamp_to_edge, address_mode_v: :clamp_to_edge, address_mode_w: :clamp_to_edge, mag_filter: :nearest, min_filter: :nearest, mipmap_filter: :nearest, lod_min_clamp: 0.0, lod_max_clamp: 32.0, compare: nil, max_anisotropy: 1)
207
334
  Sampler.new(self,
208
335
  label: label,
@@ -219,6 +346,11 @@ module WGPU
219
346
  )
220
347
  end
221
348
 
349
+ # Creates a mapped buffer initialized from typed Ruby data.
350
+ # @param data [Array, String, FFI::Pointer] source data
351
+ # @param usage [Symbol, Array<Symbol>, Integer] buffer usage flags
352
+ # @param type [Symbol] source element type
353
+ # @return [Buffer]
222
354
  def create_buffer_with_data(label: nil, data:, usage:, type: :f32)
223
355
  data_ptr, byte_size = DataTypes.to_pointer(data, type:)
224
356
  DataTypes.validate_alignment!(byte_size, 4, name: "buffer data size")
@@ -233,10 +365,14 @@ module WGPU
233
365
  buffer
234
366
  end
235
367
 
368
+ # Creates a GPU query set.
369
+ # @return [QuerySet]
236
370
  def create_query_set(label: nil, type:, count:)
237
371
  QuerySet.new(self, label: label, type: type, count: count)
238
372
  end
239
373
 
374
+ # Creates an encoder for reusable render commands.
375
+ # @return [RenderBundleEncoder]
240
376
  def create_render_bundle_encoder(color_formats:, depth_stencil_format: nil, sample_count: 1,
241
377
  depth_read_only: false, stencil_read_only: false, label: nil)
242
378
  RenderBundleEncoder.new(self,
@@ -249,6 +385,8 @@ module WGPU
249
385
  )
250
386
  end
251
387
 
388
+ # Lists optional features enabled on the device.
389
+ # @return [Array<Symbol>]
252
390
  def features
253
391
  supported = Native::SupportedFeatures.new
254
392
  Native.wgpuDeviceGetFeatures(@handle, supported)
@@ -260,12 +398,19 @@ module WGPU
260
398
  end
261
399
  end
262
400
  result
401
+ ensure
402
+ Native.wgpuSupportedFeaturesFreeMembers(supported) if supported
263
403
  end
264
404
 
405
+ # Reports whether a feature is enabled on the device.
406
+ # @param feature [Symbol] feature name
407
+ # @return [Boolean]
265
408
  def has_feature?(feature)
266
409
  features.include?(feature)
267
410
  end
268
411
 
412
+ # Returns resource limits supported by the device.
413
+ # @return [Hash{Symbol => Integer}]
269
414
  def limits
270
415
  supported = Native::SupportedLimits.new
271
416
  supported[:next_in_chain] = nil
@@ -273,6 +418,9 @@ module WGPU
273
418
  limits_to_hash(supported[:limits])
274
419
  end
275
420
 
421
+ # Processes device work, optionally waiting for queue progress.
422
+ # @param wait [Boolean] whether to wait for submitted work
423
+ # @return [Integer] native poll status
276
424
  def poll(wait: false)
277
425
  if Native.device_poll_available?
278
426
  Native.wgpuDevicePoll(@handle, wait ? 1 : 0, nil)
@@ -282,65 +430,68 @@ module WGPU
282
430
  end
283
431
  end
284
432
 
433
+ # Pushes a scope that captures matching GPU errors.
434
+ # Scopes belong to the pushing thread and serialize scoped work on this device.
435
+ # Pop on the same thread; do not wait here for another thread's scoped work.
436
+ # @param filter [Symbol, Integer] error filter
437
+ # @return [void]
285
438
  def push_error_scope(filter = :validation)
286
439
  filter_value = Native::EnumHelper.coerce(Native::ErrorFilter, filter, name: "error filter")
287
- Native.wgpuDevicePushErrorScope(@handle, filter_value)
288
- end
289
-
290
- def pop_error_scope(timeout: nil)
291
- error_holder = { done: false, status: nil, type: nil, message: nil }
292
-
293
- callback = FFI::Function.new(
294
- :void, [:uint32, :uint32, Native::StringView.by_value, :pointer, :pointer]
295
- ) do |status, error_type, message, _userdata1, _userdata2|
296
- error_holder[:done] = true
297
- error_holder[:status] = Native::PopErrorScopeStatus[status]
298
- error_holder[:type] = Native::ErrorType[error_type]
299
- if message[:data] && !message[:data].null? && message[:length] > 0
300
- error_holder[:message] = message[:data].read_string(message[:length])
301
- end
302
- end
303
-
304
- callback_info = Native::PopErrorScopeCallbackInfo.new
305
- callback_info[:next_in_chain] = nil
306
- callback_info[:mode] = AsyncWaiter.callback_mode(instance: @adapter&.instance)
307
- callback_info[:callback] = callback
308
- callback_info[:userdata1] = nil
309
- callback_info[:userdata2] = nil
310
-
311
- callback_token = CallbackKeepalive.retain(self, callback)
440
+ error_scope_monitor.enter
312
441
  begin
313
- future = Native.wgpuDevicePopErrorScope(@handle, callback_info)
314
- AsyncWaiter.wait(
315
- status_holder: error_holder,
316
- instance: @adapter&.instance,
317
- device: self,
318
- future: future,
319
- timeout: timeout
320
- )
321
- ensure
322
- CallbackKeepalive.release(self, callback_token)
442
+ ensure_not_released!
443
+ Native.wgpuDevicePushErrorScope(@handle, filter_value)
444
+ @error_scope_depth = (@error_scope_depth || 0) + 1
445
+ @error_scope_owner = Thread.current
446
+ rescue StandardError
447
+ error_scope_monitor.exit
448
+ raise
323
449
  end
450
+ end
324
451
 
325
- error_holder
452
+ # Pops the latest error scope and waits for its result.
453
+ # @param timeout [Numeric, nil] maximum wait time in seconds
454
+ # @return [Hash] native status, error type, and message
455
+ def pop_error_scope(timeout: nil)
456
+ timeout = AsyncWaiter.normalize_timeout(timeout)
457
+ error_holder, future = begin_pop_error_scope
458
+ wait_for_error_scope(error_holder, future, timeout: timeout)
326
459
  end
327
460
 
461
+ # Starts popping on the calling thread and waits for completion in a background task.
462
+ #
463
+ # @param timeout [Numeric, nil] maximum wait time in seconds
464
+ # @return [AsyncTask] task whose value is the error hash
328
465
  def pop_error_scope_async(timeout: nil)
329
- AsyncTask.new { pop_error_scope(timeout: timeout) }
466
+ timeout = AsyncWaiter.normalize_timeout(timeout)
467
+ error_holder, future = begin_pop_error_scope
468
+ AsyncTask.new { wait_for_error_scope(error_holder, future, timeout: timeout) }
330
469
  end
331
470
 
471
+ # Pops the latest error scope as a typed error.
472
+ # @param timeout [Numeric, nil] maximum wait time in seconds
473
+ # @return [GPUError, nil]
332
474
  def pop_error_scope_typed(timeout: nil)
333
475
  GPUError.from_hash(pop_error_scope(timeout: timeout))
334
476
  end
335
477
 
478
+ # Runs a block inside an error scope and raises captured GPU errors.
479
+ # @param filter [Symbol, Integer] error filter
480
+ # @yieldreturn [Object] block result
481
+ # @return [Object] block result
336
482
  def with_error_scope(filter = :validation)
337
- push_error_scope(filter)
338
- result = yield
339
- error = GPUError.from_hash(pop_error_scope)
340
- error&.raise!
483
+ raise ArgumentError, "block is required" unless block_given?
484
+
485
+ result = nil
486
+ error = capture_error_scope(filter) { result = yield }
487
+ GPUError.from_hash(error)&.raise!
341
488
  result
342
489
  end
343
490
 
491
+ # Registers the handler for uncaptured GPU errors.
492
+ # @yieldparam error [GPUError] reported error
493
+ # @return [Device] this device
494
+ # @raise [ArgumentError] if no block is provided
344
495
  def on_uncaptured_error(&handler)
345
496
  raise ArgumentError, "on_uncaptured_error requires a block" unless handler
346
497
 
@@ -348,6 +499,11 @@ module WGPU
348
499
  self
349
500
  end
350
501
 
502
+ # Registers the handler invoked when the device is lost.
503
+ # @yieldparam reason [Symbol] native device-lost reason
504
+ # @yieldparam message [String] native diagnostic message
505
+ # @return [Device] this device
506
+ # @raise [ArgumentError] if no block is provided
351
507
  def on_device_lost(&handler)
352
508
  raise ArgumentError, "on_device_lost requires a block" unless handler
353
509
 
@@ -355,18 +511,26 @@ module WGPU
355
511
  self
356
512
  end
357
513
 
514
+ # Destroys device-owned native resources.
515
+ # @return [void]
358
516
  def destroy
359
517
  return if @handle.null?
360
518
  Native.wgpuDeviceDestroy(@handle)
361
519
  end
362
520
 
521
+ # Releases the default queue and native device handle.
522
+ #
523
+ # Device callbacks remain alive until all derived wrappers and pending
524
+ # callback operations have also completed their native releases.
525
+ #
526
+ # Calling this method more than once has no effect.
527
+ # @return [void]
363
528
  def release
364
529
  @queue&.release
365
530
  return if @handle.null?
366
531
  Native.wgpuDeviceRelease(@handle)
367
532
  @handle = FFI::Pointer::NULL
368
- Array(@device_callback_tokens).each { |token| CallbackKeepalive.release(self, token) }
369
- @device_callback_tokens&.clear
533
+ @device_callback_tokens = nil
370
534
  end
371
535
 
372
536
  def self.normalize_required_features(required_features)
@@ -416,12 +580,17 @@ module WGPU
416
580
  FFI::Function.new(
417
581
  :void, [:pointer, :uint32, Native::StringView.by_value, :pointer, :pointer]
418
582
  ) do |_device, type, message, _userdata1, _userdata2|
419
- error = GPUError.new(
420
- type: Native::ErrorType[type] || :unknown,
421
- message: string_from_callback(message)
422
- )
423
- dispatch_device_callback(state, :uncaptured_error, error) do
424
- warn "Uncaptured GPU error (#{error.type}): #{error.message}"
583
+ callback_lifetime = retain_device_callback_lifetime(state)
584
+ begin
585
+ error = GPUError.new(
586
+ type: Native::ErrorType[type] || :unknown,
587
+ message: string_from_callback(message)
588
+ )
589
+ dispatch_device_callback(state, :uncaptured_error, error) do
590
+ warn "Uncaptured GPU error (#{error.type}): #{error.message}"
591
+ end
592
+ ensure
593
+ callback_lifetime&.release
425
594
  end
426
595
  end
427
596
  end
@@ -430,14 +599,69 @@ module WGPU
430
599
  FFI::Function.new(
431
600
  :void, [:pointer, :uint32, Native::StringView.by_value, :pointer, :pointer]
432
601
  ) do |_device, reason, message, _userdata1, _userdata2|
433
- reason_name = Native::DeviceLostReason[reason] || :unknown
434
- message_text = string_from_callback(message)
435
- dispatch_device_callback(state, :device_lost, reason_name, message_text) do
436
- warn "GPU device lost (#{reason_name}): #{message_text}" unless reason_name == :destroyed
602
+ callback_lifetime = retain_device_callback_lifetime(state)
603
+ begin
604
+ reason_name = Native::DeviceLostReason[reason] || :unknown
605
+ message_text = string_from_callback(message)
606
+ dispatch_device_callback(state, :device_lost, reason_name, message_text) do
607
+ warn "GPU device lost (#{reason_name}): #{message_text}" unless reason_name == :destroyed
608
+ end
609
+ ensure
610
+ callback_lifetime&.release
437
611
  end
438
612
  end
439
613
  end
440
614
 
615
+ def self.retain_device_callback_lifetime(state)
616
+ state[:mutex].synchronize do
617
+ lifetime = state[:callback_lifetime]
618
+ lifetime&.retain
619
+ lifetime
620
+ end
621
+ end
622
+
623
+ def self.configure_device_callback_keepalive(state, owner:, tokens:)
624
+ state[:mutex].synchronize do
625
+ state[:callback_owner] = owner
626
+ state[:callback_tokens] = tokens
627
+ state[:callbacks_completed] = false
628
+ end
629
+ end
630
+
631
+ def self.transfer_device_callback_keepalive(state, new_owner)
632
+ state[:mutex].synchronize do
633
+ return [] if state[:callbacks_completed]
634
+
635
+ previous_owner = state[:callback_owner]
636
+ tokens = Array(state[:callback_tokens])
637
+ transferred = tokens.select do |token|
638
+ CallbackKeepalive.transfer(previous_owner, new_owner, token)
639
+ end
640
+ state[:callback_owner] = new_owner
641
+ state[:callback_tokens] = transferred
642
+ transferred
643
+ end
644
+ end
645
+
646
+ def self.release_device_callback_keepalive(state)
647
+ owner, tokens = state[:mutex].synchronize do
648
+ return if state[:callbacks_completed]
649
+
650
+ state[:callbacks_completed] = true
651
+ retained_owner = state.delete(:callback_owner)
652
+ retained_tokens = Array(state.delete(:callback_tokens))
653
+ state.delete(:callback_lifetime)
654
+ [retained_owner, retained_tokens]
655
+ end
656
+ tokens.each { |token| CallbackKeepalive.release(owner, token) } if owner
657
+ end
658
+
659
+ def self.release_abandoned_device(device, callback_state)
660
+ Native.wgpuDeviceRelease(device)
661
+ ensure
662
+ release_device_callback_keepalive(callback_state)
663
+ end
664
+
441
665
  def self.dispatch_device_callback(state, key, *args)
442
666
  handler = state[:mutex].synchronize { state[key] }
443
667
  handler ? handler.call(*args) : yield
@@ -453,12 +677,95 @@ module WGPU
453
677
 
454
678
  private_class_method :normalize_required_features, :normalize_feature_name,
455
679
  :build_required_limits, :canonical_limit_key, :build_uncaptured_error_callback,
456
- :build_device_lost_callback, :dispatch_device_callback, :string_from_callback
680
+ :build_device_lost_callback, :retain_device_callback_lifetime,
681
+ :configure_device_callback_keepalive,
682
+ :transfer_device_callback_keepalive, :release_device_callback_keepalive,
683
+ :release_abandoned_device, :dispatch_device_callback, :string_from_callback
457
684
 
458
685
  private
459
686
 
460
- def retain_device_callback(callback)
461
- @device_callback_tokens << CallbackKeepalive.retain(self, callback)
687
+ def begin_pop_error_scope
688
+ unless @error_scope_owner == Thread.current && @error_scope_depth.to_i.positive?
689
+ raise DeviceError, "No error scope belongs to the current thread"
690
+ end
691
+
692
+ error_holder = { done: false, status: nil, type: nil, message: nil }
693
+
694
+ callback_lifetime_release = device_callback_lifetime_lease
695
+ callback_token = nil
696
+ callback = FFI::Function.new(
697
+ :void, [:uint32, :uint32, Native::StringView.by_value, :pointer, :pointer]
698
+ ) do |status, error_type, message, _userdata1, _userdata2|
699
+ begin
700
+ error_holder[:status] = Native::PopErrorScopeStatus[status]
701
+ error_holder[:type] = Native::ErrorType[error_type]
702
+ if message[:data] && !message[:data].null? && message[:length] > 0
703
+ error_holder[:message] = message[:data].read_string(message[:length])
704
+ end
705
+ error_holder[:done] = true
706
+ ensure
707
+ CallbackKeepalive.release(self, callback_token)
708
+ callback_lifetime_release.call
709
+ end
710
+ end
711
+
712
+ callback_info = Native::PopErrorScopeCallbackInfo.new
713
+ callback_info[:next_in_chain] = nil
714
+ callback_info[:mode] = AsyncWaiter.callback_mode(instance: @adapter&.instance)
715
+ callback_info[:callback] = callback
716
+ callback_info[:userdata1] = nil
717
+ callback_info[:userdata2] = nil
718
+
719
+ callback_token = CallbackKeepalive.retain(self, callback)
720
+ @error_scope_depth -= 1
721
+ @error_scope_owner = nil if @error_scope_depth.zero?
722
+ future =
723
+ begin
724
+ Native.wgpuDevicePopErrorScope(@handle, callback_info)
725
+ rescue StandardError
726
+ CallbackKeepalive.release(self, callback_token)
727
+ callback_lifetime_release.call
728
+ raise
729
+ ensure
730
+ error_scope_monitor.exit
731
+ end
732
+ [error_holder, future]
733
+ end
734
+
735
+ def wait_for_error_scope(error_holder, future, timeout:)
736
+ AsyncWaiter.wait(
737
+ status_holder: error_holder,
738
+ instance: @adapter&.instance,
739
+ device: self,
740
+ future: future,
741
+ timeout: timeout
742
+ )
743
+ error_holder
744
+ end
745
+
746
+ def error_scope_monitor
747
+ @error_scope_monitor ||= Monitor.new
748
+ end
749
+
750
+ # The reentrant scope lock spans push, the operation, and native pop.
751
+ def capture_error_scope(filter = :validation)
752
+ push_error_scope(filter)
753
+ begin
754
+ yield
755
+ ensure
756
+ active_error = $!
757
+ begin
758
+ error = pop_error_scope
759
+ rescue StandardError
760
+ raise unless active_error
761
+ end
762
+ end
763
+ error
764
+ end
765
+
766
+ def adopt_device_callback_keepalive
767
+ @device_callback_tokens =
768
+ self.class.send(:transfer_device_callback_keepalive, @device_callback_state, self)
462
769
  end
463
770
 
464
771
  def set_device_callback(name, handler)