Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8526de8
Update NativeAOT-LLVM infrastructure to current ABI and add CI smoketest
cloutiertyler Mar 2, 2026
1b01ebb
Merge branch 'master' into tyler/update-nativeaot-llvm-infrastructure
rekhoff Mar 12, 2026
3e64dbd
Update C# Runtime dependencies to support NativeAOT-LLVM prerequisite…
rekhoff Mar 13, 2026
ae56bb7
Merge branch 'master' into tyler/update-nativeaot-llvm-infrastructure
rekhoff Mar 13, 2026
9316256
Merge branch 'master' into tyler/update-nativeaot-llvm-infrastructure
rekhoff Mar 18, 2026
faf7167
Add `--native-aot` flag to publishing and init populating
rekhoff Mar 19, 2026
5755153
Update NativeAOT-LLVM instructions use `--native-aot` flag
rekhoff Mar 19, 2026
e22f970
Update lints
rekhoff Mar 19, 2026
d9367b8
Add `--native-aot` to CLI Reference docs
rekhoff Mar 19, 2026
f93b9e2
Add NuGet.Config during NativeAOT init
rekhoff Mar 25, 2026
ac59591
Correct warning in NATIVEAOT-LLVM.md
rekhoff Mar 26, 2026
8172a6c
Remove redundant header NATIVEAOT-LLVM.md
rekhoff Mar 26, 2026
8dc7af9
Corrected NativeAOT-LLVM note verbiage in init.rs
rekhoff Mar 26, 2026
6b8fa90
Merge branch 'master' into tyler/update-nativeaot-llvm-infrastructure
rekhoff Mar 26, 2026
6cae7a4
Merge branch 'tyler/update-nativeaot-llvm-infrastructure' into rekhof…
rekhoff Mar 26, 2026
590a878
Resolves not checking module directory for `spacetime.json` when `--…
rekhoff Mar 30, 2026
788b289
Resolves `RawIndexDefV10.Equals` when using `NativeAOT-LLVM`
rekhoff Mar 30, 2026
252ab92
Update lints
rekhoff Mar 30, 2026
e417d94
More lint updates
rekhoff Mar 30, 2026
22d8d8d
Fix `NativeAOT-LLVM` vtable error for sum types
rekhoff Mar 31, 2026
a2b0eb7
Add the functionality to use NativeAOT-LLVM to build a P1 .wasm (core…
JasonAtClockwork Apr 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -893,7 +893,43 @@
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:

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
name: Internal Tests
needs: [lints]
# Skip if not a PR or a push to master
Expand Down
186 changes: 150 additions & 36 deletions crates/bindings-csharp/BSATN.Codegen/Type.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Variants>"
&& named
.GetAttributes()
.Any(a => a.AttributeClass?.ToString() == "SpacetimeDB.TypeAttribute")
? new SumTypeUse(type, typeInfo)
: new ReferenceUse(type, typeInfo)
),
},
_ => throw new InvalidOperationException($"Unsupported type {type}"),
};
Expand Down Expand Up @@ -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();";
}

/// <summary>Sum type use that calls static helpers to avoid NativeAOT-LLVM vtable issues.</summary>
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});";
}

/// <summary>
/// A use of an array type.
/// </summary>
Expand Down Expand Up @@ -729,54 +760,137 @@ public override int GetHashCode()
"""
);

if (!Scope.IsRecord)
// Sum types use EqualityComparer<T>.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<T>.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
"""
);
}

Expand Down
10 changes: 6 additions & 4 deletions crates/bindings-csharp/BSATN.Runtime/Attrs.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
namespace SpacetimeDB;
namespace SpacetimeDB;

using System.Runtime.CompilerServices;

Expand All @@ -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<Variants>
// 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<Variants> : TaggedEnum
where Variants : struct, ITuple { }
5 changes: 5 additions & 0 deletions crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
<!-- <EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles> -->
</PropertyGroup>

<PropertyGroup Condition="'$(EXPERIMENTAL_WASM_AOT)' == '1'">
<TargetFrameworks>netstandard2.1;net10.0</TargetFrameworks>
<DefineConstants>$(DefineConstants);EXPERIMENTAL_WASM_AOT</DefineConstants>
</PropertyGroup>

<ItemGroup>
<!-- We want to build BSATN.Codegen both to include it in our NuGet package but also we want it to transform [SpacetimeDB.Type] usages in BSATN.Runtime code itself. -->
<ProjectReference
Expand Down
Loading
Loading