Files
Ryan 484f3b579e Add .NET 10 support via NativeAOT-LLVM along side .NET 8 support (#4915)
# Description of changes

Builds on #4741 to clean up and refine the NativeAOT-LLVM integration.
The major changes:

- **Standardized the repo on .NET 10 SDK** (`global.json` → `10.0.100`).
The team is migrating to .NET 10 ahead of .NET 8 EOL. End users are
unaffected as their SDK version is controlled by their own `global.json`
emitted by `spacetime init`.

- **Multi-target NuGet packages** instead of conditional single-target:
  - `SpacetimeDB.Runtime` now unconditionally targets `net8.0;net10.0`
- `SpacetimeDB.BSATN.Runtime` now unconditionally targets
`netstandard2.1;net8.0;net10.0`
- NuGet packages ship both TFMs in a single `.nupkg`. No
`EXPERIMENTAL_WASM_AOT` env var needed at pack time
- `EXPERIMENTAL_WASM_AOT` define constant is set automatically when
`TargetFramework == net10.0`
- `ILCompiler.LLVM` package references conditioned on `TargetFramework
== net10.0` (not env var)

- **Centralized NativeAOT-LLVM build logic** in
`SpacetimeDB.Runtime.props` and `SpacetimeDB.Runtime.targets`:
- Moved `PublishTrimmed`, `SelfContained`, `WasmEnableThreads` into
`.props` (previously duplicated across 4+ `.csproj` files)
- Moved the `.wit`-stripping `UseWasiRuntimeOverlayWithoutComponentWit`
target into `.targets` (was duplicated in every server `.csproj`)
- Added `IlcLlvmTarget=wasm32-unknown-wasip1` override after
ILCompiler.LLVM.targets import (previously passed as `/p:` arg)
- Added `_InitializeWasiSdk` to `ObtainWasiSdk` BeforeTargets for .NET
10 compatibility
  - Consumer `.csproj` files no longer need any AOT boilerplate

- **Refactored `csharp.rs` build logic** to support three build paths
via `CsharpBuildPath` enum:
- `Net8Jit` : stable .NET 8 path using `wasi-experimental` workload
(Mono WASM)
- `Net8Aot` : .NET 8 NativeAOT-LLVM (opt-in via `--native-aot` or
`EXPERIMENTAL_WASM_AOT=1`)
- `Net10Aot` : .NET 10 NativeAOT-LLVM (auto-detected when .NET 10 SDK is
active)
- Auto-detects .NET SDK version from `dotnet --version` (respects
`global.json`)
- Automatically sets `EXPERIMENTAL_WASM_AOT=1` for AOT paths so MSBuild
conditionals activate
- Unified `dotnet publish` command for all paths. Build-specific config
handled by props/targets
- Removed redundant `/p:IlcLlvmTarget` and `/p:WasmEnableThreads` CLI
args (now in `.targets`)

- **Refactored `init.rs`** for .NET version-aware scaffolding:
  - Added `--dotnet-version` CLI arg to explicitly select .NET 8 or 10
- Added `resolve_dotnet_major()` which auto-detects or prompts
interactively when multiple SDKs are installed
  - .NET 10 auto-enables NativeAOT-LLVM (`--native-aot` not required)
- .NET 10 template emits `<TargetFramework>net10.0</TargetFramework>`
directly (no conditional)
- .NET 8 AOT template adds `ILCompiler.LLVM 8.0.0-*` package refs gated
on `EXPERIMENTAL_WASM_AOT=1`
- Emits correct `global.json` per SDK version (`8.0.100` or `10.0.100`)

- **Added `_initialize` call in `wasmtime_module.rs`**: NativeAOT-LLVM
modules are WASI reactors that export `_initialize` to bootstrap the
native runtime. This is called before preinit functions. Traditional
.NET 8 WASI modules export `_start` instead and are unaffected.

- **Updated `FFI.cs` `WasmImportLinkageAttribute` guard** to `#if
EXPERIMENTAL_WASM_AOT && NET10_0_OR_GREATER`, ensuring the real
attribute is only used when both the AOT flag and .NET 10+ TFM are
active. The dummy shim is used for all other builds.

- **Updated `csharp_aot_module.rs` smoketest**:
  - Auto-detects .NET SDK version and adjusts TFM accordingly
- .NET 8 AOT: Windows-only (ILCompiler.LLVM 8.0.0-* not published for
Linux)
  - .NET 10 AOT: Windows and Linux
- Removed emscripten dependency (NativeAOT-LLVM uses WASI SDK, not
emscripten)

- **Updated CLI reference docs** to reflect `--native-aot` behavior
change (not needed for .NET 10) and new `--dotnet-version` arg.

This PR addresses several issues from #4741 review:

1. **NativeAOT-LLVM build logic was duplicated** in every server
`.csproj` (4+ files had identical `PropertyGroup` and `Target` blocks).
This is now centralized in the shipped `.props`/`.targets`.

2. **NuGet packages only shipped a single TFM**. The env var controlled
which TFM was built, meaning you needed `EXPERIMENTAL_WASM_AOT=1` at
pack time to get the `net10.0` DLL. Now both TFMs are always included.

3. **`spacetime init` emitted unnecessary conditional wrappers** in the
generated `.csproj` for .NET 10 projects. Since the project is
definitively targeting .NET 10, the TFM should be unconditional.

4. **`spacetime publish` passed MSBuild properties via `/p:` args** that
are now handled by centralized `.targets`, simplifying the CLI code.

# API and ABI breaking changes

- `global.json` now requires .NET 10 SDK for repo development. All
developers and CI agents need .NET SDK 10.0+ installed.
- End user module projects are **not affected**. They continue to use
whichever SDK their local `global.json` specifies.
- .NET 8 JIT and .NET 8 AOT paths remain functional for end users until
.NET 8 EOL.

# Expected complexity level and risk

2 - The core risk is the same as #4741: future changes in the upstream
NativeAOT-LLVM/WASI pipeline. Centralizing the build logic in
`.props`/`.targets` reduces the surface area for breaking changes (one
place to update vs. N consumer projects). The `.wit`-stripping
workaround remains necessary until
[dotnet/runtimelab#3144](https://github.com/dotnet/runtimelab/issues/3144)
is resolved.

# Testing

- Ran C# module publish + client connection tests for all three build
paths:
  - .NET 8 JIT (`spacetime publish` without `--native-aot`)
- .NET 8 AOT (`spacetime publish` with `EXPERIMENTAL_WASM_AOT=1` and
.NET 8 SDK)
  - .NET 10 AOT (`spacetime publish` with .NET 10 SDK, auto-detected)
- Verified `spacetime init --lang csharp` generates correct project
structure for both .NET 8 and .NET 10
- Verified `spacetime init --lang csharp --dotnet-version 10` emits
`net10.0` TFM and `10.0.100` in `global.json`
- Verified C# regression tests complete successfully when ran under .NET
8 JIT, .NET 8 AOT, .NET 10 AOT
- This required making local customizations to the repo versions of the
regression tests, in order to conform to the configuration output by
`spacetime init`, but the tests themselves remained identical.
- Verified NuGet packages contain both `lib/net8.0/` and `lib/net10.0/`
DLLs
- Updated smoketest `test_build_csharp_module_aot` to work with both
.NET 8 and .NET 10 SDK

---------

Signed-off-by: Ryan <r.ekhoff@clockworklabs.io>
Co-authored-by: Jason Larabie <jason@clockworklabs.io>
Co-authored-by: John Detter <4099508+jdetter@users.noreply.github.com>
Co-authored-by: Lisandro Crespo <lisandroct@gmail.com>
Co-authored-by: Zeke Foppa <196249+bfops@users.noreply.github.com>
2026-07-17 17:43:39 +00:00

118 lines
3.8 KiB
C#

namespace SpacetimeDB;
using System;
using System.Diagnostics.CodeAnalysis;
#pragma warning disable STDB_UNSTABLE
public abstract class HandlerContextBase
{
public Random Rng => txState.Rng;
public Timestamp Timestamp => txState.Timestamp;
// NOTE: The host rejects procedure HTTP requests while a mut transaction is open
// (WOULD_BLOCK_TRANSACTION). Avoid calling `Http.*` inside WithTx.
public HttpClient Http { get; } = new();
// **Note:** must be 0..=u32::MAX
protected int CounterUuid = 0;
private readonly TransactionalContextState<HandlerTxContextBase> txState;
protected HandlerContextBase(Random random, Timestamp time)
{
txState = new(
random,
time,
timestamp => new Internal.TxContext(
CreateLocal(),
default,
null,
timestamp,
AuthCtx.BuildFromSystemTables(null, default),
random
),
inner => CreateTxContext(inner)
);
}
protected abstract HandlerTxContextBase CreateTxContext(Internal.TxContext inner);
protected abstract LocalBase CreateLocal();
public Internal.TxContext EnterTxContext(long timestampMicros) =>
txState.EnterTxContext(timestampMicros);
public void ExitTxContext() => txState.ExitTxContext();
public readonly struct TxOutcome<TResult>(bool isSuccess, TResult? value, Exception? error)
{
public bool IsSuccess { get; } = isSuccess;
public TResult? Value { get; } = value;
public Exception? Error { get; } = error;
public static TxOutcome<TResult> Success(TResult value) => new(true, value, null);
public static TxOutcome<TResult> Failure(Exception error) => new(false, default, error);
public TResult UnwrapOrThrow() =>
IsSuccess
? Value!
: throw (
Error
?? new InvalidOperationException("Transaction failed without an error object.")
);
}
[Experimental("STDB_UNSTABLE")]
public TResult WithTx<TResult>(Func<HandlerTxContextBase, TResult> body) =>
txState.WithTx(body);
[Experimental("STDB_UNSTABLE")]
public TxOutcome<TResult> TryWithTx<TResult, TError>(
Func<HandlerTxContextBase, Result<TResult, TError>> body
)
where TError : Exception
{
var outcome = txState.TryWithTx(body);
return outcome.IsSuccess
? TxOutcome<TResult>.Success(outcome.Value!)
: TxOutcome<TResult>.Failure(
outcome.Error
?? new InvalidOperationException("Transaction failed without an error object.")
);
}
}
public abstract class HandlerTxContextBase(Internal.TxContext inner) : IRefreshableTxContext
{
internal Internal.TxContext Inner { get; private set; } = inner;
internal void Refresh(Internal.TxContext inner) => Inner = inner;
void IRefreshableTxContext.Refresh(Internal.TxContext inner) => Refresh(inner);
public LocalBase Db => (LocalBase)Inner.Db;
public Timestamp Timestamp => Inner.Timestamp;
public Random Rng => Inner.Rng;
}
internal sealed partial class RuntimeHandlerContext(Random random, Timestamp timestamp)
: HandlerContextBase(random, timestamp)
{
private readonly RuntimeLocal _db = new();
protected override LocalBase CreateLocal() => _db;
protected override HandlerTxContextBase CreateTxContext(Internal.TxContext inner) =>
_cached ??= new RuntimeHandlerTxContext(inner);
private RuntimeHandlerTxContext? _cached;
}
internal sealed class RuntimeHandlerTxContext : HandlerTxContextBase
{
internal RuntimeHandlerTxContext(Internal.TxContext inner)
: base(inner) { }
public new RuntimeLocal Db => (RuntimeLocal)base.Db;
}
#pragma warning restore STDB_UNSTABLE