Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -948,6 +948,7 @@ jobs:
with:
enable_pr_comment: ${{ github.event_name == 'pull_request' }}
target_path: sdks/csharp
ignored_file_path: sdks/csharp/.meta-check-ignore
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

Expand Down Expand Up @@ -1140,10 +1141,15 @@ jobs:
dotnet workload config --update-mode manifests
dotnet workload update --from-previous-sdk
# Explicitly install wasi-experimental for .NET 8 SDK (needed for test_build_csharp_module)
# Create temp global.json to target .NET 8 SDK for workload install
echo '{"sdk":{"version":"8.0.100","rollForward":"latestFeature"}}' > global.json
dotnet workload install wasi-experimental
rm global.json
# Create temp global.json to target .NET 8 SDK for workload install without
# overwriting the repository's .NET 10 global.json, which later C# harness
# tests rely on for SDK selection.
workload_dir="$(mktemp -d)"
echo '{"sdk":{"version":"8.0.100","rollForward":"latestFeature"}}' > "$workload_dir/global.json"
(
cd "$workload_dir"
dotnet workload install wasi-experimental
)

- name: Override NuGet packages
run: |
Expand Down Expand Up @@ -1214,6 +1220,9 @@ jobs:
# Add a handy alias using the old binary name, so that we don't have to rewrite all scripts (incl. in submodules).
ln -sf $CARGO_HOME/bin/spacetimedb-cli $CARGO_HOME/bin/spacetime

- name: Run C# SDK harness tests
run: cargo test -p sdk-csharp-test-harness --test sdk_csharp

