diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 148979c886b..cc08307d62e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -153,6 +153,26 @@ jobs: ./emsdk install 4.0.21 ./emsdk activate 4.0.21 + # Install WASI SDK for .NET 10 NativeAOT-LLVM compilation. + - name: Install WASI SDK (Linux) + if: runner.os == 'Linux' + shell: bash + run: | + WASI_SDK_VERSION="29" + WASI_SDK_PATH="/opt/wasi-sdk" + if [ -d "$WASI_SDK_PATH" ] && [ -f "$WASI_SDK_PATH/bin/clang" ]; then + echo "WASI SDK already installed at $WASI_SDK_PATH" + else + echo "Installing WASI SDK version $WASI_SDK_VERSION..." + wget -q "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" + tar -xzf "wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" + sudo mv "wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux" "$WASI_SDK_PATH" + rm -f "wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" + echo "WASI SDK installed successfully" + fi + echo "WASI_SDK_PATH=$WASI_SDK_PATH" >> "$GITHUB_ENV" + echo "Using WASI SDK at: $WASI_SDK_PATH" + - name: Install emscripten (Windows) if: runner.os == 'Windows' shell: pwsh @@ -162,6 +182,29 @@ jobs: .\emsdk install 4.0.21 .\emsdk activate 4.0.21 + # Install WASI SDK for .NET 10 NativeAOT-LLVM compilation. + - name: Install WASI SDK (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + $wasiSdkVersion = "25" + $wasiDir = "$env:USERPROFILE\.wasi-sdk" + $clangPath = Join-Path $wasiDir "bin\clang.exe" + if (Test-Path $clangPath) { + Write-Host "WASI SDK already installed at $wasiDir" + } else { + Write-Host "Installing WASI SDK version $wasiSdkVersion..." + $wasiUrl = "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${wasiSdkVersion}/wasi-sdk-${wasiSdkVersion}.0-x86_64-windows.tar.gz" + Invoke-WebRequest -Uri $wasiUrl -OutFile "$env:TEMP\wasi-sdk.tar.gz" + New-Item -ItemType Directory -Force -Path $wasiDir | Out-Null + & "$env:SystemRoot\System32\tar.exe" -xzf "$env:TEMP\wasi-sdk.tar.gz" -C $wasiDir --strip-components=1 + Remove-Item "$env:TEMP\wasi-sdk.tar.gz" -Force -ErrorAction SilentlyContinue + Write-Host "WASI SDK installed successfully" + } + echo "WASI_SDK_PATH=$wasiDir" >> $env:GITHUB_ENV + Write-Host "Using WASI SDK at: $wasiDir" + - name: Install psql (Windows) if: runner.os == 'Windows' shell: pwsh @@ -182,9 +225,14 @@ jobs: $PSNativeCommandUseErrorActionPreference = $true cd modules - # the sdk-manifests on windows-latest are messed up, so we need to update them dotnet workload config --update-mode manifests - dotnet workload update + dotnet workload update --from-previous-sdk + # Explicitly install wasi-experimental for .NET 8 SDK (needed for test_build_csharp_module) + # Create temp global.json to target .NET 8 SDK for workload install + $sdk8Json = '{"sdk":{"version":"8.0.400","rollForward":"latestFeature"}}' + $sdk8Json | Out-File -FilePath global.json -Encoding utf8 + dotnet workload install wasi-experimental + Remove-Item global.json - name: Override NuGet packages shell: bash @@ -323,6 +371,25 @@ jobs: ./emsdk install 4.0.21 ./emsdk activate 4.0.21 + # Install WASI SDK for .NET 10 NativeAOT-LLVM compilation (idempotent - checks if already exists). + - name: Install WASI SDK + shell: bash + run: | + WASI_SDK_VERSION="29" + WASI_SDK_PATH="/opt/wasi-sdk" + if [ -d "$WASI_SDK_PATH" ] && [ -f "$WASI_SDK_PATH/bin/clang" ]; then + echo "WASI SDK already installed at $WASI_SDK_PATH" + else + echo "Installing WASI SDK version $WASI_SDK_VERSION..." + wget -q "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" + tar -xzf "wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" + sudo mv "wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux" "$WASI_SDK_PATH" + rm -f "wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" + echo "WASI SDK installed successfully" + fi + echo "WASI_SDK_PATH=$WASI_SDK_PATH" >> "$GITHUB_ENV" + echo "Using WASI SDK at: $WASI_SDK_PATH" + - name: Install wasm-bindgen CLI run: | REQUIRED_WASM_BINDGEN_VERSION="$( @@ -1041,7 +1108,8 @@ jobs: run: dotnet restore --configfile ../../../NuGet.Config blackholio.csproj - name: Build Godot project - run: godot --headless --verbose --path demo/Blackholio/client-godot --build-solutions --quit + working-directory: demo/Blackholio/client-godot + run: godot --headless --verbose --build-solutions --quit - name: Start SpacetimeDB run: | @@ -1055,7 +1123,8 @@ jobs: bash ./publish.sh - name: Run Godot tests - run: godot --headless --path demo/Blackholio/client-godot --scene res://tests/GodotPlayModeTests.tscn + working-directory: demo/Blackholio/client-godot + run: godot --headless --scene res://tests/GodotPlayModeTests.tscn csharp-testsuite: needs: [merge_queue_noop, lints] @@ -1075,7 +1144,54 @@ jobs: with: global-json-file: global.json + - name: Install .NET workloads + run: | + dotnet workload config --update-mode manifests + dotnet workload update --from-previous-sdk + # Explicitly install wasi-experimental for .NET 8 SDK (needed for test_build_csharp_module) + # Create temp global.json to target .NET 8 SDK for workload install + echo '{"sdk":{"version":"8.0.100","rollForward":"latestFeature"}}' > global.json + dotnet workload install wasi-experimental + rm global.json + # Reinstall wasi-experimental for .NET 10 SDK: the .NET 8 install above triggers garbage + # collection that removes the .NET 10 workload packs installed by `workload update`. + # The regression-test server targets net10.0, so we need the .NET 10 packs restored. + dotnet workload install wasi-experimental + + # Install native WASI SDK toolchain (needed by WasiApp.Native.targets to compile native files). + - name: Install WASI SDK + run: | + WASI_SDK_VERSION="29" + WASI_SDK_PATH="/opt/wasi-sdk" + if [ -d "$WASI_SDK_PATH" ] && [ -f "$WASI_SDK_PATH/bin/clang" ]; then + echo "WASI SDK already installed at $WASI_SDK_PATH" + else + echo "Installing WASI SDK version $WASI_SDK_VERSION..." + wget -q "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" + tar -xzf "wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" + sudo mv "wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux" "$WASI_SDK_PATH" + rm -f "wasi-sdk-${WASI_SDK_VERSION}.0-x86_64-linux.tar.gz" + echo "WASI SDK installed successfully" + fi + echo "WASI_SDK_PATH=$WASI_SDK_PATH" >> "$GITHUB_ENV" + echo "Using WASI SDK at: $WASI_SDK_PATH" + + # Remove broken global.json symlinks before regression scripts temporarily pin + # each C# module directory to the SDK version under test. + - name: Fix global.json symlinks + run: | + for dir in sdks/csharp/examples~/regression-tests/server \ + sdks/csharp/examples~/regression-tests/republishing/server-initial \ + sdks/csharp/examples~/regression-tests/republishing/server-republish; do + if [ -L "$dir/global.json" ] && [ ! -f "$dir/global.json" ]; then + echo "Fixing broken symlink at $dir/global.json" + rm -f "$dir/global.json" + fi + done + - name: Override NuGet packages + env: + EXPERIMENTAL_WASM_AOT: 1 run: | dotnet pack crates/bindings-csharp/BSATN.Runtime dotnet pack crates/bindings-csharp/Runtime @@ -1146,9 +1262,11 @@ jobs: - name: Check quickstart-chat bindings are up to date run: | - bash sdks/csharp/tools~/gen-quickstart.sh + for dotnet_version in 8 10; do + bash sdks/csharp/tools~/gen-quickstart.sh "$dotnet_version" + done tools/check-diff.sh templates/chat-console-cs/module_bindings || { - echo 'Error: quickstart-chat bindings have changed. Please run `sdks/csharp/tools~/gen-quickstart.sh`.' + echo 'Error: quickstart-chat bindings have changed. Please run `sdks/csharp/tools~/gen-quickstart.sh 10`.' exit 1 } @@ -1170,6 +1288,21 @@ jobs: - name: Run regression tests run: | bash sdks/csharp/tools~/run-regression-tests.sh + # Restore global.json symlinks (we replaced them with files to work around .NET 10 SDK bug) + # server is 5 levels deep from root, republishing dirs are 6 levels deep + if [ -f sdks/csharp/examples~/regression-tests/server/global.json ] && [ ! -L sdks/csharp/examples~/regression-tests/server/global.json ]; then + echo "Restoring symlink at server/global.json" + rm -f sdks/csharp/examples~/regression-tests/server/global.json + ln -s ../../../../../global.json sdks/csharp/examples~/regression-tests/server/global.json + fi + for dir in sdks/csharp/examples~/regression-tests/republishing/server-initial \ + sdks/csharp/examples~/regression-tests/republishing/server-republish; do + if [ -f "$dir/global.json" ] && [ ! -L "$dir/global.json" ]; then + echo "Restoring symlink at $dir/global.json" + rm -f "$dir/global.json" + ln -s ../../../../../../global.json "$dir/global.json" + fi + done tools/check-diff.sh sdks/csharp/examples~/regression-tests || { echo 'Error: Bindings are dirty. Please run `sdks/csharp/tools~/gen-regression-tests.sh`.' exit 1 diff --git a/crates/bindings-csharp/BSATN.Codegen/BSATN.Codegen.csproj b/crates/bindings-csharp/BSATN.Codegen/BSATN.Codegen.csproj index a8e8206462a..719cd442a91 100644 --- a/crates/bindings-csharp/BSATN.Codegen/BSATN.Codegen.csproj +++ b/crates/bindings-csharp/BSATN.Codegen/BSATN.Codegen.csproj @@ -20,6 +20,7 @@ + diff --git a/crates/bindings-csharp/BSATN.Codegen/Diag.cs b/crates/bindings-csharp/BSATN.Codegen/Diag.cs index 2ae3b2513ba..ed6c016a0bb 100644 --- a/crates/bindings-csharp/BSATN.Codegen/Diag.cs +++ b/crates/bindings-csharp/BSATN.Codegen/Diag.cs @@ -164,13 +164,16 @@ EnumDeclarationSyntax @enum @enum => @enum.Members[256] ); - public static readonly ErrorDescriptor TaggedEnumInlineTuple = + public static readonly ErrorDescriptor<( + INamedTypeSymbol baseType, + SyntaxNode location + )> TaggedEnumInlineTuple = new( group, "Tagged enum variants must be declared with inline tuples", - baseType => - $"{baseType} does not have the expected format SpacetimeDB.TaggedEnum<(TVariant1 v1, ..., TVariantN vN)>.", - baseType => baseType + ctx => + $"{ctx.baseType} does not have the expected format SpacetimeDB.TaggedEnum<(TVariant1 v1, ..., TVariantN vN)>.", + ctx => ctx.location ); public static readonly ErrorDescriptor TaggedEnumField = diff --git a/crates/bindings-csharp/BSATN.Codegen/Type.cs b/crates/bindings-csharp/BSATN.Codegen/Type.cs index 53746bf2182..537c1c9f2dc 100644 --- a/crates/bindings-csharp/BSATN.Codegen/Type.cs +++ b/crates/bindings-csharp/BSATN.Codegen/Type.cs @@ -563,7 +563,13 @@ public BaseTypeDeclaration(GeneratorAttributeSyntaxContext context, DiagReporter is not INamedTypeSymbol { IsTupleType: true, TupleElements: var taggedEnumVariants } ) { - diag.Report(ErrorDescriptor.TaggedEnumInlineTuple, type.BaseType); + diag.Report( + ErrorDescriptor.TaggedEnumInlineTuple, + ( + type.BaseType, + (SyntaxNode?)typeSyntax.BaseList?.Types.FirstOrDefault()?.Type ?? typeSyntax + ) + ); } if (fields.FirstOrDefault() is { } field) @@ -734,13 +740,13 @@ public override string ToString() => write = "value.WriteFields(writer);"; - var declHashName = (MemberDeclaration decl) => $"___hash{decl.Name}"; + static string DeclHashName(MemberDeclaration decl) => $"___hash{decl.Name}"; getHashCode = $$""" - {{string.Join("\n", bsatnDecls.Select(decl => decl.Type.GetHashCodeStatement(decl.Identifier, declHashName(decl))))}} + {{string.Join("\n", bsatnDecls.Select(decl => decl.Type.GetHashCodeStatement(decl.Identifier, DeclHashName(decl))))}} return {{JoinOrValue( " ^\n ", - bsatnDecls.Select(declHashName), + bsatnDecls.Select(DeclHashName), "0" // if there are no members, the hash is 0. )}}; """; @@ -783,7 +789,7 @@ public override int GetHashCode() // 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}"; + static string DeclEqualsName(MemberDeclaration decl) => $"___eq{decl.Name}"; extensions.Contents.Append( $$""" @@ -792,10 +798,10 @@ public override int GetHashCode() 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))))}} + {{string.Join("\n", bsatnDecls.Select(decl => decl.Type.EqualsStatement($"this.{decl.Identifier}", $"that.{decl.Identifier}", DeclEqualsName(decl))))}} return {{JoinOrValue( " &&\n ", - bsatnDecls.Select(declEqualsName), + bsatnDecls.Select(DeclEqualsName), "true" // if there are no elements, the structs are equal :) )}}; } diff --git a/crates/bindings-csharp/BSATN.Runtime.Tests/BSATN.Runtime.Tests.csproj b/crates/bindings-csharp/BSATN.Runtime.Tests/BSATN.Runtime.Tests.csproj index b4bdf12f40e..8244effbff0 100644 --- a/crates/bindings-csharp/BSATN.Runtime.Tests/BSATN.Runtime.Tests.csproj +++ b/crates/bindings-csharp/BSATN.Runtime.Tests/BSATN.Runtime.Tests.csproj @@ -5,7 +5,7 @@ - net8.0 + net8.0;net10.0 SpacetimeDB true diff --git a/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj b/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj index a44c2cee73d..c328df7c28e 100644 --- a/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj +++ b/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj @@ -9,7 +9,7 @@ - netstandard2.1;net8.0 + netstandard2.1;net8.0;net10.0 SpacetimeDB diff --git a/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs b/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs index eeba596929b..badbb277c92 100644 --- a/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs +++ b/crates/bindings-csharp/BSATN.Runtime/BSATN/Runtime.cs @@ -129,7 +129,9 @@ public interface IReadWrite /// Note: the [Type] macro rejects enums with explicitly set values (see Codegen.Tests), /// so this array is guaranteed to be continuous and indexed starting from 0. /// +#pragma warning disable CA2263, IDE0305 // netstandard2.1 lacks the generic Enum overloads. private static readonly T[] TagToValue = Enum.GetValues(typeof(T)).Cast().ToArray(); +#pragma warning restore CA2263, IDE0305 public T Read(BinaryReader reader) { @@ -177,9 +179,9 @@ public AlgebraicType GetAlgebraicType(ITypeRegistrar registrar) => registrar.RegisterType( (_) => new AlgebraicType.Sum( - Enum.GetNames(typeof(T)) - .Select(name => new AggregateElement(name, AlgebraicType.Unit)) - .ToArray() +#pragma warning disable CA2263 // netstandard2.1 lacks the generic Enum overloads. + [.. Enum.GetNames(typeof(T)).Select(name => new AggregateElement(name, AlgebraicType.Unit))] +#pragma warning restore CA2263 ) ); } diff --git a/crates/bindings-csharp/BSATN.Runtime/BSATN/U128.cs b/crates/bindings-csharp/BSATN.Runtime/BSATN/U128.cs index 936e4756d1b..5158d9fc213 100644 --- a/crates/bindings-csharp/BSATN.Runtime/BSATN/U128.cs +++ b/crates/bindings-csharp/BSATN.Runtime/BSATN/U128.cs @@ -45,8 +45,8 @@ public static U128 FromBytesBigEndian(ReadOnlySpan bytes) ); } - var upper = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(0, 8)); - var lower = BinaryPrimitives.ReadUInt64BigEndian(bytes.Slice(8, 8)); + var upper = BinaryPrimitives.ReadUInt64BigEndian(bytes[..8]); + var lower = BinaryPrimitives.ReadUInt64BigEndian(bytes[8..16]); return new U128(upper, lower); } diff --git a/crates/bindings-csharp/BSATN.Runtime/Builtins.cs b/crates/bindings-csharp/BSATN.Runtime/Builtins.cs index e204985b54a..f8cb375f284 100644 --- a/crates/bindings-csharp/BSATN.Runtime/Builtins.cs +++ b/crates/bindings-csharp/BSATN.Runtime/Builtins.cs @@ -357,7 +357,7 @@ public static implicit operator DateTimeOffset(Timestamp t) => DateTimeOffset.UnixEpoch.AddTicks(t.MicrosecondsSinceUnixEpoch * Util.TicksPerMicrosecond); public static implicit operator Timestamp(DateTimeOffset offset) => - new Timestamp(offset.Subtract(DateTimeOffset.UnixEpoch).Ticks / Util.TicksPerMicrosecond); + new(offset.Subtract(DateTimeOffset.UnixEpoch).Ticks / Util.TicksPerMicrosecond); // For backwards-compatibility. public readonly DateTimeOffset ToStd() => this; @@ -373,7 +373,7 @@ public override readonly string ToString() public static readonly Timestamp UNIX_EPOCH = new(0); public static Timestamp FromTimeDurationSinceUnixEpoch(TimeDuration timeDuration) => - new Timestamp(timeDuration.Microseconds); + new(timeDuration.Microseconds); public readonly TimeDuration ToTimeDurationSinceUnixEpoch() => TimeDurationSince(UNIX_EPOCH); @@ -383,13 +383,13 @@ public static Timestamp FromTimeSpanSinceUnixEpoch(TimeSpan timeSpan) => public readonly TimeSpan ToTimeSpanSinceUnixEpoch() => (TimeSpan)ToTimeDurationSinceUnixEpoch(); public readonly TimeDuration TimeDurationSince(Timestamp earlier) => - new TimeDuration(checked(MicrosecondsSinceUnixEpoch - earlier.MicrosecondsSinceUnixEpoch)); + new(checked(MicrosecondsSinceUnixEpoch - earlier.MicrosecondsSinceUnixEpoch)); public static Timestamp operator +(Timestamp point, TimeDuration interval) => - new Timestamp(checked(point.MicrosecondsSinceUnixEpoch + interval.Microseconds)); + new(checked(point.MicrosecondsSinceUnixEpoch + interval.Microseconds)); public static Timestamp operator -(Timestamp point, TimeDuration interval) => - new Timestamp(checked(point.MicrosecondsSinceUnixEpoch - interval.Microseconds)); + new(checked(point.MicrosecondsSinceUnixEpoch - interval.Microseconds)); public readonly int CompareTo(Timestamp that) { @@ -492,10 +492,10 @@ public static implicit operator TimeDuration(TimeSpan timeSpan) => new(timeSpan.Ticks / Util.TicksPerMicrosecond); public static TimeDuration operator +(TimeDuration lhs, TimeDuration rhs) => - new TimeDuration(checked(lhs.Microseconds + rhs.Microseconds)); + new(checked(lhs.Microseconds + rhs.Microseconds)); public static TimeDuration operator -(TimeDuration lhs, TimeDuration rhs) => - new TimeDuration(checked(lhs.Microseconds + rhs.Microseconds)); + new(checked(lhs.Microseconds + rhs.Microseconds)); // For backwards-compatibility. public readonly TimeSpan ToStd() => this; @@ -574,7 +574,7 @@ long microsSinceUnixEpoch // --- auto-generated --- private ScheduleAt() { } - internal enum @enum : byte + internal enum ScheduleAtVariant : byte { Interval, Time, @@ -586,15 +586,15 @@ public sealed record Time(Timestamp Time_) : ScheduleAt; public readonly partial struct BSATN : IReadWrite { - internal static readonly SpacetimeDB.BSATN.Enum<@enum> __enumTag = new(); + internal static readonly SpacetimeDB.BSATN.Enum __enumTag = new(); internal static readonly TimeDuration.BSATN Interval = new(); internal static readonly Timestamp.BSATN Time = new(); public ScheduleAt Read(BinaryReader reader) => __enumTag.Read(reader) switch { - @enum.Interval => new Interval(Interval.Read(reader)), - @enum.Time => new Time(Time.Read(reader)), + ScheduleAtVariant.Interval => new Interval(Interval.Read(reader)), + ScheduleAtVariant.Time => new Time(Time.Read(reader)), _ => throw new InvalidOperationException( "Invalid tag value, this state should be unreachable." ), @@ -605,12 +605,12 @@ public void Write(BinaryWriter writer, ScheduleAt value) switch (value) { case Interval(var inner): - __enumTag.Write(writer, @enum.Interval); + __enumTag.Write(writer, ScheduleAtVariant.Interval); Interval.Write(writer, inner); break; case Time(var inner): - __enumTag.Write(writer, @enum.Time); + __enumTag.Write(writer, ScheduleAtVariant.Time); Time.Write(writer, inner); break; } @@ -682,7 +682,7 @@ public T UnwrapOrElse(Func f) => private Result() { } - internal enum @enum : byte + internal enum ResultVariant : byte { Ok, Err, @@ -702,15 +702,15 @@ private enum Variant : byte where OkRW : struct, IReadWrite where ErrRW : struct, IReadWrite { - private static readonly SpacetimeDB.BSATN.Enum<@enum> __enumTag = new(); + private static readonly SpacetimeDB.BSATN.Enum __enumTag = new(); private static readonly OkRW okRW = new(); private static readonly ErrRW errRW = new(); public Result Read(BinaryReader reader) => __enumTag.Read(reader) switch { - @enum.Ok => new OkR(okRW.Read(reader)), - @enum.Err => new ErrR(errRW.Read(reader)), + ResultVariant.Ok => new OkR(okRW.Read(reader)), + ResultVariant.Err => new ErrR(errRW.Read(reader)), _ => throw new InvalidOperationException(), }; @@ -719,12 +719,12 @@ public void Write(BinaryWriter writer, Result value) switch (value) { case OkR(var v): - __enumTag.Write(writer, @enum.Ok); + __enumTag.Write(writer, ResultVariant.Ok); okRW.Write(writer, v); break; case ErrR(var e): - __enumTag.Write(writer, @enum.Err); + __enumTag.Write(writer, ResultVariant.Err); errRW.Write(writer, e); break; } diff --git a/crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs b/crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs index db16bd56cd7..e8d52a084e6 100644 --- a/crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs +++ b/crates/bindings-csharp/BSATN.Runtime/QueryBuilder.cs @@ -1,5 +1,3 @@ -#nullable enable - namespace SpacetimeDB; using System; @@ -68,14 +66,9 @@ public interface IQuery string ToSql(); } -public readonly struct BoolExpr +public readonly struct BoolExpr(string sql) { - public string Sql { get; } - - public BoolExpr(string sql) - { - Sql = sql; - } + public string Sql { get; } = sql; public BoolExpr And(BoolExpr other) => new($"({Sql} AND {other.Sql})"); @@ -120,18 +113,9 @@ internal IxJoinEq(string leftRefSql, string rightRefSql) } } -public readonly struct Col +public readonly struct Col(string tableName, string columnName) where TValue : notnull { - private readonly string tableName; - private readonly string columnName; - - public Col(string tableName, string columnName) - { - this.tableName = tableName; - this.columnName = columnName; - } - internal string RefSql => $"{SqlFormat.QuoteIdent(tableName)}.{SqlFormat.QuoteIdent(columnName)}"; @@ -162,18 +146,9 @@ public Col(string tableName, string columnName) public override string ToString() => RefSql; } -public readonly struct IxCol +public readonly struct IxCol(string tableName, string columnName) where TValue : notnull { - private readonly string tableName; - private readonly string columnName; - - public IxCol(string tableName, string columnName) - { - this.tableName = tableName; - this.columnName = columnName; - } - internal string RefSql => $"{SqlFormat.QuoteIdent(tableName)}.{SqlFormat.QuoteIdent(columnName)}"; @@ -187,19 +162,9 @@ public IxJoinEq Eq(IxCol other) = public override string ToString() => RefSql; } -public sealed class Table : IQuery +public sealed class Table(string tableName, TCols cols, TIxCols ixCols) + : IQuery { - private readonly string tableName; - private readonly TCols cols; - private readonly TIxCols ixCols; - - public Table(string tableName, TCols cols, TIxCols ixCols) - { - this.tableName = tableName; - this.cols = cols; - this.ixCols = ixCols; - } - internal string TableRefSql => SqlFormat.QuoteIdent(tableName); internal TCols Cols => cols; @@ -229,7 +194,7 @@ public LeftSemiJoin L >( Table right, Func> on - ) => new(this, right, on(ixCols, right.ixCols), whereExpr: null); + ) => new(this, right, on(ixCols, right.IxCols), whereExpr: null); public RightSemiJoin RightSemijoin< TRightRow, @@ -238,7 +203,7 @@ public RightSemiJoin >( Table right, Func> on - ) => new(this, right, on(ixCols, right.ixCols), leftWhereExpr: null); + ) => new(this, right, on(ixCols, right.IxCols), leftWhereExpr: null); } public sealed class FromWhere : IQuery @@ -889,7 +854,7 @@ public static string FormatHexLiteral(string hex) var s = hex; if (s.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) { - s = s.Substring(2); + s = s[2..]; } s = s.Replace("-", string.Empty); diff --git a/crates/bindings-csharp/Codegen.Tests/Codegen.Tests.csproj b/crates/bindings-csharp/Codegen.Tests/Codegen.Tests.csproj index 5f02268f537..e13fb986442 100644 --- a/crates/bindings-csharp/Codegen.Tests/Codegen.Tests.csproj +++ b/crates/bindings-csharp/Codegen.Tests/Codegen.Tests.csproj @@ -6,7 +6,7 @@ - net8.0 + net8.0;net10.0 SpacetimeDB.Codegen.Tests $(DefaultItemExcludes);fixtures/**/* @@ -18,10 +18,6 @@ - - - - @@ -29,6 +25,22 @@ + + + + + + + + + + + + + + + + diff --git a/crates/bindings-csharp/Codegen.Tests/Tests.cs b/crates/bindings-csharp/Codegen.Tests/Tests.cs index 463958ef835..4133819ef9c 100644 --- a/crates/bindings-csharp/Codegen.Tests/Tests.cs +++ b/crates/bindings-csharp/Codegen.Tests/Tests.cs @@ -26,18 +26,11 @@ public static class GeneratorSnapshotTests record struct StepOutput(string Key, IncrementalStepRunReason Reason, object Value); - class Fixture + private class Fixture(string projectDir, CSharpCompilation sampleCompilation) { - private readonly string projectDir; - private readonly CSharpCompilation sampleCompilation; - - public Fixture(string projectDir, CSharpCompilation sampleCompilation) - { - this.projectDir = projectDir; - this.sampleCompilation = sampleCompilation; - } - - public CSharpCompilation SampleCompilation => sampleCompilation; + public CSharpCompilation SampleCompilation { get; } = sampleCompilation; + public CSharpParseOptions ParseOptions { get; } = + (CSharpParseOptions)sampleCompilation.SyntaxTrees.First().Options; public static async Task Compile(string name) { @@ -53,7 +46,7 @@ public Task Verify(string fileName, object target) => private static CSharpGeneratorDriver CreateDriver( IIncrementalGenerator generator, - LanguageVersion languageVersion + CSharpParseOptions parseOptions ) { return CSharpGeneratorDriver.Create( @@ -62,8 +55,8 @@ LanguageVersion languageVersion disabledOutputs: IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true ), - // Make sure that generated files are parsed with the same language version. - parseOptions: new(languageVersion) + // Make sure generated files are parsed with the same language version and feature flags. + parseOptions: parseOptions ); } @@ -71,30 +64,30 @@ private async Task> RunAndCheckGenerator( IIncrementalGenerator generator ) { - var driver = CreateDriver(generator, sampleCompilation.LanguageVersion); + var driver = CreateDriver(generator, ParseOptions); // Store the new driver instance - it contains the results and the cache. - var driverAfterGen = driver.RunGenerators(sampleCompilation); + var driverAfterGen = driver.RunGenerators(SampleCompilation); var genResult = driverAfterGen.GetRunResult(); // Verify the generated code against the snapshots. await Verify(generator.GetType().Name, genResult); - CheckCacheWorking(sampleCompilation, driverAfterGen); + CheckCacheWorking(SampleCompilation, driverAfterGen); return genResult.GeneratedTrees; } public GeneratorDriverRunResult RunGeneratorAndGetResult(IIncrementalGenerator generator) { - var driver = CreateDriver(generator, sampleCompilation.LanguageVersion); - return driver.RunGenerators(sampleCompilation).GetRunResult(); + var driver = CreateDriver(generator, ParseOptions); + return driver.RunGenerators(SampleCompilation).GetRunResult(); } public async Task RunAndCheckGenerators( params IIncrementalGenerator[] generators ) => - sampleCompilation.AddSyntaxTrees( + SampleCompilation.AddSyntaxTrees( (await Task.WhenAll(generators.Select(RunAndCheckGenerator))).SelectMany(output => output ) @@ -241,10 +234,7 @@ public static async Task TypeAndModuleGeneratorsOnServer() // make sure a downstream "user" file that references SpacetimeDB.Local doesn't trigger CS0436. var userCode = "namespace User; public sealed class UseLocal { public SpacetimeDB.Local Db; }"; - var userTree = CSharpSyntaxTree.ParseText( - userCode, - new CSharpParseOptions(compilationAfterGen.LanguageVersion) - ); + var userTree = CSharpSyntaxTree.ParseText(userCode, fixture.ParseOptions); var compilationWithUserCode = compilationAfterGen.AddSyntaxTrees(userTree); AssertNoCs0436Diagnostics(compilationWithUserCode); } @@ -332,8 +322,11 @@ public static void @params(ProcedureContext ctx) } """; - var parseOptions = new CSharpParseOptions(fixture.SampleCompilation.LanguageVersion); - var tree = CSharpSyntaxTree.ParseText(source, parseOptions, path: "KeywordNames.cs"); + var tree = CSharpSyntaxTree.ParseText( + source, + fixture.ParseOptions, + path: "KeywordNames.cs" + ); var compilation = fixture.SampleCompilation.AddSyntaxTrees(tree); var driver = CSharpGeneratorDriver.Create( @@ -345,7 +338,7 @@ public static void @params(ProcedureContext ctx) disabledOutputs: IncrementalGeneratorOutputKind.None, trackIncrementalGeneratorSteps: true ), - parseOptions: parseOptions + parseOptions: fixture.ParseOptions ); var runResult = driver.RunGenerators(compilation).GetRunResult(); diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/diag.csproj b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/diag.csproj index 426f04d2bc6..2740799f029 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/diag.csproj +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/diag.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs index edb27202d22..e6df8c9e52a 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/diag/snapshots/Module#FFI.verified.cs @@ -3389,7 +3389,7 @@ public static List ToListOrEmpty(T? value) public static List ToListOrEmpty(T? value) where T : class => value is null ? new List() : new List { value }; -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER // In AOT mode we're building a library. // Main method won't be called automatically, so we need to export it as a preinit function. [UnmanagedCallersOnly(EntryPoint = "__preinit__10_init_csharp")] @@ -3738,7 +3738,7 @@ public static void Main() } // Exports only work from the main assembly, so we need to generate forwarding methods. -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER [UnmanagedCallersOnly(EntryPoint = "__describe_module__")] public static void __describe_module__(SpacetimeDB.Internal.BytesSink d) => SpacetimeDB.Internal.Module.__describe_module__(d); diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/explicitnames.csproj b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/explicitnames.csproj index 65397bf9f03..7277d2ba3dc 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/explicitnames.csproj +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/explicitnames.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs index 76adb1188bb..1602df16086 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/explicitnames/snapshots/Module#FFI.verified.cs @@ -559,7 +559,7 @@ public static List ToListOrEmpty(T? value) public static List ToListOrEmpty(T? value) where T : class => value is null ? new List() : new List { value }; -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER // In AOT mode we're building a library. // Main method won't be called automatically, so we need to export it as a preinit function. [UnmanagedCallersOnly(EntryPoint = "__preinit__10_init_csharp")] @@ -628,7 +628,7 @@ public static void Main() } // Exports only work from the main assembly, so we need to generate forwarding methods. -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER [UnmanagedCallersOnly(EntryPoint = "__describe_module__")] public static void __describe_module__(SpacetimeDB.Internal.BytesSink d) => SpacetimeDB.Internal.Module.__describe_module__(d); diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/server/server.csproj b/crates/bindings-csharp/Codegen.Tests/fixtures/server/server.csproj index 426f04d2bc6..2740799f029 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/server/server.csproj +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/server/server.csproj @@ -1,7 +1,7 @@  - net8.0 + net10.0 enable diff --git a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs index da074b5e5f8..1ce8d795d15 100644 --- a/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs +++ b/crates/bindings-csharp/Codegen.Tests/fixtures/server/snapshots/Module#FFI.verified.cs @@ -2467,7 +2467,7 @@ public static List ToListOrEmpty(T? value) public static List ToListOrEmpty(T? value) where T : class => value is null ? new List() : new List { value }; -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER // In AOT mode we're building a library. // Main method won't be called automatically, so we need to export it as a preinit function. [UnmanagedCallersOnly(EntryPoint = "__preinit__10_init_csharp")] @@ -2556,7 +2556,7 @@ public static void Main() } // Exports only work from the main assembly, so we need to generate forwarding methods. -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER [UnmanagedCallersOnly(EntryPoint = "__describe_module__")] public static void __describe_module__(SpacetimeDB.Internal.BytesSink d) => SpacetimeDB.Internal.Module.__describe_module__(d); diff --git a/crates/bindings-csharp/Codegen/Codegen.csproj b/crates/bindings-csharp/Codegen/Codegen.csproj index 65fef8a8dfd..6a976ff2a84 100644 --- a/crates/bindings-csharp/Codegen/Codegen.csproj +++ b/crates/bindings-csharp/Codegen/Codegen.csproj @@ -21,10 +21,11 @@ - + + diff --git a/crates/bindings-csharp/Codegen/Module.cs b/crates/bindings-csharp/Codegen/Module.cs index f130a3efb05..3e76b20c6ef 100644 --- a/crates/bindings-csharp/Codegen/Module.cs +++ b/crates/bindings-csharp/Codegen/Module.cs @@ -762,15 +762,15 @@ public sealed class {{{identifierName}}}Index /// Represents a generated accessor for a table, providing different access patterns /// and visibility levels for the underlying table data. /// - /// Name of the generated accessor type - /// Fully qualified name of the table type - /// C# source code for the accessor implementation - /// C# property getter for accessing the accessor + /// Name of the generated accessor type + /// Fully qualified name of the table type + /// C# source code for the accessor implementation + /// C# property getter for accessing the accessor public record struct GeneratedTableAccessor( - string tableAccessorName, - string tableName, - string tableAccessor, - string getter + string TableAccessorName, + string TableName, + string TableAccessor, + string Getter ); /// @@ -862,10 +862,10 @@ v.Scheduled is { } scheduled } public record struct GeneratedReadOnlyAccessor( - string tableAccessorName, - string tableName, - string readOnlyAccessor, - string readOnlyGetter + string TableAccessorName, + string TableName, + string ReadOnlyAccessor, + string ReadOnlyGetter ); public IEnumerable GenerateReadOnlyAccessors() @@ -1023,14 +1023,14 @@ public readonly partial struct QueryBuilder /// /// Represents a default value for a table field, used during table creation. /// - /// Name of the table containing the field - /// Index of the column in the table - /// String representation of the default value + /// Name of the table containing the field + /// Index of the column in the table + /// String representation of the default value /// BSATN Type name of the default value public record struct FieldDefaultValue( - string tableName, - string columnId, - string value, + string TableName, + string ColumnId, + string Value, string BSATNTypeName ); @@ -1059,8 +1059,7 @@ public IEnumerable GenerateDefaultValues() .Select(m => m.Attrs.FirstOrDefault(a => a.Mask == ColumnAttrs.Default)) ); - var withDefaultValues = - fieldsWithDefaultValues as ColumnDeclaration[] ?? fieldsWithDefaultValues.ToArray(); + var withDefaultValues = fieldsWithDefaultValues as ColumnDeclaration[] ?? [.. fieldsWithDefaultValues]; foreach (var fieldsWithDefaultValue in withDefaultValues) { if ( @@ -1722,49 +1721,48 @@ public string GenerateClass() if (HasWrongSignature) { - bodyLines = new[] - { + bodyLines = + [ "throw new System.InvalidOperationException(\"Invalid procedure signature.\");", - }; + ]; } else if (HasTxWrapper) { - var successLines = txPayloadIsUnit - ? new[] { "return System.Array.Empty();" } - : new[] - { + string[] successLines = txPayloadIsUnit + ? ["return System.Array.Empty();"] + : + [ "using var output = new MemoryStream();", "using var writer = new BinaryWriter(output);", "__txReturnRW.Write(writer, outcome.Value!);", "return output.ToArray();", - }; + ]; - bodyLines = new[] - { + bodyLines = + [ $"var outcome = {invocation};", "if (!outcome.IsSuccess)", "{", " throw outcome.Error ?? new System.InvalidOperationException(\"Transaction failed.\");", "}", - } - .Concat(successLines) - .ToArray(); + .. successLines, + ]; } else if (ReturnType.Name == "SpacetimeDB.Unit") { - bodyLines = new[] { $"{invocation};", "return System.Array.Empty();" }; + bodyLines = [$"{invocation};", "return System.Array.Empty();"]; } else { var serializer = $"new {ReturnType.ToBSATNString()}()"; - bodyLines = new[] - { + bodyLines = + [ $"var result = {invocation};", "using var output = new MemoryStream();", "using var writer = new BinaryWriter(output);", $"{serializer}.Write(writer, result);", "return output.ToArray();", - }; + ]; } var invokeBody = string.Join("\n", bodyLines.Select(line => $" {line}")); @@ -2348,8 +2346,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) tables .SelectMany((t, ct) => t.GenerateTableAccessors()) .WithTrackingName("SpacetimeDB.Table.GenerateTableAccessors"), - v => v.tableAccessorName, - v => v.tableName + v => v.TableAccessorName, + v => v.TableName ); var readOnlyAccessors = CollectDistinct( @@ -2358,8 +2356,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) tables .SelectMany((t, ct) => t.GenerateReadOnlyAccessors()) .WithTrackingName("SpacetimeDB.Table.GenerateReadOnlyAccessors"), - v => v.tableAccessorName + "ReadOnly", - v => v.tableName + v => v.TableAccessorName + "ReadOnly", + v => v.TableName ); var rlsFilters = context @@ -2391,8 +2389,8 @@ public void Initialize(IncrementalGeneratorInitializationContext context) tables .SelectMany((t, ct) => t.GenerateDefaultValues()) .WithTrackingName("SpacetimeDB.Table.GenerateDefaultValues"), - v => v.tableName + "_" + v.columnId, - v => v.tableName + "_" + v.columnId + v => v.TableName + "_" + v.ColumnId, + v => v.TableName + "_" + v.ColumnId ); var moduleOutputInputs = tableAccessors @@ -2749,7 +2747,7 @@ internal HandlerTxContext(Internal.TxContext inner) : base(inner) {} } public sealed class Local : global::SpacetimeDB.LocalBase { - {{string.Join("\n", tableAccessors.Select(v => v.getter))}} + {{string.Join("\n", tableAccessors.Select(v => v.Getter))}} } public sealed record ViewContext : DbContext, Internal.IViewContext @@ -2775,7 +2773,7 @@ internal AnonymousViewContext(Internal.LocalReadOnly db) } namespace SpacetimeDB.Internal.TableHandles { - {{string.Join("\n", tableAccessors.Select(v => v.tableAccessor))}} + {{string.Join("\n", tableAccessors.Select(v => v.TableAccessor))}} } {{string.Join("\n", @@ -2788,12 +2786,12 @@ namespace SpacetimeDB.Internal.TableHandles { )}} namespace SpacetimeDB.Internal.ViewHandles { - {{string.Join("\n", readOnlyAccessors.Array.Select(v => v.readOnlyAccessor))}} + {{string.Join("\n", readOnlyAccessors.Array.Select(v => v.ReadOnlyAccessor))}} } namespace SpacetimeDB.Internal { public sealed partial class LocalReadOnly { - {{string.Join("\n", readOnlyAccessors.Select(v => v.readOnlyGetter))}} + {{string.Join("\n", readOnlyAccessors.Select(v => v.ReadOnlyGetter))}} } } @@ -2810,7 +2808,7 @@ public static List ToListOrEmpty(T? value) where T : struct public static List ToListOrEmpty(T? value) where T : class => value is null ? new List() : new List { value }; - #if EXPERIMENTAL_WASM_AOT + #if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER // In AOT mode we're building a library. // Main method won't be called automatically, so we need to export it as a preinit function. [UnmanagedCallersOnly(EntryPoint = "__preinit__10_init_csharp")] @@ -2865,7 +2863,7 @@ public static void Main() { {{string.Join( "\n", - tableAccessors.Select(t => $"SpacetimeDB.Internal.Module.RegisterTable<{t.tableName}, SpacetimeDB.Internal.TableHandles.{EscapeIdentifier(t.tableAccessorName)}>();") + tableAccessors.Select(t => $"SpacetimeDB.Internal.Module.RegisterTable<{t.TableName}, SpacetimeDB.Internal.TableHandles.{EscapeIdentifier(t.TableAccessorName)}>();") )}} {{( httpRouters.Array.FirstOrDefault(r => r.IsValid) is { } router @@ -2883,15 +2881,15 @@ public static void Main() { + $"var value = new {d.BSATNTypeName}();\n" + "__memoryStream.Position = 0;\n" + "__memoryStream.SetLength(0);\n" - + $"value.Write(__writer, {d.value});\n" + + $"value.Write(__writer, {d.Value});\n" + "var array = __memoryStream.ToArray();\n" - + $"SpacetimeDB.Internal.Module.RegisterTableDefaultValue(\"{d.tableName}\", {d.columnId}, array);" + + $"SpacetimeDB.Internal.Module.RegisterTableDefaultValue(\"{d.TableName}\", {d.ColumnId}, array);" + "\n}\n") )}} } // Exports only work from the main assembly, so we need to generate forwarding methods. - #if EXPERIMENTAL_WASM_AOT + #if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER [UnmanagedCallersOnly(EntryPoint = "__describe_module__")] public static void __describe_module__(SpacetimeDB.Internal.BytesSink d) => SpacetimeDB.Internal.Module.__describe_module__(d); diff --git a/crates/bindings-csharp/NATIVEAOT-LLVM.md b/crates/bindings-csharp/NATIVEAOT-LLVM.md index 3c1dc2438d3..45926ff26b4 100644 --- a/crates/bindings-csharp/NATIVEAOT-LLVM.md +++ b/crates/bindings-csharp/NATIVEAOT-LLVM.md @@ -1,174 +1,288 @@ # Using NativeAOT-LLVM with SpacetimeDB C# Modules -This guide provides instructions for enabling NativeAOT-LLVM compilation for C# SpacetimeDB modules, which can provide performance improvements. +This guide provides instructions for enabling NativeAOT-LLVM compilation for C# SpacetimeDB modules, which can provide performance improvements by compiling C# directly to native WebAssembly (WASM) using the .NET NativeAOT-LLVM toolchain. + +> [!WARNING] +> NativeAOT-LLVM is experimental. ## Overview -NativeAOT-LLVM compiles C# modules to native WebAssembly (WASM) instead of using the Mono runtime. +SpacetimeDB supports three build targets for C# modules: -> [!WARNING] -> This is currently only supported for Windows server modules and is experimental. +| Build Target | .NET Version | Platforms | Description | +|--------------|--------------|-----------|-------------| +| **JIT (Mono)** | .NET 8.0 | Windows, Linux, macOS | Uses the Mono runtime interpreter (default) | +| **NativeAOT-LLVM** | .NET 8.0 | **Windows only** | Compiles C# to native WASM | +| **NativeAOT-LLVM** | .NET 10.0+ | Windows, Linux | Compiles C# to native WASM | + +> [!NOTE] +> .NET 8.0 NativeAOT-LLVM is Windows-only because `runtime.linux-x64.Microsoft.DotNet.ILCompiler.LLVM` was never published to the dotnet-experimental feed. ## 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): - ``` - $env:EMSDK="D:\Tools\emsdk" - ``` - -### Install Binaryen (Optional) +- **.NET SDK 8.0** or **.NET SDK 10.0** +- **WASI SDK** (automatically downloaded during first AOT build) +- **(Optional) Binaryen (wasm-opt)** for WASM optimization + +### WASI SDK (Auto-Downloaded) + +The WASI SDK is required for NativeAOT-LLVM compilation and is **automatically downloaded**: + +| Platform | Download Location | +|----------|-------------------| +| Windows | `%USERPROFILE%\.wasi-sdk\wasi-sdk-29` | +| Linux/macOS | `~/.wasi-sdk/wasi-sdk-29` | + +Override with the `WASI_SDK_PATH` environment variable: + +```bash +# Windows +$env:WASI_SDK_PATH="C:\Tools\wasi-sdk" + +# Linux/macOS +export WASI_SDK_PATH=/opt/wasi-sdk +``` + +--- + +## Build Target: .NET 8.0 NativeAOT-LLVM (Windows Only) + +For Windows users who want NativeAOT-LLVM compilation using .NET 8.0 SDK. + +### Requirements +- .NET SDK 8.0 +- Windows operating system +- NuGet.Config with dotnet-experimental feed + +### Project Configuration + +Your `.csproj` must include the conditional LLVM package references: + +```xml + + + net8.0 + wasi-wasm + + + + + + + + + + + + +``` -Binaryen provides `wasm-opt` for WASM optimization (recommended for performance): +Your `NuGet.Config` must include: -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` - - To temporarily add to your current PowerShell session: - ``` - $env:PATH += ";D:\Tools\binaryen\bin" - ``` -4. Verify: - ``` - wasm-opt --version - ``` +```xml + + + + + + + + + + + + + + + + + +``` -## Creating a New NativeAOT Project +### Activating NativeAOT-LLVM (.NET 8) -When creating a new C# project, use the `--native-aot` flag: +There are three ways to enable NativeAOT-LLVM for .NET 8 builds. +**Option 1: `--native-aot` flag during init** ``` -spacetime init --lang csharp --native-aot my-native-aot-project +spacetime init --lang csharp --native-aot --dotnet-version 8 my-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 +**Option 2: `--native-aot` flag during publish** +``` +spacetime publish --native-aot my-database-name +``` -## Converting an Existing Project +**Option 3: `spacetime.json` configuration** +```json +{ + "module": "my-module", + "native-aot": true +} +``` -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. +Technically all of these options just set the `EXPERIMENTAL_WASM_AOT` environment variable, but they provide different user experiences. Using `--native-aot` during `init` will create a project with a `spacetime.json` configured like Option 3 so the new project is consistently published with NativeAOT-LLVM. -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 - - - - - - ``` +## Build Target: .NET 10.0+ NativeAOT-LLVM (Windows & Linux) - Your complete `.csproj` should look like: - ```xml - - - net8.0 - wasi-wasm - enable - enable - - - - - - - - - - - ``` +For users who want NativeAOT-LLVM compilation on Windows **or** Linux. -## Publishing Your NativeAOT Module +### Requirements +- .NET SDK 10.0 +- Windows or Linux operating system +- NuGet.Config with dotnet-experimental feed -After completing either the **Creating a New NativeAOT Project** or **Converting an Existing Project** steps above, you can publish your module normally: +### Project Configuration -``` -# 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: - -``` -spacetime publish --native-aot your-database-name -``` - -The CLI will display "Using NativeAOT-LLVM compilation (experimental)" when NativeAOT is enabled. +For .NET 10, the project configuration is simpler - no conditional package references needed: -## Troubleshooting +```xml + + + net10.0 + wasi-wasm + + + + + + +``` -### Package source mapping enabled -If you have **package source mapping** enabled in `NuGet.Config`, add mappings for the LLVM packages: +Your `NuGet.Config` must include: ```xml - - - - - - - + + + + + + + + - - + + - + - + + +``` + +### global.json (if needed) + +If .NET 10 is not your default SDK, create a `global.json`: + +```json +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestMinor" + } +} +``` + +This is automatically created by the CLI when using the `init` command with `--dotnet-version 10`. + +### Activating NativeAOT-LLVM (.NET 10) + +NativeAOT-LLVM is automatically used when targeting .NET 10. You can also explicitly enable it: + +**Option 1: Target .NET 10 during init (recommended)** +``` +spacetime init --lang csharp --dotnet-version 10 my-project +``` + +**Option 2: Use `--native-aot` flag** +``` +spacetime init --lang csharp --native-aot my-project +``` + +**Option 3: `spacetime.json` configuration** +```json +{ + "module": "my-module", + "native-aot": true +} +``` + +--- + +## Publishing Your Module + +Once configured, publish normally: + +``` +spacetime publish my-database-name +``` + +The CLI will display which build path is being used: +- "Using NativeAOT-LLVM compilation (experimental)" for AOT builds +- Standard output for JIT builds + +### Controlling the .NET Version During Publish + +To explicitly publish with a specific .NET version: + ``` +# Force .NET 8 build (requires --native-aot for AOT) +spacetime publish --dotnet-version 8 --native-aot my-database-name -### wasi-experimental workload install fails -If the CLI cannot install the `wasi-experimental` workload automatically, install it manually: +# Force .NET 10 build (automatically uses AOT) +spacetime publish --dotnet-version 10 my-database-name +``` + +--- + +## Troubleshooting + +### WASI SDK not found + +**Error**: +``` +error : Could not find wasi-sdk. Either set $(WASI_SDK_PATH), or use workloads to get the sdk. +``` + +**Solution**: +1. The WASI SDK should auto-download during first AOT build +2. If it fails, manually install from https://github.com/WebAssembly/wasi-sdk/releases +3. Set `WASI_SDK_PATH` environment variable +4. Restart your terminal/IDE + +### .NET 8 AOT fails on Linux + +**Error**: Missing `runtime.linux-x64.Microsoft.DotNet.ILCompiler.LLVM` + +**Cause**: .NET 8 NativeAOT-LLVM packages were only published for Windows. + +**Solution**: Use .NET 10 for Linux NativeAOT builds: +``` +spacetime init --lang csharp --dotnet-version 10 my-project +``` + +### JIT builds fail: Missing wasi-experimental workload + +For **JIT builds only** (not NativeAOT), you need the `wasi-experimental` workload: ``` dotnet workload install wasi-experimental ``` -### Duplicate PackageReference warning -You may see a `NU1504` warning about duplicate `PackageReference` items. This is expected and non-blocking. +NativeAOT-LLVM builds do **not** use this workload; they use the WASI SDK instead. ### 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` + +If you see "Code generation failed for method" errors: +1. Ensure `NuGet.Config` includes the `dotnet-experimental` feed +2. For .NET 8: Verify the `EXPERIMENTAL_WASM_AOT` condition is in your `.csproj` +3. For .NET 10: Verify `TargetFramework` is `net10.0` +4. Check that `global.json` exists if .NET 10 is not your default SDK + +### Duplicate PackageReference warning (NU1504) + +This warning is expected for .NET 8 AOT builds and is non-blocking. diff --git a/crates/bindings-csharp/Runtime.Tests/RouterTests.cs b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs index 3bd57d32508..f78fbfb43be 100644 --- a/crates/bindings-csharp/Runtime.Tests/RouterTests.cs +++ b/crates/bindings-csharp/Runtime.Tests/RouterTests.cs @@ -101,7 +101,7 @@ protected override HandlerTxContextBase CreateTxContext( SpacetimeDB.Internal.TxContext inner ) => throw new NotSupportedException(); - protected internal override LocalBase CreateLocal() => throw new NotSupportedException(); + protected override LocalBase CreateLocal() => throw new NotSupportedException(); } private static HttpResponse GetHandler(TestHandlerContext _, HttpRequest __) => default; diff --git a/crates/bindings-csharp/Runtime.Tests/Runtime.Tests.csproj b/crates/bindings-csharp/Runtime.Tests/Runtime.Tests.csproj index d5cb92f3a77..a631bc813b1 100644 --- a/crates/bindings-csharp/Runtime.Tests/Runtime.Tests.csproj +++ b/crates/bindings-csharp/Runtime.Tests/Runtime.Tests.csproj @@ -1,7 +1,7 @@ - net8.0 + net8.0;net10.0 enable enable @@ -20,8 +20,8 @@ - - + + diff --git a/crates/bindings-csharp/Runtime/HandlerContext.cs b/crates/bindings-csharp/Runtime/HandlerContext.cs index 8ad7fe14239..76b13230426 100644 --- a/crates/bindings-csharp/Runtime/HandlerContext.cs +++ b/crates/bindings-csharp/Runtime/HandlerContext.cs @@ -35,7 +35,7 @@ protected HandlerContextBase(Random random, Timestamp time) } protected abstract HandlerTxContextBase CreateTxContext(Internal.TxContext inner); - protected internal abstract LocalBase CreateLocal(); + protected abstract LocalBase CreateLocal(); public Internal.TxContext EnterTxContext(long timestampMicros) => txState.EnterTxContext(timestampMicros); @@ -99,7 +99,7 @@ internal sealed partial class RuntimeHandlerContext(Random random, Timestamp tim { private readonly RuntimeLocal _db = new(); - protected internal override LocalBase CreateLocal() => _db; + protected override LocalBase CreateLocal() => _db; protected override HandlerTxContextBase CreateTxContext(Internal.TxContext inner) => _cached ??= new RuntimeHandlerTxContext(inner); diff --git a/crates/bindings-csharp/Runtime/Http.cs b/crates/bindings-csharp/Runtime/Http.cs index be98b704fcc..8dfbe262e76 100644 --- a/crates/bindings-csharp/Runtime/Http.cs +++ b/crates/bindings-csharp/Runtime/Http.cs @@ -1,6 +1,7 @@ namespace SpacetimeDB; using System; +using System.Diagnostics.CodeAnalysis; using System.Collections.Generic; using System.IO; using System.Linq; @@ -57,7 +58,7 @@ public HttpHeader(string name, string value) /// public readonly record struct HttpBody(byte[] Bytes) { - public static HttpBody Empty => new(Array.Empty()); + public static HttpBody Empty => new([]); public byte[] ToBytes() => Bytes; @@ -82,7 +83,7 @@ public sealed class HttpRequest public HttpMethod Method { get; init; } = HttpMethod.Get; /// HTTP headers to include with the request. - public List Headers { get; init; } = new(); + public List Headers { get; init; } = []; /// Request body bytes. public HttpBody Body { get; init; } = HttpBody.Empty; @@ -271,6 +272,7 @@ public Result Get(string uri, TimeSpan? timeout = null) /// } /// /// + [SuppressMessage("Performance", "CA1822", Justification = "Public instance API exposed through ProcedureContext.Http.")] public Result Send(HttpRequest request) { // The host syscall expects BSATN-encoded spacetimedb_lib::http::Request bytes. @@ -306,10 +308,7 @@ public Result Send(HttpRequest request) var requestWire = new HttpRequestWire { Method = ToWireMethod(request.Method), - Headers = new HttpHeadersWire - { - Entries = request.Headers.Select(ToWireHeader).ToArray(), - }, + Headers = new HttpHeadersWire { Entries = [.. request.Headers.Select(ToWireHeader)] }, Timeout = timeout is null ? null : new HttpTimeoutWire { Timeout = (TimeDuration)timeout.Value }, @@ -423,9 +422,7 @@ internal static HttpRequest FromWire(HttpRequestWire requestWire, byte[] body) = { Uri = requestWire.Uri, Method = FromWireMethod(requestWire.Method), - Headers = requestWire - .Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)) - .ToList(), + Headers = [.. requestWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false))], Body = new HttpBody(body), Version = FromWireVersion(requestWire.Version), }; @@ -434,10 +431,7 @@ internal static (HttpResponseWire Response, byte[] Body) ToWire(HttpResponse res ( new HttpResponseWire { - Headers = new HttpHeadersWire - { - Entries = response.Headers.Select(ToWireHeader).ToArray(), - }, + Headers = new HttpHeadersWire { Entries = [.. response.Headers.Select(ToWireHeader)] }, Version = ToWireVersion(response.Version), Code = response.StatusCode, }, @@ -479,9 +473,7 @@ List headers { var version = FromWireVersion(responseWire.Version); - var headers = responseWire - .Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)) - .ToList(); + var headers = responseWire.Headers.Entries.Select(h => new HttpHeader(h.Name, h.Value, false)).ToList(); return (responseWire.Code, version, headers); } diff --git a/crates/bindings-csharp/Runtime/Internal/Bounds.cs b/crates/bindings-csharp/Runtime/Internal/Bounds.cs index 2bdd42c3aa1..f3e302aa8de 100644 --- a/crates/bindings-csharp/Runtime/Internal/Bounds.cs +++ b/crates/bindings-csharp/Runtime/Internal/Bounds.cs @@ -1,6 +1,3 @@ -using System.IO; -using SpacetimeDB.BSATN; - namespace SpacetimeDB { public readonly struct Bound(T min, T max) @@ -16,6 +13,8 @@ public readonly struct Bound(T min, T max) namespace SpacetimeDB.Internal { + using SpacetimeDB.BSATN; + enum BoundVariant : byte { Inclusive, diff --git a/crates/bindings-csharp/Runtime/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index 261233303dc..c9f07994f1d 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 && NET10_0_OR_GREATER +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 @@ -51,7 +57,7 @@ internal static partial class FFI // In the future C# will allow to specify Wasm import namespace in // `LibraryImport` directly. const string StdbNamespace10_0 = -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER "spacetime_10.0" #else "bindings" @@ -59,7 +65,7 @@ internal static partial class FFI ; const string StdbNamespace10_1 = -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER "spacetime_10.1" #else "bindings" @@ -67,7 +73,7 @@ internal static partial class FFI ; const string StdbNamespace10_2 = -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER "spacetime_10.2" #else "bindings" @@ -75,7 +81,7 @@ internal static partial class FFI ; const string StdbNamespace10_3 = -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER "spacetime_10.3" #else "bindings" @@ -83,7 +89,7 @@ internal static partial class FFI ; const string StdbNamespace10_4 = -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER "spacetime_10.4" #else "bindings" @@ -91,7 +97,7 @@ internal static partial class FFI ; const string StdbNamespace10_5 = -#if EXPERIMENTAL_WASM_AOT +#if EXPERIMENTAL_WASM_AOT || NET10_0_OR_GREATER "spacetime_10.5" #else "bindings" @@ -190,6 +196,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, @@ -197,6 +204,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, @@ -204,15 +212,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, @@ -221,6 +232,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, @@ -229,6 +241,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, @@ -242,6 +255,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, @@ -249,9 +263,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, @@ -259,6 +275,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, @@ -267,6 +284,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, @@ -280,6 +298,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, @@ -288,9 +307,11 @@ public static partial CheckedStatus datastore_delete_all_by_eq_bsatn( out uint out_ ); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_5)] public static partial CheckedStatus datastore_clear(TableId table_id, out ulong out_); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial Errno bytes_source_read( BytesSource source, @@ -298,6 +319,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, @@ -315,6 +337,7 @@ public enum LogLevel : byte Panic = 5, } + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial void console_log( LogLevel level, @@ -346,18 +369,21 @@ private ConsoleTimerId(uint id) )] internal static class ConsoleTimerIdMarshaller { - public static ConsoleTimerId ConvertToManaged(uint id) => new ConsoleTimerId(id); + public static ConsoleTimerId ConvertToManaged(uint id) => new(id); public static uint ConvertToUnmanaged(ConsoleTimerId id) => id.timer_id; } } + [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, @@ -374,22 +400,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(); @@ -400,6 +432,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/Internal/ITable.cs b/crates/bindings-csharp/Runtime/Internal/ITable.cs index 5294ce0e7fe..a322216a1d3 100644 --- a/crates/bindings-csharp/Runtime/Internal/ITable.cs +++ b/crates/bindings-csharp/Runtime/Internal/ITable.cs @@ -146,7 +146,9 @@ protected override void IterStart(out FFI.RowIter handle) => return out_; }); +#pragma warning disable IDE1006 // Used by static interface member call sites. internal static FFI.TableId tableId => tableId_.Value; +#pragma warning restore IDE1006 ulong Count { get; } diff --git a/crates/bindings-csharp/Runtime/Internal/Module.cs b/crates/bindings-csharp/Runtime/Internal/Module.cs index cec7d980b98..ad5c8324381 100644 --- a/crates/bindings-csharp/Runtime/Internal/Module.cs +++ b/crates/bindings-csharp/Runtime/Internal/Module.cs @@ -31,11 +31,11 @@ partial class RawModuleDefV10 // Fix it up to a different mangling scheme if it causes problems. private static string GetFriendlyName(Type type) => type.IsGenericType - ? $"{type.Name.Remove(type.Name.IndexOf('`'))}_{string.Join("_", type.GetGenericArguments().Select(GetFriendlyName))}" + ? $"{type.Name[..type.Name.IndexOf('`')]}_{string.Join("_", type.GetGenericArguments().Select(GetFriendlyName))}" : type.Name; private static RawScopedTypeNameV10 MakeScopedTypeName(Type type) => - new(new List(), GetFriendlyName(type)); + new([], GetFriendlyName(type)); internal AlgebraicType.Ref RegisterType(Func makeType) { @@ -84,7 +84,7 @@ internal void RegisterTable(RawTableDefV10 table, RawScheduleDefV10? schedule) internal void RegisterView(RawViewDefV10 view) => viewDefs.Add(view); internal void RegisterViewPrimaryKey(string viewSourceName, IEnumerable columns) => - viewPrimaryKeyDefs.Add(new RawViewPrimaryKeyDefV10(viewSourceName, columns.ToList())); + viewPrimaryKeyDefs.Add(new RawViewPrimaryKeyDefV10(viewSourceName, [.. columns])); internal void RegisterRowLevelSecurity(RawRowLevelSecurityDefV9 rls) => rowLevelSecurityDefs.Add(rls); @@ -96,7 +96,7 @@ internal void RegisterTableDefaultValue(string table, ushort colId, byte[] value defaults = []; defaultValuesByTable.Add(table, defaults); } - defaults.Add(new RawColumnDefaultValueV10(colId, new List(value))); + defaults.Add(new RawColumnDefaultValueV10(colId, [.. value])); } internal void SetCaseConversionPolicy(SpacetimeDB.CaseConversionPolicy policy) => @@ -131,7 +131,7 @@ internal RawModuleDefV10 BuildModuleDefinition() TableAccess: table.TableAccess, DefaultValues: defaults is null ? [] - : new List(defaults), + : [.. defaults], IsEvent: table.IsEvent ) ); @@ -212,7 +212,7 @@ internal RawModuleDefV10 BuildModuleDefinition() { sections.Add( new RawModuleDefV10Section.ExplicitNames( - new ExplicitNames(new List(explicitNames)) + new ExplicitNames([.. explicitNames]) ) ); } @@ -244,7 +244,9 @@ private static void EnsureNativeAotTypeRoots() // These constructions are never executed at runtime — they exist solely // to make the IL scanner compute vtables for TaggedEnum subtypes. // The condition is always false but the scanner must assume it could be true. +#pragma warning disable IDE0078 // Keep this opaque to the NativeAOT IL scanner. if (Environment.TickCount < 0 && Environment.TickCount > 0) +#pragma warning restore IDE0078 { _ = new RawIndexAlgorithm.BTree(null!); _ = new RawConstraintDataV9.Unique(null!); @@ -567,7 +569,7 @@ BytesSink error } catch (Exception e) { - var error_str = e.Message ?? e.GetType().FullName; + var error_str = e.Message ?? e.GetType().FullName ?? e.GetType().Name; var error_bytes = System.Text.Encoding.UTF8.GetBytes(error_str); error.Write(error_bytes); return Errno.HOST_CALL_FAILURE; diff --git a/crates/bindings-csharp/Runtime/JwtClaims.cs b/crates/bindings-csharp/Runtime/JwtClaims.cs index 6e656cca202..3ca3e11e029 100644 --- a/crates/bindings-csharp/Runtime/JwtClaims.cs +++ b/crates/bindings-csharp/Runtime/JwtClaims.cs @@ -72,11 +72,13 @@ private List ExtractAudience() return aud.ValueKind switch { - JsonValueKind.String => new List { aud.GetString()! }, - JsonValueKind.Array => aud.EnumerateArray() - .Where(e => e.ValueKind == JsonValueKind.String) - .Select(e => e.GetString()!) - .ToList(), + JsonValueKind.String => [aud.GetString()!], + JsonValueKind.Array => + [ + .. aud.EnumerateArray() + .Where(e => e.ValueKind == JsonValueKind.String) + .Select(e => e.GetString()!), + ], _ => throw new InvalidOperationException("Unexpected type for 'aud' claim in JWT"), }; } diff --git a/crates/bindings-csharp/Runtime/Log.cs b/crates/bindings-csharp/Runtime/Log.cs index 757157c0e49..55a78afde85 100644 --- a/crates/bindings-csharp/Runtime/Log.cs +++ b/crates/bindings-csharp/Runtime/Log.cs @@ -17,7 +17,7 @@ public static void Debug( string message, [CallerMemberName] string RESERVED_target = "", [CallerFilePath] string RESERVED_filename = "", - [CallerLineNumber] uint RESERVED_lineNumber = 0 + [CallerLineNumber] int RESERVED_lineNumber = 0 ) => LogInternal( message, @@ -38,7 +38,7 @@ public static void Trace( string message, [CallerMemberName] string RESERVED_target = "", [CallerFilePath] string RESERVED_filename = "", - [CallerLineNumber] uint RESERVED_lineNumber = 0 + [CallerLineNumber] int RESERVED_lineNumber = 0 ) => LogInternal( message, @@ -59,7 +59,7 @@ public static void Info( string message, [CallerMemberName] string RESERVED_target = "", [CallerFilePath] string RESERVED_filename = "", - [CallerLineNumber] uint RESERVED_lineNumber = 0 + [CallerLineNumber] int RESERVED_lineNumber = 0 ) => LogInternal( message, @@ -80,7 +80,7 @@ public static void Warn( string message, [CallerMemberName] string RESERVED_target = "", [CallerFilePath] string RESERVED_filename = "", - [CallerLineNumber] uint RESERVED_lineNumber = 0 + [CallerLineNumber] int RESERVED_lineNumber = 0 ) => LogInternal( message, @@ -101,7 +101,7 @@ public static void Error( string message, [CallerMemberName] string RESERVED_target = "", [CallerFilePath] string RESERVED_filename = "", - [CallerLineNumber] uint RESERVED_lineNumber = 0 + [CallerLineNumber] int RESERVED_lineNumber = 0 ) => LogInternal( message, @@ -122,7 +122,7 @@ public static void Exception( string message, [CallerMemberName] string RESERVED_target = "", [CallerFilePath] string RESERVED_filename = "", - [CallerLineNumber] uint RESERVED_lineNumber = 0 + [CallerLineNumber] int RESERVED_lineNumber = 0 ) => LogInternal( message, @@ -143,7 +143,7 @@ public static void Exception( Exception exception, [CallerMemberName] string RESERVED_target = "", [CallerFilePath] string RESERVED_filename = "", - [CallerLineNumber] uint RESERVED_lineNumber = 0 + [CallerLineNumber] int RESERVED_lineNumber = 0 ) => LogInternal( exception.ToString(), @@ -158,7 +158,7 @@ private static void LogInternal( FFI.LogLevel level, string target, string filename, - uint lineNumber + int lineNumber ) { var target_bytes = Encoding.UTF8.GetBytes(target); @@ -171,7 +171,7 @@ uint lineNumber (uint)target_bytes.Length, filename_bytes, (uint)filename_bytes.Length, - lineNumber, + (uint)lineNumber, text_bytes, (uint)text_bytes.Length ); diff --git a/crates/bindings-csharp/Runtime/Router.cs b/crates/bindings-csharp/Runtime/Router.cs index e146bd5057d..7377d7b2561 100644 --- a/crates/bindings-csharp/Runtime/Router.cs +++ b/crates/bindings-csharp/Runtime/Router.cs @@ -91,7 +91,7 @@ private Router AddRoute(MethodOrAny method, string path, Handler handler) return new Router(merged); } - private List CloneRoutes() => new(routes); + private List CloneRoutes() => [.. routes]; private static void AddRoute( List routes, @@ -160,5 +160,5 @@ private static void AssertValidPath(string path) } private static bool CharacterIsAcceptableForRoutePath(char c) => - (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c is '-' or '_' or '~' or '/'; + c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '_' or '~' or '/'; } diff --git a/crates/bindings-csharp/Runtime/Runtime.csproj b/crates/bindings-csharp/Runtime/Runtime.csproj index a64cd27ab3b..33f22791acd 100644 --- a/crates/bindings-csharp/Runtime/Runtime.csproj +++ b/crates/bindings-csharp/Runtime/Runtime.csproj @@ -8,29 +8,36 @@ - net8.0 + net8.0;net10.0 true SpacetimeDB true - https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json;$(RestoreAdditionalProjectSources) + https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json;$(RestoreAdditionalProjectSources) + + + + $(DefineConstants);EXPERIMENTAL_WASM_AOT - + - + - + - + - + + + + diff --git a/crates/bindings-csharp/Runtime/TransactionalContextState.cs b/crates/bindings-csharp/Runtime/TransactionalContextState.cs index ef0d72de410..9fbdaebaa70 100644 --- a/crates/bindings-csharp/Runtime/TransactionalContextState.cs +++ b/crates/bindings-csharp/Runtime/TransactionalContextState.cs @@ -79,26 +79,26 @@ Func> body } } - private long StartMutTx() + private static long StartMutTx() { var status = FFI.procedure_start_mut_tx(out var micros); FFI.ErrnoHelpers.ThrowIfError(status); return micros; } - private void CommitMutTx() + private static void CommitMutTx() { var status = FFI.procedure_commit_mut_tx(); FFI.ErrnoHelpers.ThrowIfError(status); } - private void AbortMutTx() + private static void AbortMutTx() { var status = FFI.procedure_abort_mut_tx(); FFI.ErrnoHelpers.ThrowIfError(status); } - private bool CommitMutTxWithRetry(Func retryBody) + private static bool CommitMutTxWithRetry(Func retryBody) { try { diff --git a/crates/bindings-csharp/Runtime/bindings.c b/crates/bindings-csharp/Runtime/bindings.c index 57ed816d939..de25c99e3e0 100644 --- a/crates/bindings-csharp/Runtime/bindings.c +++ b/crates/bindings-csharp/Runtime/bindings.c @@ -270,9 +270,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.props b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.props index 58ccaa0de6e..bb4124289aa 100644 --- a/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.props +++ b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.props @@ -1,7 +1,6 @@ - true full true false @@ -9,19 +8,30 @@ false - + + + <_UseNativeAotLlvm Condition="$(TargetFramework.StartsWith('net10.'))">true + <_UseNativeAotLlvm Condition="'$(EXPERIMENTAL_WASM_AOT)' == '1'">true + + + Library Shared $(DefineConstants);EXPERIMENTAL_WASM_AOT false false + true + true + false + false spacetime_10.0 - https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json + https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json;$(RestoreAdditionalProjectSources) - + Exe + true false diff --git a/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets index 5f183e0e040..e3b4bd0e942 100644 --- a/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets +++ b/crates/bindings-csharp/Runtime/build/SpacetimeDB.Runtime.targets @@ -4,7 +4,23 @@ Project="$(PkgMicrosoft_DotNet_ILCompiler_LLVM)\build\Microsoft.DotNet.ILCompiler.LLVM.targets" Condition="'$(EXPERIMENTAL_WASM_AOT)' == '1' and '$(ILCompilerTargetsPath)' == '' and '$(PkgMicrosoft_DotNet_ILCompiler_LLVM)' != '' and Exists('$(PkgMicrosoft_DotNet_ILCompiler_LLVM)\build\Microsoft.DotNet.ILCompiler.LLVM.targets')" /> - + + + <_UseNativeAotLlvm Condition="$(TargetFramework.StartsWith('net10.'))">true + <_UseNativeAotLlvm Condition="'$(EXPERIMENTAL_WASM_AOT)' == '1'">true + + + + + + wasm32-unknown-wasip1 + false + false + + + @@ -46,21 +62,54 @@ + + + - + + <_WasmNativeFileForLinking Include="@(NativeFileReference)" /> + + + + <_OriginalIlcSdkPath>$(IlcSdkPath) + <_WasiRuntimeOverlayDir>$(IntermediateOutputPath)native-hidden-no-wit\ + + + + <_WasiRuntimeOverlaySource Include="$(IlcFrameworkNativePath)**\*" Exclude="$(IlcFrameworkNativePath)**\*.wit" /> + + + + + + + + $(_WasiRuntimeOverlayDir) + $(_OriginalIlcSdkPath) + + + - + + - 24 + 29 + 24 $([System.IO.Path]::Combine($(IntermediateOutputPath), "wasi-sdk.$(WasiSdkVersion).tar.gz")) @@ -73,23 +122,54 @@ https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-$(WasiSdkVersion)/wasi-sdk-$(WasiSdkVersion).0-$(WasiSdkArch)-$(WasiSdkOS).tar.gz - $([System.IO.Path]::Combine($([System.Environment]::GetFolderPath(SpecialFolder.UserProfile)), '.wasi-sdk', "wasi-sdk-$(WasiSdkVersion)")) + + <_WasiSdkFromEnv>$([System.Environment]::GetEnvironmentVariable('WASI_SDK_PATH')) + <_WasiClangFromEnv Condition="'$(_WasiSdkFromEnv)' != ''">$([System.IO.Path]::Combine($(_WasiSdkFromEnv), 'bin', 'clang')) + <_WasiClangFromEnv Condition="'$(_WasiSdkFromEnv)' != '' and $([MSBuild]::IsOSPlatform('Windows'))">$(_WasiClangFromEnv).exe + <_HasValidWasiSdkFromEnv Condition="'$(_WasiSdkFromEnv)' != '' and Exists('$(_WasiClangFromEnv)')">true + + + $([System.IO.Path]::Combine($([System.Environment]::GetFolderPath(SpecialFolder.UserProfile)), '.wasi-sdk', "wasi-sdk-$(WasiSdkVersion)")) + $(_WasiSdkFromEnv) + $(WasiSdkRoot) $([System.IO.Path]::Combine($(WasiSdkRoot), 'share', 'wasi-sysroot')) $([System.IO.Path]::Combine($(WasiSdkRoot), 'bin', 'clang')) $(WasiClang).exe + + Condition="'$(_HasValidWasiSdkFromEnv)' != 'true' and !Exists('$(WasiClang)')" /> - - + + - + + + + + + + <_FoundExpectedWasiSdkVersion>true + <_IsToolchainMissing>false + + + + + + + + + diff --git a/crates/cli/src/subcommands/build.rs b/crates/cli/src/subcommands/build.rs index 5ef4cea7300..707d249ceab 100644 --- a/crates/cli/src/subcommands/build.rs +++ b/crates/cli/src/subcommands/build.rs @@ -39,6 +39,12 @@ pub fn cli() -> clap::Command { .action(SetTrue) .help("Builds the module using debug instead of release (intended to speed up local iteration, not recommended for CI)"), ) + .arg( + Arg::new("dotnet_version") + .long("dotnet-version") + .value_name("VERSION") + .help("Target .NET SDK major version for C# projects (e.g. 8 or 10). Auto-detected when omitted.") + ) } pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(PathBuf, &'static str), anyhow::Error> { @@ -60,8 +66,9 @@ pub async fn exec(_config: Config, args: &ArgMatches) -> Result<(PathBuf, &'stat }; let build_debug = args.get_flag("debug"); let features = features.cloned(); + let dotnet_version = parse_dotnet_version(args.get_one::("dotnet_version"))?; - run_build(module_path, lint_dir, build_debug, features) + run_build(module_path, lint_dir, build_debug, features, false, dotnet_version) } pub fn run_build( @@ -69,6 +76,8 @@ pub fn run_build( lint_dir: Option, build_debug: bool, features: Option, + native_aot: bool, + dotnet_version: Option, ) -> Result<(PathBuf, &'static str), anyhow::Error> { // Create the project path, or make sure the target project path is empty. if module_path.exists() { @@ -85,7 +94,14 @@ pub fn run_build( )); } - let result = crate::tasks::build(&module_path, lint_dir.as_deref(), build_debug, features.as_ref())?; + let result = crate::tasks::build( + &module_path, + lint_dir.as_deref(), + build_debug, + features.as_ref(), + native_aot, + dotnet_version, + )?; println!("Build finished successfully."); Ok(result) @@ -94,6 +110,7 @@ pub fn run_build( pub async fn exec_with_argstring( project_path: &Path, arg_string: &str, + native_aot: bool, ) -> Result<(PathBuf, &'static str), anyhow::Error> { let argv = exec_with_argstring_argv(project_path, arg_string); let arg_matches = cli().get_matches_from(argv); @@ -110,8 +127,19 @@ pub async fn exec_with_argstring( Some(PathBuf::from(lint_dir)) }; let build_debug = arg_matches.get_flag("debug"); + let dotnet_version = parse_dotnet_version(arg_matches.get_one::("dotnet_version"))?; + + run_build(module_path, lint_dir, build_debug, features, native_aot, dotnet_version) +} - run_build(module_path, lint_dir, build_debug, features) +fn parse_dotnet_version(dotnet_version: Option<&String>) -> anyhow::Result> { + dotnet_version + .map(|version| match version.parse::() { + Ok(version @ (8 | 10)) => Ok(version), + Ok(version) => anyhow::bail!("Unsupported --dotnet-version {version}. Supported values: 8, 10."), + Err(error) => anyhow::bail!("Invalid --dotnet-version: {error}"), + }) + .transpose() } fn exec_with_argstring_argv(project_path: &Path, arg_string: &str) -> Vec { diff --git a/crates/cli/src/subcommands/dev.rs b/crates/cli/src/subcommands/dev.rs index 70acd76eadc..6c9fb8d3c0a 100644 --- a/crates/cli/src/subcommands/dev.rs +++ b/crates/cli/src/subcommands/dev.rs @@ -93,6 +93,12 @@ pub fn cli() -> Command { .value_name("TEMPLATE") .help("Template ID or GitHub repository (owner/repo or URL) for project initialization"), ) + .arg( + Arg::new("dotnet_version") + .long("dotnet-version") + .value_name("VERSION") + .help("Target .NET SDK major version for C# projects (e.g. 8 or 10). Auto-detected when omitted."), + ) .arg( Arg::new("run") .long("run") @@ -189,6 +195,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E let no_config = args.get_flag("no_config"); let skip_publish = args.get_flag("skip_publish"); let skip_generate = args.get_flag("skip_generate"); + let dotnet_version = args.get_one::("dotnet_version").map(String::as_str); // --env defaults to "dev" for spacetime dev let env = args.get_one::("env").map(|s| s.as_str()).unwrap_or("dev"); @@ -404,6 +411,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E project_name_default: database_name_from_cli_for_init.clone(), database_name_default: database_name_from_cli_for_init.clone(), skip_next_steps: true, + dotnet_version: parse_dotnet_version(dotnet_version)?, ..Default::default() }; let created_project_path = init::exec_with_options(&mut config, &init_options).await?; @@ -746,6 +754,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E using_spacetime_config, server_from_cli, force, + dotnet_version, skip_publish, skip_generate, ) @@ -860,6 +869,7 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> Result<(), anyhow::E using_spacetime_config, server_from_cli, force, + dotnet_version, skip_publish, skip_generate, ) @@ -1006,12 +1016,20 @@ async fn generate_build_and_publish( using_spacetime_config: bool, server: Option<&str>, yes: bool, + dotnet_version: Option<&str>, skip_publish: bool, skip_generate: bool, ) -> Result<(), anyhow::Error> { println!("{}", "Building...".cyan()); - let (_path_to_program, _host_type) = - tasks::build(spacetimedb_dir, Some(Path::new("src")), false, None).context("Failed to build project")?; + let (_path_to_program, _host_type) = tasks::build( + spacetimedb_dir, + Some(Path::new("src")), + false, + None, + false, + parse_dotnet_version(dotnet_version)?, + ) + .context("Failed to build project")?; println!("{}", "Build complete!".green()); // For TypeScript client, always update .env.local with the database name @@ -1047,23 +1065,22 @@ async fn generate_build_and_publish( ); } else { println!("{}", "Generating module bindings from spacetime.json...".cyan()); - generate::exec_from_entries( - generate_configs.to_vec(), - crate::generate::extract_descriptions, - yes, - config_dir, - ) - .await?; + let generate_configs = with_dotnet_version_override(generate_configs.to_vec(), dotnet_version); + generate::exec_from_entries(generate_configs, crate::generate::extract_descriptions, yes, config_dir) + .await?; } } else { let resolved_client_language = generate::resolve_language(spacetimedb_dir, client_language.copied())?; println!("{}", "Generating module bindings...".cyan()); - let generate_entry = generate::build_generate_entry( + let mut generate_entry = generate::build_generate_entry( Some(spacetimedb_dir), Some(resolved_client_language), Some(module_bindings_dir), ); + if let Some(version) = dotnet_version { + generate_entry.insert("dotnet-version".to_string(), json!(version)); + } generate::exec_from_entries( vec![generate_entry], crate::generate::extract_descriptions, @@ -1118,6 +1135,12 @@ async fn generate_build_and_publish( publish_entry.insert("build-options".to_string(), json!(build_opts)); } + if let Some(version) = + dotnet_version.or_else(|| config_entry.get_config_value("dotnet_version").and_then(|v| v.as_str())) + { + publish_entry.insert("dotnet-version".to_string(), json!(version)); + } + // Forward break-clients if set if config_entry .get_config_value("break_clients") @@ -1136,6 +1159,28 @@ async fn generate_build_and_publish( Ok(()) } +fn parse_dotnet_version(dotnet_version: Option<&str>) -> anyhow::Result> { + dotnet_version + .map(|version| match version.parse::() { + Ok(version @ (8 | 10)) => Ok(version), + Ok(version) => anyhow::bail!("Unsupported --dotnet-version {version}. Supported values: 8, 10."), + Err(error) => anyhow::bail!("Invalid --dotnet-version: {error}"), + }) + .transpose() +} + +fn with_dotnet_version_override( + mut entries: Vec>, + dotnet_version: Option<&str>, +) -> Vec> { + if let Some(version) = dotnet_version { + for entry in &mut entries { + entry.insert("dotnet-version".to_string(), json!(version)); + } + } + entries +} + async fn select_database(config: &Config, server: &str, token: &str) -> Result { let identity = crate::util::decode_identity(&token.to_string())?; @@ -1956,6 +2001,36 @@ mod tests { assert!(matches.get_flag("skip_generate")); } + #[test] + fn test_cli_dotnet_version_flag_exists() { + let cmd = cli(); + let matches = cmd.clone().get_matches_from(vec!["dev", "--dotnet-version", "8"]); + + assert_eq!( + matches.get_one::("dotnet_version").map(|s| s.as_str()), + Some("8") + ); + } + + #[test] + fn test_parse_dotnet_version() { + assert_eq!(parse_dotnet_version(None).unwrap(), None); + assert_eq!(parse_dotnet_version(Some("8")).unwrap(), Some(8)); + assert_eq!(parse_dotnet_version(Some("10")).unwrap(), Some(10)); + assert!(parse_dotnet_version(Some("9")).is_err()); + assert!(parse_dotnet_version(Some("not-a-number")).is_err()); + } + + #[test] + fn test_with_dotnet_version_override_updates_generate_entries() { + let mut entry = HashMap::new(); + entry.insert("language".to_string(), json!("csharp")); + + let entries = with_dotnet_version_override(vec![entry], Some("10")); + + assert_eq!(entries[0].get("dotnet-version"), Some(&json!("10"))); + } + #[test] fn test_cli_env_flag_accepts_value() { let cmd = cli(); diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index 94686939cba..0522e976efc 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -50,6 +50,7 @@ fn build_generate_config_schema(command: &clap::Command) -> Result clap::Command { .default_value("") .help("Options to pass to the build command, for example --build-options='--lint-dir='"), ) + .arg( + Arg::new("dotnet_version") + .long("dotnet-version") + .value_name("VERSION") + .conflicts_with("wasm_file") + .conflicts_with("js_file") + .help("Target .NET SDK major version for C# projects (e.g. 8 or 10). Auto-detected when omitted.") + ) .arg( Arg::new("include_private") .long("include-private") @@ -293,6 +302,7 @@ pub struct GenerateRunConfig { pub module_name: Option, pub module_prefix: Option, pub build_options: String, + pub dotnet_version: Option, pub out_dir: PathBuf, pub include_private: bool, } @@ -324,6 +334,7 @@ fn prepare_generate_run_configs<'a>( let build_options = command_config .get_one::("build_options")? .unwrap_or_else(String::new); + let dotnet_version = command_config.get_one::("dotnet_version")?; // Validate Unreal-specific args first to preserve focused errors for this mode. if requested_lang == Some(Language::UnrealCpp) { @@ -381,6 +392,7 @@ fn prepare_generate_run_configs<'a>( module_name, module_prefix, build_options, + dotnet_version, out_dir, include_private, }); @@ -486,7 +498,9 @@ pub async fn run_prepared_generate_configs( println!("Skipping build. Instead we are inspecting {}", path.display()); path.clone() } else { - let (path, _) = build::exec_with_argstring(&run.project_path, &run.build_options).await?; + let build_options = + build_options_with_dotnet_version(&run.build_options, run.dotnet_version.as_deref()); + let (path, _) = build::exec_with_argstring(&run.project_path, &build_options, false).await?; path }; let spinner = indicatif::ProgressBar::new_spinner(); @@ -595,6 +609,14 @@ pub async fn run_prepared_generate_configs( Ok(()) } +fn build_options_with_dotnet_version(build_options: &str, dotnet_version: Option<&str>) -> String { + match dotnet_version { + Some(version) if build_options.is_empty() => format!("--dotnet-version {version}"), + Some(version) => format!("{build_options} --dotnet-version {version}"), + None => build_options.to_string(), + } +} + /// Like `exec`, but lets you specify a custom a function to extract a schema from a file. pub async fn exec_ex( _config: Config, diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 77eb580943d..c398d3c248c 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -6,6 +6,7 @@ use clap::{Arg, ArgMatches}; use colored::Colorize; use convert_case::{Case, Casing}; use dialoguer::{theme::ColorfulTheme, Confirm, FuzzySelect, Input, Select}; +use regex::Regex; use reqwest::Url; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -16,7 +17,6 @@ use std::fs; use std::path::{Path, PathBuf}; use std::time::{SystemTime, UNIX_EPOCH}; use toml_edit::{value, DocumentMut, Item}; -use xmltree::{Element, XMLNode}; use crate::spacetime_config::{PackageManager, SpacetimeConfig, CONFIG_FILENAME}; use crate::subcommands::login::{spacetimedb_login_and_save, DEFAULT_AUTH_HOST}; @@ -134,6 +134,8 @@ pub struct InitOptions { pub skip_next_steps: bool, /// When true, configure C# projects for NativeAOT-LLVM compilation. pub native_aot: bool, + /// Explicit .NET major version override (e.g. 8 or 10). When set, skips auto-detection. + pub dotnet_version: Option, } impl InitOptions { @@ -150,6 +152,9 @@ impl InitOptions { non_interactive: args.get_flag("non-interactive"), skip_next_steps: false, native_aot: args.get_flag("native-aot"), + dotnet_version: args + .get_one::("dotnet-version") + .and_then(|s| s.parse::().ok()), } } } @@ -201,6 +206,12 @@ pub fn cli() -> clap::Command { .action(clap::ArgAction::SetTrue) .help("Configure C# project for NativeAOT-LLVM compilation (experimental, Windows only)"), ) + .arg( + Arg::new("dotnet-version") + .long("dotnet-version") + .value_name("VERSION") + .help("Target .NET SDK major version for C# projects (e.g. 8 or 10). Defaults to 10 except on macOS or when only .NET 8 is installed."), + ) } pub async fn fetch_templates_list() -> anyhow::Result> { @@ -545,21 +556,41 @@ pub async fn exec_with_options(config: &mut Config, options: &InitOptions) -> an template_config.use_local = use_local; + // For C# projects, resolve the target .NET version before scaffolding. + let dotnet_major = if template_config.server_lang == Some(ServerLanguage::Csharp) { + Some(resolve_dotnet_major(options)?) + } else { + None + }; + ensure_empty_directory( &template_config.project_name, &template_config.project_path, is_server_only, )?; - init_from_template(&template_config, &template_config.project_path, is_server_only).await?; - - // Add NativeAOT-LLVM package references to C# projects if --native-aot was specified - if options.native_aot && template_config.server_lang == Some(ServerLanguage::Csharp) { + init_from_template( + &template_config, + &template_config.project_path, + is_server_only, + dotnet_major, + ) + .await?; + + // Add NativeAOT-LLVM project configuration for C# projects when: + // - --native-aot was explicitly specified, OR + // - .NET 10 was selected/detected as the target + let needs_native_aot = if template_config.server_lang == Some(ServerLanguage::Csharp) { + options.native_aot || dotnet_major == Some(10) + } else { + false + }; + if needs_native_aot { let server_dir = template_config.project_path.join("spacetimedb"); - add_native_aot_packages_to_csproj(&server_dir)?; + add_native_aot_packages_to_csproj(&server_dir, dotnet_major)?; } let default_server = config.default_server_name().unwrap_or("maincloud"); - if let Some(path) = create_default_spacetime_config_if_missing(&project_path, options.native_aot, default_server)? { + if let Some(path) = create_default_spacetime_config_if_missing(&project_path, needs_native_aot, default_server)? { println!("{} Created {}", "✓".green(), path.display()); } @@ -1139,17 +1170,12 @@ pub fn update_csproj_server_to_nuget(dir: &Path) -> anyhow::Result<()> { if let Some(csproj_path) = find_first_csproj(dir)? { let original = fs::read_to_string(&csproj_path).with_context(|| format!("reading {}", csproj_path.display()))?; - let mut root: Element = - Element::parse(original.as_bytes()).with_context(|| format!("parsing xml {}", csproj_path.display()))?; - - upsert_packageref( - &mut root, + let updated = update_csproj_package_ref_to_nuget( + &original, "SpacetimeDB.Runtime", &get_spacetimedb_csharp_runtime_version(), ); - remove_all_project_references(&mut root); - - write_if_changed(csproj_path, original, root)?; + write_if_changed(csproj_path, original, updated)?; } Ok(()) } @@ -1158,28 +1184,19 @@ pub fn update_csproj_client_to_nuget(dir: &Path) -> anyhow::Result<()> { if let Some(csproj_path) = find_first_csproj(dir)? { let original = fs::read_to_string(&csproj_path).with_context(|| format!("reading {}", csproj_path.display()))?; - let mut root: Element = - Element::parse(original.as_bytes()).with_context(|| format!("parsing xml {}", csproj_path.display()))?; - - upsert_packageref( - &mut root, + let updated = update_csproj_package_ref_to_nuget( + &original, "SpacetimeDB.ClientSDK", &get_spacetimedb_csharp_clientsdk_version(), ); - remove_all_project_references(&mut root); - - write_if_changed(csproj_path, original, root)?; + write_if_changed(csproj_path, original, updated)?; } Ok(()) } // Helpers -fn write_if_changed(path: PathBuf, original: String, root: Element) -> anyhow::Result<()> { - let mut out = Vec::new(); - root.write(&mut out)?; - let compact = String::from_utf8(out)?; - let updated = pretty_format_xml(&compact)?; +fn write_if_changed(path: PathBuf, original: String, updated: String) -> anyhow::Result<()> { if updated != original { fs::write(&path, updated).with_context(|| format!("writing {}", path.display()))?; } @@ -1199,99 +1216,75 @@ fn find_first_csproj(dir: &Path) -> anyhow::Result> { Ok(None) } -/// Remove every under any -fn remove_all_project_references(project: &mut Element) { - for node in project.children.iter_mut() { - if let XMLNode::Element(item_group) = node - && item_group.name == "ItemGroup" - { - item_group - .children - .retain(|n| !matches!(n, XMLNode::Element(el) if el.name == "ProjectReference")); - } - } - // Optional: prune empty ItemGroups - project.children.retain(|n| { - if let XMLNode::Element(el) = n - && el.name == "ItemGroup" - { - return el.children.iter().any(|c| matches!(c, XMLNode::Element(_))); - } - true - }); +fn update_csproj_package_ref_to_nuget(original: &str, include: &str, version: &str) -> String { + let updated = remove_project_reference_lines(original); + upsert_package_reference_text(&updated, include, version) } -/// Insert or update -fn upsert_packageref(project: &mut Element, include: &str, version: &str) { - // Try to find an existing PackageReference - for node in project.children.iter_mut() { - if let XMLNode::Element(item_group) = node - && item_group.name == "ItemGroup" - && let Some(XMLNode::Element(existing)) = item_group.children.iter_mut().find(|n| { - matches!(n, - XMLNode::Element(e) - if e.name == "PackageReference" - && e.attributes.get("Include").map(|v| v == include).unwrap_or(false) - ) - }) - { - existing.attributes.insert("Version".to_string(), version.to_string()); - return; - } +fn remove_project_reference_lines(original: &str) -> String { + let mut updated = original + .lines() + .filter(|line| !line.contains(">() + .join("\n"); + if original.ends_with('\n') { + updated.push('\n'); + } + updated +} + +fn upsert_package_reference_text(original: &str, include: &str, version: &str) -> String { + let include_attr = format!(r#"Include="{include}""#); + let mut found = false; + let mut updated = original + .lines() + .map(|line| { + if line.contains(">() + .join("\n"); + if original.ends_with('\n') { + updated.push('\n'); } - // Otherwise create one in (or create) an ItemGroup - let item_group = get_or_create_direct_child(project, "ItemGroup"); - let mut pr = Element::new("PackageReference"); - pr.attributes.insert("Include".into(), include.to_string()); - pr.attributes.insert("Version".into(), version.to_string()); - item_group.children.push(XMLNode::Element(pr)); -} -fn get_or_create_direct_child<'a>(parent: &'a mut Element, name: &str) -> &'a mut Element { - // First, scan IMMUTABLY to find the index of an existing child. - if let Some(idx) = parent.children.iter().enumerate().find_map(|(i, n)| match n { - XMLNode::Element(e) if e.name == name => Some(i), - _ => None, - }) { - // Now borrow MUTABLY by index. - if let XMLNode::Element(el) = &mut parent.children[idx] { - return el; - } - unreachable!("Matched non-element while checking by name"); + if found { + return updated; } - // Not found: create, then borrow by index. - parent.children.push(XMLNode::Element(Element::new(name))); - let idx = parent.children.len() - 1; - match &mut parent.children[idx] { - XMLNode::Element(el) => el, - _ => unreachable!("just pushed an Element"), + let package_reference = format!( + r#" + + +"# + ); + if let Some(pos) = updated.rfind("") { + let (before, after) = updated.split_at(pos); + format!("{}{}{}", before.trim_end(), package_reference, after) + } else { + updated } } -/// Pretty-print XML with indentation. -/// Keeps UTF-8 declaration if present. -fn pretty_format_xml(xml: &str) -> anyhow::Result { - use quick_xml::events::Event; - use quick_xml::Reader; - use quick_xml::Writer; - use std::io::Cursor; - - let mut reader = Reader::from_str(xml); - reader.trim_text(true); - let mut buf = Vec::new(); - let mut writer = Writer::new_with_indent(Cursor::new(Vec::new()), b' ', 2); - - loop { - match reader.read_event_into(&mut buf)? { - Event::Eof => break, - e => writer.write_event(e)?, - } - buf.clear(); +fn set_package_reference_version(line: &str, version: &str) -> String { + let version_attr = Regex::new(r#"Version="[^"]*""#).expect("valid regex"); + if version_attr.is_match(line) { + return version_attr + .replace(line, format!(r#"Version="{version}""#).as_str()) + .into_owned(); } - let result = writer.into_inner().into_inner(); - Ok(String::from_utf8(result)?) + if let Some(pos) = line.rfind("/>") { + return format!("{} Version=\"{}\" {}", line[..pos].trim_end(), version, &line[pos..]); + } + if let Some(pos) = line.rfind('>') { + return format!("{} Version=\"{}\"{}", &line[..pos], version, &line[pos..]); + } + line.to_string() } fn get_spacetimedb_csharp_runtime_version() -> String { @@ -1353,13 +1346,14 @@ pub async fn init_from_template( config: &TemplateConfig, project_path: &Path, is_server_only: bool, + dotnet_major: Option, ) -> anyhow::Result<()> { println!("{}", "Initializing project from template...".cyan()); match config.template_type { - TemplateType::Builtin => init_builtin(config, project_path, is_server_only)?, + TemplateType::Builtin => init_builtin(config, project_path, is_server_only, dotnet_major)?, TemplateType::GitHub => init_github_template(config, project_path, is_server_only)?, - TemplateType::Empty => init_empty(config, project_path)?, + TemplateType::Empty => init_empty(config, project_path, dotnet_major)?, } // Install AI assistant rules for multiple editors/tools @@ -1370,7 +1364,12 @@ pub async fn init_from_template( Ok(()) } -fn init_builtin(config: &TemplateConfig, project_path: &Path, is_server_only: bool) -> anyhow::Result<()> { +fn init_builtin( + config: &TemplateConfig, + project_path: &Path, + is_server_only: bool, + dotnet_major: Option, +) -> anyhow::Result<()> { let template_def = config .template_def .as_ref() @@ -1439,6 +1438,10 @@ fn init_builtin(config: &TemplateConfig, project_path: &Path, is_server_only: bo None => {} } + if config.server_lang == Some(ServerLanguage::Csharp) { + configure_csharp_project_files(&server_dir, dotnet_major.unwrap_or_else(resolve_default_dotnet_major))?; + } + Ok(()) } @@ -1476,7 +1479,7 @@ fn init_github_template(config: &TemplateConfig, project_path: &Path, is_server_ Ok(()) } -fn init_empty(config: &TemplateConfig, project_path: &Path) -> anyhow::Result<()> { +fn init_empty(config: &TemplateConfig, project_path: &Path, dotnet_major: Option) -> anyhow::Result<()> { match config.server_lang { Some(ServerLanguage::Rust) => { println!("Setting up Rust server..."); @@ -1486,7 +1489,7 @@ fn init_empty(config: &TemplateConfig, project_path: &Path) -> anyhow::Result<() Some(ServerLanguage::Csharp) => { println!("Setting up C# server..."); let server_dir = project_path.join("spacetimedb"); - init_empty_csharp_server(&server_dir, &config.project_name)?; + init_empty_csharp_server(&server_dir, &config.project_name, dotnet_major)?; } Some(ServerLanguage::TypeScript) => { println!("Setting up TypeScript server..."); @@ -1510,8 +1513,8 @@ fn init_empty_rust_server(server_dir: &Path, project_name: &str) -> anyhow::Resu Ok(()) } -fn init_empty_csharp_server(server_dir: &Path, _project_name: &str) -> anyhow::Result<()> { - init_csharp_project(server_dir) +fn init_empty_csharp_server(server_dir: &Path, _project_name: &str, dotnet_major: Option) -> anyhow::Result<()> { + init_csharp_project(server_dir, dotnet_major) } fn init_empty_typescript_server(server_dir: &Path, project_name: &str) -> anyhow::Result<()> { @@ -1622,6 +1625,82 @@ fn check_for_cargo() -> bool { false } +/// Determine the target .NET major version for a C# project. +/// +/// Resolution order: +/// 1. Explicit `--dotnet-version` flag +/// 2. .NET 8 on macOS, where NativeAOT-LLVM is not supported +/// 3. .NET 8, if it is the only installed .NET SDK major version +/// 4. .NET 10 default +fn resolve_dotnet_major(options: &InitOptions) -> anyhow::Result { + if let Some(v) = options.dotnet_version { + match v { + 8 | 10 => return Ok(v), + _ => anyhow::bail!("Unsupported --dotnet-version {v}. Supported values: 8, 10."), + } + } + + Ok(resolve_default_dotnet_major()) +} + +fn resolve_default_dotnet_major() -> u8 { + let installed_majors = installed_dotnet_sdk_majors(); + if let Some(reason) = dotnet8_default_reason(std::env::consts::OS, installed_majors.as_deref()) { + match reason { + Dotnet8DefaultReason::MacOsHost => println!( + "{}", + "Warning: NativeAOT-LLVM does not support macOS hosts, so this C# project will target .NET 8. .NET 8 support will be deprecated soon." + .yellow() + ), + Dotnet8DefaultReason::OnlySdkInstalled => println!( + "{}", + "Warning: Only the .NET 8 SDK is installed, so this C# project will target .NET 8. .NET 8 support will be deprecated soon; install .NET 10 to create new C# modules targeting .NET 10." + .yellow() + ), + } + return 8; + } + + 10 +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Dotnet8DefaultReason { + MacOsHost, + OnlySdkInstalled, +} + +fn dotnet8_default_reason(os: &str, majors: Option<&[u8]>) -> Option { + if os == "macos" { + return Some(Dotnet8DefaultReason::MacOsHost); + } + + majors + .is_some_and(|majors| majors == [8]) + .then_some(Dotnet8DefaultReason::OnlySdkInstalled) +} + +fn installed_dotnet_sdk_majors() -> Option> { + let dotnet = match std::env::consts::OS { + "windows" => find_executable("dotnet.exe").or_else(|| find_executable("dotnet"))?, + _ => find_executable("dotnet")?, + }; + let output = std::process::Command::new(dotnet).arg("--list-sdks").output().ok()?; + if !output.status.success() { + return None; + } + + let stdout = String::from_utf8(output.stdout).ok()?; + let mut majors = stdout.lines().filter_map(parse_dotnet_sdk_major).collect::>(); + majors.sort_unstable(); + majors.dedup(); + Some(majors) +} + +fn parse_dotnet_sdk_major(line: &str) -> Option { + line.split_whitespace().next()?.split('.').next()?.parse().ok() +} + fn check_for_dotnet() -> bool { use std::fmt::Write; @@ -1723,6 +1802,19 @@ pub async fn exec(mut config: Config, args: &ArgMatches) -> anyhow::Result anyhow::Result<()> { Ok(()) } -pub fn init_csharp_project(project_path: &Path) -> anyhow::Result<()> { +pub fn init_csharp_project(project_path: &Path, dotnet_major: Option) -> anyhow::Result<()> { + check_for_dotnet(); + check_for_git(); + + let dotnet_major = dotnet_major.unwrap_or_else(resolve_default_dotnet_major); + if dotnet_major == 10 { + println!("Configuring for .NET 10 (NativeAOT-LLVM)."); + } + let export_files = vec![ ( - include_str!("../../../../templates/basic-cs/spacetimedb/StdbModule.csproj"), + csharp_csproj_for_target( + include_str!("../../../../templates/basic-cs/spacetimedb/StdbModule.csproj"), + dotnet_major, + )?, "StdbModule.csproj", ), ( - include_str!("../../../../templates/basic-cs/spacetimedb/Lib.cs"), + include_str!("../../../../templates/basic-cs/spacetimedb/Lib.cs").to_string(), "Lib.cs", ), - ( - include_str!("../../../../templates/basic-cs/spacetimedb/global.json"), - "global.json", - ), + (csharp_global_json(dotnet_major).to_string(), "global.json"), ]; - check_for_dotnet(); - check_for_git(); - for data_file in export_files { let path = project_path.join(data_file.1); create_directory(path.parent().unwrap())?; @@ -1788,9 +1885,55 @@ pub fn init_csharp_project(project_path: &Path) -> anyhow::Result<()> { Ok(()) } -/// Adds NativeAOT-LLVM package references 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<()> { +fn csharp_global_json(dotnet_major: u8) -> &'static str { + match dotnet_major { + 8 => "{\n \"sdk\": {\n \"version\": \"8.0.100\",\n \"rollForward\": \"latestFeature\"\n }\n}\n", + 10 => "{\n \"sdk\": {\n \"version\": \"10.0.100\",\n \"rollForward\": \"latestMinor\"\n }\n}\n", + _ => unreachable!("unsupported .NET version should have been validated before init"), + } +} + +fn csharp_csproj_for_target(content: &str, dotnet_major: u8) -> anyhow::Result { + let target_framework = match dotnet_major { + 8 => "net8.0", + 10 => "net10.0", + _ => unreachable!("unsupported .NET version should have been validated before init"), + }; + let target_framework_property = format!("{target_framework}"); + if content.contains("net8.0;net10.0") { + return Ok(content.replace( + "net8.0;net10.0", + &target_framework_property, + )); + } + + anyhow::bail!("Invalid C# template: missing net8.0/net10.0 TargetFrameworks property") +} + +fn configure_csharp_project_files(project_path: &Path, dotnet_major: u8) -> anyhow::Result<()> { + let global_json_path = project_path.join("global.json"); + std::fs::write(&global_json_path, csharp_global_json(dotnet_major))?; + + let csproj_path = project_path.join("StdbModule.csproj"); + let csproj = std::fs::read_to_string(&csproj_path)?; + std::fs::write(&csproj_path, csharp_csproj_for_target(&csproj, dotnet_major)?)?; + + Ok(()) +} + +/// Adds NativeAOT-LLVM project configuration to an existing C# .csproj file and creates NuGet.Config. +/// +/// The configuration differs depending on the target .NET version: +/// +/// **.NET 8 AOT** (`--native-aot`): Keeps `net8.0` TFM and adds explicit ILCompiler.LLVM 8.0.0-* +/// package references, gated on `EXPERIMENTAL_WASM_AOT=1`. +/// +/// **.NET 10 AOT**: Replaces the TFM with `net10.0` directly (no conditional needed since the +/// project is definitively targeting .NET 10). ILCompiler.LLVM refs are provided transitively +/// by the SpacetimeDB.Runtime NuGet package. +/// +/// Both paths need a NuGet.Config with the dotnet-experimental feed for ILCompiler.LLVM resolution. +fn add_native_aot_packages_to_csproj(project_path: &Path, dotnet_major: Option) -> 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()); @@ -1798,31 +1941,37 @@ fn add_native_aot_packages_to_csproj(project_path: &Path) -> anyhow::Result<()> let content = std::fs::read_to_string(&csproj_path)?; - // The NativeAOT-LLVM ItemGroup to add - let native_aot_item_group = r#" + let new_content = if dotnet_major == Some(8) { + // .NET 8 AOT: keep net8.0 TFM, add explicit ILCompiler.LLVM package references. + let native_aot_config = r#" - "#; - - // Insert the ItemGroup 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_item_group, after) + 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"); + } } else { - anyhow::bail!("Invalid .csproj file: missing tag"); + // .NET 10 AOT: directly set TFM to net10.0 (no conditional needed). + // ILCompiler.LLVM comes transitively via the SpacetimeDB.Runtime NuGet package. + content.replace( + "net8.0", + "net10.0", + ) }; std::fs::write(&csproj_path, new_content)?; println!( - "{} Added NativeAOT-LLVM package references to {}", + "{} Added NativeAOT-LLVM project configuration to {}", "✓".green(), csproj_path.display() ); - // Create NuGet.Config with the dotnet-experimental feed required for NativeAOT-LLVM packages + // Create NuGet.Config with the dotnet-experimental feed required for ILCompiler.LLVM packages let nuget_config_path = project_path.join("NuGet.Config"); let nuget_config_content = r#" @@ -1831,6 +1980,17 @@ fn add_native_aot_packages_to_csproj(project_path: &Path) -> anyhow::Result<()> + + + + + + + + + + + "#; @@ -1913,7 +2073,7 @@ pub async fn exec_init_rust(args: &ArgMatches) -> anyhow::Result<()> { pub async fn exec_init_csharp(args: &ArgMatches) -> anyhow::Result<()> { let project_path = args.get_one::("project-path").unwrap(); - init_csharp_project(project_path)?; + init_csharp_project(project_path, None)?; println!( "{}", @@ -2298,6 +2458,9 @@ bytes.workspace = true std::fs::write( &csproj, r#" + + true + @@ -2310,13 +2473,16 @@ bytes.workspace = true update_csproj_server_to_nuget(temp.path()).unwrap(); let content = std::fs::read_to_string(csproj).unwrap(); - let root = Element::parse(content.as_bytes()).unwrap(); + assert!(content + .contains("Condition=\"$(TargetFramework.StartsWith('net10.')) Or '$(EXPERIMENTAL_WASM_AOT)' == '1'\"")); + assert!(!content.contains("'")); + let root = xmltree::Element::parse(content.as_bytes()).unwrap(); let runtime_version = root.children.iter().find_map(|node| { - let XMLNode::Element(item_group) = node else { + let xmltree::XMLNode::Element(item_group) = node else { return None; }; item_group.children.iter().find_map(|node| { - let XMLNode::Element(package_ref) = node else { + let xmltree::XMLNode::Element(package_ref) = node else { return None; }; if package_ref.name == "PackageReference" @@ -2332,6 +2498,51 @@ bytes.workspace = true assert!(!content.contains("ProjectReference")); } + #[test] + fn test_parse_dotnet_sdk_major() { + assert_eq!(parse_dotnet_sdk_major("8.0.125 [/usr/lib64/dotnet/sdk]"), Some(8)); + assert_eq!(parse_dotnet_sdk_major("10.0.108 [/usr/lib64/dotnet/sdk]"), Some(10)); + assert_eq!(parse_dotnet_sdk_major("not-a-version [/usr/lib64/dotnet/sdk]"), None); + assert_eq!(parse_dotnet_sdk_major(""), None); + } + + #[test] + fn test_dotnet_init_default_is_8_on_macos_or_when_it_is_the_only_sdk_major() { + assert_eq!( + dotnet8_default_reason("macos", Some(&[8, 10])), + Some(Dotnet8DefaultReason::MacOsHost) + ); + assert_eq!( + dotnet8_default_reason("linux", Some(&[8])), + Some(Dotnet8DefaultReason::OnlySdkInstalled) + ); + assert_eq!(dotnet8_default_reason("linux", Some(&[])), None); + assert_eq!(dotnet8_default_reason("linux", Some(&[10])), None); + assert_eq!(dotnet8_default_reason("linux", Some(&[8, 10])), None); + assert_eq!(dotnet8_default_reason("linux", None), None); + } + + #[test] + fn test_csharp_global_json_matches_selected_target() { + assert!(csharp_global_json(8).contains("\"version\": \"8.0.100\"")); + assert!(csharp_global_json(8).contains("\"rollForward\": \"latestFeature\"")); + assert!(csharp_global_json(10).contains("\"version\": \"10.0.100\"")); + assert!(csharp_global_json(10).contains("\"rollForward\": \"latestMinor\"")); + } + + #[test] + fn test_csharp_csproj_for_target_uses_single_target_framework() { + let template = include_str!("../../../../templates/basic-cs/spacetimedb/StdbModule.csproj"); + + let net8 = csharp_csproj_for_target(template, 8).unwrap(); + assert!(net8.contains("net8.0")); + assert!(!net8.contains("net10.0")); + assert!(!net10.contains(" Result( 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); + let dotnet_version = command_config.get_one::("dotnet_version")?; // If the user didn't specify an identity and we didn't specify an anonymous identity, then // we want to use the default identity @@ -545,21 +553,17 @@ 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"); - } - } + let build_options = match dotnet_version.as_deref() { + Some(version) if build_options.is_empty() => format!("--dotnet-version {version}"), + Some(version) => format!("{build_options} --dotnet-version {version}"), + None => build_options, + }; build::exec_with_argstring( path_to_project .as_ref() .expect("path_to_project must exist when publishing from source"), &build_options, + native_aot, ) .await? }; diff --git a/crates/cli/src/tasks/csharp.rs b/crates/cli/src/tasks/csharp.rs index 5df8b730448..128c7f67347 100644 --- a/crates/cli/src/tasks/csharp.rs +++ b/crates/cli/src/tasks/csharp.rs @@ -1,14 +1,229 @@ use anyhow::Context; use itertools::Itertools; +use std::collections::HashSet; use std::ffi::OsString; use std::fs; use std::path::{Path, PathBuf}; -fn parse_major_version(version: &str) -> Option { +pub(crate) fn parse_major_version(version: &str) -> Option { version.split('.').next()?.parse::().ok() } -pub(crate) fn build_csharp(project_path: &Path, build_debug: bool) -> anyhow::Result { +enum OriginalGlobalJson { + Missing, + File(String), + Symlink(PathBuf), +} + +struct TemporaryGlobalJson { + path: PathBuf, + original: OriginalGlobalJson, +} + +impl Drop for TemporaryGlobalJson { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + match &self.original { + OriginalGlobalJson::Missing => {} + OriginalGlobalJson::File(content) => { + let _ = fs::write(&self.path, content); + } + OriginalGlobalJson::Symlink(target) => { + #[cfg(unix)] + let _ = std::os::unix::fs::symlink(target, &self.path); + #[cfg(windows)] + let _ = std::os::windows::fs::symlink_file(target, &self.path); + } + } + } +} + +fn dotnet_global_json(major: u8) -> anyhow::Result { + match major { + 8 => Ok(r#"{"sdk":{"version":"8.0.100","rollForward":"latestFeature"}}"#.to_string()), + 10 => Ok(r#"{"sdk":{"version":"10.0.100","rollForward":"latestMinor"}}"#.to_string()), + _ => anyhow::bail!("Unsupported .NET SDK version: {major}. SpacetimeDB requires .NET SDK 8.0 or 10.0."), + } +} + +fn temporarily_pin_project_sdk(project_path: &Path, major: u8) -> anyhow::Result { + let path = project_path.join("global.json"); + let original = match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() => OriginalGlobalJson::Symlink(fs::read_link(&path)?), + Ok(_) => OriginalGlobalJson::File(fs::read_to_string(&path)?), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => OriginalGlobalJson::Missing, + Err(error) => return Err(error.into()), + }; + + fs::write(&path, dotnet_global_json(major)?)?; + Ok(TemporaryGlobalJson { path, original }) +} + +#[derive(Debug)] +struct CsprojTargetFrameworks { + path: PathBuf, + majors: Vec, +} + +impl CsprojTargetFrameworks { + fn single_major(&self) -> Option { + (self.majors.len() == 1).then_some(self.majors[0]) + } +} + +/// Read the target framework major versions directly from the project's `.csproj` file. +/// Returns `net8.0`, `net10.0`, etc. from both `` and ``. +/// Multi-target projects do not imply a single selected SDK version; callers should use a +/// higher-priority signal or default policy in that case. +fn read_tfms_from_csproj(project_path: &Path) -> Option { + let entries: Vec<_> = match std::fs::read_dir(project_path) { + Ok(rd) => rd.filter_map(|e| e.ok()).collect(), + Err(e) => { + eprintln!("read_tfm: read_dir({}) failed: {e}", project_path.display()); + return None; + } + }; + let csproj_entry = entries + .iter() + .find(|e| e.path().extension().and_then(|x| x.to_str()) == Some("csproj")); + let csproj = match csproj_entry { + Some(e) => e.path(), + None => { + let names: Vec<_> = entries.iter().map(|e| e.file_name()).collect(); + eprintln!( + "read_tfm: no .csproj found in {}. Files: {:?}", + project_path.display(), + names + ); + return None; + } + }; + eprintln!("read_tfm: found csproj at {}", csproj.display()); + let content = match std::fs::read_to_string(&csproj) { + Ok(c) => c, + Err(e) => { + eprintln!("read_tfm: failed to read {}: {e}", csproj.display()); + return None; + } + }; + let mut has_target_frameworks = false; + let mut majors = Vec::new(); + for tag in ["TargetFramework", "TargetFrameworks"] { + let open = format!("<{tag}>"); + let close = format!(""); + let Some(start) = content.find(&open).map(|s| s + open.len()) else { + continue; + }; + let Some(end) = content[start..].find(&close).map(|e| start + e) else { + continue; + }; + + has_target_frameworks |= tag == "TargetFrameworks"; + for tfm in content[start..end].split(';') { + let Some(version) = tfm.trim().strip_prefix("net") else { + continue; + }; + if let Some(major) = version.split('.').next().and_then(|v| v.parse::().ok()) + && !majors.contains(&major) + { + majors.push(major); + } + } + } + + majors.sort(); + eprintln!("read_tfm: parsed majors={majors:?}, has_target_frameworks={has_target_frameworks}"); + Some(CsprojTargetFrameworks { path: csproj, majors }) +} + +fn find_global_json(start: &Path) -> Option { + start + .ancestors() + .map(|path| path.join("global.json")) + .find(|path| path.exists()) +} + +fn remove_project_assets(project_dir: &Path) { + for obj_dir in ["obj", "obj~"] { + let assets = project_dir.join(obj_dir).join("project.assets.json"); + if assets.exists() { + let _ = fs::remove_file(&assets); + } + } +} + +fn project_reference_paths(csproj: &Path) -> Vec { + let Ok(content) = fs::read_to_string(csproj) else { + return Vec::new(); + }; + let Some(project_dir) = csproj.parent() else { + return Vec::new(); + }; + + content + .split("').next()?; + let start = include.find("Include=\"")? + "Include=\"".len(); + let end = include[start..].find('"')? + start; + Some(project_dir.join(&include[start..end])) + }) + .collect() +} + +fn remove_project_assets_recursive(csproj: &Path, visited: &mut HashSet) { + let key = fs::canonicalize(csproj).unwrap_or_else(|_| csproj.to_path_buf()); + if !visited.insert(key) { + return; + } + + if let Some(project_dir) = csproj.parent() { + remove_project_assets(project_dir); + } + for reference in project_reference_paths(csproj) { + remove_project_assets_recursive(&reference, visited); + } +} + +fn collect_project_references_recursive(csproj: &Path, visited: &mut HashSet, references: &mut Vec) { + let key = fs::canonicalize(csproj).unwrap_or_else(|_| csproj.to_path_buf()); + if !visited.insert(key) { + return; + } + + for reference in project_reference_paths(csproj) { + references.push(reference.clone()); + collect_project_references_recursive(&reference, visited, references); + } +} + +fn targets_netstandard20(csproj: &Path) -> bool { + fs::read_to_string(csproj) + .map(|content| { + content.contains("netstandard2.0") + || content.contains("netstandard2.0") + }) + .unwrap_or(false) +} + +/// Describes which C# build path to use. +#[derive(Debug)] +enum CsharpBuildPath { + /// .NET 8 JIT via the `wasi-experimental` workload (Mono WASM). + Net8Jit, + /// .NET 8 NativeAOT-LLVM (opt-in via `--native-aot`). + Net8Aot, + /// .NET 10 NativeAOT-LLVM (auto-detected, only available path for .NET 10). + Net10Aot, +} + +pub(crate) fn build_csharp( + project_path: &Path, + build_debug: bool, + native_aot: bool, + dotnet_version_override: Option, +) -> anyhow::Result { // All `dotnet` commands must execute in the project directory, otherwise // global.json won't have any effect and wrong .NET SDK might be picked. macro_rules! dotnet { @@ -17,49 +232,166 @@ 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 native_aot_flag = native_aot; + + let project_global_json = find_global_json(project_path); + let cwd = std::env::current_dir()?; + let cwd_global_json = find_global_json(&cwd); + let dotnet_version_result = if project_global_json.is_some() { + Some(dotnet!("--version").read()) + } else if cwd_global_json.is_some() { + Some(duct::cmd!("dotnet", "--version").read()) + } else { + None + }; + let dotnet_version_str = match dotnet_version_result { + Some(Ok(v)) => Some(v), + Some(Err(error)) if error.kind() == std::io::ErrorKind::NotFound => { + anyhow::bail!("dotnet not found in PATH. Please install .NET SDK 8.0 or 10.0.") + } + Some(Err(error)) => anyhow::bail!("{error}"), + None => None, + }; + + // Resolution order: + // 1. --dotnet-version CLI flag (explicit user override) + // 2. global.json-selected SDK, if one applies to the project or current directory + // 3. Single in the project's .csproj + // 4. .NET 10 default for missing or multi-target project context + let csproj_tfms = read_tfms_from_csproj(project_path); + eprintln!( + "dotnet version detection: override={:?}, global_json={:?}, csproj_tfms={:?}, dotnet_version={:?}, project_path={}", + dotnet_version_override, + project_global_json.as_ref().or(cwd_global_json.as_ref()), + csproj_tfms, + dotnet_version_str, + project_path.display() + ); + let dotnet_major = dotnet_version_override + .or_else(|| dotnet_version_str.as_deref().and_then(parse_major_version)) + .or_else(|| csproj_tfms.as_ref().and_then(CsprojTargetFrameworks::single_major)) + .or(Some(10)); + + // Determine the build path based on SDK version and --native-aot flag. + let build_path = match (dotnet_major, native_aot_flag) { + // .NET 10: always use NativeAOT-LLVM, no flag needed. + (Some(10), _) => { + if native_aot_flag { + println!("Note: --native-aot is not needed with .NET 10 (NativeAOT-LLVM is used automatically)."); } + CsharpBuildPath::Net10Aot + } + // .NET 8 with --native-aot: use NativeAOT-LLVM with .NET 8 ILCompiler packages. + (Some(8), true) => CsharpBuildPath::Net8Aot, + // .NET 8 without flag: use the existing wasi-experimental JIT path. + (Some(8), false) => CsharpBuildPath::Net8Jit, + // Unsupported version. + _ => { + let selected_version = dotnet_major + .map(|v| v.to_string()) + .or_else(|| dotnet_version_str.clone()) + .unwrap_or_else(|| "".to_string()); + anyhow::bail!( + "Unsupported .NET SDK version: {}. SpacetimeDB requires .NET SDK 8.0 or 10.0.\n\ + If you have multiple versions installed, configure your project using \ + https://learn.microsoft.com/en-us/dotnet/core/tools/global-json, \ + or use --dotnet-version to specify the target version explicitly.", + selected_version + ); + } + }; - // 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 desired_sdk_major = match build_path { + CsharpBuildPath::Net8Jit | CsharpBuildPath::Net8Aot => 8, + CsharpBuildPath::Net10Aot => 10, }; + let active_sdk_major = dotnet_version_str.as_deref().and_then(parse_major_version); + let temporary_global_json = if active_sdk_major != Some(desired_sdk_major) { + let active = dotnet_version_str.as_deref().map(str::trim).unwrap_or(""); + let global_json = temporarily_pin_project_sdk(project_path, desired_sdk_major)?; + println!( + "Note: temporarily pinned {} to the .NET {desired_sdk_major} SDK (active SDK was .NET {active}).", + project_path.join("global.json").display() + ); + Some(global_json) + } else { + None + }; + + // Manage the EXPERIMENTAL_WASM_AOT environment variable for MSBuild. + // - Net8Aot / Net10Aot: must SET it — the ILCompiler.LLVM.targets import in + // SpacetimeDB.Runtime.targets is gated on this env var. Without it, the NativeAOT + // toolchain is not activated and dotnet produces managed DLLs instead of a .wasm. + // - Net8Jit: must UNSET it — prevents MSBuild from incorrectly enabling NativeAOT mode + // when the env var is set globally (e.g., in CI). + match &build_path { + CsharpBuildPath::Net8Aot | CsharpBuildPath::Net10Aot => { + // SAFETY: We are single-threaded at this point and no other code is reading + // this environment variable concurrently. + unsafe { + std::env::set_var("EXPERIMENTAL_WASM_AOT", "1"); + } + } + CsharpBuildPath::Net8Jit => { + // SAFETY: We are single-threaded at this point. + unsafe { + std::env::remove_var("EXPERIMENTAL_WASM_AOT"); + } + } + } + + // For the JIT path, ensure the wasi-experimental workload is installed. + if matches!(build_path, CsharpBuildPath::Net8Jit) { + // 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(_) => { + // 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) => anyhow::bail!("{error}"), + }; + } let config_name = if build_debug { "Debug" } else { "Release" }; + // For .NET 8 builds, force a re-restore by deleting any cached project.assets.json. + // If a prior `dotnet restore` ran without EXPERIMENTAL_WASM_AOT=1 (e.g. as part of a + // solution restore), the cached assets won't include ILCompiler.LLVM, causing + // `dotnet publish` to silently fall back to the net8.0 Mono wasi-experimental path. + // Conversely, if a prior restore ran with NativeAOT enabled, the JIT path can import + // ILCompiler targets from stale assets before restore has a chance to correct them. + // Deleting the file makes dotnet re-restore with the correct environment. + // + // Net10Aot does NOT need this: the ILCompiler.LLVM dependency is unconditional for + // net10.0 in the Runtime.csproj, so the solution-level restore already resolves it. + // Deleting the assets file here would force an implicit re-restore that uses the + // project's local NuGet.Config, which may have stale/invalid package source paths + // (e.g. Windows-only paths in CI on Linux), breaking the build. + if matches!(build_path, CsharpBuildPath::Net8Aot | CsharpBuildPath::Net8Jit) { + if let Some(csproj_path) = csproj_tfms.as_ref().map(|tfms| &tfms.path) { + remove_project_assets_recursive(csproj_path, &mut HashSet::new()); + } else { + remove_project_assets(project_path); + } + } + // Ensure the project path exists. fs::metadata(project_path).with_context(|| { format!( @@ -68,16 +400,72 @@ 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()?; - - // check if file exists - let subdir = if std::env::var_os("EXPERIMENTAL_WASM_AOT").is_some_and(|v| v == "1") { - "publish" - } else { - "AppBundle" + // Determine the target framework moniker and output subdirectory for this build path. + // Both JIT and AOT builds produce StdbModule.wasm, but in different subdirectories: + // - JIT (wasi-experimental): AppBundle/StdbModule.wasm + // - AOT (NativeAOT-LLVM): publish/StdbModule.wasm + let (target_framework, subdir) = match &build_path { + CsharpBuildPath::Net10Aot => ("net10.0", "native"), + CsharpBuildPath::Net8Aot => ("net8.0", "native"), + CsharpBuildPath::Net8Jit => ("net8.0", "AppBundle"), }; - // 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). + let target_frameworks_override = format!("TargetFrameworks={target_framework}"); + let csproj_file_name = csproj_tfms.as_ref().map(|tfms| { + tfms.path + .file_name() + .map(OsString::from) + .unwrap_or_else(|| tfms.path.as_os_str().to_os_string()) + }); + + if matches!(build_path, CsharpBuildPath::Net8Aot | CsharpBuildPath::Net8Jit) { + let mut restore_args: Vec = vec!["restore".into()]; + if let Some(csproj_file_name) = &csproj_file_name { + restore_args.push(csproj_file_name.clone()); + } + restore_args.extend(["--force".into(), format!("-p:{target_frameworks_override}").into()]); + duct::cmd("dotnet", restore_args).dir(project_path).run()?; + + if let Some(csproj_path) = csproj_tfms.as_ref().map(|tfms| &tfms.path) { + let mut references = Vec::new(); + collect_project_references_recursive(csproj_path, &mut HashSet::new(), &mut references); + for reference in references + .into_iter() + .unique_by(|path| fs::canonicalize(path).unwrap_or_else(|_| path.clone())) + .filter(|path| targets_netstandard20(path)) + { + let reference = fs::canonicalize(&reference).unwrap_or(reference); + duct::cmd("dotnet", ["restore".as_ref(), reference.as_os_str()]) + .dir(project_path) + .run()?; + } + } + } + + // JIT and AOT builds use the same `dotnet publish` command. + // Build-specific configuration (TFM, AOT settings, ILCompiler packages) + // is handled by build_path detection and MSBuild props/targets. + // We pass -f {target_framework} explicitly so that the correct TFM is used + // even when the system-default SDK version differs from the csproj TFM + // (e.g. system is .NET 10 but csproj says net8.0 → must publish as net8.0). + let mut publish_args: Vec = vec!["publish".into()]; + if let Some(csproj_file_name) = &csproj_file_name { + publish_args.push(csproj_file_name.clone()); + } + publish_args.extend([ + "-c".into(), + config_name.into(), + "-f".into(), + target_framework.into(), + "-v".into(), + "quiet".into(), + ]); + if matches!(build_path, CsharpBuildPath::Net8Aot | CsharpBuildPath::Net8Jit) { + publish_args.push("--no-restore".into()); + publish_args.push("--force".into()); + publish_args.push(format!("-p:{target_frameworks_override}").into()); + } + duct::cmd("dotnet", publish_args).dir(project_path).run()?; + // check for the old .NET 7 path for projects that haven't migrated yet let bad_output_paths = [ project_path.join(format!("bin/{config_name}/net7.0/StdbModule.wasm")), @@ -91,18 +479,75 @@ 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")), + // Standard publish output paths (JIT and some AOT builds) + 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" + )), + // NativeAOT-LLVM outputs to 'native' subdirectory instead of 'publish' + 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" + )), + // Also check for raw wasm output without wasi-wasm RID folder (NativeAOT-LLVM sometimes does this) + project_path.join(format!("bin/{config_name}/{target_framework}/native/StdbModule.wasm")), + project_path.join(format!("bin~/{config_name}/{target_framework}/native/StdbModule.wasm")), ]; - if possible_output_paths.iter().all(|p| p.exists()) { - anyhow::bail!(concat!( - "For some reason, your project has both a `bin` and a `bin~` folder.\n", - "I don't know which to use, so please delete both and rerun this command so that we can see which is up-to-date." - )); + // Check if both bin and bin~ variants exist for the same output path (indicates a conflict) + for i in (0..possible_output_paths.len()).step_by(2) { + if i + 1 < possible_output_paths.len() { + let bin_path = &possible_output_paths[i]; + let bin_tilde_path = &possible_output_paths[i + 1]; + if bin_path.exists() && bin_tilde_path.exists() { + anyhow::bail!(concat!( + "For some reason, your project has both a `bin` and a `bin~` folder.\n", + "I don't know which to use, so please delete both and rerun this command so that we can see which is up-to-date." + )); + } + } } - for output_path in possible_output_paths { + for output_path in &possible_output_paths { if output_path.exists() { - return Ok(output_path); + drop(temporary_global_json); + return Ok(output_path.clone()); + } + } + + // Diagnostic: list what we expected and what actually exists in the output directories + eprintln!("Build path: {build_path:?}, target_framework: {target_framework}, subdir: {subdir}"); + eprintln!("Expected output in one of:"); + for p in &possible_output_paths { + eprintln!(" {}", p.display()); + } + for bin_dir_name in ["bin", "bin~"] { + let bin_dir = project_path.join(bin_dir_name); + if bin_dir.exists() { + eprintln!("Contents of {}:", bin_dir.display()); + fn list_recursive(dir: &std::path::Path, depth: usize) { + if depth > 6 { + return; + } + if let Ok(entries) = std::fs::read_dir(dir) { + for entry in entries.flatten() { + let path = entry.path(); + let name = path.file_name().unwrap_or_default().to_string_lossy(); + eprintln!( + "{}{}{}", + " ".repeat(depth + 1), + name, + if path.is_dir() { "/" } else { "" } + ); + if path.is_dir() { + list_recursive(&path, depth + 1); + } + } + } + } + list_recursive(&bin_dir, 0); } } anyhow::bail!("Built project successfully but couldn't find the output file."); diff --git a/crates/cli/src/tasks/mod.rs b/crates/cli/src/tasks/mod.rs index 16414efbe97..6b357c2f704 100644 --- a/crates/cli/src/tasks/mod.rs +++ b/crates/cli/src/tasks/mod.rs @@ -15,6 +15,8 @@ pub fn build( lint_dir: Option<&Path>, build_debug: bool, features: Option<&std::ffi::OsString>, + native_aot: bool, + dotnet_version: Option, ) -> anyhow::Result<(PathBuf, &'static str)> { let lang = util::detect_module_language(project_path)?; if features.is_some() && lang != ModuleLanguage::Rust { @@ -22,7 +24,7 @@ pub fn build( } let output_path = match lang { ModuleLanguage::Rust => build_rust(project_path, features, lint_dir, build_debug), - ModuleLanguage::Csharp => build_csharp(project_path, build_debug), + ModuleLanguage::Csharp => build_csharp(project_path, build_debug, native_aot, dotnet_version), ModuleLanguage::Javascript => build_javascript(project_path, build_debug), ModuleLanguage::Cpp => build_cpp(project_path, build_debug), }?; diff --git a/crates/core/src/host/wasmtime/wasmtime_module.rs b/crates/core/src/host/wasmtime/wasmtime_module.rs index 1ec062ff1d5..25357dc876f 100644 --- a/crates/core/src/host/wasmtime/wasmtime_module.rs +++ b/crates/core/src/host/wasmtime/wasmtime_module.rs @@ -366,6 +366,18 @@ fn instantiate_wasmtime_instance( set_store_fuel(&mut store, FunctionBudget::DEFAULT_BUDGET.into()); store.set_epoch_deadline(EPOCH_TICKS_PER_SECOND); + // NativeAOT-LLVM modules are WASI reactors that export `_initialize` + // to set up the native runtime. This must be called before any other exports. + // Traditional .NET 8 WASI modules export `_start` instead (which is not called here). + if let Ok(init) = instance.get_typed_func::<(), ()>(&mut store, "_initialize") { + call_sync_typed_func(&init, &mut store, (), supports_async).map_err(|err| { + InitializationError::RuntimeError { + err, + func: "_initialize".to_owned(), + } + })?; + } + for preinit in &func_names.preinits { let func = instance.get_typed_func::<(), ()>(&mut store, preinit).unwrap(); call_sync_typed_func(&func, &mut store, (), supports_async).map_err(|err| { diff --git a/crates/guard/src/lib.rs b/crates/guard/src/lib.rs index af937910a54..eead324c936 100644 --- a/crates/guard/src/lib.rs +++ b/crates/guard/src/lib.rs @@ -42,7 +42,10 @@ fn target_dir() -> PathBuf { /// Returns the expected CLI binary path. fn cli_binary_path() -> PathBuf { - let profile = "release"; + // Use CARGO_BUILD_PROFILE if set, otherwise default to release for backwards compatibility + let profile = env::var("CARGO_BUILD_PROFILE") + .or_else(|_| env::var("PROFILE")) + .unwrap_or_else(|_| "release".to_string()); let cli_name = if cfg!(windows) { "spacetimedb-cli.exe" } else { diff --git a/crates/smoketests/src/csharp.rs b/crates/smoketests/src/csharp.rs index 5b832f4781f..e4b33adba70 100644 --- a/crates/smoketests/src/csharp.rs +++ b/crates/smoketests/src/csharp.rs @@ -172,12 +172,17 @@ pub(crate) fn prepare_csharp_module(module_path: &Path) -> Result<()> { + + + + + diff --git a/crates/smoketests/src/lib.rs b/crates/smoketests/src/lib.rs index 1fc9521dade..23b2810589b 100644 --- a/crates/smoketests/src/lib.rs +++ b/crates/smoketests/src/lib.rs @@ -1261,6 +1261,8 @@ impl Smoketest { "--non-interactive", "--lang", "csharp", + "--dotnet-version", + "10", "--project-path", module_root_str, module_name, @@ -1277,6 +1279,8 @@ impl Smoketest { &self.server_url, "--module-path", module_path_str, + "--dotnet-version", + "10", "--yes", ]; if clear { diff --git a/crates/smoketests/tests/smoketests/csharp_aot_module.rs b/crates/smoketests/tests/smoketests/csharp_aot_module.rs index 56df1100861..3824043bf1a 100644 --- a/crates/smoketests/tests/smoketests/csharp_aot_module.rs +++ b/crates/smoketests/tests/smoketests/csharp_aot_module.rs @@ -1,30 +1,54 @@ #![allow(clippy::disallowed_macros)] use spacetimedb_guard::ensure_binaries_built; -use spacetimedb_smoketests::{have_emscripten, require_dotnet, workspace_root}; +use spacetimedb_smoketests::{require_dotnet, workspace_root}; use std::process::Command; +/// Detect the major version of the active .NET SDK. +fn dotnet_major_version() -> Option { + Command::new("dotnet") + .arg("--version") + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| { + let v = String::from_utf8_lossy(&o.stdout); + v.trim().split('.').next()?.parse::().ok() + }) +} + /// Test NativeAOT-LLVM build path for C# modules. -/// Requires emscripten to be installed. -/// Only runs on Windows since runtime.linux-x64.Microsoft.DotNet.ILCompiler.LLVM -/// is not available on the dotnet-experimental NuGet feed. +/// +/// Platform support depends on the .NET SDK version: +/// - .NET 8 AOT: Windows-only (runtime.linux-x64.Microsoft.DotNet.ILCompiler.LLVM +/// 8.0.0-* was never published to the dotnet-experimental NuGet feed). +/// - .NET 10 AOT: Windows and Linux (both runtime packages are available). +/// +/// NativeAOT-LLVM targets WASI and uses WASI SDK (clang), not the wasi-experimental +/// workload or emscripten. WASI SDK is auto-downloaded by SpacetimeDB.Runtime.targets. +/// The user must set EXPERIMENTAL_WASM_AOT=1 to enable the AOT build path. #[test] fn test_build_csharp_module_aot() { require_dotnet!(); - // NativeAOT-LLVM is only available on Windows - if std::env::consts::OS != "windows" { - eprintln!("Skipping AOT test - NativeAOT-LLVM for .NET 8 only available on Windows"); + let major = dotnet_major_version(); + let target_framework = match major { + Some(v) if v >= 10 => "net10.0", + Some(8) => "net8.0", + _ => { + eprintln!("Skipping AOT test - unsupported .NET SDK version: {:?}", major); + return; + } + }; + + // .NET 8 ILCompiler.LLVM packages are only available for Windows. + // .NET 10+ ILCompiler.LLVM packages are available for Windows and Linux. + if target_framework == "net8.0" && std::env::consts::OS != "windows" { + eprintln!("Skipping .NET 8 AOT test - ILCompiler.LLVM 8.0.0-* only available on Windows"); return; } - - // Check for emscripten - fail with helpful message if not available - // Uses have_emscripten() which checks for both `emcc` and `emcc.bat` on Windows - if !have_emscripten() { - panic!( - "NativeAOT-LLVM test requires emscripten but it was not found.\n\ - Install from: https://emscripten.org/docs/getting_started/downloads.html\n\ - Or ensure `emcc` is in your PATH." - ); + if std::env::consts::OS != "windows" && std::env::consts::OS != "linux" { + eprintln!("Skipping AOT test - NativeAOT-LLVM only available on Windows and Linux"); + return; } let workspace = workspace_root(); @@ -40,6 +64,8 @@ fn test_build_csharp_module_aot() { cmd.arg("publish") .arg("-c") .arg("Release") + .arg("-f") + .arg(target_framework) .current_dir(workspace.join("modules/sdk-test-cs")) .env("EXPERIMENTAL_WASM_AOT", "1") .env("NUGET_PACKAGES", nuget_packages_dir.path()); @@ -57,7 +83,9 @@ fn test_build_csharp_module_aot() { // This ensures subsequent tests can clear NuGet locals without conflicts drop(nuget_packages_dir); - // Verify StdbModule.wasm was produced - let wasm_path = workspace.join("modules/sdk-test-cs/bin/Release/net8.0/wasi-wasm/publish/StdbModule.wasm"); + // Verify StdbModule.wasm was produced at the correct TFM-specific output path + let wasm_path = workspace.join(format!( + "modules/sdk-test-cs/bin/Release/{target_framework}/wasi-wasm/publish/StdbModule.wasm" + )); assert!(wasm_path.exists(), "StdbModule.wasm not found at {:?}", wasm_path); } diff --git a/crates/smoketests/tests/smoketests/csharp_module.rs b/crates/smoketests/tests/smoketests/csharp_module.rs index 6ad79e001be..b9fa3f1c663 100644 --- a/crates/smoketests/tests/smoketests/csharp_module.rs +++ b/crates/smoketests/tests/smoketests/csharp_module.rs @@ -39,15 +39,20 @@ fn test_build_csharp_module() { .expect("Failed to pack bindings"); assert!(status.success(), "Failed to pack C# bindings"); + // The non-AOT path is only supported on .NET 8. .NET 10 C# modules use NativeAOT-LLVM + // and are covered by csharp_aot_module.rs. + let dotnet_version = "8"; + // Create temp directory for the project let tmpdir = tempfile::tempdir().expect("Failed to create temp directory"); - // Initialize C# project let output = Command::new(&cli_path) .args([ "init", "--non-interactive", "--lang=csharp", + "--dotnet-version", + dotnet_version, "--project-path", tmpdir.path().to_str().unwrap(), "csharp-project", @@ -56,7 +61,8 @@ fn test_build_csharp_module() { .expect("Failed to run spacetime init"); assert!( output.status.success(), - "spacetime init failed:\nstdout: {}\nstderr: {}", + "spacetime init --dotnet-version {} failed:\nstdout: {}\nstderr: {}", + dotnet_version, String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ); @@ -68,6 +74,8 @@ fn test_build_csharp_module() { let packed_projects = ["BSATN.Runtime", "Runtime"]; let mut sources = String::from(" \n \n"); + // Add experimental NuGet feed for Microsoft.DotNet.ILCompiler.LLVM packages + sources.push_str(" \n"); let mut mappings = String::new(); for project in &packed_projects { @@ -83,6 +91,8 @@ fn test_build_csharp_module() { package_name, package_name )); } + // Add mappings for experimental packages + mappings.push_str(" \n \n \n \n"); // Add fallback for other packages mappings.push_str(" \n \n \n"); @@ -103,7 +113,7 @@ fn test_build_csharp_module() { // Run dotnet publish let output = Command::new("dotnet") - .args(["publish"]) + .args(["publish", "-f", "net8.0"]) .current_dir(&server_path) .output() .expect("Failed to run dotnet publish"); diff --git a/crates/smoketests/tests/smoketests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs index a6700f5ff06..db38f5a82e8 100644 --- a/crates/smoketests/tests/smoketests/quickstart.rs +++ b/crates/smoketests/tests/smoketests/quickstart.rs @@ -111,11 +111,24 @@ fn create_nuget_config(sources: &[(String, PathBuf)], mappings: &[(String, Strin source_lines.push_str(&format!(" \n", key, path.display())); } + // Group patterns by source while preserving source order (first seen first) + let mut patterns_by_source: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut source_order: Vec = Vec::new(); for (key, pattern) in mappings { - mapping_lines.push_str(&format!( - " \n \n \n", - key, pattern - )); + if !patterns_by_source.contains_key(key) { + source_order.push(key.clone()); + } + patterns_by_source.entry(key.clone()).or_default().push(pattern.clone()); + } + + // Write mappings in insertion order (ensures nuget.org with * comes last) + for key in source_order { + let patterns = patterns_by_source.get(&key).unwrap(); + mapping_lines.push_str(&format!(" \n", key)); + for pattern in patterns { + mapping_lines.push_str(&format!(" \n", pattern)); + } + mapping_lines.push_str(" \n"); } format!( @@ -229,6 +242,44 @@ fn override_nuget_package_from_project( mappings.push((package.to_string(), package.to_string())); } + // Ensure dotnet-experimental feed exists (needed for NativeAOT-LLVM ILCompiler packages) + if !sources.iter().any(|(k, _)| k == "dotnet-experimental") { + sources.push(( + "dotnet-experimental".to_string(), + PathBuf::from( + "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json", + ), + )); + } + if !mappings.iter().any(|(k, _)| k == "dotnet-experimental") { + mappings.push(( + "dotnet-experimental".to_string(), + "Microsoft.DotNet.ILCompiler.LLVM".to_string(), + )); + mappings.push(("dotnet-experimental".to_string(), "runtime.*".to_string())); + } + + // Add package source mappings for SpacetimeDB packages to local sources + // This must come BEFORE the nuget.org wildcard mapping to ensure local packages are used + let local_runtime_source = sources + .iter() + .find(|(k, _)| k.contains("runtime") || k.contains("Runtime")) + .map(|(k, _)| k.clone()); + if let Some(source_key) = local_runtime_source { + if !mappings + .iter() + .any(|(k, p)| k == &source_key && p == "SpacetimeDB.Runtime") + { + mappings.push((source_key.clone(), "SpacetimeDB.Runtime".to_string())); + } + if !mappings + .iter() + .any(|(k, p)| k == &source_key && p == "SpacetimeDB.BSATN.Runtime") + { + mappings.push((source_key, "SpacetimeDB.BSATN.Runtime".to_string())); + } + } + // Ensure nuget.org fallback exists if !sources.iter().any(|(k, _)| k == "nuget.org") { sources.push(( @@ -242,6 +293,7 @@ fn override_nuget_package_from_project( // Write config let config = create_nuget_config(&sources, &mappings); + eprintln!("Generated nuget.config at {:?}:\n{}", nuget_config_path, config); fs::write(&nuget_config_path, config)?; let _ = Command::new("dotnet") @@ -265,9 +317,16 @@ fn parse_nuget_config(content: &str) -> (Vec<(String, PathBuf)>, Vec<(String, St sources.push((cap[1].to_string(), PathBuf::from(&cap[2]))); } - let mapping_re = regex::Regex::new(r#"\s*(.*?)<\/packageSource>"#).unwrap(); + let pattern_re = regex::Regex::new(r#" String { /// Runs `spacetime init --template ` into a fresh temp directory. /// Returns `(tmpdir, project_path)` - caller must keep `tmpdir` alive. fn init_template(test: &Smoketest, template_id: &str) -> Result<(TempDir, PathBuf)> { + init_template_with_dotnet_version(test, template_id, None) +} + +/// Runs `spacetime init --template --dotnet-version ` into a fresh temp directory. +/// Returns `(tmpdir, project_path)` - caller must keep `tmpdir` alive. +fn init_template_with_dotnet_version( + test: &Smoketest, + template_id: &str, + dotnet_version: Option<&str>, +) -> Result<(TempDir, PathBuf)> { let tmpdir = tempfile::tempdir().context("Failed to create temp dir")?; let project_name = format!("test-{}", template_id); let project_path = tmpdir.path().join(&project_name); - test.spacetime(&[ + let mut init_args: Vec<&str> = vec![ "init", "--template", template_id, @@ -106,8 +117,13 @@ fn init_template(test: &Smoketest, template_id: &str) -> Result<(TempDir, PathBu project_path.to_str().unwrap(), "--non-interactive", &project_name, - ]) - .with_context(|| format!("spacetime init --template {} failed", template_id))?; + ]; + if let Some(dotnet_version) = dotnet_version { + init_args.extend(["--dotnet-version", dotnet_version]); + } + + test.spacetime(&init_args) + .with_context(|| format!("spacetime init --template {} failed", template_id))?; if !project_path.exists() { bail!("Project directory not created for template {}", template_id); @@ -116,6 +132,132 @@ fn init_template(test: &Smoketest, template_id: &str) -> Result<(TempDir, PathBu Ok((tmpdir, project_path)) } +fn fake_dotnet_path(dir: &Path, sdk_list_output: &str) -> Result { + let executable_name = if cfg!(windows) { "dotnet.exe" } else { "dotnet" }; + let dotnet_path = dir.join(executable_name); + let echo_lines = sdk_list_output + .lines() + .map(|line| format!("echo {line}")) + .collect::>() + .join(if cfg!(windows) { "\r\n" } else { "\n" }); + + if cfg!(windows) { + let source_path = dir.join("fake_dotnet.rs"); + fs::write( + &source_path, + format!( + r#"fn main() {{ + if std::env::args().nth(1).as_deref() == Some("--list-sdks") {{ + print!("{{}}", {sdk_list_output:?}); + return; + }} + + std::process::exit(1); +}} +"# + ), + ) + .with_context(|| format!("Failed to write fake dotnet source {:?}", source_path))?; + + let output = Command::new("rustc") + .arg(&source_path) + .arg("-o") + .arg(&dotnet_path) + .output() + .context("Failed to spawn rustc for fake dotnet")?; + if !output.status.success() { + bail!( + "rustc failed to compile fake dotnet:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } else { + fs::write( + &dotnet_path, + format!("#!/usr/bin/env sh\nif [ \"$1\" = \"--list-sdks\" ]; then\n{echo_lines}\nexit 0\nfi\nexit 1\n"), + ) + .with_context(|| format!("Failed to write fake dotnet executable {:?}", dotnet_path))?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut permissions = fs::metadata(&dotnet_path)?.permissions(); + permissions.set_mode(0o755); + fs::set_permissions(&dotnet_path, permissions)?; + } + } + + Ok(dotnet_path) +} + +fn init_basic_cs_with_fake_dotnet(sdk_list_output: &str) -> Result<(TempDir, PathBuf)> { + let tmpdir = tempfile::tempdir().context("Failed to create temp dir")?; + let fake_bin = tmpdir.path().join("bin"); + fs::create_dir(&fake_bin).context("Failed to create fake dotnet bin dir")?; + fake_dotnet_path(&fake_bin, sdk_list_output)?; + + let current_path = env::var_os("PATH").unwrap_or_default(); + let test_path = env::join_paths(std::iter::once(fake_bin).chain(env::split_paths(¤t_path))) + .context("Failed to build test PATH")?; + + let project_name = "test-basic-cs-default-dotnet"; + let project_path = tmpdir.path().join(project_name); + let config_path = tmpdir.path().join("config.toml"); + let output = Command::new(ensure_binaries_built()) + .arg("--config-path") + .arg(&config_path) + .args([ + "init", + "--template", + "basic-cs", + "--project-path", + project_path.to_str().unwrap(), + "--non-interactive", + project_name, + ]) + .env("PATH", test_path) + .current_dir(tmpdir.path()) + .output() + .context("Failed to execute spacetime init")?; + + if !output.status.success() { + bail!( + "spacetime init with fake dotnet failed:\nstdout: {}\nstderr: {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + + Ok((tmpdir, project_path)) +} + +fn assert_basic_cs_default_dotnet(sdk_list_output: &str, expected_major: u8) -> Result<()> { + let (_tmpdir, project_path) = init_basic_cs_with_fake_dotnet(sdk_list_output)?; + let server_path = project_path.join("spacetimedb"); + + let global_json = fs::read_to_string(server_path.join("global.json")).context("Failed to read global.json")?; + assert!( + global_json.contains(&format!("\"version\": \"{expected_major}.0.100\"")), + "global.json did not target .NET {expected_major}:\n{global_json}" + ); + + let csproj_path = find_csproj(&server_path)?; + let csproj = fs::read_to_string(&csproj_path).with_context(|| format!("Failed to read {:?}", csproj_path))?; + assert!( + csproj.contains(&format!("net{expected_major}.0")), + "{:?} did not target net{expected_major}.0:\n{csproj}", + csproj_path + ); + assert!( + !csproj.contains(""), + "{:?} should use a single TargetFramework after init:\n{csproj}", + csproj_path + ); + + Ok(()) +} + /// Updates a `[dependencies]` entry in a `Cargo.toml` to use a local path. fn update_cargo_toml_dependency(cargo_toml_path: &Path, package_name: &str, local_path: &Path) -> Result<()> { if !cargo_toml_path.exists() { @@ -642,43 +784,72 @@ fn setup_csharp_nuget(project_path: &Path) -> Result { clear_cached_nuget_package(package)?; } + let bindings = workspace_root().join("crates/bindings-csharp"); + let bsatn_runtime_output = bindings.join("BSATN.Runtime").join("bin").join("Release"); + let runtime_output = bindings.join("Runtime").join("bin").join("Release"); + let bsatn_codegen_output = bindings.join("BSATN.Codegen").join("bin").join("Release"); + let codegen_output = bindings.join("Codegen").join("bin").join("Release"); + let client_sdk = workspace_root().join("sdks/csharp"); + let client_sdk_output = client_sdk.join("bin~").join("Release"); + let nuget_config = project_path.join("nuget.config"); if !nuget_config.exists() { fs::write( &nuget_config, - r#" + format!( + r#" + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + "#, + normalize_dependency_path(&bsatn_runtime_output), + normalize_dependency_path(&runtime_output), + normalize_dependency_path(&bsatn_codegen_output), + normalize_dependency_path(&codegen_output), + normalize_dependency_path(&client_sdk_output), + ), ) .context("Failed to write nuget.config")?; } - let bindings = workspace_root().join("crates/bindings-csharp"); for pkg in &["BSATN.Runtime", "Runtime", "BSATN.Codegen", "Codegen"] { run_dotnet(&["pack", "-c", "Release"], &bindings.join(pkg))?; - let pkg_output = bindings.join(pkg).join("bin").join("Release"); - run_dotnet( - &[ - "nuget", - "add", - "source", - pkg_output.to_str().unwrap(), - "-n", - &format!("SpacetimeDB.{}", pkg), - "--configfile", - nuget_config.to_str().unwrap(), - ], - project_path, - )?; } // Pack and register the client SDK (needed by client templates). - let client_sdk = workspace_root().join("sdks/csharp"); let client_sdk_proj = client_sdk.join("SpacetimeDB.ClientSDK.csproj"); run_dotnet( &[ @@ -691,20 +862,6 @@ fn setup_csharp_nuget(project_path: &Path) -> Result { ], project_path, )?; - let client_sdk_output = client_sdk.join("bin~").join("Release"); - run_dotnet( - &[ - "nuget", - "add", - "source", - client_sdk_output.to_str().unwrap(), - "-n", - "SpacetimeDB.ClientSDK", - "--configfile", - nuget_config.to_str().unwrap(), - ], - project_path, - )?; Ok(nuget_config) } @@ -789,7 +946,21 @@ fn test_typescript_template(test: &Smoketest, template: &Template, project_path: } /// Publishes a C# server module and verifies the C# client builds. -fn test_csharp_template(test: &Smoketest, template: &Template, project_path: &Path) -> Result<()> { +fn test_csharp_template(test: &Smoketest, template: &Template) -> Result<()> { + for dotnet_version in ["8", "10"] { + let (_tmpdir, project_path) = init_template_with_dotnet_version(test, &template.id, Some(dotnet_version))?; + test_csharp_template_for_dotnet_version(test, template, &project_path, dotnet_version)?; + } + + Ok(()) +} + +fn test_csharp_template_for_dotnet_version( + test: &Smoketest, + template: &Template, + project_path: &Path, + dotnet_version: &str, +) -> Result<()> { // Use one nuget.config at the project root, shared between server and client. setup_csharp_nuget(project_path)?; @@ -797,16 +968,18 @@ fn test_csharp_template(test: &Smoketest, template: &Template, project_path: &Pa pin_csharp_server_runtime_package_version(&server_path)?; // Copy nuget.config into the server directory so `spacetime publish` (which runs // `dotnet publish` from the server dir) can find the local package sources. + // Overwrite any template-provided NuGet.Config; .NET 10 templates include one + // for the experimental feed, but this test needs the local packed packages too. let root_nuget = project_path.join("nuget.config"); let server_nuget = server_path.join("nuget.config"); - if root_nuget.exists() && !server_nuget.exists() { + if root_nuget.exists() { fs::copy(&root_nuget, &server_nuget).context("Failed to copy nuget.config to server dir")?; } // Debug package resolution to diagnose CI/local NuGet source/version drift. debug_log_csharp_packages(&server_path); - let domain = format!("test-{}-{}", template.id, random_string()); + let domain = format!("test-{}-net{}-{}", template.id, dotnet_version, random_string()); test.spacetime(&[ "publish", "--server", @@ -814,9 +987,16 @@ fn test_csharp_template(test: &Smoketest, template: &Template, project_path: &Pa "--yes", "--module-path", server_path.to_str().unwrap(), + "--dotnet-version", + dotnet_version, &domain, ]) - .with_context(|| format!("spacetime publish failed for C# server in template {}", template.id))?; + .with_context(|| { + format!( + "spacetime publish failed for C# server in template {} with .NET {}", + template.id, dotnet_version + ) + })?; let _ = test.spacetime(&["delete", "--server", &test.server_url, "--yes", &domain]); if template.client_lang.as_deref() == Some("csharp") { @@ -830,12 +1010,21 @@ fn test_csharp_template(test: &Smoketest, template: &Template, project_path: &Pa fn test_template(test: &Smoketest, template: &Template) -> Result<()> { eprintln!("[TEMPLATES] Testing template: {}", template.id); + // While we support both .NET 8 and .NET 10, C# templates need a fresh init per + // target so the generated global.json and single TargetFramework match the + // publish target. Once .NET 8 is dropped, this can move back into the match + // below and share the generic init path again. + if template.server_lang.as_deref() == Some("csharp") { + test_csharp_template(test, template)?; + return Ok(()); + } + let (_tmpdir, project_path) = init_template(test, &template.id)?; match template.server_lang.as_deref() { Some("rust") => test_rust_template(test, template, &project_path)?, Some("typescript") => test_typescript_template(test, template, &project_path)?, - Some("csharp") => test_csharp_template(test, template, &project_path)?, + Some("csharp") => unreachable!("C# templates are handled before generic template init"), Some(other) => { eprintln!( "[TEMPLATES] Skipping template {} with unsupported server language: {}", @@ -892,6 +1081,16 @@ fn test_basic_template_dependency_versions() -> Result<()> { Ok(()) } +#[test] +fn test_basic_cs_init_default_dotnet_selection() -> Result<()> { + assert_basic_cs_default_dotnet("8.0.416 [/usr/share/dotnet/sdk]", 8)?; + assert_basic_cs_default_dotnet("10.0.100 [/usr/share/dotnet/sdk]", 10)?; + assert_basic_cs_default_dotnet("8.0.416 [/usr/share/dotnet/sdk]\n10.0.100 [/usr/share/dotnet/sdk]", 10)?; + assert_basic_cs_default_dotnet("", 10)?; + + Ok(()) +} + /// Tests all templates discovered in the `templates/` directory. /// /// For each template the test: diff --git a/crates/testing/src/modules.rs b/crates/testing/src/modules.rs index b03b87df9b5..cb44d4903e8 100644 --- a/crates/testing/src/modules.rs +++ b/crates/testing/src/modules.rs @@ -188,6 +188,8 @@ impl CompiledModule { Some(PathBuf::from("src")).as_deref(), mode == CompilationMode::Debug, None, + false, + None, ) .expect("Module compilation failed"); Self { 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 e038add1874..307d3596ff9 100644 --- a/demo/Blackholio/server-csharp/StdbModule.csproj +++ b/demo/Blackholio/server-csharp/StdbModule.csproj @@ -1,19 +1,34 @@ - net8.0 - + net8.0;net10.0 wasi-wasm enable enable + + https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json;https://api.nuget.org/v3/index.json;$(RestoreAdditionalProjectSources) $(NoWarn);CS8981;IDE1006 + + true + true + false + + https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-experimental/nuget/v3/index.json;https://api.nuget.org/v3/index.json;$(RestoreAdditionalProjectSources) + + + + + + + + + + + diff --git a/docs/docs/00100-intro/00200-quickstarts/00600-c-sharp.md b/docs/docs/00100-intro/00200-quickstarts/00600-c-sharp.md index 314e9569f8d..6d978eabf78 100644 --- a/docs/docs/00100-intro/00200-quickstarts/00600-c-sharp.md +++ b/docs/docs/00100-intro/00200-quickstarts/00600-c-sharp.md @@ -7,19 +7,124 @@ hide_table_of_contents: true import { InstallCardLink } from "@site/src/components/InstallCardLink"; import { StepByStep, Step, StepText, StepCode } from "@site/src/components/Steps"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; Get a SpacetimeDB C# app running in under 5 minutes. ## Prerequisites -- [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) installed -- [SpacetimeDB CLI](https://spacetimedb.com/install) installed +- [.NET 10 SDK](https://dotnet.microsoft.com/download/dotnet/10.0) installed ([.NET 8](https://dotnet.microsoft.com/download/dotnet/8.0) is supported but will be deprecated soon). Please note that target .NET 10 is currently unsupported on Mac. +- [SpacetimeDB CLI](https://spacetimedb.com/install) installed. --- + + + + + + Run the `spacetime dev` command to create a new project with a C# SpacetimeDB module. + + This will start the local SpacetimeDB server, compile and publish your module, and generate C# client bindings. + + +```bash +spacetime dev --template basic-cs +``` + + + + + + Your project contains both server and client code. + + Edit `spacetimedb/Lib.cs` to add tables and reducers. Use the generated bindings in the client project. + + +``` +my-spacetime-app/ +├── spacetimedb/ # Your SpacetimeDB module +│ ├── StdbModule.csproj +│ └── Lib.cs # Server-side logic +├── client.csproj +├── Program.cs # Client application +└── module_bindings/ # Auto-generated types +``` + + + + + + Open `spacetimedb/Lib.cs` to see the module code. The template includes a `Person` table and two reducers: `Add` to insert a person, and `SayHello` to greet everyone. + + Tables store your data. Reducers are functions that modify data — they're the only way to write to the database. + + +```csharp +using SpacetimeDB; + +public static partial class Module +{ + [SpacetimeDB.Table(Accessor = "Person", Public = true)] + public partial struct Person + { + public string Name; + } + + [SpacetimeDB.Reducer] + public static void Add(ReducerContext ctx, string name) + { + ctx.Db.Person.Insert(new Person { Name = name }); + } + + [SpacetimeDB.Reducer] + public static void SayHello(ReducerContext ctx) + { + foreach (var person in ctx.Db.Person.Iter()) + { + Log.Info($"Hello, {person.Name}!"); + } + Log.Info("Hello, World!"); + } +} +``` + + + + + + Open a new terminal and navigate to your project directory. Then use the SpacetimeDB CLI to call reducers and query your data directly. + + +```bash +cd my-spacetime-app + +# Call the add reducer to insert a person +spacetime call add Alice + +# Query the person table +spacetime sql "SELECT * FROM Person" + name +--------- + "Alice" + +# Call say_hello to greet everyone +spacetime call say_hello + +# View the module logs +spacetime logs +2025-01-13T12:00:00.000000Z INFO: Hello, Alice! +2025-01-13T12:00:00.000000Z INFO: Hello, World! +``` + + + + + @@ -130,6 +235,8 @@ spacetime logs + + ## Next steps diff --git a/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md b/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md index 8bf81f5b1c1..9668483854d 100644 --- a/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md +++ b/docs/docs/00100-intro/00300-tutorials/00100-chat-app.md @@ -68,20 +68,14 @@ No additional installation needed - Node.js/npm will handle dependencies. -Next we need to [install .NET 8 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/8.0) so that we can build and publish our module. +Next we need to [install .NET 10 SDK](https://dotnet.microsoft.com/en-us/download/dotnet/10.0) so that we can build and publish our module. -You may already have .NET 8 installed: +You may already have .NET 10 installed: ```bash dotnet --list-sdks ``` -.NET 8.0 is the earliest to have the `wasi-experimental` workload that we rely on, but requires manual activation: - -```bash -dotnet workload install wasi-experimental -``` - 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 319a8288108..b4748e0285f 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 @@ -131,6 +131,7 @@ Run `spacetime help publish` for more detailed information. * `--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) +* `--dotnet-version ` — Target .NET SDK major version for C# projects (e.g. 8 or 10). Auto-detected when omitted. @@ -264,6 +265,7 @@ Start development mode with auto-regenerate client module bindings, auto-rebuild Possible values: `always`, `on-conflict`, `never` * `-t`, `--template