diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47cb20b6893..db5234db17b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,6 +95,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 @@ -104,6 +124,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 @@ -124,9 +167,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 @@ -237,6 +285,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="$( @@ -964,7 +1031,57 @@ 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.400","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" + + # Ensure global.json files exist for regression tests (symlinks may not work in CI) + - 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 + if [ ! -f "$dir/global.json" ]; then + echo "Creating $dir/global.json" + echo '{"sdk":{"version":"10.0.100","rollForward":"latestMinor"}}' > "$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 @@ -1059,6 +1176,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/Cargo.lock b/Cargo.lock index efc58dc007b..9eafc9ba891 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8748,6 +8748,7 @@ dependencies = [ "spacetimedb-client-api-messages", "spacetimedb-core", "spacetimedb-data-structures", + "spacetimedb-guard", "spacetimedb-lib", "spacetimedb-paths", "spacetimedb-schema", diff --git a/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj b/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj index a2bb9f49952..afea4eb8500 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/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/Internal/FFI.cs b/crates/bindings-csharp/Runtime/Internal/FFI.cs index 261233303dc..3fe39825483 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 @@ -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, @@ -352,12 +375,15 @@ internal static class ConsoleTimerIdMarshaller } } + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial ConsoleTimerId console_timer_start([In] byte[] name, uint name_len); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial CheckedStatus console_timer_end(ConsoleTimerId stopwatch_id); + [WasmImportLinkage] [LibraryImport(StdbNamespace10_0)] public static partial void volatile_nonatomic_schedule_immediate( [In] byte[] name, @@ -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/Runtime.csproj b/crates/bindings-csharp/Runtime/Runtime.csproj index 0d7e22edbf5..ac9038f0f98 100644 --- a/crates/bindings-csharp/Runtime/Runtime.csproj +++ b/crates/bindings-csharp/Runtime/Runtime.csproj @@ -8,11 +8,15 @@ - 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 @@ -27,10 +31,9 @@ - - - - + + + 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..bf71937a161 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)' == 'net10.0' or $(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 - + 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..65568456650 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)' == 'net10.0' or $(TargetFramework.StartsWith('net10.'))">true + <_UseNativeAotLlvm Condition="'$(EXPERIMENTAL_WASM_AOT)' == '1'">true + + + + + + wasm32-unknown-wasip1 + false + false + + + @@ -46,21 +62,53 @@ + + + - + + <_WasmNativeFileForLinking Include="@(NativeFileReference)" /> + + + + <_OriginalIlcSdkPath>$(IlcSdkPath) + <_WasiRuntimeOverlayDir>$(IntermediateOutputPath)native-hidden-no-wit\ + + + + <_WasiRuntimeOverlaySource Include="$(IlcFrameworkNativePath)**\*" Exclude="$(IlcFrameworkNativePath)**\*.wit" /> + + + + + + + + $(_WasiRuntimeOverlayDir) + $(_OriginalIlcSdkPath) + + + - + + - 24 + 29 $([System.IO.Path]::Combine($(IntermediateOutputPath), "wasi-sdk.$(WasiSdkVersion).tar.gz")) @@ -73,23 +121,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/bindings-typescript/case-conversion-test-client/src/index.ts b/crates/bindings-typescript/case-conversion-test-client/src/index.ts index 8f7567e312f..07cf69d44ca 100644 --- a/crates/bindings-typescript/case-conversion-test-client/src/index.ts +++ b/crates/bindings-typescript/case-conversion-test-client/src/index.ts @@ -19,7 +19,10 @@ import { DbConnection, tables } from './module_bindings/index.js'; -const LOCALHOST = 'http://localhost:3000'; +const SERVER_URL = process.env.SPACETIME_SDK_TEST_SERVER_URL; +if (!SERVER_URL) { + throw new Error('Missing SPACETIME_SDK_TEST_SERVER_URL'); +} function dbNameOrPanic(): string { const name = process.env.SPACETIME_SDK_TEST_DB_NAME; @@ -53,7 +56,7 @@ function connectThen(callback: (db: DbConnection) => void): Promise { return new Promise((resolve, reject) => { const conn = DbConnection.builder() .withDatabaseName(name) - .withUri(LOCALHOST) + .withUri(SERVER_URL) .onConnect((ctx, _identity, _token) => { try { callback(ctx); diff --git a/crates/cli/src/subcommands/build.rs b/crates/cli/src/subcommands/build.rs index 5ef4cea7300..d62ff1fa877 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,18 @@ 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 = args.get_one::("dotnet_version"); + + // Set dotnet version env var if explicitly specified + if let Some(version) = dotnet_version { + // SAFETY: We are single-threaded at this point and no other code is reading + // this environment variable concurrently. + unsafe { + std::env::set_var("SPACETIMEDB_DOTNET_VERSION", version); + } + } - run_build(module_path, lint_dir, build_debug, features) + run_build(module_path, lint_dir, build_debug, features, false) } pub fn run_build( @@ -69,6 +85,7 @@ pub fn run_build( lint_dir: Option, build_debug: bool, features: Option, + native_aot: bool, ) -> 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 +102,13 @@ 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, + )?; println!("Build finished successfully."); Ok(result) @@ -94,6 +117,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); @@ -111,7 +135,7 @@ pub async fn exec_with_argstring( }; let build_debug = arg_matches.get_flag("debug"); - run_build(module_path, lint_dir, build_debug, features) + run_build(module_path, lint_dir, build_debug, features, native_aot) } 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..336ce552e90 100644 --- a/crates/cli/src/subcommands/dev.rs +++ b/crates/cli/src/subcommands/dev.rs @@ -1011,7 +1011,7 @@ async fn generate_build_and_publish( ) -> 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")?; + tasks::build(spacetimedb_dir, Some(Path::new("src")), false, None, false).context("Failed to build project")?; println!("{}", "Build complete!".green()); // For TypeScript client, always update .env.local with the database name diff --git a/crates/cli/src/subcommands/generate.rs b/crates/cli/src/subcommands/generate.rs index 94686939cba..a165b010de9 100644 --- a/crates/cli/src/subcommands/generate.rs +++ b/crates/cli/src/subcommands/generate.rs @@ -486,7 +486,7 @@ 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 (path, _) = build::exec_with_argstring(&run.project_path, &run.build_options, false).await?; path }; let spinner = indicatif::ProgressBar::new_spinner(); diff --git a/crates/cli/src/subcommands/init.rs b/crates/cli/src/subcommands/init.rs index 3f193b3b59a..a961ab19628 100644 --- a/crates/cli/src/subcommands/init.rs +++ b/crates/cli/src/subcommands/init.rs @@ -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()), } } } @@ -199,6 +204,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). Auto-detected when omitted."), + ) } pub async fn fetch_templates_list() -> anyhow::Result> { @@ -530,21 +541,42 @@ 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. + // This may prompt the user interactively if multiple SDKs are installed. + let dotnet_major = if template_config.server_lang == Some(ServerLanguage::Csharp) { + Some(resolve_dotnet_major(options, is_interactive)?) + } 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()); } @@ -1338,13 +1370,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 @@ -1355,7 +1388,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() @@ -1424,6 +1462,16 @@ fn init_builtin(config: &TemplateConfig, project_path: &Path, is_server_only: bo None => {} } + // For C# projects targeting .NET 10, override the template global.json + // (the embedded template ships with 8.0.100 which is wrong for .NET 10). + if config.server_lang == Some(ServerLanguage::Csharp) && dotnet_major == Some(10) { + let global_json_path = server_dir.join("global.json"); + let net10_global_json = + "{\n \"sdk\": {\n \"version\": \"10.0.100\",\n \"rollForward\": \"latestMinor\"\n }\n}\n"; + std::fs::write(&global_json_path, net10_global_json)?; + println!("Updating global.json to use .NET 10 (NativeAOT-LLVM)."); + } + Ok(()) } @@ -1461,7 +1509,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..."); @@ -1471,7 +1519,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..."); @@ -1495,8 +1543,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<()> { @@ -1607,6 +1655,83 @@ fn check_for_cargo() -> bool { false } +/// Returns the set of major .NET SDK versions installed (e.g. {8, 10}). +fn detect_installed_dotnet_majors() -> Vec { + let output = duct::cmd!("dotnet", "--list-sdks").read().unwrap_or_default(); + let mut majors: Vec = output + .lines() + .filter_map(|line| { + // Each line looks like: "8.0.100 [C:\Program Files\dotnet\sdk]" + let version_str = line.split_whitespace().next()?; + crate::tasks::csharp::parse_major_version(version_str) + }) + .collect(); + majors.sort(); + majors.dedup(); + majors +} + +/// Determine the target .NET major version for a C# project. +/// +/// Resolution order: +/// 1. Explicit `--dotnet-version` flag +/// 2. Interactive prompt (if multiple supported versions are installed) +/// 3. Auto-detect from `dotnet --version` (single supported version or non-interactive) +fn resolve_dotnet_major(options: &InitOptions, is_interactive: bool) -> anyhow::Result { + // 1. Explicit flag takes priority. + if let Some(v) = options.dotnet_version { + match v { + 8 | 10 => return Ok(v), + _ => anyhow::bail!("Unsupported --dotnet-version {v}. Supported values: 8, 10."), + } + } + + // --native-aot is for .NET 8 AOT builds (NativeAOT-LLVM with net8.0 TFM). + // .NET 10 always uses NativeAOT-LLVM, no flag needed. + if options.native_aot { + return Ok(8); + } + + let installed = detect_installed_dotnet_majors(); + let supported: Vec = installed.iter().copied().filter(|&v| v == 8 || v == 10).collect(); + + match supported.len() { + 0 => { + // Fall back to whatever `dotnet --version` reports. + let ver = duct::cmd!("dotnet", "--version").read().unwrap_or_default(); + crate::tasks::csharp::parse_major_version(&ver).ok_or_else(|| { + anyhow::anyhow!("Could not detect .NET SDK version. Please install .NET SDK 8.0 or 10.0.") + }) + } + 1 => Ok(supported[0]), + _ => { + // Multiple supported versions — prompt if interactive, else use highest. + if is_interactive { + let theme = ColorfulTheme::default(); + let choices: Vec = supported + .iter() + .map(|v| match v { + 8 => ".NET 8 (JIT — stable, uses wasi-experimental workload)".to_string(), + 10 => ".NET 10 (NativeAOT-LLVM — experimental, better performance)".to_string(), + other => format!(".NET {other}"), + }) + .collect(); + + let selection = Select::with_theme(&theme) + .with_prompt("Multiple .NET SDKs found. Which version should this C# module target?") + .items(&choices) + .default(choices.len() - 1) // Default to highest (typically .NET 10) + .interact()?; + + Ok(supported[selection]) + } else { + // Non-interactive: use the highest supported version. + Ok(*supported.last().unwrap()) + } + } + } +} + fn check_for_dotnet() -> bool { use std::fmt::Write; @@ -1701,6 +1826,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 global_json = match dotnet_major { + Some(10) => { + println!("Configuring for .NET 10 (NativeAOT-LLVM)."); + "{\n \"sdk\": {\n \"version\": \"10.0.100\",\n \"rollForward\": \"latestMinor\"\n }\n}\n" + } + _ => { + include_str!("../../../../templates/basic-cs/spacetimedb/global.json") + } + }; + let export_files = vec![ ( include_str!("../../../../templates/basic-cs/spacetimedb/StdbModule.csproj"), @@ -1748,15 +1899,9 @@ pub fn init_csharp_project(project_path: &Path) -> anyhow::Result<()> { include_str!("../../../../templates/basic-cs/spacetimedb/Lib.cs"), "Lib.cs", ), - ( - include_str!("../../../../templates/basic-cs/spacetimedb/global.json"), - "global.json", - ), + (global_json, "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())?; @@ -1766,9 +1911,19 @@ 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<()> { +/// 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()); @@ -1776,31 +1931,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#" @@ -1809,6 +1970,17 @@ fn add_native_aot_packages_to_csproj(project_path: &Path) -> anyhow::Result<()> + + + + + + + + + + + "#; @@ -1891,7 +2063,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!( "{}", diff --git a/crates/cli/src/subcommands/publish.rs b/crates/cli/src/subcommands/publish.rs index 34ebf311df0..cb5bc65c81b 100644 --- a/crates/cli/src/subcommands/publish.rs +++ b/crates/cli/src/subcommands/publish.rs @@ -96,6 +96,7 @@ pub fn build_publish_schema(command: &clap::Command) -> 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 @@ -555,11 +563,20 @@ async fn execute_publish_configs<'a>( env::set_var("EXPERIMENTAL_WASM_AOT", "1"); } } + // Pass explicit dotnet version to C# build system if specified + if let Ok(Some(version)) = dotnet_version.as_ref() { + // SAFETY: We are single-threaded at this point and no other code is reading + // this environment variable concurrently. + unsafe { + env::set_var("SPACETIMEDB_DOTNET_VERSION", version); + } + } 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..0f41d607535 100644 --- a/crates/cli/src/tasks/csharp.rs +++ b/crates/cli/src/tasks/csharp.rs @@ -4,11 +4,71 @@ 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 { +/// Read the `` major version directly from the project's `.csproj` file. +/// Returns `Some(8)` for `net8.0`, `Some(10)` for `net10.0`, etc., or `None` if unreadable. +/// This is the most reliable project-level signal of intended .NET version and takes +/// precedence over the system-default `dotnet --version`. +fn read_tfm_major_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 tag = "net"; + let start = match content.find(tag) { + Some(s) => s + tag.len(), + None => { + eprintln!("read_tfm: no net tag found in {}", csproj.display()); + return None; + } + }; + let version_str: String = content[start..].split(['.', '<']).next()?.to_string(); + let result = version_str.parse::().ok(); + eprintln!("read_tfm: parsed version_str={version_str:?} -> {result:?}"); + result +} + +/// 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) -> 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 +77,169 @@ 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; - // 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." - ))?; - } + // Check for explicit dotnet version override from CLI (--dotnet-version flag) + // This takes precedence over auto-detection. + let dotnet_version_override = std::env::var("SPACETIMEDB_DOTNET_VERSION").ok(); + + // Detect the system-default .NET SDK version as a last-resort fallback. + // Run from the project directory only if global.json exists there, so that + // any user-authored SDK pin is respected. Otherwise run from the current + // directory to avoid the .NET 10 SDK crash that occurs when it is invoked + // in a directory without a global.json. + let global_json_exists = project_path.join("global.json").exists(); + let dotnet_version_result = if global_json_exists { + dotnet!("--version").read() + } else { + duct::cmd!("dotnet", "--version").read() + }; + let dotnet_version_str = match dotnet_version_result { + Ok(v) => v, Err(error) if error.kind() == std::io::ErrorKind::NotFound => { - anyhow::bail!("dotnet not found in PATH. Please install .NET SDK 8.0.") + anyhow::bail!("dotnet not found in PATH. Please install .NET SDK 8.0 or 10.0.") } Err(error) => anyhow::bail!("{error}"), }; + // Resolution order: + // 1. --dotnet-version CLI flag (explicit user override) + // 2. in the project's .csproj (project author's intent) + // 3. dotnet --version system default (last resort fallback) + let tfm_major = read_tfm_major_from_csproj(project_path); + eprintln!( + "dotnet version detection: override={:?}, csproj_tfm={:?}, dotnet_version={:?}, project_path={}", + dotnet_version_override, + tfm_major, + dotnet_version_str, + project_path.display() + ); + let dotnet_major = dotnet_version_override + .as_deref() + .and_then(|v| v.parse().ok()) + .or(tfm_major) + .or_else(|| parse_major_version(&dotnet_version_str)); + + // 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. + _ => { + anyhow::bail!( + "Unsupported .NET SDK version: {dotnet_version_str}. 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." + ); + } + }; + + // For the Net8Jit path the .NET 8 SDK must be active (wasi-experimental is .NET 8 only). + // If the active SDK is not .NET 8 and no global.json exists, auto-create one to pin .NET 8 + // and inform the user — mirroring the auto-global.json behaviour used for Net10Aot. + if matches!(build_path, CsharpBuildPath::Net8Jit) { + let active_sdk_major = parse_major_version(&dotnet_version_str); + if active_sdk_major != Some(8) && !project_path.join("global.json").exists() { + let active = dotnet_version_str.trim(); + let global_json_path = project_path.join("global.json"); + fs::write( + &global_json_path, + r#"{"sdk":{"version":"8.0.100","rollForward":"latestMinor"}}"#, + )?; + // Only print the note when the user hasn't already declared intent via --dotnet-version 8. + if dotnet_version_override.is_none() { + println!( + "Note: created {} to pin the .NET 8 SDK (active SDK is .NET {active}).\n\ + To suppress this message, add a global.json to your project or pass --dotnet-version 8.", + global_json_path.display() + ); + } + } + } + + // 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 Net8Aot, 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. + // 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) { + for obj_dir in ["obj", "obj~"] { + let assets = project_path.join(obj_dir).join("project.assets.json"); + if assets.exists() { + let _ = fs::remove_file(&assets); + } + } + } + // Ensure the project path exists. fs::metadata(project_path).with_context(|| { format!( @@ -68,16 +248,24 @@ 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", "publish"), + CsharpBuildPath::Net8Aot => ("net8.0", "publish"), + 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). + + // 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). + dotnet!("publish", "-c", config_name, "-f", target_framework, "-v", "quiet").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 +279,74 @@ 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/native/StdbModule.wasm" + )), + project_path.join(format!( + "bin~/{config_name}/{target_framework}/wasi-wasm/native/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); + 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..f96dba3a385 100644 --- a/crates/cli/src/tasks/mod.rs +++ b/crates/cli/src/tasks/mod.rs @@ -15,6 +15,7 @@ pub fn build( lint_dir: Option<&Path>, build_debug: bool, features: Option<&std::ffi::OsString>, + native_aot: bool, ) -> anyhow::Result<(PathBuf, &'static str)> { let lang = util::detect_module_language(project_path)?; if features.is_some() && lang != ModuleLanguage::Rust { @@ -22,7 +23,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), 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/sats/src/de.rs b/crates/sats/src/de.rs index 1c16d8e46b8..3a0987c9a74 100644 --- a/crates/sats/src/de.rs +++ b/crates/sats/src/de.rs @@ -771,7 +771,12 @@ impl GrowingVec for SmallVec<[T; N]> { /// A basic implementation of `ArrayVisitor::visit` using the provided size hint. pub fn array_visit<'de, A: ArrayAccess<'de>, V: GrowingVec>(mut access: A) -> Result { - let mut v = V::try_with_capacity(access.size_hint().unwrap_or(0))?; + // Don’t blindly trust length prefixes when reserving initial capacity + // for decoding array elements, as malformed input could generate a huge allocation, + // potentially resulting in an OOM kill. + const RESERVE_ARRAY_ELEMENTS: usize = 4096; + let cap = access.size_hint().unwrap_or(0); + let mut v = V::try_with_capacity(cap.min(RESERVE_ARRAY_ELEMENTS))?; while let Some(x) = access.next_element()? { v.push(x) } 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/tests/smoketests/csharp_aot_module.rs b/crates/smoketests/tests/smoketests/csharp_aot_module.rs index 56df1100861..f0d551c454a 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(); @@ -57,7 +81,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..6a2fc7fe343 100644 --- a/crates/smoketests/tests/smoketests/csharp_module.rs +++ b/crates/smoketests/tests/smoketests/csharp_module.rs @@ -42,12 +42,14 @@ fn test_build_csharp_module() { // Create temp directory for the project let tmpdir = tempfile::tempdir().expect("Failed to create temp directory"); - // Initialize C# project + // Initialize C# project with explicit .NET 8 to test JIT path let output = Command::new(&cli_path) .args([ "init", "--non-interactive", "--lang=csharp", + "--dotnet-version", + "8", "--project-path", tmpdir.path().to_str().unwrap(), "csharp-project", @@ -68,6 +70,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 +87,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"); diff --git a/crates/smoketests/tests/smoketests/quickstart.rs b/crates/smoketests/tests/smoketests/quickstart.rs index cfa4f88400e..c4d5431714e 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#" Result<(TempDir, PathBu let project_name = format!("test-{}", template_id); let project_path = tmpdir.path().join(&project_name); - test.spacetime(&[ - "init", - "--template", - template_id, - "--project-path", - project_path.to_str().unwrap(), - "--non-interactive", - &project_name, - ]) - .with_context(|| format!("spacetime init --template {} failed", template_id))?; + // For C# templates, force .NET 8 to match template TFM and avoid + // CLI auto-detecting .NET 10 in CI environments. + let is_csharp = template_id.ends_with("-cs"); + let init_args: Vec<&str> = if is_csharp { + vec![ + "init", + "--template", + template_id, + "--project-path", + project_path.to_str().unwrap(), + "--non-interactive", + "--dotnet-version", + "8", + &project_name, + ] + } else { + vec![ + "init", + "--template", + template_id, + "--project-path", + project_path.to_str().unwrap(), + "--non-interactive", + &project_name, + ] + }; + + 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); @@ -651,7 +670,17 @@ fn setup_csharp_nuget(project_path: &Path) -> Result { + + + + + + + + + + "#, ) @@ -814,6 +843,8 @@ fn test_csharp_template(test: &Smoketest, template: &Template, project_path: &Pa "--yes", "--module-path", server_path.to_str().unwrap(), + "--dotnet-version", + "8", // Force .NET 8 JIT path to match template TFM &domain, ]) .with_context(|| format!("spacetime publish failed for C# server in template {}", template.id))?; diff --git a/crates/testing/Cargo.toml b/crates/testing/Cargo.toml index bf8c305526d..84295a77f46 100644 --- a/crates/testing/Cargo.toml +++ b/crates/testing/Cargo.toml @@ -16,6 +16,7 @@ spacetimedb-core.workspace = true spacetimedb-standalone.workspace = true spacetimedb-client-api.workspace = true spacetimedb-client-api-messages.workspace = true +spacetimedb-guard.workspace = true spacetimedb-paths.workspace = true spacetimedb-schema.workspace = true diff --git a/crates/testing/src/modules.rs b/crates/testing/src/modules.rs index 7dcf58df89c..a1b2cad6502 100644 --- a/crates/testing/src/modules.rs +++ b/crates/testing/src/modules.rs @@ -186,6 +186,7 @@ impl CompiledModule { Some(PathBuf::from("src")).as_deref(), mode == CompilationMode::Debug, None, + false, ) .expect("Module compilation failed"); Self { diff --git a/crates/testing/src/sdk.rs b/crates/testing/src/sdk.rs index e995aa28019..eb45353aa2b 100644 --- a/crates/testing/src/sdk.rs +++ b/crates/testing/src/sdk.rs @@ -2,64 +2,26 @@ use duct::cmd; use rand::seq::IteratorRandom; use spacetimedb::messages::control_db::HostType; use spacetimedb_data_structures::map::HashMap; +use spacetimedb_guard::SpacetimeDbGuard; use spacetimedb_paths::{RootDir, SpacetimePaths}; use std::fs::create_dir_all; -use std::sync::{Mutex, OnceLock}; -use std::thread::JoinHandle; +use std::sync::Mutex; use crate::invoke_cli; -use crate::modules::{start_runtime, CompilationMode, CompiledModule}; +use crate::modules::{CompilationMode, CompiledModule}; use tempfile::TempDir; -/// Ensure that the server thread we're testing against is still running, starting -/// it if it hasn't been started yet. -pub fn ensure_standalone_process() -> &'static SpacetimePaths { - static PATHS: OnceLock = OnceLock::new(); - static JOIN_HANDLE: OnceLock>>>> = OnceLock::new(); - - let paths = PATHS.get_or_init(|| { - let dir = TempDir::with_prefix("stdb-sdk-test") - .expect("Failed to create tempdir") - // TODO: This leaks the tempdir. - // We need the tempdir to live for the duration of the process, - // and all the options for post-`main` cleanup seem sketchy. - .keep(); - SpacetimePaths::from_root_dir(&RootDir(dir)) - }); - - let join_handle = JOIN_HANDLE.get_or_init(|| { - Mutex::new(Some(std::thread::spawn(move || { - start_runtime().block_on(spacetimedb_standalone::start_server( - &paths.data_dir, - Some(&paths.cli_config_dir.0), - )) - }))) - }); - - let mut join_handle = join_handle.lock().unwrap_or_else(|e| e.into_inner()); - - if join_handle - .as_ref() - .expect("Standalone process already finished") - .is_finished() - { - match join_handle.take().unwrap().join() { - Ok(Ok(())) => {} - Ok(Err(e)) => panic!("standalone process failed: {e:?}"), - Err(e) => { - let msg = if let Some(s) = e.downcast_ref::() { - s - } else if let Some(s) = e.downcast_ref::<&str>() { - s - } else { - "dyn Any" - }; - panic!("standalone process failed by panic: {msg}") - } - } - } +struct SdkTestPaths { + paths: SpacetimePaths, + _root: TempDir, +} - paths +impl SdkTestPaths { + fn new() -> Self { + let root = TempDir::with_prefix("stdb-sdk-test").expect("Failed to create tempdir"); + let paths = SpacetimePaths::from_root_dir(&RootDir(root.path().to_path_buf())); + Self { paths, _root: root } + } } pub struct Test { @@ -105,11 +67,13 @@ pub struct Test { /// Will run with access to the env vars: /// - `SPACETIME_SDK_TEST_CLIENT_PROJECT` bound to the `client_project` path. /// - `SPACETIME_SDK_TEST_DB_NAME` bound to the database identity or name. + /// - `SPACETIME_SDK_TEST_SERVER_URL` bound to the server URL for this test. run_command: String, } pub const TEST_MODULE_PROJECT_ENV_VAR: &str = "SPACETIME_SDK_TEST_MODULE_PROJECT"; pub const TEST_DB_NAME_ENV_VAR: &str = "SPACETIME_SDK_TEST_DB_NAME"; +pub const TEST_SERVER_URL_ENV_VAR: &str = "SPACETIME_SDK_TEST_SERVER_URL"; pub const TEST_CLIENT_PROJECT_ENV_VAR: &str = "SPACETIME_SDK_TEST_CLIENT_PROJECT"; fn language_is_unreal(language: &str) -> bool { @@ -121,7 +85,8 @@ impl Test { TestBuilder::default() } pub fn run(self) { - let paths = ensure_standalone_process(); + let sdk_paths = SdkTestPaths::new(); + let paths = &sdk_paths.paths; let (file, host_type) = compile_module(&self.module_name); @@ -137,9 +102,11 @@ impl Test { compile_client(&self.compile_command, &self.client_project); - let db_name = publish_module(paths, &file, host_type); + let guard = SpacetimeDbGuard::spawn_in_temp_data_dir(); + let server_url = guard.host_url.as_str(); + let db_name = publish_module(paths, server_url, &file, host_type); - run_client(&self.run_command, &self.client_project, &db_name); + run_client(&self.run_command, &self.client_project, server_url, &db_name); } } @@ -213,7 +180,7 @@ fn compile_module(module: &str) -> (String, HostType) { // Note: this function does not memoize because we want each test to publish the same // module as a separate clean database instance for isolation purposes. -fn publish_module(paths: &SpacetimePaths, wasm_file: &str, host_type: HostType) -> String { +fn publish_module(paths: &SpacetimePaths, server_url: &str, wasm_file: &str, host_type: HostType) -> String { let name = random_module_name(); invoke_cli( paths, @@ -221,7 +188,7 @@ fn publish_module(paths: &SpacetimePaths, wasm_file: &str, host_type: HostType) "publish", "--anonymous", "--server", - "local", + server_url, match host_type { HostType::Wasm => "--bin-path", HostType::Js => "--js-path", @@ -268,10 +235,7 @@ fn publish_module(paths: &SpacetimePaths, wasm_file: &str, host_type: HostType) /// If you need bindings for multiple different modules, put them in different subdirs. /// - If multiple distinct test harness processes run concurrently, /// they will encounter the race condition described above, -/// because the `BINDINGS_GENERATED` lock is not shared between harness processes. -/// Running multiple test harness processes concurrently will break anyways -/// because each will try to run `spacetime start` as a subprocess and will therefore -/// contend over port 3000. +/// because the binding-generation lock is not shared between harness processes. /// Prefer constructing multiple `Test`s and `Test::run`ing them /// from within the same harness process. // @@ -384,12 +348,13 @@ fn compile_client(compile_command: &str, client_project: &str) { }) } -fn run_client(run_command: &str, client_project: &str, db_name: &str) { +fn run_client(run_command: &str, client_project: &str, server_url: &str, db_name: &str) { let (exe, args) = split_command_string(run_command); let output = cmd(exe, args) .dir(client_project) .env(TEST_CLIENT_PROJECT_ENV_VAR, client_project) + .env(TEST_SERVER_URL_ENV_VAR, server_url) .env(TEST_DB_NAME_ENV_VAR, db_name) .env( "RUST_LOG", 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 be002493810..8b8c14da10d 100644 --- a/demo/Blackholio/server-csharp/StdbModule.csproj +++ b/demo/Blackholio/server-csharp/StdbModule.csproj @@ -12,8 +12,46 @@ $(NoWarn);CS8981;IDE1006 + + net10.0 + $(DefineConstants);EXPERIMENTAL_WASM_AOT + + + + true + true + false + + + + + <_OriginalIlcSdkPath>$(IlcSdkPath) + <_WasiRuntimeOverlayDir>$(IntermediateOutputPath)native-hidden-no-wit\ + + + <_WasiRuntimeOverlaySource Include="$(IlcFrameworkNativePath)**\*" Exclude="$(IlcFrameworkNativePath)**\*.wit" /> + + + + + + + + $(_WasiRuntimeOverlayDir) + $(_OriginalIlcSdkPath) + + + + + + + diff --git a/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md b/docs/docs/00300-resources/00200-reference/00100-cli-reference/00100-cli-reference.md index 81524e95d7b..ac4d1be1b37 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 @@ -127,6 +127,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. @@ -431,6 +432,7 @@ Initializes a new spacetime project. * `--local` — Use local deployment instead of Maincloud * `--non-interactive` — Run in non-interactive mode * `--native-aot` — Configure C# project for NativeAOT-LLVM compilation (experimental, Windows only) +* `--dotnet-version ` — Target .NET SDK major version for C# projects (e.g. 8 or 10). Auto-detected when omitted. @@ -447,6 +449,7 @@ Builds a spacetime module. Default value: `src` * `-d`, `--debug` — Builds the module using debug instead of release (intended to speed up local iteration, not recommended for CI) +* `--dotnet-version ` — Target .NET SDK major version for C# projects (e.g. 8 or 10). Auto-detected when omitted. diff --git a/global.json b/global.json index c19a2e057c7..1e7fdfa95fd 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.100", + "version": "10.0.100", "rollForward": "latestMinor" } } diff --git a/modules/Directory.Build.props b/modules/Directory.Build.props index a03bac5df3a..e66478ad229 100644 --- a/modules/Directory.Build.props +++ b/modules/Directory.Build.props @@ -5,6 +5,7 @@ StdbModule net8.0 + net10.0 wasi-wasm enable enable diff --git a/modules/sdk-test-cs/sdk-test-cs.csproj b/modules/sdk-test-cs/sdk-test-cs.csproj index 09cf1192bb7..19453d0b44f 100644 --- a/modules/sdk-test-cs/sdk-test-cs.csproj +++ b/modules/sdk-test-cs/sdk-test-cs.csproj @@ -10,8 +10,7 @@ - - - + + diff --git a/modules/sdk-test-procedure-cpp/src/lib.cpp b/modules/sdk-test-procedure-cpp/src/lib.cpp index 31e3669703a..da1278ccdca 100644 --- a/modules/sdk-test-procedure-cpp/src/lib.cpp +++ b/modules/sdk-test-procedure-cpp/src/lib.cpp @@ -140,15 +140,18 @@ SPACETIMEDB_PROCEDURE(Unit, insert_with_tx_rollback, ProcedureContext ctx) { #ifdef SPACETIMEDB_UNSTABLE_FEATURES // Test HTTP GET request to the module's own schema endpoint -SPACETIMEDB_PROCEDURE(std::string, read_my_schema, ProcedureContext ctx) { +SPACETIMEDB_PROCEDURE(std::string, read_my_schema, ProcedureContext ctx, std::string server_url) { // Get the module identity (database address) Identity module_identity = ctx.database_identity(); std::string identity_hex = module_identity.to_hex_string(); + while (!server_url.empty() && server_url.back() == '/') { + server_url.pop_back(); + } LOG_INFO("read_my_schema using identity: " + identity_hex); // Make HTTP GET request to the schema endpoint (matches Rust) - std::string url = "http://localhost:3000/v1/database/" + identity_hex + "/schema?version=9"; + std::string url = server_url + "/v1/database/" + identity_hex + "/schema?version=9"; auto result = ctx.http.get(url); if (!result.is_ok()) { diff --git a/modules/sdk-test-procedure-cs/Lib.cs b/modules/sdk-test-procedure-cs/Lib.cs index 56476a3d18a..2e405c3a9bc 100644 --- a/modules/sdk-test-procedure-cs/Lib.cs +++ b/modules/sdk-test-procedure-cs/Lib.cs @@ -66,10 +66,11 @@ public static void WillPanic(ProcedureContext ctx) /// Test HTTP GET request to the module's own schema endpoint /// [SpacetimeDB.Procedure] - public static string ReadMySchema(ProcedureContext ctx) + public static string ReadMySchema(ProcedureContext ctx, string serverUrl) { var moduleIdentity = ProcedureContextBase.Identity; - var result = ctx.Http.Get($"http://localhost:3000/v1/database/{moduleIdentity}/schema?version=9"); + serverUrl = serverUrl.TrimEnd('/'); + var result = ctx.Http.Get($"{serverUrl}/v1/database/{moduleIdentity}/schema?version=9"); return result.Match( response => response.Body.ToStringUtf8Lossy(), error => throw new Exception($"HTTP request failed: {error}") @@ -243,4 +244,4 @@ public static void SortedUuidsInsert(ProcedureContext ctx) return 0; }); } -} \ No newline at end of file +} diff --git a/modules/sdk-test-procedure-ts/src/index.ts b/modules/sdk-test-procedure-ts/src/index.ts index f89aa76665a..1885eafd156 100644 --- a/modules/sdk-test-procedure-ts/src/index.ts +++ b/modules/sdk-test-procedure-ts/src/index.ts @@ -90,13 +90,18 @@ export const will_panic = spacetimedb.procedure(t.unit(), _ctx => { throw new Error('This procedure is expected to panic'); }); -export const read_my_schema = spacetimedb.procedure(t.string(), ctx => { - const module_identity = ctx.databaseIdentity; - const response = ctx.http.fetch( - `http://localhost:3000/v1/database/${module_identity}/schema?version=9` - ); - return response.text(); -}); +export const read_my_schema = spacetimedb.procedure( + { server_url: t.string() }, + t.string(), + (ctx, { server_url }) => { + const module_identity = ctx.databaseIdentity; + const base_url = server_url.replace(/\/+$/, ''); + const response = ctx.http.fetch( + `${base_url}/v1/database/${module_identity}/schema?version=9` + ); + return response.text(); + } +); export const invalid_request = spacetimedb.procedure(t.string(), ctx => { try { diff --git a/modules/sdk-test-procedure/src/lib.rs b/modules/sdk-test-procedure/src/lib.rs index 95ae9b523b1..2c51e7ee26f 100644 --- a/modules/sdk-test-procedure/src/lib.rs +++ b/modules/sdk-test-procedure/src/lib.rs @@ -41,11 +41,13 @@ fn will_panic(_ctx: &mut ProcedureContext) { } #[procedure] -fn read_my_schema(ctx: &mut ProcedureContext) -> String { +fn read_my_schema(ctx: &mut ProcedureContext, server_url: String) -> String { let module_identity = ctx.identity(); - match ctx.http.get(format!( - "http://localhost:3000/v1/database/{module_identity}/schema?version=9" - )) { + let server_url = server_url.trim_end_matches('/'); + match ctx + .http + .get(format!("{server_url}/v1/database/{module_identity}/schema?version=9")) + { Ok(result) => result.into_body().into_string_lossy(), Err(e) => panic!("{e}"), } diff --git a/sdks/csharp/after.SpacetimeDB.ClientSDK.sln.targets b/sdks/csharp/after.SpacetimeDB.ClientSDK.sln.targets index a9197c5098f..bc0f150b4a5 100644 --- a/sdks/csharp/after.SpacetimeDB.ClientSDK.sln.targets +++ b/sdks/csharp/after.SpacetimeDB.ClientSDK.sln.targets @@ -5,6 +5,7 @@ <_UnsupportedDLLs Include="packages/**/net8.0/**" /> + <_UnsupportedDLLs Include="packages/**/net10.0/**" /> diff --git a/sdks/csharp/examples~/regression-tests/client/client.csproj b/sdks/csharp/examples~/regression-tests/client/client.csproj index 540e15ad427..d63b7158f32 100644 --- a/sdks/csharp/examples~/regression-tests/client/client.csproj +++ b/sdks/csharp/examples~/regression-tests/client/client.csproj @@ -8,6 +8,11 @@ true + + net10.0 + $(DefineConstants);EXPERIMENTAL_WASM_AOT + + diff --git a/sdks/csharp/examples~/regression-tests/procedure-client/client.csproj b/sdks/csharp/examples~/regression-tests/procedure-client/client.csproj index 04759b33920..809f2112a87 100644 --- a/sdks/csharp/examples~/regression-tests/procedure-client/client.csproj +++ b/sdks/csharp/examples~/regression-tests/procedure-client/client.csproj @@ -10,6 +10,11 @@ $(NoWarn);CS0067 + + net10.0 + $(DefineConstants);EXPERIMENTAL_WASM_AOT + + diff --git a/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/Procedures/ReadMySchema.g.cs b/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/Procedures/ReadMySchema.g.cs index 0ec376947f5..76ab7984fe9 100644 --- a/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/Procedures/ReadMySchema.g.cs +++ b/sdks/csharp/examples~/regression-tests/procedure-client/module_bindings/Procedures/ReadMySchema.g.cs @@ -12,10 +12,10 @@ namespace SpacetimeDB.Types { public sealed partial class RemoteProcedures : RemoteBase { - public void ReadMySchema(ProcedureCallback callback) + public void ReadMySchema(string serverUrl, ProcedureCallback callback) { // Convert the clean callback to the wrapper callback - InternalReadMySchema((ctx, result) => + InternalReadMySchema(serverUrl, (ctx, result) => { if (result.IsSuccess && result.Value != null) { @@ -28,9 +28,9 @@ public void ReadMySchema(ProcedureCallback callback) }); } - private void InternalReadMySchema(ProcedureCallback callback) + private void InternalReadMySchema(string serverUrl, ProcedureCallback callback) { - conn.InternalCallProcedure(new Procedure.ReadMySchemaArgs(), callback); + conn.InternalCallProcedure(new Procedure.ReadMySchemaArgs(serverUrl), callback); } } @@ -58,6 +58,19 @@ public ReadMySchema() [DataContract] public sealed partial class ReadMySchemaArgs : Procedure, IProcedureArgs { + [DataMember(Name = "server_url")] + public string ServerUrl; + + public ReadMySchemaArgs(string ServerUrl) + { + this.ServerUrl = ServerUrl; + } + + public ReadMySchemaArgs() + { + this.ServerUrl = ""; + } + string IProcedureArgs.ProcedureName => "read_my_schema"; } diff --git a/sdks/csharp/examples~/regression-tests/republishing/client/client.csproj b/sdks/csharp/examples~/regression-tests/republishing/client/client.csproj index 9c07c1d1c1b..1bbdfd8c02f 100644 --- a/sdks/csharp/examples~/regression-tests/republishing/client/client.csproj +++ b/sdks/csharp/examples~/regression-tests/republishing/client/client.csproj @@ -7,6 +7,11 @@ enable + + net10.0 + $(DefineConstants);EXPERIMENTAL_WASM_AOT + + diff --git a/sdks/csharp/examples~/regression-tests/republishing/server-initial/StdbModule.csproj b/sdks/csharp/examples~/regression-tests/republishing/server-initial/StdbModule.csproj index bc0917e7692..0bb234045ef 100644 --- a/sdks/csharp/examples~/regression-tests/republishing/server-initial/StdbModule.csproj +++ b/sdks/csharp/examples~/regression-tests/republishing/server-initial/StdbModule.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 wasi-wasm enable enable diff --git a/sdks/csharp/examples~/regression-tests/republishing/server-republish/StdbModule.csproj b/sdks/csharp/examples~/regression-tests/republishing/server-republish/StdbModule.csproj index bc0917e7692..0bb234045ef 100644 --- a/sdks/csharp/examples~/regression-tests/republishing/server-republish/StdbModule.csproj +++ b/sdks/csharp/examples~/regression-tests/republishing/server-republish/StdbModule.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 wasi-wasm enable enable diff --git a/sdks/csharp/examples~/regression-tests/server/StdbModule.csproj b/sdks/csharp/examples~/regression-tests/server/StdbModule.csproj index bc0917e7692..0bb234045ef 100644 --- a/sdks/csharp/examples~/regression-tests/server/StdbModule.csproj +++ b/sdks/csharp/examples~/regression-tests/server/StdbModule.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 wasi-wasm enable enable diff --git a/sdks/csharp/unity-meta-skeleton~/spacetimedb.bsatn.runtime/lib/net10.0.meta b/sdks/csharp/unity-meta-skeleton~/spacetimedb.bsatn.runtime/lib/net10.0.meta new file mode 100644 index 00000000000..4d0dbab5184 --- /dev/null +++ b/sdks/csharp/unity-meta-skeleton~/spacetimedb.bsatn.runtime/lib/net10.0.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 3c6f8e9a2b5d4e7f9a1b2c3d4e5f6a7b +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/unity-meta-skeleton~/spacetimedb.bsatn.runtime/lib/net10.0/SpacetimeDB.BSATN.Runtime.dll.meta b/sdks/csharp/unity-meta-skeleton~/spacetimedb.bsatn.runtime/lib/net10.0/SpacetimeDB.BSATN.Runtime.dll.meta new file mode 100644 index 00000000000..f502b295917 --- /dev/null +++ b/sdks/csharp/unity-meta-skeleton~/spacetimedb.bsatn.runtime/lib/net10.0/SpacetimeDB.BSATN.Runtime.dll.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 7d2e8f4c9a3b5e6d8f1a2b3c4d5e6f7a +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 1 + isExplicitlyReferenced: 0 + validateReferences: 0 + platformData: + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + - first: + Windows Store Apps: WindowsStoreApps + second: + enabled: 0 + settings: + CPU: AnyCPU + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/csharp/unity-meta-skeleton~/spacetimedb.runtime/lib/net10.0.meta b/sdks/csharp/unity-meta-skeleton~/spacetimedb.runtime/lib/net10.0.meta new file mode 100644 index 00000000000..7246b376f90 --- /dev/null +++ b/sdks/csharp/unity-meta-skeleton~/spacetimedb.runtime/lib/net10.0.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/sdks/rust/tests/case-conversion-client/src/main.rs b/sdks/rust/tests/case-conversion-client/src/main.rs index 606b22dfbae..3bcc10dadee 100644 --- a/sdks/rust/tests/case-conversion-client/src/main.rs +++ b/sdks/rust/tests/case-conversion-client/src/main.rs @@ -8,9 +8,7 @@ use module_bindings::*; use spacetimedb_sdk::error::InternalError; use spacetimedb_sdk::{DbContext, Table, TableWithPrimaryKey}; use std::sync::Arc; -use test_counter::TestCounter; - -const LOCALHOST: &str = "http://localhost:3000"; +use test_counter::{server_url, TestCounter}; fn db_name_or_panic() -> String { std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env") @@ -83,7 +81,7 @@ fn connect_then( let name = db_name_or_panic(); let conn = DbConnection::builder() .with_database_name(name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect(move |ctx, _, _| { callback(ctx); connected_result(Ok(())); diff --git a/sdks/rust/tests/connect_disconnect_client/src/lib.rs b/sdks/rust/tests/connect_disconnect_client/src/lib.rs index da4421aa5e0..b86eda1125f 100644 --- a/sdks/rust/tests/connect_disconnect_client/src/lib.rs +++ b/sdks/rust/tests/connect_disconnect_client/src/lib.rs @@ -8,9 +8,10 @@ use wasm_bindgen::prelude::wasm_bindgen; #[cfg(all(target_arch = "wasm32", feature = "browser"))] #[wasm_bindgen] -pub async fn run(_test_name: String, db_name: String) { +pub async fn run(_test_name: String, db_name: String, server_url: String) { console_error_panic_hook::set_once(); // The shared wasm test harness always passes `(test_name, db_name)`, even for // fixed-flow clients like this one that ignore the selector. + test_counter::set_server_url(server_url); test_handlers::dispatch(&db_name).await; } diff --git a/sdks/rust/tests/connect_disconnect_client/src/test_handlers.rs b/sdks/rust/tests/connect_disconnect_client/src/test_handlers.rs index 56753d78039..a7795e8530b 100644 --- a/sdks/rust/tests/connect_disconnect_client/src/test_handlers.rs +++ b/sdks/rust/tests/connect_disconnect_client/src/test_handlers.rs @@ -2,9 +2,7 @@ use crate::module_bindings::*; use spacetimedb_sdk::{DbConnectionBuilder, DbContext, Table}; -use test_counter::TestCounter; - -const LOCALHOST: &str = "http://localhost:3000"; +use test_counter::{server_url, TestCounter}; pub async fn dispatch(db_name: &str) { let disconnect_test_counter = TestCounter::new(); @@ -16,7 +14,7 @@ pub async fn dispatch(db_name: &str) { let connection = DbConnection::builder() .with_database_name(db_name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect_error(|_ctx, error| panic!("on_connect_error: {error:?}")) .on_connect(move |ctx, _, _| { connected_result(Ok(())); @@ -80,7 +78,7 @@ pub async fn dispatch(db_name: &str) { reconnected_result(Ok(())); }) .with_database_name(db_name) - .with_uri(LOCALHOST); + .with_uri(server_url()); let new_connection = build_connection(new_connection).await; new_connection diff --git a/sdks/rust/tests/event-table-client/src/lib.rs b/sdks/rust/tests/event-table-client/src/lib.rs index 6a3a638c5c2..04d313e5699 100644 --- a/sdks/rust/tests/event-table-client/src/lib.rs +++ b/sdks/rust/tests/event-table-client/src/lib.rs @@ -8,9 +8,10 @@ use wasm_bindgen::prelude::wasm_bindgen; #[cfg(all(target_arch = "wasm32", feature = "browser"))] #[wasm_bindgen] -pub async fn run(test_name: String, db_name: String) { +pub async fn run(test_name: String, db_name: String, server_url: String) { console_error_panic_hook::set_once(); - // The shared wasm test harness passes both the selected test name and the - // published database name. wasm clients cannot rely on the native env-var path. + // The shared wasm test harness passes test settings explicitly + // WASM clients cannot rely on the native env-var path. + test_counter::set_server_url(server_url); test_handlers::dispatch(&test_name, &db_name).await; } diff --git a/sdks/rust/tests/event-table-client/src/main.rs b/sdks/rust/tests/event-table-client/src/main.rs index a2a79eb19fe..d5e92029c1c 100644 --- a/sdks/rust/tests/event-table-client/src/main.rs +++ b/sdks/rust/tests/event-table-client/src/main.rs @@ -17,7 +17,6 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); let db_name = std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env"); - // Keep the CLI entrypoint thin so both native and wasm execute the same handlers. tokio::runtime::Runtime::new() .unwrap() diff --git a/sdks/rust/tests/event-table-client/src/test_handlers.rs b/sdks/rust/tests/event-table-client/src/test_handlers.rs index f77c78e3896..3e361125c66 100644 --- a/sdks/rust/tests/event-table-client/src/test_handlers.rs +++ b/sdks/rust/tests/event-table-client/src/test_handlers.rs @@ -2,9 +2,7 @@ use crate::module_bindings::*; use spacetimedb_sdk::{DbConnectionBuilder, DbContext, Event, EventTable}; use std::sync::atomic::{AtomicU32, Ordering}; -use test_counter::TestCounter; - -const LOCALHOST: &str = "http://localhost:3000"; +use test_counter::{server_url, TestCounter}; macro_rules! assert_eq_or_bail { ($expected:expr, $found:expr) => {{ @@ -57,7 +55,7 @@ async fn connect_then( let name = db_name.to_owned(); let conn = DbConnection::builder() .with_database_name(name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect(|ctx, _, _| { callback(ctx); connected_result(Ok(())); diff --git a/sdks/rust/tests/procedural-view-pk-client/src/lib.rs b/sdks/rust/tests/procedural-view-pk-client/src/lib.rs index 0ffa7e5dacd..c2f33c2db84 100644 --- a/sdks/rust/tests/procedural-view-pk-client/src/lib.rs +++ b/sdks/rust/tests/procedural-view-pk-client/src/lib.rs @@ -8,7 +8,8 @@ use wasm_bindgen::prelude::wasm_bindgen; #[cfg(all(target_arch = "wasm32", feature = "browser"))] #[wasm_bindgen] -pub async fn run(test_name: String, db_name: String) { +pub async fn run(test_name: String, db_name: String, server_url: String) { console_error_panic_hook::set_once(); + test_counter::set_server_url(server_url); test_handlers::dispatch(&test_name, &db_name).await; } diff --git a/sdks/rust/tests/procedural-view-pk-client/src/main.rs b/sdks/rust/tests/procedural-view-pk-client/src/main.rs index 5a8fdf8970e..4681a87c617 100644 --- a/sdks/rust/tests/procedural-view-pk-client/src/main.rs +++ b/sdks/rust/tests/procedural-view-pk-client/src/main.rs @@ -16,7 +16,6 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); let db_name = std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env"); - tokio::runtime::Runtime::new() .unwrap() .block_on(test_handlers::dispatch(&test, &db_name)); diff --git a/sdks/rust/tests/procedural-view-pk-client/src/test_handlers.rs b/sdks/rust/tests/procedural-view-pk-client/src/test_handlers.rs index 4404d3715b1..319faf66b8b 100644 --- a/sdks/rust/tests/procedural-view-pk-client/src/test_handlers.rs +++ b/sdks/rust/tests/procedural-view-pk-client/src/test_handlers.rs @@ -1,8 +1,6 @@ use crate::module_bindings::*; use spacetimedb_sdk::{error::InternalError, DbConnectionBuilder, DbContext, Table, TableWithPrimaryKey}; -use test_counter::TestCounter; - -const LOCALHOST: &str = "http://localhost:3000"; +use test_counter::{server_url, TestCounter}; type ResultRecorder = Box)>; @@ -52,7 +50,7 @@ async fn connect_then_named( let name = db_name.to_owned(); let conn = DbConnection::builder() .with_database_name(name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect(|ctx, _, _| { callback(ctx); connected_result(Ok(())); diff --git a/sdks/rust/tests/procedure-client/src/lib.rs b/sdks/rust/tests/procedure-client/src/lib.rs index 6a3a638c5c2..04d313e5699 100644 --- a/sdks/rust/tests/procedure-client/src/lib.rs +++ b/sdks/rust/tests/procedure-client/src/lib.rs @@ -8,9 +8,10 @@ use wasm_bindgen::prelude::wasm_bindgen; #[cfg(all(target_arch = "wasm32", feature = "browser"))] #[wasm_bindgen] -pub async fn run(test_name: String, db_name: String) { +pub async fn run(test_name: String, db_name: String, server_url: String) { console_error_panic_hook::set_once(); - // The shared wasm test harness passes both the selected test name and the - // published database name. wasm clients cannot rely on the native env-var path. + // The shared wasm test harness passes test settings explicitly + // WASM clients cannot rely on the native env-var path. + test_counter::set_server_url(server_url); test_handlers::dispatch(&test_name, &db_name).await; } diff --git a/sdks/rust/tests/procedure-client/src/main.rs b/sdks/rust/tests/procedure-client/src/main.rs index 6739650776e..c6de4033072 100644 --- a/sdks/rust/tests/procedure-client/src/main.rs +++ b/sdks/rust/tests/procedure-client/src/main.rs @@ -22,7 +22,6 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); let db_name = std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env"); - // Keep the CLI entrypoint thin so both native and wasm execute the same handlers. tokio::runtime::Runtime::new() .unwrap() diff --git a/sdks/rust/tests/procedure-client/src/module_bindings/read_my_schema_procedure.rs b/sdks/rust/tests/procedure-client/src/module_bindings/read_my_schema_procedure.rs index eaab6c7626f..d5b63873401 100644 --- a/sdks/rust/tests/procedure-client/src/module_bindings/read_my_schema_procedure.rs +++ b/sdks/rust/tests/procedure-client/src/module_bindings/read_my_schema_procedure.rs @@ -6,7 +6,9 @@ use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; #[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] #[sats(crate = __lib)] -struct ReadMySchemaArgs {} +struct ReadMySchemaArgs { + pub server_url: String, +} impl __sdk::InModule for ReadMySchemaArgs { type Module = super::RemoteModule; @@ -17,12 +19,13 @@ impl __sdk::InModule for ReadMySchemaArgs { /// /// Implemented for [`super::RemoteProcedures`]. pub trait read_my_schema { - fn read_my_schema(&self) { - self.read_my_schema_then(|_, _| {}); + fn read_my_schema(&self, server_url: String) { + self.read_my_schema_then(server_url, |_, _| {}); } fn read_my_schema_then( &self, + server_url: String, __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, ); @@ -31,10 +34,14 @@ pub trait read_my_schema { impl read_my_schema for super::RemoteProcedures { fn read_my_schema_then( &self, + server_url: String, __callback: impl FnOnce(&super::ProcedureEventContext, Result) + Send + 'static, ) { - self.imp - .invoke_procedure_with_callback::<_, String>("read_my_schema", ReadMySchemaArgs {}, __callback); + self.imp.invoke_procedure_with_callback::<_, String>( + "read_my_schema", + ReadMySchemaArgs { server_url }, + __callback, + ); } } diff --git a/sdks/rust/tests/procedure-client/src/test_handlers.rs b/sdks/rust/tests/procedure-client/src/test_handlers.rs index d3f75c0698a..fdfc417cd9b 100644 --- a/sdks/rust/tests/procedure-client/src/test_handlers.rs +++ b/sdks/rust/tests/procedure-client/src/test_handlers.rs @@ -3,9 +3,7 @@ use anyhow::Context; use core::time::Duration; use spacetimedb_lib::db::raw_def::v9::{RawMiscModuleExportV9, RawModuleDefV9}; use spacetimedb_sdk::{DbConnectionBuilder, DbContext, Table}; -use test_counter::TestCounter; - -const LOCALHOST: &str = "http://localhost:3000"; +use test_counter::{server_url, TestCounter}; pub async fn dispatch(test: &str, db_name: &str) { match test { @@ -69,7 +67,7 @@ async fn connect_with_then( let name = db_name.to_owned(); let builder = DbConnection::builder() .with_database_name(name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect(|ctx, _, _| { callback(ctx); connected_result(Ok(())); @@ -257,26 +255,27 @@ async fn exec_procedure_http_ok(db_name: &str) { let test_counter = test_counter.clone(); move |ctx| { let result = test_counter.add_test("invoke_http"); - ctx.procedures.read_my_schema_then(move |_ctx, res| { - result( - // It's a try block! - #[allow(clippy::redundant_closure_call)] - (|| { - anyhow::ensure!(res.is_ok(), "Expected Ok result but got {res:?}"); - let module_def: RawModuleDefV9 = spacetimedb_lib::de::serde::deserialize_from( - &mut serde_json::Deserializer::from_str(&res.unwrap()), - )?; - anyhow::ensure!(module_def.misc_exports.iter().any(|misc_export| { - if let RawMiscModuleExportV9::Procedure(procedure_def) = misc_export { - &*procedure_def.name == "read_my_schema" - } else { - false - } - })); - Ok(()) - })(), - ) - }) + ctx.procedures + .read_my_schema_then(server_url().to_string(), move |_ctx, res| { + result( + // It's a try block! + #[allow(clippy::redundant_closure_call)] + (|| { + anyhow::ensure!(res.is_ok(), "Expected Ok result but got {res:?}"); + let module_def: RawModuleDefV9 = spacetimedb_lib::de::serde::deserialize_from( + &mut serde_json::Deserializer::from_str(&res.unwrap()), + )?; + anyhow::ensure!(module_def.misc_exports.iter().any(|misc_export| { + if let RawMiscModuleExportV9::Procedure(procedure_def) = misc_export { + &*procedure_def.name == "read_my_schema" + } else { + false + } + })); + Ok(()) + })(), + ) + }) } }) .await; diff --git a/sdks/rust/tests/procedure-concurrency-client/src/lib.rs b/sdks/rust/tests/procedure-concurrency-client/src/lib.rs index 0ffa7e5dacd..c2f33c2db84 100644 --- a/sdks/rust/tests/procedure-concurrency-client/src/lib.rs +++ b/sdks/rust/tests/procedure-concurrency-client/src/lib.rs @@ -8,7 +8,8 @@ use wasm_bindgen::prelude::wasm_bindgen; #[cfg(all(target_arch = "wasm32", feature = "browser"))] #[wasm_bindgen] -pub async fn run(test_name: String, db_name: String) { +pub async fn run(test_name: String, db_name: String, server_url: String) { console_error_panic_hook::set_once(); + test_counter::set_server_url(server_url); test_handlers::dispatch(&test_name, &db_name).await; } diff --git a/sdks/rust/tests/procedure-concurrency-client/src/main.rs b/sdks/rust/tests/procedure-concurrency-client/src/main.rs index 27b2f47c453..1ec4d15816c 100644 --- a/sdks/rust/tests/procedure-concurrency-client/src/main.rs +++ b/sdks/rust/tests/procedure-concurrency-client/src/main.rs @@ -16,7 +16,6 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); let db_name = std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env"); - tokio::runtime::Runtime::new() .unwrap() .block_on(test_handlers::dispatch(&test, &db_name)); diff --git a/sdks/rust/tests/procedure-concurrency-client/src/test_handlers.rs b/sdks/rust/tests/procedure-concurrency-client/src/test_handlers.rs index fa67bc11692..a7ab7df2e86 100644 --- a/sdks/rust/tests/procedure-concurrency-client/src/test_handlers.rs +++ b/sdks/rust/tests/procedure-concurrency-client/src/test_handlers.rs @@ -2,9 +2,7 @@ use crate::module_bindings::*; use anyhow::Context; use spacetimedb_sdk::{DbConnectionBuilder, DbContext, Table}; use std::sync::{Arc, Mutex}; -use test_counter::TestCounter; - -const LOCALHOST: &str = "http://localhost:3000"; +use test_counter::{server_url, TestCounter}; pub async fn dispatch(test: &str, db_name: &str) { match test { @@ -62,7 +60,7 @@ async fn connect_with_then( let name = db_name.to_owned(); let builder = DbConnection::builder() .with_database_name(name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect(|ctx, _, _| { callback(ctx); connected_result(Ok(())); diff --git a/sdks/rust/tests/test-client/src/lib.rs b/sdks/rust/tests/test-client/src/lib.rs index 8ef2391750b..9fdfec32789 100644 --- a/sdks/rust/tests/test-client/src/lib.rs +++ b/sdks/rust/tests/test-client/src/lib.rs @@ -13,7 +13,8 @@ use wasm_bindgen::prelude::wasm_bindgen; #[cfg(all(target_arch = "wasm32", feature = "browser"))] #[wasm_bindgen] -pub async fn run(test_name: String, db_name: String) { +pub async fn run(test_name: String, db_name: String, server_url: String) { console_error_panic_hook::set_once(); + test_counter::set_server_url(server_url); test_handlers::dispatch(&test_name, &db_name).await; } diff --git a/sdks/rust/tests/test-client/src/main.rs b/sdks/rust/tests/test-client/src/main.rs index 1e305289493..d83b104f28e 100644 --- a/sdks/rust/tests/test-client/src/main.rs +++ b/sdks/rust/tests/test-client/src/main.rs @@ -22,7 +22,6 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); let db_name = std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env"); - // Keep the CLI entrypoint thin so both native and wasm execute the same handlers. tokio::runtime::Runtime::new() .unwrap() diff --git a/sdks/rust/tests/test-client/src/test_handlers.rs b/sdks/rust/tests/test-client/src/test_handlers.rs index d83ee46cf4d..98b21e0c2cd 100644 --- a/sdks/rust/tests/test-client/src/test_handlers.rs +++ b/sdks/rust/tests/test-client/src/test_handlers.rs @@ -13,7 +13,7 @@ use spacetimedb_sdk::{ i256, u256, Compression, ConnectionId, DbConnectionBuilder, DbContext, Event, Identity, ReducerEvent, Status, SubscriptionHandle, Table, TimeDuration, Timestamp, Uuid, }; -use test_counter::TestCounter; +use test_counter::{server_url, TestCounter}; use crate::simple_test_table::{insert_one, on_insert_one, SimpleTestTable}; @@ -21,8 +21,6 @@ use crate::pk_test_table::{insert_update_delete_one, PkTestTable}; use crate::unique_test_table::{insert_then_delete_one, UniqueTestTable}; -const LOCALHOST: &str = "http://localhost:3000"; - /// `Timestamp::now()` is stubbed on `wasm32-unknown-unknown`, so client-side tests /// that need a timestamp value must use a deterministic literal instead of wall-clock time. fn fixed_test_timestamp() -> Timestamp { @@ -89,6 +87,7 @@ pub async fn dispatch(test: &str, db_name: &str) { // "resubscribe" => exec_resubscribe(), // + "reauth" => exec_reauth(db_name).await, "reauth-part-1" => exec_reauth_part_1(db_name).await, "reauth-part-2" => exec_reauth_part_2(db_name).await, @@ -372,7 +371,7 @@ async fn connect_with_then( let name = db_name.to_owned(); let builder = DbConnection::builder() .with_database_name(name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect(|ctx, _, _| { callback(ctx); connected_result(Ok(())); @@ -1724,8 +1723,13 @@ async fn exec_insert_primitives_as_strings(db_name: &str) { // } #[cfg(not(target_arch = "wasm32"))] -fn creds_store() -> credentials::File { - credentials::File::new("rust-sdk-test") +fn creds_store(db_name: &str) -> credentials::File { + credentials::File::new(format!("rust-sdk-test-{db_name}")) +} + +async fn exec_reauth(db_name: &str) { + exec_reauth_part_1(db_name).await; + exec_reauth_part_2(db_name).await; } /// Part of the `reauth` test, this connects to Spacetime to get new credentials, @@ -1737,14 +1741,15 @@ async fn exec_reauth_part_1(db_name: &str) { let name = db_name.to_owned(); let save_result = test_counter.add_test("save-credentials"); + let creds = creds_store(db_name); DbConnection::builder() - .on_connect(|_, _identity, token| { - save_result(creds_store().save(token).map_err(Into::into)); + .on_connect(move |_, _identity, token| { + save_result(creds.save(token).map_err(Into::into)); }) .on_connect_error(|_ctx, error| panic!("Connect failed: {error:?}")) .with_database_name(name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .build() .unwrap() .run_threaded(); @@ -1764,7 +1769,7 @@ async fn exec_reauth_part_2(db_name: &str) { let creds_match_result = test_counter.add_test("creds-match"); - let token = creds_store().load().unwrap().unwrap(); + let token = creds_store(db_name).load().unwrap().unwrap(); DbConnection::builder() .on_connect({ @@ -1780,7 +1785,7 @@ async fn exec_reauth_part_2(db_name: &str) { .on_connect_error(|_ctx, error| panic!("Connect failed: {error:?}")) .with_database_name(name) .with_token(Some(token)) - .with_uri(LOCALHOST) + .with_uri(server_url()) .build() .unwrap() .run_threaded(); @@ -1811,7 +1816,7 @@ async fn exec_reconnect_different_connection_id(db_name: &str) { let initial_connection = build_and_run( DbConnection::builder() .with_database_name(db_name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect_error(|_ctx, error| panic!("on_connect_error: {error:?}")) .on_connect(move |_, _, _| { initial_connect_result(Ok(())); @@ -1838,7 +1843,7 @@ async fn exec_reconnect_different_connection_id(db_name: &str) { let _re_connection = build_and_run( DbConnection::builder() .with_database_name(db_name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect_error(|_ctx, error| panic!("on_connect_error: {error:?}")) .on_connect(move |ctx, _, _| { reconnect_result(Ok(())); diff --git a/sdks/rust/tests/test-counter/src/lib.rs b/sdks/rust/tests/test-counter/src/lib.rs index 329d29f7025..aa774ae09fa 100644 --- a/sdks/rust/tests/test-counter/src/lib.rs +++ b/sdks/rust/tests/test-counter/src/lib.rs @@ -1,11 +1,37 @@ #![allow(clippy::disallowed_macros)] use spacetimedb_data_structures::map::{HashMap, HashSet}; -use std::sync::{Arc, Condvar, Mutex}; +use std::sync::{Arc, Condvar, Mutex, OnceLock}; #[cfg(not(target_arch = "wasm32"))] use std::time::Duration; const TEST_TIMEOUT_SECS: u64 = 5 * 60; +pub const TEST_SERVER_URL_ENV_VAR: &str = "SPACETIME_SDK_TEST_SERVER_URL"; + +static SERVER_URL: OnceLock = OnceLock::new(); + +pub fn set_server_url(url: String) { + if SERVER_URL.set(url).is_err() { + panic!("{TEST_SERVER_URL_ENV_VAR} was set more than once"); + } +} + +pub fn server_url() -> &'static str { + SERVER_URL.get_or_init(server_url_from_env).as_str() +} + +fn server_url_from_env() -> String { + #[cfg(not(target_arch = "wasm32"))] + { + std::env::var(TEST_SERVER_URL_ENV_VAR) + .unwrap_or_else(|_| panic!("{TEST_SERVER_URL_ENV_VAR} must be set by the SDK test harness")) + } + + #[cfg(target_arch = "wasm32")] + { + panic!("{TEST_SERVER_URL_ENV_VAR} must be passed to the wasm SDK test harness") + } +} #[derive(Default)] struct TestCounterInner { diff --git a/sdks/rust/tests/test.rs b/sdks/rust/tests/test.rs index 0714033956f..85cb15a970a 100644 --- a/sdks/rust/tests/test.rs +++ b/sdks/rust/tests/test.rs @@ -62,7 +62,9 @@ fn platform_test_builder(client_project: &str, run_selector: Option<&str>) -> Te if (!run) throw new Error(\"No exported run/main/start function from wasm module\"); \ const dbName = process.env.SPACETIME_SDK_TEST_DB_NAME; \ if (!dbName) throw new Error(\"Missing SPACETIME_SDK_TEST_DB_NAME\"); \ - await run({run_selector:?}, dbName); \ + const serverUrl = process.env.SPACETIME_SDK_TEST_SERVER_URL; \ + if (!serverUrl) throw new Error(\"Missing SPACETIME_SDK_TEST_SERVER_URL\"); \ + await run({run_selector:?}, dbName, serverUrl); \ // These wasm clients run under Node rather than a browser. Some tests intentionally leave // websocket/event-loop work alive once their assertions are complete, so exit here to keep // non-lifecycle tests from hanging on leftover handles after `run()` has finished. @@ -275,8 +277,7 @@ macro_rules! declare_tests_with_suffix { #[test] fn reauth() { - make_test("reauth-part-1").run(); - make_test("reauth-part-2").run(); + make_test("reauth").run(); } #[test] diff --git a/sdks/rust/tests/view-client/src/lib.rs b/sdks/rust/tests/view-client/src/lib.rs index 6a3a638c5c2..04d313e5699 100644 --- a/sdks/rust/tests/view-client/src/lib.rs +++ b/sdks/rust/tests/view-client/src/lib.rs @@ -8,9 +8,10 @@ use wasm_bindgen::prelude::wasm_bindgen; #[cfg(all(target_arch = "wasm32", feature = "browser"))] #[wasm_bindgen] -pub async fn run(test_name: String, db_name: String) { +pub async fn run(test_name: String, db_name: String, server_url: String) { console_error_panic_hook::set_once(); - // The shared wasm test harness passes both the selected test name and the - // published database name. wasm clients cannot rely on the native env-var path. + // The shared wasm test harness passes test settings explicitly + // WASM clients cannot rely on the native env-var path. + test_counter::set_server_url(server_url); test_handlers::dispatch(&test_name, &db_name).await; } diff --git a/sdks/rust/tests/view-client/src/main.rs b/sdks/rust/tests/view-client/src/main.rs index 0bc8a792198..bca3fa2307e 100644 --- a/sdks/rust/tests/view-client/src/main.rs +++ b/sdks/rust/tests/view-client/src/main.rs @@ -22,7 +22,6 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); let db_name = std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env"); - // Keep the CLI entrypoint thin so both native and wasm execute the same handlers. tokio::runtime::Runtime::new() .unwrap() diff --git a/sdks/rust/tests/view-client/src/test_handlers.rs b/sdks/rust/tests/view-client/src/test_handlers.rs index 21b9dc43ae7..c8b0743166b 100644 --- a/sdks/rust/tests/view-client/src/test_handlers.rs +++ b/sdks/rust/tests/view-client/src/test_handlers.rs @@ -1,9 +1,7 @@ use crate::module_bindings::*; use spacetimedb_lib::Identity; use spacetimedb_sdk::{error::InternalError, DbConnectionBuilder, DbContext, Table}; -use test_counter::TestCounter; - -const LOCALHOST: &str = "http://localhost:3000"; +use test_counter::{server_url, TestCounter}; pub async fn dispatch(test: &str, db_name: &str) { match test { @@ -43,7 +41,7 @@ async fn build_connection( let name = db_name.to_owned(); let builder = DbConnection::builder() .with_database_name(name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect(|ctx, _, _| { callback(ctx); }) diff --git a/sdks/rust/tests/view-pk-client/src/lib.rs b/sdks/rust/tests/view-pk-client/src/lib.rs index 6a3a638c5c2..04d313e5699 100644 --- a/sdks/rust/tests/view-pk-client/src/lib.rs +++ b/sdks/rust/tests/view-pk-client/src/lib.rs @@ -8,9 +8,10 @@ use wasm_bindgen::prelude::wasm_bindgen; #[cfg(all(target_arch = "wasm32", feature = "browser"))] #[wasm_bindgen] -pub async fn run(test_name: String, db_name: String) { +pub async fn run(test_name: String, db_name: String, server_url: String) { console_error_panic_hook::set_once(); - // The shared wasm test harness passes both the selected test name and the - // published database name. wasm clients cannot rely on the native env-var path. + // The shared wasm test harness passes test settings explicitly + // WASM clients cannot rely on the native env-var path. + test_counter::set_server_url(server_url); test_handlers::dispatch(&test_name, &db_name).await; } diff --git a/sdks/rust/tests/view-pk-client/src/main.rs b/sdks/rust/tests/view-pk-client/src/main.rs index 3e1c03945ef..b8bd4bad76e 100644 --- a/sdks/rust/tests/view-pk-client/src/main.rs +++ b/sdks/rust/tests/view-pk-client/src/main.rs @@ -17,7 +17,6 @@ fn main() { .nth(1) .expect("Pass a test name as a command-line argument to the test client"); let db_name = std::env::var("SPACETIME_SDK_TEST_DB_NAME").expect("Failed to read db name from env"); - // Keep the CLI entrypoint thin so both native and wasm execute the same handlers. tokio::runtime::Runtime::new() .unwrap() diff --git a/sdks/rust/tests/view-pk-client/src/test_handlers.rs b/sdks/rust/tests/view-pk-client/src/test_handlers.rs index 6306d8a2562..e19a5677449 100644 --- a/sdks/rust/tests/view-pk-client/src/test_handlers.rs +++ b/sdks/rust/tests/view-pk-client/src/test_handlers.rs @@ -1,9 +1,7 @@ use crate::module_bindings::*; use spacetimedb_sdk::TableWithPrimaryKey; use spacetimedb_sdk::{error::InternalError, DbConnectionBuilder, DbContext}; -use test_counter::TestCounter; - -const LOCALHOST: &str = "http://localhost:3000"; +use test_counter::{server_url, TestCounter}; type ResultRecorder = Box)>; @@ -55,7 +53,7 @@ async fn connect_then_named( let name = db_name.to_owned(); let conn = DbConnection::builder() .with_database_name(name) - .with_uri(LOCALHOST) + .with_uri(server_url()) .on_connect(|ctx, _, _| { callback(ctx); connected_result(Ok(())); diff --git a/tools/ci/src/main.rs b/tools/ci/src/main.rs index 1725f498839..1b2f4837bac 100644 --- a/tools/ci/src/main.rs +++ b/tools/ci/src/main.rs @@ -79,7 +79,10 @@ fn check_global_json_policy() -> Result<()> { } let contents = fs::read_to_string(&p)?; - if contents != root_contents { + // Templates are exempt from content matching to preserve the .NET 8 JIT path for + // module developers importing templates, while the main codebase uses .NET 10 AOT. + // TODO: Remove this exemption once .NET 10 is the default and templates should use it. + if contents != root_contents && !is_template_global_json { eprintln!("Error: {} does not match the root global.json contents", p.display()); ok = false; } else if !is_template_global_json || !is_symlink { @@ -518,6 +521,20 @@ fn main() -> Result<()> { "--test-threads=2", ) .run()?; + // The SDK test harness uses the same child-process server guard as smoketests, + // which expects release CLI/standalone binaries to already exist. + cmd!( + "cargo", + "build", + "--release", + "-p", + "spacetimedb-cli", + "-p", + "spacetimedb-standalone", + "--features", + "spacetimedb-standalone/allow_loopback_http_for_tests", + ) + .run()?; // SDK procedure tests intentionally make localhost HTTP requests. cmd!( "cargo",