- name: Check quickstart-chat bindings are up to date
run: |
for dotnet_version in 8 10; do
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ members = [
"crates/sats",
"crates/schema",
"crates/smoketests",
"sdks/csharp",
"sdks/rust",
"sdks/unreal",
"crates/snapshot",
Expand Down
3 changes: 3 additions & 0 deletions sdks/csharp/.meta-check-ignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
unity-meta-skeleton~
unity-meta-skeleton~/**
Cargo.toml
tests
tests/**
13 changes: 13 additions & 0 deletions sdks/csharp/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
[package]
name = "sdk-csharp-test-harness"
version.workspace = true
edition.workspace = true
license-file = "LICENSE.txt"
description = "A C# SDK test harness for SpacetimeDB clients"

[dev-dependencies]
spacetimedb-testing = { path = "../../crates/testing" }
serial_test.workspace = true

[lints]
workspace = true
2 changes: 1 addition & 1 deletion sdks/csharp/SpacetimeDB.ClientSDK.Godot.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<RepositoryUrl>https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk</RepositoryUrl>
<AssemblyVersion>2.8.0</AssemblyVersion>
<Version>2.8.0</Version>
<DefaultItemExcludes>$(DefaultItemExcludes);*~/**</DefaultItemExcludes>
<DefaultItemExcludes>$(DefaultItemExcludes);*~/**;tests/**</DefaultItemExcludes>
<RestorePackagesPath>obj~/godot/packages</RestorePackagesPath>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<DefineConstants>$(DefineConstants);GODOT</DefineConstants>
Expand Down
2 changes: 1 addition & 1 deletion sdks/csharp/SpacetimeDB.ClientSDK.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
<RepositoryUrl>https://github.com/clockworklabs/com.clockworklabs.spacetimedbsdk</RepositoryUrl>
<AssemblyVersion>2.8.0</AssemblyVersion>
<Version>2.8.0</Version>
<DefaultItemExcludes>$(DefaultItemExcludes);*~/**</DefaultItemExcludes>
<DefaultItemExcludes>$(DefaultItemExcludes);*~/**;tests/**</DefaultItemExcludes>
<!-- We want to save DLLs for Unity which doesn't support NuGet. -->
<RestorePackagesPath>packages</RestorePackagesPath>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
Expand Down
16 changes: 8 additions & 8 deletions sdks/csharp/src/CompressionHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -98,14 +98,14 @@ internal static ServerMessage DecompressDecodeMessage(byte[] bytes)
internal static (BinaryReader reader, int rowCount) ParseRowList(BsatnRowList list) =>
(
new BinaryReader(new ListStream(list.RowsData)),
list.RowsData.Count == 0
? 0
: list.SizeHint switch
{
RowSizeHint.FixedSize(var size) => list.RowsData.Count / size,
RowSizeHint.RowOffsets(var offsets) => offsets.Count,
_ => throw new NotImplementedException()
}
list.SizeHint switch
{
RowSizeHint.FixedSize(var size) => size == 0
? throw new InvalidOperationException("Fixed-size BSATN row list cannot have zero-sized rows")
: list.RowsData.Count / size,
RowSizeHint.RowOffsets(var offsets) => offsets.Count,
_ => throw new NotImplementedException()
}
);
}
}
15 changes: 11 additions & 4 deletions sdks/csharp/src/Event.cs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ public class SubscriptionHandleBase<SubscriptionEventContext, ErrorContext> : IS
private Action<SubscriptionEventContext>? onEnded;

private QuerySetId? queryId;
private bool unsubscribeCalled;

private SubscriptionState state;

Expand Down Expand Up @@ -205,6 +206,11 @@ public bool IsActive
void ISubscriptionHandle.OnApplied(ISubscriptionEventContext ctx)
{
state = new SubscriptionState.Active(queryId ?? throw new InvalidOperationException("Subscription query id is missing."));
if (unsubscribeCalled)
{
conn.Unsubscribe(queryId);
return;
}
onApplied?.Invoke((SubscriptionEventContext)ctx);
}

Expand Down Expand Up @@ -257,11 +263,11 @@ public void Unsubscribe()
/// </summary>
public void UnsubscribeThen(Action<SubscriptionEventContext>? onEnded)
{
if (state is not SubscriptionState.Active)
if (state is SubscriptionState.Ended)
{
throw new Exception("Cannot unsubscribe from inactive subscription.");
throw new Exception("Cannot unsubscribe from ended subscription.");
}
if (this.onEnded != null)
if (unsubscribeCalled)
{
throw new Exception("Unsubscribe already called.");
}
Expand All @@ -271,11 +277,12 @@ public void UnsubscribeThen(Action<SubscriptionEventContext>? onEnded)
onEnded = (ctx) => { };
}
this.onEnded = onEnded;
unsubscribeCalled = true;
if (queryId == null)
{
Log.Warn("Unsubscribing from a query that was never submitted to the server does nothing.");
}
else
else if (state is SubscriptionState.Active)
{
conn.Unsubscribe(queryId);
}
Expand Down
9 changes: 6 additions & 3 deletions sdks/csharp/src/Table.cs
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,15 @@
// and therefore avoids using reflection when initializing the row object.

public abstract class IndexBase<Column>
where Column : IEquatable<Column>
// where Column : IEquatable<Column> // TODO: Revisit. Enums don't satisfy the `IEquatable<Column>` constraint. It shouldn't be needed though.
where Column : notnull
{
protected abstract Column GetKey(Row row);
}

public abstract class UniqueIndexBase<Column> : IndexBase<Column>
where Column : IEquatable<Column>
// where Column : IEquatable<Column> // TODO: Revisit. Enums don't satisfy the `IEquatable<Column>` constraint. It shouldn't be needed though: `Dictionary<TKey, TValue>` does not require `TKey : IEquatable<TKey>`; it uses `EqualityComparer<TKey>.Default`.
where Column : notnull
{
private readonly Dictionary<Column, Row> cache = new();

Expand All @@ -120,7 +122,8 @@
}

public abstract class BTreeIndexBase<Column> : IndexBase<Column>
where Column : IEquatable<Column>, IComparable<Column>
// where Column : IEquatable<Column>, IComparable<Column> // TODO: Revisit. Enums don't satisfy the `IEquatable<Column>` constraint. It shouldn't be needed though: `Dictionary<TKey, TValue>` does not require `TKey : IEquatable<TKey>`; it uses `EqualityComparer<TKey>.Default`. And if we change it to `SortedDictionary<TKey, TValue>`, it uses `Comparer<SimpleEnum>.Default`.
where Column : notnull
{
// TODO: change to SortedDictionary when adding support for range queries.
private readonly Dictionary<Column, HashSet<Row>> cache = new();
Expand Down Expand Up @@ -470,7 +473,7 @@
catch (Exception e)
{
var deltaString = parsedTableUpdate.ToString();
deltaString = deltaString[..Math.Min(deltaString.Length, 10_000)];

Check warning on line 476 in sdks/csharp/src/Table.cs

View workflow job for this annotation

GitHub Actions / release-csharp

Dereference of a possibly null reference.

Check warning on line 476 in sdks/csharp/src/Table.cs

View workflow job for this annotation

GitHub Actions / release-csharp

Dereference of a possibly null reference.

Check warning on line 476 in sdks/csharp/src/Table.cs

View workflow job for this annotation

GitHub Actions / unity-testsuite

Dereference of a possibly null reference.

Check warning on line 476 in sdks/csharp/src/Table.cs

View workflow job for this annotation

GitHub Actions / unity-testsuite

Dereference of a possibly null reference.
var entriesString = Entries.ToString();
entriesString = entriesString[..Math.Min(entriesString.Length, 10_000)];
throw new Exception($"While table `{RemoteTableName}` was applying:\n{deltaString} \nto:\n{entriesString}", e);
Expand Down
16 changes: 16 additions & 0 deletions sdks/csharp/tests/build-client.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env bash

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"

dotnet build "$REPO_ROOT/crates/bindings-csharp/BSATN.Runtime/BSATN.Runtime.csproj" \
-c Release \
-p:TargetFramework=net8.0 \
-p:NuGetAudit=false \
-p:RestoreIgnoreFailedSources=true

dotnet build \
-p:NuGetAudit=false \
-p:RestoreIgnoreFailedSources=true
129 changes: 129 additions & 0 deletions sdks/csharp/tests/connect-disconnect-client/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
using System;
using System.Linq;
using System.Threading;
using SpacetimeDB;
using SpacetimeDB.Types;

const string DbNameEnvVar = "SPACETIME_SDK_TEST_DB_NAME";
const string ServerUrlEnvVar = "SPACETIME_SDK_TEST_SERVER_URL";

AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
{
Console.Error.WriteLine(eventArgs.ExceptionObject);
Environment.Exit(1);
};

var dbName = Environment.GetEnvironmentVariable(DbNameEnvVar) ?? throw new InvalidOperationException($"{DbNameEnvVar} is not set");
var serverUrl = Environment.GetEnvironmentVariable(ServerUrlEnvVar) ?? "http://localhost:3000";

DbConnection db = null!;
var connected = false;
var connectedRowSeen = false;
var disconnected = false;
Identity? firstIdentity = null;

db = DbConnection
.Builder()
.WithUri(serverUrl)
.WithDatabaseName(dbName)
.OnConnect((conn, identity, _) =>
{
if (identity != conn.Identity)
{
throw new Exception("Connection identity callback did not match connection state");
}
firstIdentity = identity;
conn.SubscriptionBuilder()
.OnApplied(_ =>
{
if (conn.Db.Connected.Count != 1)
{
throw new Exception($"Expected one connected row, got {conn.Db.Connected.Count}");
}

var row = conn.Db.Connected.Iter().Single();
if (row.Identity != firstIdentity)
{
throw new Exception("Connected row identity did not match first connection identity");
}

connectedRowSeen = true;
conn.Disconnect();
})
.OnError((_, err) => throw err)
.AddQuery(qb => qb.From.Connected())
.Subscribe();
connected = true;
})
.OnConnectError(err => throw err)
.OnDisconnect((_, err) =>
{
if (err != null)
{
throw err;
}
disconnected = true;
})
.Build();

FrameTickUntil(() => connected && connectedRowSeen && disconnected);
db.Disconnect();

DbConnection reconnectDb = null!;
var reconnected = false;
var disconnectedRowSeen = false;

reconnectDb = DbConnection
.Builder()
.WithUri(serverUrl)
.WithDatabaseName(dbName)
.OnConnect((conn, _, _) =>
{
conn.SubscriptionBuilder()
.OnApplied(_ =>
{
if (conn.Db.Disconnected.Count != 1)
{
throw new Exception($"Expected one disconnected row, got {conn.Db.Disconnected.Count}");
}

var row = conn.Db.Disconnected.Iter().Single();
if (row.Identity != firstIdentity)
{
throw new Exception("Disconnected row identity did not match first connection identity");
}

disconnectedRowSeen = true;
})
.OnError((_, err) => throw err)
.AddQuery(qb => qb.From.Disconnected())
.Subscribe();
reconnected = true;
})
.OnConnectError(err => throw err)
.OnDisconnect((_, err) =>
{
if (err != null)
{
throw err;
}
})
.Build();

FrameTickUntil(() => reconnected && disconnectedRowSeen, reconnectDb);
reconnectDb.Disconnect();

void FrameTickUntil(Func<bool> isComplete, DbConnection? connection = null, int timeoutSeconds = 20)
{
var deadline = DateTime.UtcNow.AddSeconds(timeoutSeconds);
connection ??= db;
while (!isComplete())
{
connection.FrameTick();
Thread.Sleep(25);
if (DateTime.UtcNow > deadline)
{
throw new TimeoutException($"Timed out after {timeoutSeconds} seconds");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<Compile Include="../../src/**/*.cs" LinkBase="SpacetimeDB.ClientSDK" />
<Reference Include="SpacetimeDB.BSATN.Runtime">
<HintPath>../../../../crates/bindings-csharp/BSATN.Runtime/bin/Release/net8.0/SpacetimeDB.BSATN.Runtime.dll</HintPath>
</Reference>
<Analyzer Include="../../../../crates/bindings-csharp/BSATN.Codegen/bin/Release/netstandard2.0/SpacetimeDB.BSATN.Codegen.dll" />
</ItemGroup>

</Project>
Loading
Loading