Skip to content

Commit 78d680d

Browse files
Add WebGPU uncaptured-error handling and safety
1 parent 0169887 commit 78d680d

4 files changed

Lines changed: 133 additions & 71 deletions

File tree

src/ImageSharp.Drawing.WebGPU/WebGPUDeviceContext.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ internal WebGPUDeviceContext(Configuration configuration, WebGPUDeviceHandle dev
104104

105105
this.DeviceHandle = deviceHandle;
106106
this.QueueHandle = queueHandle;
107+
108+
// Device-scoped shared state owns the uncaptured-error callback, so create it
109+
// before any later surface or render-target work can report native validation errors.
110+
_ = WebGPURuntime.GetOrCreateDeviceState(WebGPURuntime.GetApi(), deviceHandle);
111+
107112
this.Backend = new WebGPUDrawingBackend();
108113
this.Configuration = configuration;
109114
}

src/ImageSharp.Drawing.WebGPU/WebGPUEnvironment.cs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ namespace SixLabors.ImageSharp.Drawing.Processing.Backends;
99
/// </summary>
1010
public static class WebGPUEnvironment
1111
{
12+
/// <summary>
13+
/// Gets or sets the callback invoked when the native WebGPU runtime reports an uncaptured device error.
14+
/// </summary>
15+
/// <remarks>
16+
/// The callback can be raised from a native WebGPU callback thread. Keep handlers short and non-blocking.
17+
/// Exceptions thrown by the handler are not propagated back through the native callback.
18+
/// </remarks>
19+
public static Action<WebGPUErrorType, string>? UncapturedError { get; set; }
20+
1221
/// <summary>
1322
/// Probes whether the library-managed WebGPU device and queue are available.
1423
/// </summary>
@@ -28,4 +37,26 @@ public static WebGPUEnvironmentError ProbeAvailability()
2837
/// </returns>
2938
public static WebGPUEnvironmentError ProbeComputePipelineSupport()
3039
=> WebGPURuntime.ProbeComputePipelineSupport();
40+
41+
/// <summary>
42+
/// Reports one uncaptured native WebGPU error to the configured public callback.
43+
/// </summary>
44+
internal static void ReportUncapturedError(WebGPUErrorType errorType, string message)
45+
{
46+
Action<WebGPUErrorType, string>? callback = UncapturedError;
47+
if (callback is null)
48+
{
49+
return;
50+
}
51+
52+
try
53+
{
54+
callback(errorType, message);
55+
}
56+
catch
57+
{
58+
// The native WebGPU callback has no managed caller to receive exceptions.
59+
// Letting them escape through the unmanaged callback boundary can terminate the process.
60+
}
61+
}
3162
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
namespace SixLabors.ImageSharp.Drawing.Processing.Backends;
5+
6+
/// <summary>
7+
/// Error categories reported by the native WebGPU runtime.
8+
/// </summary>
9+
public enum WebGPUErrorType
10+
{
11+
/// <summary>
12+
/// No error was reported.
13+
/// </summary>
14+
NoError = 0,
15+
16+
/// <summary>
17+
/// The native runtime rejected a command or resource because it violated WebGPU validation rules.
18+
/// </summary>
19+
Validation = 1,
20+
21+
/// <summary>
22+
/// The native runtime could not allocate the requested GPU resource.
23+
/// </summary>
24+
OutOfMemory = 2,
25+
26+
/// <summary>
27+
/// The native runtime reported an internal implementation failure.
28+
/// </summary>
29+
Internal = 3,
30+
31+
/// <summary>
32+
/// The native runtime reported an uncategorized failure.
33+
/// </summary>
34+
Unknown = 4,
35+
36+
/// <summary>
37+
/// The WebGPU device was lost.
38+
/// </summary>
39+
DeviceLost = 5
40+
}

src/ImageSharp.Drawing.WebGPU/WebGPURuntime.DeviceSharedState.cs

Lines changed: 57 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the Six Labors Split License.
33

44
using System.Collections.Concurrent;
5+
using System.Runtime.InteropServices;
56
using Silk.NET.WebGPU;
67
using WgpuBuffer = Silk.NET.WebGPU.Buffer;
78

@@ -10,6 +11,7 @@ namespace SixLabors.ImageSharp.Drawing.Processing.Backends;
1011
internal static unsafe partial class WebGPURuntime
1112
{
1213
private static readonly ConcurrentDictionary<nint, DeviceSharedState> DeviceStateCache = new();
14+
private static readonly object DeviceStateCacheSync = new();
1315

1416
/// <summary>
1517
/// Gets or creates process-scoped shared resources for the specified device.
@@ -20,32 +22,34 @@ internal static unsafe partial class WebGPURuntime
2022
internal static DeviceSharedState GetOrCreateDeviceState(WebGPU api, WebGPUDeviceHandle deviceHandle)
2123
{
2224
nint cacheKey = deviceHandle.DangerousGetHandle();
23-
if (DeviceStateCache.TryGetValue(cacheKey, out DeviceSharedState? existing))
24-
{
25-
return existing;
26-
}
2725

28-
DeviceSharedState created = new(api, deviceHandle);
29-
DeviceSharedState winner = DeviceStateCache.GetOrAdd(cacheKey, created);
30-
if (!ReferenceEquals(winner, created))
26+
lock (DeviceStateCacheSync)
3127
{
32-
created.Dispose();
33-
}
28+
if (DeviceStateCache.TryGetValue(cacheKey, out DeviceSharedState? existing))
29+
{
30+
return existing;
31+
}
3432

35-
return winner;
33+
DeviceSharedState created = new(api, deviceHandle);
34+
DeviceStateCache[cacheKey] = created;
35+
return created;
36+
}
3637
}
3738

3839
/// <summary>
3940
/// Disposes all cached device-scoped shared state.
4041
/// </summary>
4142
private static void ClearDeviceStateCache()
4243
{
43-
foreach (DeviceSharedState state in DeviceStateCache.Values)
44+
lock (DeviceStateCacheSync)
4445
{
45-
state.Dispose();
46-
}
46+
foreach (DeviceSharedState state in DeviceStateCache.Values)
47+
{
48+
state.Dispose();
49+
}
4750

48-
DeviceStateCache.Clear();
51+
DeviceStateCache.Clear();
52+
}
4953
}
5054

