diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2cf8dc70b47..af909d9accf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -886,6 +886,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 + +