Skip to content

Commit 7d17815

Browse files
committed
Add check command to verify native AOT toolchain
Default publish needs a platform C/C++ linker. `go check` probes readiness (Windows via `dnx vs -- where +vc`, clang/gcc on Unix) and prints install fixes; documents this in the skill and readme.
1 parent c7aed9b commit 7d17815

9 files changed

Lines changed: 423 additions & 4 deletions

File tree

readme.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ The default mode publishes the app with native AOT and then runs the resulting e
3737
with smart up-to-date checks of every C# file used to build the app (including
3838
`#include` and `#ref` directives, transitively).
3939

40+
Native AOT needs a platform C/C++ linker (VC++ build tools on Windows, `build-essential` on
41+
Ubuntu, Xcode Command Line Tools on macOS). Verify with:
42+
43+
```console
44+
dnx go -- check
45+
```
46+
47+
On failure, the command prints the recommended install command for your OS (for example
48+
`dnx vs -- install --passive --sku:build` on Windows).
49+
4050
Use `--r2r` when your app needs more dynamic .NET features (reflection, dynamic loading, etc.)
4151
that native AOT does not support, while still keeping most publish optimizations:
4252

skills/go-sharp/SKILL.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ license: MIT
1616
|-------|---------|----------|
1717
| **Tweaking / evolving** | `dnx go -- dev app.cs` | Optimized `dotnet run` + up-to-date checks; skip publish/AOT |
1818
| **Stable (exclusive)** | `dnx go -- app.cs` | Optimized `dotnet publish` (native AOT by default) + run the binary |
19+
| **Prerequisites** | `dnx go -- check` | Verifies the native C/C++ toolchain needed for AOT publishes |
1920

2021
Once the script is stable, use **`dnx go -- app.cs` only**—not `dev`, not plain `dotnet app.cs`.
2122

@@ -51,6 +52,26 @@ Run `dotnet --version`. File-based apps need **.NET 10+**. `#:include`, `#:exclu
5152
dnx go -- --help
5253
```
5354

55+
#### Native toolchain (AOT publishes)
56+
57+
The **stable** path (`dnx go -- app.cs`) publishes with **native AOT** by default and needs a platform C/C++ linker. `dev` and `--r2r` do **not** require it.
58+
59+
Verify before relying on AOT (or when publish fails with a platform linker / VC++ error):
60+
61+
```bash
62+
dnx go -- check
63+
```
64+
65+
If the check fails, install the tools for your OS and re-run `check`:
66+
67+
| OS | Fix |
68+
|----|-----|
69+
| **Windows** | `dnx vs -- install --passive --sku:build` |
70+
| **Ubuntu / Debian** | `sudo apt-get install -y build-essential` |
71+
| **macOS** | `xcode-select --install` |
72+
73+
Typical failure without tools: `Platform linker ('clang' or 'gcc') not found` (Linux/macOS) or missing Visual C++ build tools (Windows).
74+
5475
### Step 2: Write the app file
5576

5677
Create an entry-point `.cs` file with top-level statements. Place it outside directories that contain a `.csproj` to avoid project conflicts.
@@ -318,6 +339,7 @@ partial class AppJsonContext : JsonSerializerContext;
318339

319340
- [ ] `dotnet --version` reports 10.0 or later
320341
- [ ] Multi-file `#:include` / `#:exclude` only if SDK is 10.0.300 or later
342+
- [ ] For default (AOT) publish: `dnx go -- check` succeeds
321343
- [ ] While iterating: `dnx go -- dev app.cs` produces the expected result
322344
- [ ] After the last successful edit: `dnx go -- app.cs` works, and **further runs use this form only**
323345
- [ ] App arguments are not confused with go flags (`--r2r` applies to go, then app args)
@@ -332,7 +354,8 @@ partial class AppJsonContext : JsonSerializerContext;
332354
| Using `dotnet app.cs` for day-to-day runs | Use `dnx go -- app.cs` (stable) or `dnx go -- dev app.cs` (tweaking) |
333355
| Staying on `dev` after the script is done | Switch to `dnx go -- app.cs` exclusively |
334356
| Expecting AOT under `dev` | `dev` is non-AOT; AOT applies to the default publish path |
335-
| AOT failures on stable runs | Source-generated JSON or `dnx go -- app.cs --r2r` |
357+
| Platform linker / VC++ missing on stable AOT publish | `dnx go -- check`, then install per the printed fix (Windows: `dnx vs -- install --passive --sku:build`; Ubuntu: `build-essential`; macOS: Xcode CLT) |
358+
| AOT failures on stable runs (reflection/dynamic) | Source-generated JSON or `dnx go -- app.cs --r2r` |
336359
| Need verbose SDK/MSBuild diagnostics | Fall back to `dotnet --file app.cs` / `dotnet build app.cs -v:n` |
337360
| Running a remote ref without consent | Ask the user; check owner/stars/source first |
338361
| `.cs` file inside a directory with a `.csproj` | Move the app outside the project directory |

