diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f9c105f4c4c..d8201678c95 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -893,6 +893,42 @@ jobs: exit 1 } + # NativeAOT-LLVM smoketest runs on Windows because runtime.linux-x64.Microsoft.DotNet.ILCompiler.LLVM + # does not have 8.0.0 versions available on the dotnet-experimental NuGet feed. + csharp-aot-smoketest: + needs: [lints] + runs-on: windows-latest + timeout-minutes: 15 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup dotnet + uses: actions/setup-dotnet@v3 + with: + global-json-file: global.json + + - name: Install emscripten (Windows) + shell: pwsh + run: | + git clone https://github.com/emscripten-core/emsdk.git $env:USERPROFILE\emsdk + cd $env:USERPROFILE\emsdk + .\emsdk install 4.0.21 + .\emsdk activate 4.0.21 + + - name: Smoketest C# AOT build (NativeAOT-LLVM) + shell: pwsh + run: | + $env:EXPERIMENTAL_WASM_AOT = "1" + if (Test-Path "$env:USERPROFILE\emsdk\emsdk_env.ps1") { + & "$env:USERPROFILE\emsdk\emsdk_env.ps1" | Out-Null + } + dotnet publish -c Release modules/sdk-test-cs + if (-not (Test-Path "modules/sdk-test-cs/bin/Release/net8.0/wasi-wasm/publish/StdbModule.wasm")) { + Write-Error "StdbModule.wasm not found" + exit 1 + } + internal-tests: name: Internal Tests needs: [lints] diff --git a/crates/bindings-csharp/BSATN.Codegen/Type.cs b/crates/bindings-csharp/BSATN.Codegen/Type.cs index fe7962c38c3..fa97630a946 100644 --- a/crates/bindings-csharp/BSATN.Codegen/Type.cs +++ b/crates/bindings-csharp/BSATN.Codegen/Type.cs @@ -108,7 +108,16 @@ public static TypeUse Parse(ISymbol member, ITypeSymbol typeSymbol, DiagReporter ? new EnumUse(type, typeInfo) : new ValueUse(type, typeInfo) ) - : new ReferenceUse(type, typeInfo), + // Use SumTypeUse only for types with [SpacetimeDB.Type] attribute (codegen generates static helpers for these). + : ( + named.BaseType?.OriginalDefinition.ToString() + == "SpacetimeDB.TaggedEnum" + && named + .GetAttributes() + .Any(a => a.AttributeClass?.ToString() == "SpacetimeDB.TypeAttribute") + ? new SumTypeUse(type, typeInfo) + : new ReferenceUse(type, typeInfo) + ), }, _ => throw new InvalidOperationException($"Unsupported type {type}"), }; @@ -271,12 +280,34 @@ public override string EqualsStatement( string inVar2, string outVar, int level = 0 - ) => $"var {outVar} = {inVar1} == null ? {inVar2} == null : {inVar1}.Equals({inVar2});"; + ) => + // object.Equals avoids virtual dispatch which causes vtable computation issues in NativeAOT-LLVM. + $"var {outVar} = object.Equals({inVar1}, {inVar2});"; public override string GetHashCodeStatement(string inVar, string outVar, int level = 0) => $"var {outVar} = {inVar} == null ? 0 : {inVar}.GetHashCode();"; } +/// Sum type use that calls static helpers to avoid NativeAOT-LLVM vtable issues. +public record SumTypeUse(string Type, string TypeInfo) : TypeUse(Type, TypeInfo) +{ + // Strip nullable suffix from type name for static method calls + private string NonNullableType => Type.TrimEnd('?'); + + public override string EqualsStatement( + string inVar1, + string inVar2, + string outVar, + int level = 0 + ) => + // Use the static EqualsStatic helper to avoid virtual dispatch + $"var {outVar} = {NonNullableType}.EqualsStatic({inVar1}, {inVar2});"; + + public override string GetHashCodeStatement(string inVar, string outVar, int level = 0) => + // Use the static GetHashCodeStatic helper to avoid virtual dispatch + $"var {outVar} = {NonNullableType}.GetHashCodeStatic({inVar});"; +} + /// /// A use of an array type. /// @@ -729,54 +760,137 @@ public override int GetHashCode() """ ); - if (!Scope.IsRecord) + // Sum types use EqualityComparer.Default which causes NativeAOT-LLVM vtable issues. + if (Kind is TypeKind.Sum) + { + var staticHashCodeBody = string.Join( + "\n", + bsatnDecls.Select(member => + { + var hashName = $"___hash{member.Name}"; + return $""" + case {member.Identifier}(var inner): + {member.Type.GetHashCodeStatement("inner", hashName)} + return {hashName}; + """; + }) + ); + + var equalsBody = string.Join( + "\n", + bsatnDecls.Select(member => + { + var eqName = $"___eq{member.Name}"; + return $""" + case {member.Identifier}(var inner) when that is {member.Identifier}(var thatInner): + {member.Type.EqualsStatement("inner", "thatInner", eqName)} + return {eqName}; + """; + }) + ); + + extensions.Contents.Append( + $$""" + + public virtual bool Equals({{FullName}}? that) + { + if (((object?)that) == null) { return false; } + switch (this) { + {{equalsBody}} + default: + return false; + } + } + + // Static helpers to avoid virtual dispatch which causes vtable computation issues in NativeAOT-LLVM. + public static int GetHashCodeStatic({{FullName}}? value) + { + if (((object?)value) == null) { return 0; } + switch (value) { + {{staticHashCodeBody}} + default: + return 0; + } + } + + public static bool EqualsStatic({{FullName}}? a, {{FullName}}? b) + { + if (((object?)a) == null) { return ((object?)b) == null; } + if (((object?)b) == null) { return false; } + switch (a) { + {{equalsBody.Replace("that is", "b is").Replace("thatInner", "bInner")}} + default: + return false; + } + } + """ + ); + } + else if (!Scope.IsRecord) { // If we are a reference type, various equality methods need to take nullable references. // If we are a value type, everything is pleasantly by-value. var fullNameMaybeRef = $"{FullName}{(Scope.IsStruct ? "" : "?")}"; - var declEqualsName = (MemberDeclaration decl) => $"___eq{decl.Name}"; + + // Generate equality using EqualsStatement from each TypeUse. + // This avoids EqualityComparer.Default which allocates and causes issues with NativeAOT-LLVM. + // The pattern mirrors GetHashCode generation - use statements, not expressions. + var declEqName = (MemberDeclaration decl) => $"___eq{decl.Name}"; + var equalsStatements = string.Join( + "\n ", + bsatnDecls.Select(decl => + decl.Type.EqualsStatement( + $"this.{decl.Identifier}", + $"that.{decl.Identifier}", + declEqName(decl) + ) + ) + ); + var equalsReturn = JoinOrValue( + " && ", + bsatnDecls.Select(declEqName), + "true" // if there are no members, the types are equal + ); extensions.Contents.Append( $$""" - #nullable enable - public bool Equals({{fullNameMaybeRef}} that) - { - {{(Scope.IsStruct ? "" : "if (((object?)that) == null) { return false; }\n ")}} - {{string.Join("\n", bsatnDecls.Select(decl => decl.Type.EqualsStatement($"this.{decl.Identifier}", $"that.{decl.Identifier}", declEqualsName(decl))))}} - return {{JoinOrValue( - " &&\n ", - bsatnDecls.Select(declEqualsName), - "true" // if there are no elements, the structs are equal :) - )}}; - } - - public override bool Equals(object? that) { - if (that == null) { - return false; + #nullable enable + public bool Equals({{fullNameMaybeRef}} that) + { + {{( + Scope.IsStruct ? "" : "if (((object?)that) == null) { return false; }\n " + )}} + {{equalsStatements}} + return {{equalsReturn}}; } - var that_ = that as {{FullName}}{{(Scope.IsStruct ? "?" : "")}}; - if (((object?)that_) == null) { - return false; + + public override bool Equals(object? that) { + if (that == null) { + return false; + } + var that_ = that as {{FullName}}{{(Scope.IsStruct ? "?" : "")}}; + if (((object?)that_) == null) { + return false; + } + return Equals(that_); } - return Equals(that_); - } - public static bool operator == ({{fullNameMaybeRef}} this_, {{fullNameMaybeRef}} that) { - if (((object?)this_) == null || ((object?)that) == null) { - return object.Equals(this_, that); + public static bool operator == ({{fullNameMaybeRef}} this_, {{fullNameMaybeRef}} that) { + if (((object?)this_) == null || ((object?)that) == null) { + return object.Equals(this_, that); + } + return this_.Equals(that); } - return this_.Equals(that); - } - public static bool operator != ({{fullNameMaybeRef}} this_, {{fullNameMaybeRef}} that) { - if (((object?)this_) == null || ((object?)that) == null) { - return !object.Equals(this_, that); + public static bool operator != ({{fullNameMaybeRef}} this_, {{fullNameMaybeRef}} that) { + if (((object?)this_) == null || ((object?)that) == null) { + return !object.Equals(this_, that); + } + return !this_.Equals(that); } - return !this_.Equals(that); - } - #nullable restore - """ + #nullable restore + """ ); } diff --git a/crates/bindings-csharp/BSATN.Runtime/Attrs.cs b/crates/bindings-csharp/BSATN.Runtime/Attrs.cs index 6dc073d9873..69119785abe 100644 --- a/crates/bindings-csharp/BSATN.Runtime/Attrs.cs +++ b/crates/bindings-csharp/BSATN.Runtime/Attrs.cs @@ -1,4 +1,4 @@ -namespace SpacetimeDB; +namespace SpacetimeDB; using System.Runtime.CompilerServices; @@ -9,7 +9,9 @@ )] public sealed class TypeAttribute : Attribute { } -// This could be an interface, but using `record` forces C# to check that it can -// only be applied on types that are records themselves. -public abstract record TaggedEnum +// Non-generic base record for sum types to avoid NativeAOT-LLVM vtable computation issues. +public abstract record TaggedEnum { } + +// Generic version for backward compatibility; extends non-generic base to avoid vtable issues. +public abstract record TaggedEnum : TaggedEnum where Variants : struct, ITuple { } diff --git a/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj b/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj index d58dad4cde4..4f6afaa8dd6 100644 --- a/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj +++ b/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj @@ -15,6 +15,11 @@ + + netstandard2.1;net10.0 + $(DefineConstants);EXPERIMENTAL_WASM_AOT + + [!WARNING] +> This is currently only supported for Windows server modules and is experimental. + +## Prerequisites + +- **.NET SDK 8.x** (same version used by SpacetimeDB) +- **Emscripten SDK (EMSDK)** installed (must contain `upstream/emscripten/emcc.bat`) +- **(Optional) Binaryen (wasm-opt)** installed and on `PATH` (recommended: `version_116`) +- **Windows** - NativeAOT-LLVM is currently only supported for Windows server modules + +## Prerequisites Installation + +### Install Emscripten SDK (EMSDK) + +The Emscripten SDK is required for NativeAOT-LLVM compilation: + +1. **Download and extract** the Emscripten SDK from `https://github.com/emscripten-core/emsdk` + - Example path: `D:\Tools\emsdk` + +2. **Set environment variable** (optional - the CLI will detect it automatically): + ```powershell + $env:EMSDK="D:\Tools\emsdk" + ``` + +### Install Binaryen (Optional) + +Binaryen provides `wasm-opt` for WASM optimization (recommended for performance): + +1. Download Binaryen `https://github.com/WebAssembly/binaryen/releases/tag/version_116` for Windows +2. Extract to e.g. `D:\Tools\binaryen` +3. Add `D:\Tools\binaryen\bin` to `PATH` +4. Verify: + ```powershell + wasm-opt --version + ``` + +## Creating a New NativeAOT Project + +When creating a new C# project, use the `--native-aot` flag: + +```powershell +spacetime init --lang csharp --native-aot my-native-aot-project +``` + +This automatically: +- Creates a C# project with the required package references +- Generates a `spacetime.json` with `"native-aot": true` +- Configures the project for NativeAOT-LLVM compilation + +## Converting an Existing Project + +1. **Update spacetime.json** + Add `"native-aot": true` to your `spacetime.json`: + ```json + { + "module": "your-module-name", + "native-aot": true + } + ``` + + **Note:** Once `spacetime.json` has `"native-aot": true`, you can simply run `spacetime publish` without the `--native-aot` flag. The CLI will automatically detect the configuration and use NativeAOT compilation. + +2. **Ensure NuGet feed is configured** + NativeAOT-LLVM packages come from **dotnet-experimental**. Add to `NuGet.Config`: + ```xml + + + + + + + + + ``` + +3. **Add NativeAOT package references** + Add this `ItemGroup` to your `.csproj`: + ```xml + + + + + + ``` + + Your complete `.csproj` should look like: + ```xml + + + net8.0 + wasi-wasm + enable + enable + + + + + + + + + + + ``` + +## Publishing Your NativeAOT Module + +After completing either the **Creating a New NativeAOT Project** or **Converting an Existing Project** steps above, you can publish your module normally: + +```powershell +# From your project directory +spacetime publish your-database-name +``` + +If you have `"native-aot": true` in your `spacetime.json`, the CLI will automatically detect this and use NativeAOT compilation. Alternatively, you can use: + +```powershell +spacetime publish --native-aot your-database-name +``` + +The CLI will display "Using NativeAOT-LLVM compilation (experimental)" when NativeAOT is enabled. + +## Troubleshooting + +### Package source mapping enabled +If you have **package source mapping** enabled in `NuGet.Config`, add mappings for the LLVM packages: + +```xml + + + + + + + + + + + + + + + +``` + +### wasi-experimental workload install fails +If the CLI cannot install the `wasi-experimental` workload automatically, install it manually: + +```powershell +dotnet workload install wasi-experimental +``` + +### Duplicate PackageReference warning +You may see a `NU1504` warning about duplicate `PackageReference` items. This is expected and non-blocking. + +### Code generation failed +If you see errors like "Code generation failed for method", ensure: +1. You're using `SpacetimeDB.Runtime` version 2.0.4 or newer +2. All required package references are in your `.csproj` +3. The `dotnet-experimental` feed is configured in `NuGet.Config` + diff --git a/crates/bindings-csharp/Runtime/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index 9cf994e929b..5579ebdb470 100644 --- a/crates/bindings-csharp/Runtime/Internal/FFI.cs +++ b/crates/bindings-csharp/Runtime/Internal/FFI.cs @@ -2,6 +2,12 @@ namespace SpacetimeDB.Internal; using System.Runtime.InteropServices; using System.Runtime.InteropServices.Marshalling; +#if EXPERIMENTAL_WASM_AOT +using WasmImportLinkageAttribute = System.Runtime.InteropServices.WasmImportLinkageAttribute; +#else +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +file sealed class WasmImportLinkageAttribute : Attribute { } +#endif // This type is outside of the hidden `FFI` class because for now we need to do some public // forwarding in the codegen for `__describe_module__` and `__call_reducer__` exports which both @@ -182,6 +188,7 @@ public readonly record struct RowIter(uint Handle) public static readonly RowIter INVALID = new(0); } + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus table_id_from_name( [In] byte[] name, @@ -189,6 +196,7 @@ public static partial CheckedStatus table_id_from_name( out TableId out_ ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus index_id_from_name( [In] byte[] name, @@ -196,15 +204,18 @@ public static partial CheckedStatus index_id_from_name( out IndexId out_ ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus datastore_table_row_count(TableId table_id, out ulong out_); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus datastore_table_scan_bsatn( TableId table_id, out RowIter out_ ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_4)] public static partial CheckedStatus datastore_index_scan_point_bsatn( IndexId index_id, @@ -213,6 +224,7 @@ public static partial CheckedStatus datastore_index_scan_point_bsatn( out RowIter out_ ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_4)] public static partial CheckedStatus datastore_delete_by_index_scan_point_bsatn( IndexId index_id, @@ -221,6 +233,7 @@ public static partial CheckedStatus datastore_delete_by_index_scan_point_bsatn( out uint out_ ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus datastore_index_scan_range_bsatn( IndexId index_id, @@ -234,6 +247,7 @@ public static partial CheckedStatus datastore_index_scan_range_bsatn( out RowIter out_ ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial Errno row_iter_bsatn_advance( RowIter iter_handle, @@ -241,9 +255,11 @@ public static partial Errno row_iter_bsatn_advance( ref uint buffer_len ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus row_iter_bsatn_close(RowIter iter_handle); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus datastore_insert_bsatn( TableId table_id, @@ -251,6 +267,7 @@ public static partial CheckedStatus datastore_insert_bsatn( ref uint row_len ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus datastore_update_bsatn( TableId table_id, @@ -259,6 +276,7 @@ public static partial CheckedStatus datastore_update_bsatn( ref uint row_len ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus datastore_delete_by_index_scan_range_bsatn( IndexId index_id, @@ -272,6 +290,7 @@ public static partial CheckedStatus datastore_delete_by_index_scan_range_bsatn( out uint out_ ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus datastore_delete_all_by_eq_bsatn( TableId table_id, @@ -280,6 +299,7 @@ public static partial CheckedStatus datastore_delete_all_by_eq_bsatn( out uint out_ ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial Errno bytes_source_read( BytesSource source, @@ -287,6 +307,7 @@ public static partial Errno bytes_source_read( ref uint buffer_len ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus bytes_sink_write( BytesSink sink, @@ -304,6 +325,7 @@ public enum LogLevel : byte Panic = 5, } + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial void console_log( LogLevel level, @@ -341,12 +363,15 @@ internal static class ConsoleTimerIdMarshaller } } + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial ConsoleTimerId console_timer_start([In] byte[] name, uint name_len); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus console_timer_end(ConsoleTimerId stopwatch_id); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial void volatile_nonatomic_schedule_immediate( [In] byte[] name, @@ -363,22 +388,28 @@ uint args_len // which prevents source-generated PInvokes from working with types from other assemblies, and // `Identity` lives in another assembly (`BSATN.Runtime`). Luckily, `DllImport` is enough here. #pragma warning disable SYSLIB1054 // Suppress "Use 'LibraryImportAttribute' instead of 'DllImportAttribute'" warning. + [WasmImportLinkage] [DllImport(StdbNamespace10_0)] public static extern void identity(out Identity dest); #pragma warning restore SYSLIB1054 + [WasmImportLinkage] [DllImport(StdbNamespace10_1)] public static extern Errno bytes_source_remaining_length(BytesSource source, ref uint len); + [WasmImportLinkage] [DllImport(StdbNamespace10_2)] public static extern Errno get_jwt(ref ConnectionId connectionId, out BytesSource source); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_start_mut_tx")] public static partial Errno procedure_start_mut_tx(out long micros); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_commit_mut_tx")] public static partial Errno procedure_commit_mut_tx(); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_abort_mut_tx")] public static partial Errno procedure_abort_mut_tx(); @@ -389,6 +420,7 @@ public readonly struct BytesSourcePair public readonly BytesSource B; } + [WasmImportLinkage] [LibraryImport(StdbNamespace10_3, EntryPoint = "procedure_http_request")] public static partial Errno procedure_http_request( ReadOnlySpan request, diff --git a/crates/bindings-csharp/Runtime/Runtime.csproj b/crates/bindings-csharp/Runtime/Runtime.csproj index 2f4350379ee..af84834c7b2 100644 --- a/crates/bindings-csharp/Runtime/Runtime.csproj +++ b/crates/bindings-csharp/Runtime/Runtime.csproj @@ -12,6 +12,12 @@ true SpacetimeDB true + https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json;$(RestoreAdditionalProjectSources) + + + + net10.0 + $(DefineConstants);EXPERIMENTAL_WASM_AOT @@ -25,6 +31,12 @@ + + + + + + diff --git a/crates/bindings-csharp/Runtime/bindings.c b/crates/bindings-csharp/Runtime/bindings.c index 1b55d095713..8fb41c80389 100644 --- a/crates/bindings-csharp/Runtime/bindings.c +++ b/crates/bindings-csharp/Runtime/bindings.c @@ -259,9 +259,24 @@ WASI_SHIM(path_remove_directory, (int32_t, int32_t, int32_t)); WASI_SHIM(path_rename, (int32_t, int32_t, int32_t, int32_t, int32_t, int32_t)); WASI_SHIM(path_symlink, (int32_t, int32_t, int32_t, int32_t, int32_t)); WASI_SHIM(path_unlink_file, (int32_t, int32_t, int32_t)); -WASI_SHIM(poll_oneoff, (int32_t, int32_t, int32_t, int32_t)); +int32_t WASI_NAME(poll_oneoff)(int32_t, int32_t, int32_t, int32_t nevents_ptr) { + if (nevents_ptr) { + *(__wasi_size_t*)(uintptr_t)nevents_ptr = 0; + } + // Returning success with uninitialized events can wedge the runtime. + // Fail explicitly so the caller surfaces the missing capability instead. + return __WASI_ERRNO_NOSYS; +} WASI_SHIM(sched_yield, ()); -WASI_SHIM(random_get, (int32_t, int32_t)); +int32_t WASI_NAME(random_get)(int32_t buf, int32_t len) { + static uint32_t state = 0x13579BDFu; + uint8_t* out = (uint8_t*)(uintptr_t)buf; + for (int32_t i = 0; i < len; i++) { + state = state * 1664525u + 1013904223u; + out[i] = (uint8_t)(state >> 24); + } + return 0; +} WASI_SHIM(sock_accept, (int32_t, int32_t, int32_t)); WASI_SHIM(sock_recv, (int32_t, int32_t, int32_t, int32_t, int32_t, int32_t)); WASI_SHIM(sock_send, (int32_t, int32_t, int32_t, int32_t, int32_t)); diff --git a/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets index 780d6f19f43..ec8fac8b3b7 100644 --- a/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets +++ b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets @@ -1,31 +1,50 @@ + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -41,7 +60,7 @@ - 24 + 29 $([System.IO.Path]::Combine($(IntermediateOutputPath), "wasi-sdk.$(WasiSdkVersion).tar.gz")) diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index b4701b41164..5aa8d1d2e71 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -116,6 +116,7 @@ pub struct TemplateConfig { pub github_repo: Option, pub template_def: Option, pub use_local: bool, + pub native_aot: bool, } #[derive(Debug, Clone, Default)] @@ -131,6 +132,8 @@ pub struct InitOptions { pub non_interactive: bool, /// When true, suppress the "Next steps" message after init (e.g. when called from `spacetime dev`). pub skip_next_steps: bool, + /// When true, configure C# projects for NativeAOT-LLVM compilation. + pub native_aot: bool, } impl InitOptions { @@ -146,6 +149,7 @@ impl InitOptions { local: args.get_flag("local"), non_interactive: args.get_flag("non-interactive"), skip_next_steps: false, + native_aot: args.get_flag("native-aot"), } } } @@ -189,6 +193,12 @@ pub fn cli() -> clap::Command { .action(clap::ArgAction::SetTrue) .help("Run in non-interactive mode"), ) + .arg( + Arg::new("native-aot") + .long("native-aot") + .action(clap::ArgAction::SetTrue) + .help("Configure C# project for NativeAOT-LLVM compilation (experimental, Windows only)"), + ) } pub async fn fetch_templates_list() -> anyhow::Result> { @@ -346,6 +356,7 @@ fn create_template_config_from_template_str( github_repo: None, template_def: Some(template.clone()), use_local: true, + native_aot: false, }) } else { // GitHub template @@ -358,6 +369,7 @@ fn create_template_config_from_template_str( github_repo: Some(template_str.to_string()), template_def: None, use_local: true, + native_aot: false, }) } } @@ -525,7 +537,13 @@ pub async fn exec_with_options(config: &mut Config, options: &InitOptions) -> an )?; init_from_template(&template_config, &template_config.project_path, is_server_only).await?; - if let Some(path) = create_default_spacetime_config_if_missing(&project_path)? { + // Add NativeAOT-LLVM package references to C# projects if --native-aot was specified + if options.native_aot && template_config.server_lang == Some(ServerLanguage::Csharp) { + let server_dir = template_config.project_path.join("spacetimedb"); + add_native_aot_packages_to_csproj(&server_dir)?; + } + + if let Some(path) = create_default_spacetime_config_if_missing(&project_path, options.native_aot)? { println!("{} Created {}", "✓".green(), path.display()); } @@ -605,7 +623,10 @@ fn get_local_database_name(options: &InitOptions, project_name: &str, is_interac Ok(database_name) } -fn create_default_spacetime_config_if_missing(project_path: &Path) -> anyhow::Result> { +fn create_default_spacetime_config_if_missing( + project_path: &Path, + native_aot: bool, +) -> anyhow::Result> { let config_path = project_path.join(CONFIG_FILENAME); if config_path.exists() { return Ok(None); @@ -622,6 +643,10 @@ fn create_default_spacetime_config_if_missing(project_path: &Path) -> anyhow::Re .insert("module-path".to_string(), json!("./spacetimedb")); } + if native_aot { + config.additional_fields.insert("native-aot".to_string(), json!(true)); + } + Ok(Some(config.save_to_dir(project_path)?)) } @@ -696,6 +721,7 @@ async fn get_template_config_non_interactive( github_repo: None, template_def: None, use_local: true, + native_aot: false, }) } @@ -761,6 +787,7 @@ async fn get_template_config_interactive( github_repo: None, template_def: None, use_local: true, + native_aot: false, }); } @@ -845,6 +872,7 @@ async fn get_template_config_interactive( github_repo: None, template_def: Some(template.clone()), use_local: true, + native_aot: false, }); } else if client_selection == github_clone_index { return loop { @@ -889,6 +917,7 @@ async fn get_template_config_interactive( github_repo: None, template_def: None, use_local: true, + native_aot: false, }); } else { unreachable!("Invalid selection index"); @@ -1648,6 +1677,21 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> anyhow::Result anyhow::Result<()> { Ok(()) } +/// Adds NativeAOT-LLVM project configuration to an existing C# .csproj file and creates NuGet.Config. +/// This is called when `--native-aot` is specified during `spacetime init`. +fn add_native_aot_packages_to_csproj(project_path: &Path) -> anyhow::Result<()> { + let csproj_path = project_path.join("StdbModule.csproj"); + if !csproj_path.exists() { + anyhow::bail!("Could not find StdbModule.csproj at {}", csproj_path.display()); + } + + let content = std::fs::read_to_string(&csproj_path)?; + + // The NativeAOT-LLVM configuration to add + let native_aot_config = r#" + + net10.0 + $(DefineConstants);EXPERIMENTAL_WASM_AOT + + + + true + true + false + + + + + <_OriginalIlcSdkPath>$(IlcSdkPath) + <_WasiRuntimeOverlayDir>$(IntermediateOutputPath)native-hidden-no-wit\ + + + + <_WasiRuntimeOverlaySource Include="$(IlcFrameworkNativePath)**\*" Exclude="$(IlcFrameworkNativePath)**\*.wit" /> + + + + + + + + $(_WasiRuntimeOverlayDir) + $(_OriginalIlcSdkPath) + + + + + + + +"#; + + // Insert the NativeAOT config before the closing tag + let new_content = if let Some(pos) = content.rfind("") { + let (before, after) = content.split_at(pos); + format!("{}{}{}", before.trim_end(), native_aot_config, after) + } else { + anyhow::bail!("Invalid .csproj file: missing tag"); + }; + + std::fs::write(&csproj_path, new_content)?; + println!( + "{} Added NativeAOT-LLVM project configuration to {}", + "✓".green(), + csproj_path.display() + ); + + // Create NuGet.Config with the dotnet-experimental feed required for NativeAOT-LLVM packages + let nuget_config_path = project_path.join("NuGet.Config"); + let nuget_config_content = r#" + + + + + + + +"#; + + std::fs::write(&nuget_config_path, nuget_config_content)?; + println!( + "{} Created {} with dotnet-experimental feed", + "✓".green(), + nuget_config_path.display() + ); + + Ok(()) +} + pub fn init_typescript_project(project_path: &Path) -> anyhow::Result<()> { let export_files = vec![ ( @@ -2015,7 +2149,7 @@ mod tests { let project_path = temp.path(); std::fs::create_dir_all(project_path.join("spacetimedb")).unwrap(); - let created = create_default_spacetime_config_if_missing(project_path) + let created = create_default_spacetime_config_if_missing(project_path, false) .unwrap() .expect("expected config to be created"); assert_eq!(created, project_path.join("spacetime.json")); @@ -2028,6 +2162,23 @@ mod tests { parsed.get("module-path").and_then(|v| v.as_str()), Some("./spacetimedb") ); + assert!(parsed.get("native-aot").is_none()); + } + + #[test] + fn test_create_default_spacetime_config_with_native_aot() { + let temp = tempfile::TempDir::new().unwrap(); + let project_path = temp.path(); + std::fs::create_dir_all(project_path.join("spacetimedb")).unwrap(); + + let created = create_default_spacetime_config_if_missing(project_path, true) + .unwrap() + .expect("expected config to be created"); + assert_eq!(created, project_path.join("spacetime.json")); + + let content = std::fs::read_to_string(&created).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(parsed.get("native-aot").and_then(|v| v.as_bool()), Some(true)); } #[test] diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 30faf5e6052..f64efd4e598 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -12,8 +12,8 @@ use std::{env, fs}; use crate::common_args::ClearMode; use crate::config::Config; use crate::spacetime_config::{ - find_and_load_with_env, CommandConfig, CommandSchema, CommandSchemaBuilder, FlatTarget, Key, LoadedConfig, - SpacetimeConfig, + find_and_load_with_env, find_and_load_with_env_from, CommandConfig, CommandSchema, CommandSchemaBuilder, + FlatTarget, Key, LoadedConfig, SpacetimeConfig, }; use crate::util::{add_auth_header_opt, get_auth_header, strip_verbatim_prefix, AuthHeader, ResponseExt}; use crate::util::{decode_identity, y_or_n}; @@ -33,6 +33,7 @@ pub fn build_publish_schema(command: &clap::Command) -> Result("env").map(|s| s.as_str()); // Get publish configs (from spacetime.json or empty) - let owned_loaded; + let mut owned_loaded; let loaded_config_ref = if no_config { None } else if let Some(pre) = pre_loaded_config { Some(pre) } else { + // First, try to load config from current directory owned_loaded = find_and_load_with_env(env)?; + + // If no config found and --module-path is specified, try loading from module path + if owned_loaded.is_none() + && args.contains_id("module_path") + && let Some(module_path) = args.get_one::("module_path") + { + owned_loaded = find_and_load_with_env_from(env, module_path.clone())?; + } + owned_loaded.as_ref().inspect(|loaded| { if !quiet_config { for path in &loaded.loaded_files { @@ -420,6 +437,7 @@ async fn execute_publish_configs<'a>( let parent = parent_opt.as_deref(); let org_opt = command_config.get_one::("organization")?; let org = org_opt.as_deref(); + let native_aot = command_config.get_one::("native_aot")?.unwrap_or(false); // If the user didn't specify an identity and we didn't specify an anonymous identity, then // we want to use the default identity @@ -447,6 +465,16 @@ async fn execute_publish_configs<'a>( println!("(JS) Skipping build. Instead we are publishing {}", path.display()); (path.clone(), "Js") } else { + // Set EXPERIMENTAL_WASM_AOT environment variable if native_aot is enabled + // This is read by the C# build system (MSBuild) and by csharp.rs to determine output paths + if native_aot { + println!("Using NativeAOT-LLVM compilation (experimental)"); + // SAFETY: We are single-threaded at this point and no other code is reading + // this environment variable concurrently. + unsafe { + env::set_var("EXPERIMENTAL_WASM_AOT", "1"); + } + } build::exec_with_argstring( path_to_project .as_ref() diff --git a/crates/cli/src/tasks/csharp.rs b/crates/cli/src/tasks/csharp.rs index 5df8b730448..cfac60422ea 100644 --- a/crates/cli/src/tasks/csharp.rs +++ b/crates/cli/src/tasks/csharp.rs @@ -17,46 +17,58 @@ pub(crate) fn build_csharp(project_path: &Path, build_debug: bool) -> anyhow::Re }; } - // Check if the `wasi-experimental` workload is installed. Unfortunately, we - // have to do this by inspecting the human-readable output. There is a - // hidden `--machine-readable` flag but it also mixes in human-readable - // output as well as unnecessarily updates various unrelated manifests. - match dotnet!("workload", "list").read() { - Ok(workloads) if workloads.contains("wasi-experimental") => {} - Ok(_) => { - // If wasi-experimental is not found, first check if we're running - // on .NET SDK 8.0. We can't even install that workload on older - // versions, and we don't support .NET 9.0 yet, so this helps to - // provide a nicer message than "Workload ID wasi-experimental is not recognized.". - let version = dotnet!("--version").read().unwrap_or_default(); - if parse_major_version(&version) != Some(8) { - anyhow::bail!(concat!( - ".NET SDK 8.0 is required, but found {version}.\n", - "If you have multiple versions of .NET SDK installed, configure your project using https://learn.microsoft.com/en-us/dotnet/core/tools/global-json." - )); - } + let experimental_wasm_aot = std::env::var_os("EXPERIMENTAL_WASM_AOT").is_some_and(|v| v == "1"); - // Finally, try to install the workload ourselves. On some systems - // this might require elevated privileges, so print a nice error - // message if it fails. - dotnet!( - "workload", - "install", - "wasi-experimental", - "--skip-manifest-update" - ) - .stderr_capture() - .run() - .context(concat!( - "Couldn't install the required wasi-experimental workload.\n", - "You might need to install it manually by running `dotnet workload install wasi-experimental` with privileged rights." - ))?; - } - Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - anyhow::bail!("dotnet not found in PATH. Please install .NET SDK 8.0.") + if experimental_wasm_aot { + let version = dotnet!("--version").read().unwrap_or_default(); + if parse_major_version(&version) != Some(10) { + anyhow::bail!(concat!( + ".NET SDK 10.0 is required for NativeAOT-LLVM C# builds, but found {version}.\n", + "If you have multiple versions of .NET SDK installed, configure your project using https://learn.microsoft.com/en-us/dotnet/core/tools/global-json." + )); } - Err(error) => anyhow::bail!("{error}"), - }; + } else { + // Check if the `wasi-experimental` workload is installed. Unfortunately, we + // have to do this by inspecting the human-readable output. There is a + // hidden `--machine-readable` flag but it also mixes in human-readable + // output as well as unnecessarily updates various unrelated manifests. + match dotnet!("workload", "list").read() { + Ok(workloads) if workloads.contains("wasi-experimental") => {} + Ok(_) => { + // If wasi-experimental is not found, first check if we're running + // on .NET SDK 8.0. We can't even install that workload on older + // versions, and we don't support .NET 9.0 yet, so this helps to + // provide a nicer message than "Workload ID wasi-experimental is not recognized.". + let version = dotnet!("--version").read().unwrap_or_default(); + if parse_major_version(&version) != Some(8) { + anyhow::bail!(concat!( + ".NET SDK 8.0 is required, but found {version}.\n", + "If you have multiple versions of .NET SDK installed, configure your project using https://learn.microsoft.com/en-us/dotnet/core/tools/global-json." + )); + } + + // Finally, try to install the workload ourselves. On some systems + // this might require elevated privileges, so print a nice error + // message if it fails. + dotnet!( + "workload", + "install", + "wasi-experimental", + "--skip-manifest-update" + ) + .stderr_capture() + .run() + .context(concat!( + "Couldn't install the required wasi-experimental workload.\n", + "You might need to install it manually by running `dotnet workload install wasi-experimental` with privileged rights." + ))?; + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + anyhow::bail!("dotnet not found in PATH. Please install .NET SDK 8.0.") + } + Err(error) => anyhow::bail!("{error}"), + }; + } let config_name = if build_debug { "Debug" } else { "Release" }; @@ -68,15 +80,25 @@ pub(crate) fn build_csharp(project_path: &Path, build_debug: bool) -> anyhow::Re ) })?; - // run dotnet publish using cmd macro - dotnet!("publish", "-c", config_name, "-v", "quiet").run()?; + if experimental_wasm_aot { + dotnet!( + "publish", + "-c", + config_name, + "-v", + "quiet", + "/p:IlcLlvmTarget=wasm32-unknown-wasip1", + "/p:WasmEnableThreads=false" + ) + .run()?; + } else { + // run dotnet publish using cmd macro + dotnet!("publish", "-c", config_name, "-v", "quiet").run()?; + } // check if file exists - let subdir = if std::env::var_os("EXPERIMENTAL_WASM_AOT").is_some_and(|v| v == "1") { - "publish" - } else { - "AppBundle" - }; + let subdir = if experimental_wasm_aot { "publish" } else { "AppBundle" }; + let target_framework = if experimental_wasm_aot { "net10.0" } else { "net8.0" }; // TODO: This code looks for build outputs in both `bin` and `bin~` as output directories. @bfops feels like we shouldn't have to look for `bin~`, since the `~` suffix is just intended to cause Unity to ignore directories, and that shouldn't be relevant here. We do think we've seen `bin~` appear though, and it's not harmful to do the extra checks, so we're merging for now due to imminent code freeze. At some point, it would be good to figure out if we do actually see `bin~` in module directories, and where that's coming from (which could suggest a bug). // check for the old .NET 7 path for projects that haven't migrated yet let bad_output_paths = [ @@ -91,8 +113,12 @@ pub(crate) fn build_csharp(project_path: &Path, build_debug: bool) -> anyhow::Re )); } let possible_output_paths = [ - project_path.join(format!("bin/{config_name}/net8.0/wasi-wasm/{subdir}/StdbModule.wasm")), - project_path.join(format!("bin~/{config_name}/net8.0/wasi-wasm/{subdir}/StdbModule.wasm")), + project_path.join(format!( + "bin/{config_name}/{target_framework}/wasi-wasm/{subdir}/StdbModule.wasm" + )), + project_path.join(format!( + "bin~/{config_name}/{target_framework}/wasi-wasm/{subdir}/StdbModule.wasm" + )), ]; if possible_output_paths.iter().all(|p| p.exists()) { anyhow::bail!(concat!( diff --git a/demo/Blackholio/client-unity/Assets/Scripts/autogen/SpacetimeDBClient.g.cs b/demo/Blackholio/client-unity/Assets/Scripts/autogen/SpacetimeDBClient.g.cs index 423b77b5cf4..e7febb6fa13 100644 --- a/demo/Blackholio/client-unity/Assets/Scripts/autogen/SpacetimeDBClient.g.cs +++ b/demo/Blackholio/client-unity/Assets/Scripts/autogen/SpacetimeDBClient.g.cs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.0.0 (commit 6a6b5a6616f0578aa641bc0689691f953b13feb8). +// This was generated using spacetimedb cli version 2.1.0 (commit 6cae7a4ca81a3c90d01d3f3303d46fa7bf7b3d41). #nullable enable diff --git a/demo/Blackholio/server-csharp/StdbModule.csproj b/demo/Blackholio/server-csharp/StdbModule.csproj index b19fb92460c..57a38def25e 100644 --- a/demo/Blackholio/server-csharp/StdbModule.csproj +++ b/demo/Blackholio/server-csharp/StdbModule.csproj @@ -12,8 +12,46 @@ $(NoWarn);CS8981;IDE1006 + + net10.0 + $(DefineConstants);EXPERIMENTAL_WASM_AOT + + + + true + true + false + + + + + <_OriginalIlcSdkPath>$(IlcSdkPath) + <_WasiRuntimeOverlayDir>$(IntermediateOutputPath)native-hidden-no-wit\ + + + <_WasiRuntimeOverlaySource Include="$(IlcFrameworkNativePath)**\*" Exclude="$(IlcFrameworkNativePath)**\*.wit" /> + + + + + + + + $(_WasiRuntimeOverlayDir) + $(_OriginalIlcSdkPath) + + + + + + + diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index 70b33a489e8..f67d3fb4bbc 100644 --- a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md +++ b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md @@ -111,6 +111,7 @@ Run `spacetime help publish` for more detailed information. * `-y`, `--yes` — Run non-interactively wherever possible. This will answer "yes" to almost all prompts, but will sometimes answer "no" to preserve non-interactivity (e.g. when prompting whether to log in with spacetimedb.com). * `--no-config` — Ignore spacetime.json configuration * `--env ` — Environment name for config file layering (e.g., dev, staging) +* `--native-aot` — Use NativeAOT-LLVM compilation for C# modules (experimental, Windows only) @@ -415,6 +416,7 @@ Initializes a new spacetime project. * `-t`, `--template