5155
/// <summary>
@@ -59,6 +63,7 @@ internal sealed class DeviceSharedState : IDisposable
5963
private readonly ConcurrentDictionary<string, SharedBufferInfrastructure> sharedBuffers = new(StringComparer.Ordinal);
6064
private readonly HashSet<FeatureName> deviceFeatures;
6165
private WebGPUHandle.HandleReference deviceReference;
66+
private PfnErrorCallback uncapturedErrorCallback;
6267
private bool disposed;
6368

6469
internal DeviceSharedState(WebGPU api, WebGPUDeviceHandle deviceHandle)
@@ -68,12 +73,15 @@ internal DeviceSharedState(WebGPU api, WebGPUDeviceHandle deviceHandle)
6873

6974
try
7075
{
76+
this.uncapturedErrorCallback = PfnErrorCallback.From(HandleUncapturedError);
7177
this.Device = (Device*)this.deviceReference.Handle;
78+
this.Api.DeviceSetUncapturedErrorCallback(this.Device, this.uncapturedErrorCallback, null);
7279
this.deviceFeatures = EnumerateDeviceFeatures(api, this.Device);
7380
this.MaxStorageBufferBindingSize = QueryMaxStorageBufferBindingSize(api, this.Device);
7481
}
7582
catch
7683
{
84+
this.uncapturedErrorCallback.Dispose();
7785
this.deviceReference.Dispose();
7886
throw;
7987
}
@@ -121,6 +129,34 @@ internal DeviceSharedState(WebGPU api, WebGPUDeviceHandle deviceHandle)
121129
public bool HasFeature(FeatureName feature)
122130
=> this.deviceFeatures.Contains(feature);
123131