src/Tests/NativeToolchainTests.cs

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
using System.Runtime.InteropServices;
2+
using Devlooped;
3+
4+
namespace Tests;
5+
6+
public class NativeToolchainTests
7+
{
8+
[Fact]
9+
public void Evaluate_missing_dotnet_fails_without_fix()
10+
{
11+
var result = NativeToolchain.Evaluate(
12+
dotnetPath: null,
13+
isOs: _ => true,
14+
commandExists: _ => true,
15+
run: (_, _) => new NativeToolchain.CapturedProcessResult(0, "C:\\VS", ""));
16+
17+
Assert.False(result.Ok);
18+
Assert.Equal(NativeToolchain.MissingDotnetMessage, result.Message);
19+
Assert.Null(result.FixCommand);
20+
}
21+
22+
[Fact]
23+
public void Evaluate_windows_ok_when_vs_where_returns_path()
24+
{
25+
string[]? capturedArgs = null;
26+
var result = NativeToolchain.Evaluate(
27+
dotnetPath: "dotnet",
28+
isOs: p => p == OSPlatform.Windows,
29+
commandExists: _ => false,
30+
run: (file, args) =>
31+
{
32+
Assert.Equal("dotnet", file);
33+
capturedArgs = [.. args];
34+
return new NativeToolchain.CapturedProcessResult(0, @"C:\Program Files\Microsoft Visual Studio\18\BuildTools", "");
35+
});
36+
37+
Assert.True(result.Ok);
38+
Assert.Equal(NativeToolchain.ReadyMessage, result.Message);
39+
Assert.Null(result.FixCommand);
40+
Assert.NotNull(capturedArgs);
41+
Assert.Equal(
42+
["dnx", "vs", "--", "where", "+vc", "--prop", "InstallationPath"],
43+
capturedArgs);
44+
}
45+
46+
[Fact]
47+
public void Evaluate_windows_ok_when_cl_on_path_even_if_vs_fails()
48+
{
49+
var result = NativeToolchain.Evaluate(
50+
dotnetPath: "dotnet",
51+
isOs: p => p == OSPlatform.Windows,
52+
commandExists: name => name == "cl",
53+
run: (_, _) => new NativeToolchain.CapturedProcessResult(1, "", "not found"));
54+
55+
Assert.True(result.Ok);
56+
Assert.Equal(NativeToolchain.ReadyMessage, result.Message);
57+
}
58+
59+
[Fact]
60+
public void Evaluate_windows_recommends_vs_install_when_missing()
61+
{
62+
var result = NativeToolchain.Evaluate(
63+
dotnetPath: "dotnet",
64+
isOs: p => p == OSPlatform.Windows,
65+
commandExists: _ => false,
66+
run: (_, _) => new NativeToolchain.CapturedProcessResult(1, "", "not found"));
67+
68+
Assert.False(result.Ok);
69+
Assert.Equal(NativeToolchain.MissingWindowsMessage, result.Message);
70+
Assert.Equal(NativeToolchain.WindowsFixCommand, result.FixCommand);
71+
Assert.Equal("dnx vs -- install --passive --sku:build", result.FixCommand);
72+
}
73+
74+
[Fact]
75+
public void Evaluate_linux_ok_when_clang_or_gcc_present()
76+
{
77+
var withClang = NativeToolchain.Evaluate(
78+
dotnetPath: "dotnet",
79+
isOs: p => p == OSPlatform.Linux,
80+
commandExists: name => name == "clang",
81+
run: (_, _) => throw new InvalidOperationException("should not run"));
82+
83+
var withGcc = NativeToolchain.Evaluate(
84+
dotnetPath: "dotnet",
85+
isOs: p => p == OSPlatform.Linux,
86+
commandExists: name => name == "gcc",
87+
run: (_, _) => throw new InvalidOperationException("should not run"));
88+
89+
Assert.True(withClang.Ok);
90+
Assert.True(withGcc.Ok);
91+
}
92+
93+
[Fact]
94+
public void Evaluate_linux_recommends_build_essential_when_missing()
95+
{
96+
var result = NativeToolchain.Evaluate(
97+
dotnetPath: "dotnet",
98+
isOs: p => p == OSPlatform.Linux,
99+
commandExists: _ => false,
100+
run: (_, _) => throw new InvalidOperationException("should not run"));
101+
102+
Assert.False(result.Ok);
103+
Assert.Equal(NativeToolchain.MissingLinuxMessage, result.Message);
104+
Assert.Equal(NativeToolchain.LinuxFixCommand, result.FixCommand);
105+
Assert.Equal("sudo apt-get install -y build-essential", result.FixCommand);
106+
}
107+
108+
[Fact]
109+
public void Evaluate_macos_ok_when_clang_present()
110+
{
111+
var result = NativeToolchain.Evaluate(
112+
dotnetPath: "dotnet",
113+
isOs: p => p == OSPlatform.OSX,
114+
commandExists: name => name == "clang",
115+
run: (_, _) => throw new InvalidOperationException("should not run"));
116+
117+
Assert.True(result.Ok);
118+
}
119+
120+
[Fact]
121+
public void Evaluate_macos_ok_when_xcode_select_succeeds()
122+
{
123+
var result = NativeToolchain.Evaluate(
124+
dotnetPath: "dotnet",
125+
isOs: p => p == OSPlatform.OSX,
126+
commandExists: _ => false,
127+
run: (file, args) =>
128+
{
129+
Assert.Equal("xcode-select", file);
130+
Assert.Equal(["-p"], args);
131+
return new NativeToolchain.CapturedProcessResult(0, "/Library/Developer/CommandLineTools", "");
132+
});
133+
134+
Assert.True(result.Ok);
135+
}
136+
137+
[Fact]
138+
public void Evaluate_macos_recommends_xcode_select_when_missing()
139+
{
140+
var result = NativeToolchain.Evaluate(
141+
dotnetPath: "dotnet",
142+
isOs: p => p == OSPlatform.OSX,
143+
commandExists: _ => false,
144+
run: (_, _) => new NativeToolchain.CapturedProcessResult(1, "", "error"));
145+
146+
Assert.False(result.Ok);
147+
Assert.Equal(NativeToolchain.MissingMacMessage, result.Message);
148+
Assert.Equal(NativeToolchain.MacFixCommand, result.FixCommand);
149+
Assert.Equal("xcode-select --install", result.FixCommand);
150+
}
151+
152+
[Fact]
153+
public void PrepareCafArgs_passes_check_args_through_unchanged()
154+
{
155+
var caf = GoArgs.PrepareCafArgs(["check"]);
156+
Assert.Equal(["check"], caf);
157+
Assert.Empty(GoArgs.ForwardArgs);
158+
}
159+
}

