Skip to content

Commit 08f2985

Browse files
Add RemoteExecutor and WebGPU probe
1 parent a22d843 commit 08f2985

5 files changed

Lines changed: 279 additions & 25 deletions

File tree

src/ImageSharp.Drawing.WebGPU/ImageSharp.Drawing.WebGPU.csproj

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,13 @@
1212
<PackageTags>Image Draw Shape Path Font</PackageTags>
1313
<Description>An extension to ImageSharp that allows the drawing of images, paths, and text.</Description>
1414
<Configurations>Debug;Release</Configurations>
15+
<!--
16+
OutputType=Exe is required so that runtimeconfig.json and deps.json are generated.
17+
The internal RemoteExecutor uses 'dotnet exec' to spawn a child process that probes
18+
compute pipeline support. This avoids unrecoverable AccessViolationException crashes
19+
in the parent process. The assembly is still usable as a library via ProjectReference.
20+
-->
21+
<OutputType>Exe</OutputType>
1522
<IsTrimmable>true</IsTrimmable>
1623
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
1724

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
/// Entry point for child processes spawned by <see cref="RemoteExecutor"/>.
8+
/// Dispatches to the requested probe method by name.
9+
/// Adapted from Microsoft.DotNet.RemoteExecutor (MIT license).
10+
/// </summary>
11+
internal static class Program
12+
{
13+
private static int Main(string[] args)
14+
{
15+
if (args.Length < 1)
16+
{
17+
Console.Error.WriteLine("Usage: {0} methodName", typeof(Program).Assembly.GetName().Name);
18+
return -1;
19+
}
20+
21+
string methodName = args[0];
22+
23+
return methodName switch
24+
{
25+
nameof(WebGPUDrawingBackend.ProbeComputePipelineSupport) => WebGPUDrawingBackend.ProbeComputePipelineSupport(),
26+
_ => -1
27+
};
28+
}
29+
}
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
// Copyright (c) Six Labors.
2+
// Licensed under the Six Labors Split License.
3+
4+
using System.Diagnostics;
5+
using System.Runtime.InteropServices;
6+
using IOPath = System.IO.Path;
7+
8+
namespace SixLabors.ImageSharp.Drawing.Processing.Backends;
9+
10+
/// <summary>
11+
/// Minimal remote executor that invokes a named method in a child process.
12+
/// The child process entry point (<see cref="Program.Main"/>) dispatches to
13+
/// the requested method by name — no reflection is used.
14+
/// Adapted from Microsoft.DotNet.RemoteExecutor (MIT license).
15+
/// </summary>
16+
internal static class RemoteExecutor
17+
{
18+
private static readonly string? AssemblyPath;
19+
private static readonly string? HostRunner;
20+
private static readonly string? RuntimeConfigPath;
21+
private static readonly string? DepsJsonPath;
22+
23+
static RemoteExecutor()
24+
{
25+
if (!IsSupported)
26+
{
27+
return;
28+
}
29+
30+
string? processFileName = Process.GetCurrentProcess().MainModule?.FileName;
31+
if (processFileName is null)
32+
{
33+
return;
34+
}
35+
36+
string baseDir = AppContext.BaseDirectory;
37+
string assemblyName = typeof(RemoteExecutor).Assembly.GetName().Name!;
38+
AssemblyPath = IOPath.Combine(baseDir, assemblyName + ".dll");
39+
if (!File.Exists(AssemblyPath))
40+
{
41+
return;
42+
}
43+
44+
HostRunner = processFileName;
45+
string hostName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet";
46+
47+
if (!IOPath.GetFileName(HostRunner).Equals(hostName, StringComparison.OrdinalIgnoreCase))
48+
{
49+
string runtimeDir = RuntimeEnvironment.GetRuntimeDirectory();
50+
string? directory = IOPath.GetDirectoryName(IOPath.GetDirectoryName(IOPath.GetDirectoryName(runtimeDir)));
51+
if (directory is not null)
52+
{
53+
string dotnetExe = IOPath.Combine(directory, hostName);
54+
if (File.Exists(dotnetExe))
55+
{
56+
HostRunner = dotnetExe;
57+
}
58+
}
59+
}
60+
61+
string runtimeConfigCandidate = IOPath.Combine(baseDir, assemblyName + ".runtimeconfig.json");
62+
string depsJsonCandidate = IOPath.Combine(baseDir, assemblyName + ".deps.json");
63+
64+
RuntimeConfigPath = File.Exists(runtimeConfigCandidate) ? runtimeConfigCandidate : null;
65+
DepsJsonPath = File.Exists(depsJsonCandidate) ? depsJsonCandidate : null;
66+
}
67+
68+
/// <summary>
69+
/// Gets a value indicating whether this remote executor is supported on the current platform.
70+
/// </summary>
71+
internal static bool IsSupported { get; } =
72+
!RuntimeInformation.IsOSPlatform(OSPlatform.Create("IOS")) &&
73+
!RuntimeInformation.IsOSPlatform(OSPlatform.Create("ANDROID")) &&
74+
!RuntimeInformation.IsOSPlatform(OSPlatform.Create("BROWSER")) &&
75+
!RuntimeInformation.IsOSPlatform(OSPlatform.Create("WASI")) &&
76+
Environment.GetEnvironmentVariable("DOTNET_REMOTEEXECUTOR_SUPPORTED") != "0";
77+
78+
/// <summary>
79+
/// Invokes the specified static method in a child process and returns its exit code.
80+
/// The method name is dispatched by <see cref="Program.Main"/> via a switch statement,
81+
/// so no reflection is needed in the child process.
82+
/// </summary>
83+
/// <param name="method">A static method returning <see cref="int"/> (the exit code).</param>
84+
/// <param name="timeoutMilliseconds">Maximum time to wait for the child process.</param>
85+
/// <returns>The exit code from the child process, or -1 on failure.</returns>
86+
internal static int Invoke(Func<int> method, int timeoutMilliseconds = 30_000)
87+
{
88+
if (!IsSupported || AssemblyPath is null || HostRunner is null)
89+
{
90+
return -1;
91+
}
92+
93+
string methodName = method.Method.Name;
94+
95+
string args = "exec";
96+
if (RuntimeConfigPath is not null)
97+
{
98+
args += $" --runtimeconfig \"{RuntimeConfigPath}\"";
99+
}
100+
101+
if (DepsJsonPath is not null)
102+
{
103+
args += $" --depsfile \"{DepsJsonPath}\"";
104+
}
105+
106+
args += $" \"{AssemblyPath}\" \"{methodName}\"";
107+
108+
ProcessStartInfo psi = new()
109+
{
110+
FileName = HostRunner,
111+
Arguments = args,
112+
UseShellExecute = false,
113+
CreateNoWindow = true
114+
};
115+
116+
// Remove profiler environment variables from child process.
117+
psi.Environment.Remove("Cor_Profiler");
118+
psi.Environment.Remove("Cor_Enable_Profiling");
119+
psi.Environment.Remove("CoreClr_Profiler");
120+
psi.Environment.Remove("CoreClr_Enable_Profiling");
121+
122+
try
123+
{
124+
using Process? process = Process.Start(psi);
125+
if (process is null)
126+
{
127+
return -1;
128+
}
129+
130+
if (!process.WaitForExit(timeoutMilliseconds))
131+
{
132+
try
133+
{
134+
process.Kill();
135+
}
136+
catch
137+
{
138+
// Ignore cleanup errors.
139+
}
140+
141+
return -1;
142+
}
143+
144+
return process.ExitCode;
145+
}
146+
catch
147+
{
148+
return -1;
149+
}
150+
}
151+
}