132+
/// <summary>
133+
/// Forwards uncaptured native WebGPU errors through the public environment callback.
134+
/// </summary>
135+
private static void HandleUncapturedError(ErrorType errorType, byte* message, void* userData)
136+
{
137+
_ = userData;
138+
139+
string errorMessage = message is null
140+
? string.Empty
141+
: Marshal.PtrToStringUTF8((nint)message) ?? string.Empty;
142+
143+
WebGPUEnvironment.ReportUncapturedError(ToPublicErrorType(errorType), errorMessage);
144+
}
145+
146+
/// <summary>
147+
/// Maps Silk's native error enum to the public API enum without exposing Silk types.
148+
/// </summary>
149+
private static WebGPUErrorType ToPublicErrorType(ErrorType errorType)
150+
=> errorType switch
151+
{
152+
ErrorType.NoError => WebGPUErrorType.NoError,
153+
ErrorType.Validation => WebGPUErrorType.Validation,
154+
ErrorType.OutOfMemory => WebGPUErrorType.OutOfMemory,
155+
ErrorType.Internal => WebGPUErrorType.Internal,
156+
ErrorType.DeviceLost => WebGPUErrorType.DeviceLost,
157+
_ => WebGPUErrorType.Unknown
158+
};
159+
124160
/// <summary>
125161
/// Snapshots the feature set currently reported by the native device.
126162
/// </summary>
@@ -190,23 +226,7 @@ public bool TryGetOrCreateCompositePipeline(
190226
bindGroupLayout = null;
191227
pipeline = null;
192228

193-
if (this.disposed)
194-
{
195-
error = "WebGPU device state is disposed.";
196-
return false;
197-
}
198-
199-
if (string.IsNullOrWhiteSpace(pipelineKey))
200-
{
201-
error = "Composite pipeline key cannot be empty.";
202-
return false;
203-
}
204-
205-
if (shaderCode.IsEmpty)
206-
{
207-
error = $"Composite shader code is missing for pipeline '{pipelineKey}'.";
208-
return false;
209-
}
229+
ObjectDisposedException.ThrowIf(this.disposed, this);
210230

211231
CompositePipelineInfrastructure infrastructure = this.compositePipelines.GetOrAdd(
212232
pipelineKey,
@@ -276,29 +296,7 @@ public bool TryGetOrCreateCompositeComputePipeline(
276296
bindGroupLayout = null;
277297
pipeline = null;
278298

279-
if (this.disposed)
280-
{
281-
error = "WebGPU device state is disposed.";
282-
return false;
283-
}
284-
285-
if (string.IsNullOrWhiteSpace(pipelineKey))
286-
{
287-
error = "Composite compute pipeline key cannot be empty.";
288-
return false;
289-
}
290-
291-
if (shaderCode.IsEmpty)
292-
{
293-
error = $"Composite compute shader code is missing for pipeline '{pipelineKey}'.";
294-
return false;
295-
}
296-
297-
if (entryPoint.IsEmpty)
298-
{
299-
error = $"Composite compute entry point is missing for pipeline '{pipelineKey}'.";
300-
return false;
301-
}
299+
ObjectDisposedException.ThrowIf(this.disposed, this);
302300

303301
CompositeComputePipelineInfrastructure infrastructure = this.compositeComputePipelines.GetOrAdd(
304302
pipelineKey,
@@ -365,23 +363,7 @@ public bool TryGetOrCreateSharedBuffer(
365363
buffer = null;
366364
capacity = 0;
367365

368-
if (this.disposed)
369-
{
370-
error = "WebGPU device state is disposed.";
371-
return false;
372-
}
373-
374-
if (string.IsNullOrWhiteSpace(bufferKey))
375-
{
376-
error = "Shared buffer key cannot be empty.";
377-
return false;
378-
}
379-
380-
if (requiredSize == 0)
381-
{
382-
error = $"Shared buffer '{bufferKey}' requires a non-zero size.";
383-
return false;
384-
}
366+
ObjectDisposedException.ThrowIf(this.disposed, this);
385367

386368
SharedBufferInfrastructure infrastructure = this.sharedBuffers.GetOrAdd(
387369
bufferKey,
@@ -467,6 +449,10 @@ public void Dispose()
467449

468450
this.sharedBuffers.Clear();
469451

452+
// Clear the native callback slot before freeing Silk's delegate thunk.
453+
this.Api.DeviceSetUncapturedErrorCallback(this.Device, default, null);
454+
this.uncapturedErrorCallback.Dispose();
455+
470456
this.deviceReference.Dispose();
471457
this.disposed = true;
472458
}

0 commit comments

Comments
 (0)