src/go/CheckCommands.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using ConsoleAppFramework;
2+
3+
namespace Devlooped;
4+
5+
public class CheckCommands
6+
{
7+
/// <summary>Verifies the native toolchain required for native AOT publishes.</summary>
8+
[Command("check")]
9+
public int Check()
10+
{
11+
var result = NativeToolchain.Evaluate();
12+
if (result.Ok)
13+
{
14+
Console.WriteLine(result.Message);
15+
return 0;
16+
}
17+
18+
ConsoleApp.LogError(result.Message);
19+
if (result.FixCommand is not null)
20+
{
21+
Console.WriteLine("To fix, run:");
22+
Console.WriteLine($" {result.FixCommand}");
23+
}
24+
25+
return 1;
26+
}
27+
}

src/go/GoArgs.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ public static class GoArgs
55
public static readonly string[] ReadyToRunPublishArgs = ["/p:PublishAot=false", "/p:PublishReadyToRun=true"];
66

77
static readonly string[] GoSwitchNames = ["debug", "r2r", "gdbg"];
8-
static readonly HashSet<string> Subcommands = new(StringComparer.OrdinalIgnoreCase) { "dev", "clean", "skill" };
8+
static readonly HashSet<string> Subcommands = new(StringComparer.OrdinalIgnoreCase) { "dev", "clean", "check", "skill" };
99

1010
static string[]? forwardArgs;
1111

@@ -96,8 +96,8 @@ internal static string[] PrepareCafArgs(string[] args)
9696
index = 1;
9797
}
9898

99-
// clean / skill (and nested skill remove) own their args; do not split for app forwarding.
100-
if (subcommand is "clean" or "skill")
99+
// clean / check / skill (and nested skill remove) own their args; do not split for app forwarding.
100+
if (subcommand is "clean" or "check" or "skill")
101101
{
102102
forwardArgs = [];
103103
return args;

0 commit comments

Comments
 (0)