src/ImageSharp.Drawing.WebGPU/WebGPUDrawingBackend.cs

Lines changed: 60 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -142,33 +142,76 @@ private enum PreparedBrushType : uint
142142
/// This probes the runtime by attempting to acquire an adapter and device.
143143
/// The result is cached after the first probe.
144144
/// </summary>
145-
public bool IsSupported => isSupported ??= ProbeSupport();
145+
public bool IsSupported => isSupported ??= ProbeFullSupport();
146146

147147
/// <summary>
148-
/// Determines whether WebGPU compute support is available on the current system.
148+
/// Probes whether WebGPU compute is fully supported on the current system.
149+
/// First checks adapter/device availability in-process. If that succeeds,
150+
/// spawns a child process via <see cref="RemoteExecutor"/> to test compute
151+
/// pipeline creation, which can crash with an unrecoverable access violation
152+
/// on some systems. If the remote executor is not available, falls back to
153+
/// the device-only check.
149154
/// </summary>
150-
/// <remarks>
151-
/// This method goes beyond checking adapter and device availability — it also compiles
152-
/// a trivial compute shader and creates a compute pipeline to verify the full compute
153-
/// path works. Some systems report a valid device but crash on pipeline creation due to
154-
/// driver or runtime issues.
155-
/// </remarks>
156155
/// <returns>Returns <see langword="true"/> if WebGPU compute support is available; otherwise, <see langword="false"/>.</returns>
156+
private static bool ProbeFullSupport()
157+
{
158+
// Step 1: Quick in-process check for adapter/device availability.
159+
if (!ProbeSupport())
160+
{
161+
return false;
162+
}
163+
164+
// Step 2: Out-of-process probe for compute pipeline support.
165+
// DeviceCreateComputePipeline can crash with an AccessViolationException
166+
// on some systems (e.g. Windows CI with software renderers). This native
167+
// crash cannot be caught in managed code, so we run it in a child process.
168+
if (!RemoteExecutor.IsSupported)
169+
{
170+
// If we can't spawn a child process, assume device availability is sufficient.
171+
return true;
172+
}
173+
174+
return RemoteExecutor.Invoke(ProbeComputePipelineSupport) == 0;
175+
}
176+
177+
/// <summary>
178+
/// Determines whether WebGPU adapter and device are available on the current system.
179+
/// </summary>
180+
/// <remarks>This method only checks adapter and device availability. It does not attempt
181+
/// compute pipeline creation. Use <see cref="ProbeFullSupport"/> for a complete check.</remarks>
182+
/// <returns>Returns <see langword="true"/> if a WebGPU device is available; otherwise, <see langword="false"/>.</returns>
157183
public static bool ProbeSupport()
184+
{
185+
try
186+
{
187+
using WebGPURuntime.Lease lease = WebGPURuntime.Acquire();
188+
return WebGPURuntime.TryGetOrCreateDevice(out _, out _, out _);
189+
}
190+
catch
191+
{
192+
return false;
193+
}
194+
}
195+
196+
/// <summary>
197+
/// Probes full WebGPU compute pipeline support by compiling a trivial shader and
198+
/// creating a compute pipeline. This method may crash with an access violation on
199+
/// systems with broken WebGPU compute support — callers should run it in a child
200+
/// process (e.g. via <c>RemoteExecutor</c>) to isolate the crash.
201+
/// </summary>
202+
/// <returns>Exit code: 0 on success, 1 on failure.</returns>
203+
public static int ProbeComputePipelineSupport()
158204
{
159205
try
160206
{
161207
using WebGPURuntime.Lease lease = WebGPURuntime.Acquire();
162208
if (!WebGPURuntime.TryGetOrCreateDevice(out Device* device, out _, out _))
163209
{
164-
return false;
210+
return 1;
165211
}
166212

167213
WebGPU api = lease.Api;
168214

169-
// Compile a trivial compute shader and create a pipeline to verify the
170-
// full compute path works end-to-end. Some drivers/runtimes crash at
171-
// DeviceCreateComputePipeline despite successful device creation.
172215
ReadOnlySpan<byte> probeShader = "@compute @workgroup_size(1) fn cs_main() {}\0"u8;
173216
fixed (byte* shaderCodePtr = probeShader)
174217
{
@@ -186,7 +229,7 @@ public static bool ProbeSupport()
186229
ShaderModule* shaderModule = api.DeviceCreateShaderModule(device, in shaderDescriptor);
187230
if (shaderModule is null)
188231
{
189-
return false;
232+
return 1;
190233
}
191234

192235
try
@@ -209,7 +252,7 @@ public static bool ProbeSupport()
209252
PipelineLayout* pipelineLayout = api.DeviceCreatePipelineLayout(device, in layoutDescriptor);
210253
if (pipelineLayout is null)
211254
{
212-
return false;
255+
return 1;
213256
}
214257

215258
try
@@ -223,11 +266,11 @@ public static bool ProbeSupport()
223266
ComputePipeline* pipeline = api.DeviceCreateComputePipeline(device, in pipelineDescriptor);
224267
if (pipeline is null)
225268
{
226-
return false;
269+
return 1;
227270
}
228271

229272
api.ComputePipelineRelease(pipeline);
230-
return true;
273+
return 0;
231274
}
232275
finally
233276
{
@@ -243,7 +286,7 @@ public static bool ProbeSupport()
243286
}
244287
catch
245288
{
246-
return false;
289+
return 1;
247290
}
248291
}
249292

tests/ImageSharp.Drawing.Tests/TestUtilities/Attributes/WebGPUFactAttribute.cs

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,31 +6,55 @@
66
namespace SixLabors.ImageSharp.Drawing.Tests.TestUtilities.Attributes;
77

88
/// <summary>
9-
/// A <see cref="FactAttribute"/> that skips when WebGPU is not available on the current system.
9+
/// A <see cref="FactAttribute"/> that skips when WebGPU compute is not available on the current system.
1010
/// </summary>
1111
public class WebGPUFactAttribute : FactAttribute
1212
{
1313
public WebGPUFactAttribute()
1414
{
15-
using WebGPUDrawingBackend backend = new();
16-
if (!backend.IsSupported)
15+
if (!WebGPUProbe.IsComputeSupported)
1716
{
18-
this.Skip = "WebGPU is not available on this system.";
17+
this.Skip = "WebGPU compute is not available on this system.";
1918
}
2019
}
2120
}
2221

2322
/// <summary>
24-
/// A <see cref="TheoryAttribute"/> that skips when WebGPU is not available on the current system.
23+
/// A <see cref="TheoryAttribute"/> that skips when WebGPU compute is not available on the current system.
2524
/// </summary>
2625
public class WebGPUTheoryAttribute : TheoryAttribute
2726
{
2827
public WebGPUTheoryAttribute()
2928
{
30-
using WebGPUDrawingBackend backend = new();
31-
if (!backend.IsSupported)
29+
if (!WebGPUProbe.IsComputeSupported)
3230
{
33-
this.Skip = "WebGPU is not available on this system.";
31+
this.Skip = "WebGPU compute is not available on this system.";
32+
}
33+
}
34+
}
35+
36+
/// <summary>
37+
/// Caches the result of the WebGPU compute pipeline probe.
38+
/// The backend's <see cref="WebGPUDrawingBackend.IsSupported"/> already performs
39+
/// a full out-of-process probe via the internal RemoteExecutor, so we simply
40+
/// instantiate the backend and check its result.
41+
/// </summary>
42+
internal static class WebGPUProbe
43+
{
44+
private static bool? computeSupported;
45+
46+
internal static bool IsComputeSupported => computeSupported ??= Probe();
47+
48+
private static bool Probe()
49+
{
50+
try
51+
{
52+
using WebGPUDrawingBackend backend = new();
53+
return backend.IsSupported;
54+
}
55+
catch
56+
{
57+
return false;
3458
}
3559
}
3660
}

0 commit comments

Comments
 (0)